From 15d115e86751942547ac8f80dbc20be06a7f01f1 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Fri, 31 Jul 2026 17:29:16 +0900 Subject: [PATCH 01/52] =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EC=97=90=EC=84=9C=20gi?= =?UTF-8?q?t=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=83=89=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=B4=EC=97=AC=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 바뀐 파일이 무엇인지 알려면 소스 컨트롤 패널까지 가야 했다. 트리에는 아무 표시가 없어서, 파일을 열기 전에는 그게 새로 만든 것인지 고친 것인지 알 방법이 없었다. 충돌 > 새 파일 > 수정 순으로 우선한다 — 충돌이 가장 급하고, 새 파일은 수정을 겸한다. 색은 지난 판에서 만든 시맨틱 토큰을 그대로 쓴다(밝은 테마에서도 대비가 보장된다). git 상태가 없거나 저장소가 아니면 표를 아예 만들지 않는다 — 트리 렌더가 매번 빈 Map 을 들고 다닐 이유가 없다. --- ide/src/App.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 50c5c84..dfad26d 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -6072,6 +6072,22 @@ ${(r.output || "").slice(0, 2000)}`; } // ── 좌 패널: 소스 컨트롤 (Git) ── + /** 파일 경로 → 그 파일의 git 상태 색. 없으면 null. + * + * 소스 컨트롤 패널까지 가야만 무엇이 바뀌었는지 알 수 있었다 — 트리에는 아무 표시가 + * 없어서, 파일을 열기 전엔 그게 새 파일인지 고친 파일인지 알 방법이 없다. + * 충돌 > 새 파일 > 수정 순으로 우선한다(충돌이 가장 급하고, 새 파일은 수정을 겸한다). */ + private gitTreeColors(): Map | null { + const g = this.state.git; + if (!g || g.notRepo) return null; + const m = new Map(); + for (const e of g.staged) m.set(e.path, e.code === "A" ? "var(--ok)" : "var(--dirty)"); + for (const e of g.unstaged) m.set(e.path, "var(--dirty)"); + for (const e of g.untracked) m.set(e.path, "var(--ok)"); + for (const e of g.conflicted) m.set(e.path, "var(--err)"); // 마지막 = 가장 센 신호 + return m.size ? m : null; + } + private gitCodeColor(code: string): string { if (code === "A" || code === "?") return "var(--ok)"; if (code === "M") return "var(--dirty)"; @@ -6447,6 +6463,7 @@ ${(r.output || "").slice(0, 2000)}`; return false; }; const te = s.treeEdit; + const gitColors = this.gitTreeColors(); const rows: React.ReactNode[] = []; for (const en of ws.entries) { const pad = 16 + Math.min(en.depth, 8) * 14; // 상한 없이 밀면 깊은 경로의 이름 칸이 0 이 된다 @@ -6489,7 +6506,7 @@ ${(r.output || "").slice(0, 2000)}`; onContextMenu={e => { e.preventDefault(); this.setState({ ctxMenu: { x: e.clientX, y: e.clientY, rel: en.rel, isDir: false } }); }} style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: `0 8px 0 ${pad}px`, cursor: "pointer", background: inPane ? "rgba(125,145,131,.08)" : "transparent", transition: "background var(--dur-fast) var(--ease)" }}> - {en.name} + {en.name}
{dirty && }
From e3bf1626e31f3fca091ba3f6bdc4dd50f0af598f Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Fri, 31 Jul 2026 17:43:45 +0900 Subject: [PATCH 02/52] =?UTF-8?q?Alt+=E2=86=90=20/=20Alt+=E2=86=92=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=A9=EA=B8=88=20=EC=9E=88=EB=8D=98=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=EB=8F=8C=EC=95=84=EA=B0=84=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정의로 점프하고 나면 원래 보던 곳으로 돌아올 길이 없었다 — 파일 이름과 줄 번호를 외우고 있거나, 탭을 뒤져 찾은 뒤 다시 스크롤해야 했다. - 파일이 아니라 **자리**를 기록한다. 같은 파일 안에서 크게 뛴 것도 별개의 자리다. - 한 줄씩 걸어 내려간 것은 기록하지 않는다. 기준이 매번 최신으로 갱신돼 차이가 쌓이지 않는다 — 방향키로 200줄을 내려간 건 "다른 곳으로 갔다" 가 아니고, 그걸 기록하면 뒤로 가기가 커서 이동 취소가 되어 쓸모없어진다. - 뒤로 간 뒤 새로운 곳으로 가면 앞쪽 기록은 버린다(브라우저와 같다). - 지워진 파일은 기록에서 걷어낸다. 안 그러면 되돌아갔을 때 빈 탭이 열린다. - diff·프리뷰 탭은 되돌아갈 자리가 아니라 아예 넣지 않는다. 커서 이동마다 Monaco 리스너를 다는 대신 **떠나기 직전**에 한 번 읽는다. 페인마다 리스너를 붙이지 않아도 결과가 같다. 규칙은 engine/navHistory.ts 에 순수 함수로 두고 테스트 16개를 붙였다. --- ide/src/App.tsx | 48 +++++++++++ ide/src/engine/navHistory.test.ts | 133 ++++++++++++++++++++++++++++++ ide/src/engine/navHistory.ts | 89 ++++++++++++++++++++ ide/src/i18n/dict/key.ts | 2 + ide/src/keymap.ts | 6 +- 5 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 ide/src/engine/navHistory.test.ts create mode 100644 ide/src/engine/navHistory.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index dfad26d..25d710b 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -31,6 +31,7 @@ import { } from "./engine/checkpoints"; import { resolveRenameTarget, isMove } from "./engine/movePath"; import { applyProposal } from "./engine/editApply"; +import { emptyNav, push as navPush, back as navBack, forward as navForward, current as navCurrent, dropMissing as navDropMissing, type NavState } from "./engine/navHistory"; import { shouldProbeQuota } from "./engine/quotaPoll"; import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction } from "./overlays"; import { @@ -2346,7 +2347,49 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { resolveAll(accept: boolean) { this.state.files.filter(f => f.status === "pending").forEach(f => this.resolveFile(f.path, accept)); } /** 파일을 포커스된 슬롯의 탭으로 연다 (이미 어느 슬롯에 열려 있으면 그 슬롯을 활성화) */ + /** 편집 위치 기록. "방금 어디 있었더라" 를 되짚는 길이 없어서, 정의로 점프하고 나면 + * 원래 자리로 돌아오려면 파일 이름과 줄 번호를 외우고 있어야 했다. */ + private _nav: NavState = emptyNav(); + /** 뒤로/앞으로 이동하는 중 — 그때의 openFile 은 새 자리로 기록하면 안 된다(무한 전진). */ + private _navJumping = false; + + /** 지금 커서가 있는 자리를 기록에 반영한다. 같은 자리면 줄 번호만 갱신된다. + * 커서 이동마다 이벤트를 다는 대신, **떠나기 직전**에 한 번 읽는다 — + * Monaco 리스너를 페인마다 붙이지 않아도 결과가 같다. */ + private navSync() { + const rel = this.state.active[this._focusSlot]; + if (!rel || this.parseDiffKey(rel) || this.parsePreviewKey(rel)) return; + const line = paneRegistry.panes.get(rel)?.editor.getPosition()?.lineNumber ?? 1; + this._nav = navPush(this._nav, { rel, line }); + } + + /** 어딘가로 갔다 — 기록에 남긴다. diff·프리뷰 탭은 되돌아갈 "자리" 가 아니라 뺀다. */ + private navRecord(rel: string) { + if (this._navJumping) return; + if (this.parseDiffKey(rel) || this.parsePreviewKey(rel)) return; + this.navSync(); // 떠나기 직전 자리를 최신으로 + const line = paneRegistry.panes.get(rel)?.editor.getPosition()?.lineNumber ?? 1; + this._nav = navPush(this._nav, { rel, line }); + } + + /** Alt+← / Alt+→. 갈 곳이 없으면 아무 일도 하지 않는다(VS Code 와 같다). */ + private navGo(dir: -1 | 1) { + this.navSync(); + const next = dir < 0 ? navBack(this._nav) : navForward(this._nav); + if (next === this._nav) return; // 같은 객체 = 끝에 닿았다 + this._nav = next; + const spot = navCurrent(next); + if (!spot) return; + this._navJumping = true; + this.openFile(spot.rel); + this.revealInPane(spot.rel, spot.line, 1); + // openFile 의 setState 콜백과 revealInPane 폴링이 끝난 뒤에 풀어야 그 사이의 + // openFile 이 새 자리로 기록되지 않는다. + setTimeout(() => { this._navJumping = false; }, 0); + } + openFile(path: string) { + this.navRecord(path); this._touchMru(path); this._cancelPendingClose(path); // 닫힘 애니 중 재오픈 시 뒤늦은 제거 취소 this.setState(s => { @@ -4282,6 +4325,7 @@ ${(r.output || "").slice(0, 2000)}`; if (tree && !(tree as any).truncated) { const present = new Set(tree.entries.filter(e => !e.dir).map(e => e.rel)); projectModels.dropMissing(ws.root, present); + this._nav = navDropMissing(this._nav, rel => present.has(rel)); // 지워진 파일로 되돌아가면 빈 탭이 열린다 } // 대량 변경(브랜치 전환/pull/stash pop): 열지 않은 preload 모델도 디스크로 재로드해 stale 진단 방지 if (opts?.bulk) void projectModels.reloadAll(ws.root, (r, rel) => window.schutz!.readFile(r, rel), this.isDirtyRel); @@ -4897,6 +4941,10 @@ ${(r.output || "").slice(0, 2000)}`; } case "tabs.reopen": prevent(); this.reopenClosedTab(); return; + // 위치 기록 이동. 에디터 안에서도 동작해야 한다 — Monaco 는 Alt+←/→ 를 안 쓴다. + case "nav.back": prevent(); this.navGo(-1); return; + case "nav.forward": prevent(); this.navGo(1); return; + // 글자 크기 — 설정 모달을 열지 않고. wrap·minimap 팔레트 명령과 같은 경로다. case "editor.fontUp": prevent(); this.bumpFontSize(1); return; case "editor.fontDown": prevent(); this.bumpFontSize(-1); return; diff --git a/ide/src/engine/navHistory.test.ts b/ide/src/engine/navHistory.test.ts new file mode 100644 index 0000000..8b29d76 --- /dev/null +++ b/ide/src/engine/navHistory.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "vitest"; +import { + emptyNav, current, isNewSpot, push, back, forward, canBack, canForward, dropMissing, + NAV_LIMIT, NAV_MIN_JUMP, type NavState, +} from "./navHistory"; + +const S = (rel: string, line: number) => ({ rel, line }); +const trail = (s: NavState) => s.spots.map(p => `${p.rel}:${p.line}`); + +describe("빈 상태", () => { + it("아무 데도 못 간다", () => { + const s = emptyNav(); + expect(current(s)).toBeNull(); + expect(canBack(s)).toBe(false); + expect(canForward(s)).toBe(false); + expect(back(s)).toBe(s); // 같은 객체 → 호출측이 "안 움직였다" 를 안다 + expect(forward(s)).toBe(s); + }); +}); + +describe("자리를 남긴다", () => { + it("다른 파일은 늘 새 자리다", () => { + let s = push(emptyNav(), S("a.ts", 1)); + s = push(s, S("b.ts", 1)); + expect(trail(s)).toEqual(["a.ts:1", "b.ts:1"]); + expect(current(s)).toEqual(S("b.ts", 1)); + }); + + it("같은 파일에서 크게 뛰면 새 자리", () => { + let s = push(emptyNav(), S("a.ts", 10)); + s = push(s, S("a.ts", 10 + NAV_MIN_JUMP)); + expect(trail(s)).toEqual(["a.ts:10", "a.ts:20"]); + }); + + it("같은 파일에서 조금 움직인 것은 기록하지 않는다 — 뒤로 가기가 커서 취소가 되면 안 된다", () => { + let s = push(emptyNav(), S("a.ts", 10)); + for (let i = 1; i < NAV_MIN_JUMP; i++) s = push(s, S("a.ts", 10 + i)); + expect(s.spots.length).toBe(1); + expect(current(s)).toEqual(S("a.ts", 19)); // 줄 번호만 최신으로 갱신 + }); + + it("한 줄씩 걸어 내려가면 아무리 멀리 가도 새 자리가 안 생긴다", () => { + // 기준이 매번 최신으로 갱신되므로 차이가 쌓이지 않는다. 의도된 동작이다 — + // 방향키로 200줄을 내려간 것은 "다른 곳으로 갔다" 가 아니다. + let s = push(emptyNav(), S("a.ts", 1)); + for (let i = 2; i <= 200; i++) s = push(s, S("a.ts", i)); + expect(s.spots.length).toBe(1); + expect(current(s)).toEqual(S("a.ts", 200)); + }); + + it("경계에서 딱 1 모자라면 아직 같은 자리", () => { + let s = push(emptyNav(), S("a.ts", 10)); + s = push(s, S("a.ts", 10 + NAV_MIN_JUMP - 1)); + expect(s.spots.length).toBe(1); + }); + + it("위로 뛴 것도 똑같이 센다", () => { + let s = push(emptyNav(), S("a.ts", 100)); + s = push(s, S("a.ts", 100 - NAV_MIN_JUMP)); + expect(trail(s)).toEqual(["a.ts:100", "a.ts:90"]); + }); + + it("상한을 넘으면 오래된 것부터 버린다", () => { + let s = emptyNav(); + for (let i = 0; i < NAV_LIMIT + 5; i++) s = push(s, S("f" + i + ".ts", 1)); + expect(s.spots.length).toBe(NAV_LIMIT); + expect(s.spots[0]!.rel).toBe("f5.ts"); + expect(s.idx).toBe(NAV_LIMIT - 1); // 잘라낸 뒤에도 커서는 맨 끝 + }); +}); + +describe("뒤로·앞으로", () => { + const three = () => push(push(push(emptyNav(), S("a.ts", 1)), S("b.ts", 2)), S("c.ts", 3)); + + it("뒤로 갔다 앞으로 오면 제자리", () => { + const s = three(); + expect(current(back(back(s)))).toEqual(S("a.ts", 1)); + expect(current(forward(forward(back(back(s)))))).toEqual(S("c.ts", 3)); + }); + + it("맨 앞·맨 뒤에서는 안 움직인다", () => { + const s = back(back(three())); + expect(canBack(s)).toBe(false); + expect(back(s)).toBe(s); + const e = three(); + expect(canForward(e)).toBe(false); + expect(forward(e)).toBe(e); + }); + + it("뒤로 간 뒤 새 곳으로 가면 앞쪽 기록은 버린다 — 브라우저와 같다", () => { + let s = back(three()); // a, b, [c] → a, [b], c + expect(canForward(s)).toBe(true); + s = push(s, S("d.ts", 4)); + expect(trail(s)).toEqual(["a.ts:1", "b.ts:2", "d.ts:4"]); + expect(canForward(s)).toBe(false); + }); + + it("뒤로 간 자리에서 잔이동해도 앞쪽 기록은 살아 있다", () => { + let s = back(three()); + s = push(s, S("b.ts", 3)); // 같은 자리 갱신 + expect(canForward(s)).toBe(true); + expect(trail(s)).toEqual(["a.ts:1", "b.ts:3", "c.ts:3"]); + }); +}); + +describe("사라진 파일 정리", () => { + it("지워진 파일은 기록에서 뺀다 — 되돌아가면 빈 탭이 열린다", () => { + const s = push(push(push(emptyNav(), S("a.ts", 1)), S("gone.ts", 2)), S("c.ts", 3)); + const out = dropMissing(s, rel => rel !== "gone.ts"); + expect(trail(out)).toEqual(["a.ts:1", "c.ts:3"]); + expect(current(out)).toEqual(S("c.ts", 3)); + }); + + it("보고 있던 자리가 사라지면 가장 최근으로 옮긴다", () => { + let s = push(push(emptyNav(), S("a.ts", 1)), S("gone.ts", 2)); + s = { spots: s.spots, idx: 1 }; + const out = dropMissing(s, rel => rel !== "gone.ts"); + expect(current(out)).toEqual(S("a.ts", 1)); + }); + + it("정리 후 이어지는 중복은 하나로 접는다", () => { + const s = push(push(push(emptyNav(), S("a.ts", 1)), S("gone.ts", 2)), S("a.ts", 1)); + const out = dropMissing(s, rel => rel !== "gone.ts"); + expect(trail(out)).toEqual(["a.ts:1"]); + }); + + it("전부 사라지면 빈 기록", () => { + const s = push(emptyNav(), S("gone.ts", 1)); + const out = dropMissing(s, () => false); + expect(out.spots).toEqual([]); + expect(current(out)).toBeNull(); + }); +}); diff --git a/ide/src/engine/navHistory.ts b/ide/src/engine/navHistory.ts new file mode 100644 index 0000000..e8d4497 --- /dev/null +++ b/ide/src/engine/navHistory.ts @@ -0,0 +1,89 @@ +/** + * 편집 위치 기록 — Alt+← / Alt+→. + * + * "방금 어디 있었더라" 를 되짚는 길이 없었다. 정의로 점프하고 나면 원래 보던 자리로 + * 돌아오려면 파일 이름과 줄 번호를 기억하고 있어야 했다. + * + * 브라우저 뒤로 가기와 같은 규칙이다: 뒤로 간 뒤에 **새로운 곳**으로 가면 앞쪽 기록은 + * 버린다. 부수효과 없는 순수 함수로 두어 그 규칙을 테스트로 못 박는다. + */ + +export interface NavSpot { + rel: string; + line: number; +} + +export interface NavState { + /** 방문 기록. 오래된 것이 앞. */ + spots: NavSpot[]; + /** 지금 어디를 보고 있는가. spots 의 인덱스. 비어 있으면 -1. */ + idx: number; +} + +export const NAV_LIMIT = 60; +/** 같은 파일 안에서 이만큼 이상 뛰면 별도의 자리로 친다. 한 줄씩 내려가는 것까지 + * 기록하면 뒤로 가기가 커서 이동 취소가 되어 쓸모없어진다. */ +export const NAV_MIN_JUMP = 10; + +export const emptyNav = (): NavState => ({ spots: [], idx: -1 }); + +/** 지금 보고 있는 자리. 없으면 null. */ +export function current(s: NavState): NavSpot | null { + return s.idx >= 0 && s.idx < s.spots.length ? s.spots[s.idx]! : null; +} + +/** 새로 간 자리를 기록할 만한가 — 같은 파일의 코앞이면 아니다. */ +export function isNewSpot(s: NavState, next: NavSpot): boolean { + const cur = current(s); + if (!cur) return true; + if (cur.rel !== next.rel) return true; + return Math.abs(cur.line - next.line) >= NAV_MIN_JUMP; +} + +/** 자리를 하나 남긴다. 뒤로 간 상태에서 부르면 앞쪽 기록은 버려진다(브라우저와 같다). */ +export function push(s: NavState, next: NavSpot): NavState { + if (!isNewSpot(s, next)) { + // 같은 자리 안에서의 잔이동 — 줄 번호만 최신으로 갱신해 둔다. + if (s.idx < 0) return s; + const spots = s.spots.slice(); + spots[s.idx] = next; + return { spots, idx: s.idx }; + } + const kept = s.spots.slice(0, s.idx + 1); + kept.push(next); + const over = kept.length - NAV_LIMIT; + const spots = over > 0 ? kept.slice(over) : kept; + return { spots, idx: spots.length - 1 }; +} + +export const canBack = (s: NavState): boolean => s.idx > 0; +export const canForward = (s: NavState): boolean => s.idx >= 0 && s.idx < s.spots.length - 1; + +/** 한 칸 뒤로. 갈 곳이 없으면 상태를 그대로 돌려준다(호출측이 === 로 판별한다). */ +export function back(s: NavState): NavState { + return canBack(s) ? { spots: s.spots, idx: s.idx - 1 } : s; +} +export function forward(s: NavState): NavState { + return canForward(s) ? { spots: s.spots, idx: s.idx + 1 } : s; +} + +/** 사라진 파일을 기록에서 걷어낸다 — 닫은 게 아니라 **지워진** 파일로 되돌아가면 + * 빈 탭이 열린다. 이어지는 중복은 하나로 접는다. */ +export function dropMissing(s: NavState, exists: (rel: string) => boolean): NavState { + const cur = current(s); + const kept: NavSpot[] = []; + let idx = -1; + for (let i = 0; i < s.spots.length; i++) { + const sp = s.spots[i]!; + if (!exists(sp.rel)) continue; + const last = kept[kept.length - 1]; + if (last && last.rel === sp.rel && last.line === sp.line) { + if (s.spots[i] === cur) idx = kept.length - 1; + continue; + } + kept.push(sp); + if (s.spots[i] === cur) idx = kept.length - 1; + } + if (idx < 0) idx = kept.length - 1; // 보고 있던 자리가 사라졌다 → 가장 최근으로 + return { spots: kept, idx }; +} diff --git a/ide/src/i18n/dict/key.ts b/ide/src/i18n/dict/key.ts index b3fd1e3..bdb0e1c 100644 --- a/ide/src/i18n/dict/key.ts +++ b/ide/src/i18n/dict/key.ts @@ -12,6 +12,8 @@ export const dict: Record Date: Fri, 31 Jul 2026 18:11:05 +0900 Subject: [PATCH 03/52] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9D=B4=20IDE=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B2=8C=EC=96=B4=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EA=B5=AC=EB=8F=85=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확장 API 는 명령 등록·패널 표시·토스트뿐이었다. 확장이 먼저 말을 걸 수는 있어도 사용자가 파일을 열거나 저장하는 것, 에이전트가 편집을 수락하는 것은 알 방법이 없었다 — "저장할 때마다 무언가 하는" 종류의 확장은 아예 쓸 수 없었다. schutz.on(event, fn) 을 열고 일곱 가지를 알린다: workspace.open, file.open, file.save, proposal.accept, proposal.reject, run.start, run.end. 목록은 schutz.events 로도 읽을 수 있다. 관찰 전용이다. 훅은 무엇도 막거나 바꾸지 못한다. 계약을 나중에 넓힐 수는 있지만 지금 열어 두면 확장 하나가 저장을 영영 붙잡을 수 있다. - 핸들러 하나가 터져도 나머지는 계속 돈다. 확장이 IDE 의 저장 경로를 멈추게 두면 안 된다. 터진 것은 토스트로 보고한다. - emit 도중 구독/해제가 일어나도 그 판은 흔들리지 않는다(스냅숏을 뜬다). - 확장을 다시 읽으면 훅도 같이 걷어낸다 — 명령과 같은 수명이다. 두 가지가 이 과정에서 드러나 같이 고쳤다. - loadExtensions 가 겹쳐 돌면 앞선 판의 activate 가 뒤늦게 끝나면서 이미 정리된 자리에 다시 등록해, 같은 확장의 핸들러가 두 벌 살아 훅이 두 번씩 왔다. 리로드 버튼을 연타하면 닿는 경로다. 로드 세대를 두어 지난 판의 늦은 등록을 버린다. - workspace.open 이 아무에게도 안 갔다. 프로젝트가 확장 로드보다 먼저 열리면 구독자가 없어 사건이 허공에 사라진다. 확장 로드가 끝난 뒤에도 한 번 알리되, 같은 root 를 두 번 알리지 않는다. file.save 는 dirty=false 와 다르다(저장 말고도 생긴다) — 페인이 디스크에 실제로 쓴 뒤 onSaved 로 따로 알린다. --- ide/electron/extensions.cjs | 3 + ide/src/App.tsx | 26 +++++++ ide/src/editor/MonacoPane.tsx | 5 +- ide/src/ext/extHost.ts | 25 ++++++- ide/src/ext/hooks.test.ts | 136 ++++++++++++++++++++++++++++++++++ ide/src/ext/hooks.ts | 81 ++++++++++++++++++++ ide/vitest.config.ts | 1 + 7 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 ide/src/ext/hooks.test.ts create mode 100644 ide/src/ext/hooks.ts diff --git a/ide/electron/extensions.cjs b/ide/electron/extensions.cjs index 94c8a9b..162d0ed 100644 --- a/ide/electron/extensions.cjs +++ b/ide/electron/extensions.cjs @@ -41,6 +41,9 @@ function ensureSample(app) { }, null, 2)); fs.writeFileSync(path.join(dir, "extension.js"), `// Schutz 확장 엔트리 — activate(schutz) 를 export. // schutz API: commands.register(id,title,fn), ui.showPanel(title,html), toast(kind,msg), getActiveFile() +// on(event, fn) -> off — IDE 에서 벌어지는 일을 구독한다(관찰 전용). +// 사건: schutz.events — workspace.open, file.open, file.save, +// proposal.accept, proposal.reject, run.start, run.end exports.activate = function (schutz) { schutz.commands.register("hello.say", "Hello: 인사하기", function () { schutz.toast("ok", "안녕하세요! Schutz 확장이 동작합니다 👋"); diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 25d710b..ebbf0e4 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -747,6 +747,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { * (onDidFocusEditorWidget). 그래서 `paneRegistry.focused?.save()` 하나만 두면, * 트리에서 파일을 골라 열기만 한 상태나 크로스파일 이름 바꾸기 직후처럼 팬이 * 포커스를 받은 적 없는 흔한 상황에서 Ctrl+S 가 아무 일도 안 하고 아무 말도 안 했다. */ + /** 저장을 확장에 알린다. 저장 경로가 둘(페인·모델)이라 여기 한 곳으로 모은다. */ + private notifySaved = (rel: string) => extHost.notifyExtensions("file.save", { rel }); + async saveActive() { const p = paneRegistry.focused; if (p) { await p.save(); return; } @@ -773,6 +776,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { try { await window.schutz.writeFile(ws.root, rel, content); projectModels.markSaved(ws.root, rel, content); + this.notifySaved(rel); this.setState(st => ({ paneDirty: { ...st.paneDirty, [rel]: false } })); n++; } catch (e) { @@ -1220,6 +1224,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { }); void this.loadGit(); void this.loadTasks(tree); // package.json scripts → 팔레트의 "작업: …" + this.notifyWorkspaceOpen(tree.root); void this.refreshCheckpoints(tree.root); // 지난 실행의 되돌리기 지점 (프로젝트별로 따로 보관된다) void this.loadAgentCommands(); // 프로젝트 .claude/commands 반영 window.schutz.watchStart(tree.root); // 외부 변경 감지 시작 @@ -1821,6 +1826,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { editStart = res.start; editEnd = res.end; } + extHost.notifyExtensions("proposal.accept", { rel: p.rel, agent: p.agent }); this._proposalsById.set(id, { ...p, status: "accepted" }); // 동기 레지스트리도 갱신 — 자동수락 호출측이 결과를 즉시 읽는다 this.setState(s => ({ proposals: s.proposals.map(x => x.id === id ? { ...x, status: "accepted" as const } : x), @@ -1892,6 +1898,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { } rejectProposal(id: string) { + const p = this._proposalsById.get(id) ?? this.state.proposals.find(x => x.id === id); + if (p && p.status === "pending") extHost.notifyExtensions("proposal.reject", { rel: p.rel, agent: p.agent }); this.setState(s => ({ proposals: s.proposals.map(x => x.id === id && x.status === "pending" ? { ...x, status: "rejected" as const } : x), })); @@ -2389,6 +2397,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { } openFile(path: string) { + extHost.notifyExtensions("file.open", { rel: path }); this.navRecord(path); this._touchMru(path); this._cancelPendingClose(path); // 닫힘 애니 중 재오픈 시 뒤늦은 제거 취소 @@ -3513,6 +3522,7 @@ ${(r.output || "").slice(0, 2000)}`; cancel: () => abort.abort(), }); opts.onCancel?.(() => abort.abort()); + extHost.notifyExtensions("run.start", { agent: agentId, manager: opts.isManager }); this.abortCtls.set(run.runId, abort); this.setAgent(agentId, { status: opts.isManager ? "plan" : "edit" }); @@ -3697,6 +3707,7 @@ ${(r.output || "").slice(0, 2000)}`; } } + extHost.notifyExtensions("run.end", { agent: agentId, manager: opts.isManager, cause: stopCause }); // 부모(또는 위임 호출자)에게 돌려주는 구조체. 산문은 여기서 만들지 않는다. if (stopCause === "abort") return { status: "aborted" }; if (stopCause === "error") return { status: "failed", message: failMsg }; @@ -5350,6 +5361,17 @@ ${(r.output || "").slice(0, 2000)}`; } // ── 확장 ───────────────────────────────────────────────────────────────── + /** workspace.open 을 확장에 **한 번만** 알린다. + * 프로젝트는 확장 로드보다 먼저 열릴 수도, 나중에 열릴 수도 있다. 먼저 열리면 + * 구독자가 아직 없어서 사건이 허공에 사라졌다 — 그래서 확장 로드가 끝난 뒤에도 + * 한 번 알린다. 같은 root 를 두 번 알리지 않도록 마지막으로 알린 것을 기억한다. */ + private _notifiedRoot: string | null = null; + private notifyWorkspaceOpen(root: string) { + if (this._notifiedRoot === root) return; + this._notifiedRoot = root; + extHost.notifyExtensions("workspace.open", { root }); + } + async reloadExtensions() { if (!window.schutz) return; const res = await extHost.loadExtensions({ @@ -5365,6 +5387,9 @@ ${(r.output || "").slice(0, 2000)}`; for (const e of raw) { if (e.kind === "vscode" && e.enabled) for (const it of (e.contributes?.iconThemes || [])) iconThemes.push({ extId: e.id, label: it.label || e.name, path: it.path }); } iconTheme.setIconThemeChangeHandler(() => this.setState(s => ({ iconVer: s.iconVer + 1 }))); const list = await extHost.listExtensions(); + // 확장이 붙기 전에 이미 열려 있던 프로젝트를 알려 준다(위 주석 참고). + const openRoot = this.state.workspace?.root; + if (openRoot) { this._notifiedRoot = null; this.notifyWorkspaceOpen(openRoot); } this.setState({ extCommands: extHost.getExtCommands(), extList: list, extErrors: res.errors, extLimited: res.limited, extThemes: vres.themes, extIconThemes: iconThemes }); // TextMate 문법 연결 (있으면 VS Code급 하이라이팅) — 완료 후 테마 조율 await textmate.loadTextMateGrammars().catch(() => 0); @@ -7551,6 +7576,7 @@ ${(r.output || "").slice(0, 2000)}`; root={s.workspace!.root} rel={activeRel} onDirtyChange={this.handleDirtyChange} + onSaved={this.notifySaved} onStatus={this.handleStatus} onInlineEdit={this.handleInlineEdit} breakpoints={s.breakpoints[activeRel]} diff --git a/ide/src/editor/MonacoPane.tsx b/ide/src/editor/MonacoPane.tsx index 3557145..6dd528f 100644 --- a/ide/src/editor/MonacoPane.tsx +++ b/ide/src/editor/MonacoPane.tsx @@ -12,6 +12,8 @@ interface Props { root: string; rel: string; onDirtyChange?: (rel: string, dirty: boolean) => void; + /** 디스크에 실제로 쓴 뒤. 확장 훅(file.save)이 여기서 나간다 — dirty=false 는 저장 말고도 생긴다. */ + onSaved?: (rel: string) => void; onStatus?: (info: { rel: string; lang: string; line: number; col: number }) => void; /** Ctrl+K 인라인 편집 — 선택 영역과 지시를 App으로 전달 */ onInlineEdit?: (rel: string, selection: string, instruction: string, range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }) => void; @@ -60,7 +62,7 @@ export const paneRegistry: { panes: Map; focused: PaneApi | nul type LoadState = "loading" | "ready" | "error"; /** 실제 파일을 여는 Monaco 편집 페인 (Electron 전용 — window.schutz 필요) */ -function MonacoPaneImpl({ root, rel, onDirtyChange, onStatus, onInlineEdit, breakpoints, stoppedLine, onToggleBreakpoint, gitVer }: Props) { +function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onStatus, onInlineEdit, breakpoints, stoppedLine, onToggleBreakpoint, gitVer }: Props) { // React.memo 가 부모의 forceUpdate 를 막으므로 언어는 여기서 직접 구독한다. const langTick = useLang(); const hostRef = useRef(null); @@ -159,6 +161,7 @@ function MonacoPaneImpl({ root, rel, onDirtyChange, onStatus, onInlineEdit, brea savedRef.current = text; setDirty(false); onDirtyChange?.(rel, false); + onSaved?.(rel); void drawGutterRef.current?.(); // 저장했으니 거터도 다시 — 안 하면 방금 고친 줄이 안 뜬다 setFlash(true); setTimeout(() => setFlash(false), 900); diff --git a/ide/src/ext/extHost.ts b/ide/src/ext/extHost.ts index e1a8e5d..8dc55eb 100644 --- a/ide/src/ext/extHost.ts +++ b/ide/src/ext/extHost.ts @@ -2,6 +2,7 @@ // 확장은 신뢰 코드로 간주(VS Code와 동일 모델)하되, 편의 API는 이 표면으로 한정한다. // Schutz 네이티브(schutz API) + VS Code 프로그램형(vscode 셰임으로 activate 실행) 둘 다 지원. import { makeVscodeApi, disposeShimRegistrations } from "./vscodeShim"; +import { onHook, clearHooks, emitHook, HOOK_EVENTS, type HookEvent } from "./hooks"; import { t } from "../i18n"; export interface ExtCommand { id: string; title: string; run: (...args: any[]) => any; source: string; } @@ -20,6 +21,7 @@ let activeContexts: { name: string; ctx: any; deactivate?: (...a: any[]) => any /** 이전 로드의 확장 정리 — ctx.subscriptions 의 disposable 해제 + 확장의 deactivate() 호출 */ function teardownExtensions() { + clearHooks(); // 리로드마다 핸들러가 쌓이지 않게 — 명령과 같은 수명이다 for (const { ctx, deactivate } of activeContexts) { for (const sub of (ctx?.subscriptions || [])) { try { sub?.dispose?.(); } catch { /* */ } } try { deactivate?.(); } catch { /* */ } @@ -29,6 +31,13 @@ function teardownExtensions() { export function getExtCommands(): ExtCommand[] { return commands; } +/** IDE 쪽에서 사건을 알린다. 확장 핸들러가 터지면 토스트로 보고하되 흐름은 막지 않는다. */ +export function notifyExtensions(ev: HookEvent, payload: Record): void { + emitHook(ev, payload, (source, err) => { + deps?.toast("error", t("exth.commandError", { source, msg: err instanceof Error ? err.message : String(err) })); + }); +} + /** 등록된 확장 명령 실행 (vscode 셰임의 executeCommand 위임용). * 해석 순서: (1) 정확 일치(전체 id 로 호출) → (2) 호출 확장 자신의 네임스페이스(callerExtId:id). * 기존 전역 `endsWith(":"+id)` 는 다른 확장의 동일 접미 명령으로 오매칭되어 제거. */ @@ -57,7 +66,13 @@ function addCommand(id: string, title: string, run: (...args: any[]) => any, sou commands.push({ id, title, run: wrapRun(run, source), source }); } -function makeApi(ext: { id: string; name: string }) { +/** 로드 세대. loadExtensions 가 겹쳐 돌면(리로드 버튼 연타·StrictMode 이중 마운트) + * 앞선 판의 activate 가 뒤늦게 끝나면서 이미 정리된 자리에 다시 등록한다. 그러면 + * 같은 확장의 핸들러가 두 벌 살아 **훅이 두 번씩 온다**(실제로 그랬다). + * 등록 시점에 자기가 아직 최신 판인지 확인하게 해서 막는다. */ +let loadGen = 0; + +function makeApi(ext: { id: string; name: string }, gen: number) { return { extId: ext.id, commands: { @@ -68,6 +83,11 @@ function makeApi(ext: { id: string; name: string }) { }, }, ui: { showPanel: (title: string, html: string) => deps?.showPanel(title, html) }, + /** IDE 에서 벌어지는 일을 구독한다. 돌려받은 함수를 부르면 해제된다. + * 관찰 전용이다 — 훅은 무엇도 막거나 바꾸지 못한다. 목록은 hooks.ts 의 HOOK_EVENTS. */ + on: (event: string, fn: (payload: Record) => void) => + (gen === loadGen ? onHook(event, fn, ext.name) : () => { /* 지난 판의 늦은 등록 — 버린다 */ }), + events: HOOK_EVENTS.slice(), toast: (kind: "ok" | "error" | "info", msg: string) => deps?.toast(kind, msg), getActiveFile: () => deps?.getActiveFile() ?? null, }; @@ -128,6 +148,7 @@ function makeHostRequire(vscode: any) { /** 활성 확장 로드 → 커맨드 등록. 반환: 로드 수·하드 오류·기능 제한 목록 */ export async function loadExtensions(d: HostDeps): Promise<{ loaded: number; errors: string[]; limited: { id: string; name: string; reason: string }[] }> { + const gen = ++loadGen; deps = d; teardownExtensions(); // 이전 로드의 ctx.subscriptions·deactivate 정리 commands = []; @@ -193,7 +214,7 @@ export async function loadExtensions(d: HostDeps): Promise<{ loaded: number; err try { code = await window.schutz.extReadEntry(ext.id, ext.main || "extension.js"); } catch { errors.push(ext.id + ": " + t("exth.entryReadFailed")); continue; } if (typeof code !== "string") { errors.push(ext.id + ": " + (code?.error || t("exth.entryMissing"))); continue; } try { - const api = makeApi(ext); + const api = makeApi(ext, gen); const moduleObj = { exports: {} as any }; // eslint-disable-next-line no-new-func const fn = new Function("exports", "module", "schutz", "console", code); diff --git a/ide/src/ext/hooks.test.ts b/ide/src/ext/hooks.test.ts new file mode 100644 index 0000000..e2425f5 --- /dev/null +++ b/ide/src/ext/hooks.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { onHook, emitHook, clearHooks, hookCount, isHookEvent, HOOK_EVENTS, MAX_PER_SOURCE } from "./hooks"; + +beforeEach(() => clearHooks()); + +describe("구독", () => { + it("건 순서대로 부른다", () => { + const seen: string[] = []; + onHook("file.save", () => seen.push("a"), "x"); + onHook("file.save", () => seen.push("b"), "y"); + emitHook("file.save", { rel: "a.ts" }); + expect(seen).toEqual(["a", "b"]); + }); + + it("payload 를 그대로 넘긴다", () => { + let got: unknown = null; + onHook("file.open", p => { got = p; }, "x"); + emitHook("file.open", { rel: "src/a.ts" }); + expect(got).toEqual({ rel: "src/a.ts" }); + }); + + it("다른 사건에는 안 불린다", () => { + let n = 0; + onHook("file.save", () => n++, "x"); + emitHook("file.open", {}); + expect(n).toBe(0); + }); + + it("해제하면 더 안 불린다", () => { + let n = 0; + const off = onHook("run.start", () => n++, "x"); + emitHook("run.start", {}); + off(); + emitHook("run.start", {}); + expect(n).toBe(1); + }); + + it("두 번 해제해도 남의 것을 지우지 않는다", () => { + let n = 0; + const off = onHook("run.end", () => n++, "x"); + onHook("run.end", () => n++, "y"); + off(); off(); + emitHook("run.end", {}); + expect(n).toBe(1); + }); + + it("모르는 사건은 조용히 무시한다 — 오타 하나로 확장 로드가 깨지면 안 된다", () => { + const off = onHook("file.saved", () => { /* */ }, "x"); // 실제 이름은 file.save + expect(hookCount()).toBe(0); + expect(() => off()).not.toThrow(); + }); + + it("함수가 아니면 무시한다", () => { + onHook("file.save", undefined as never, "x"); + expect(hookCount()).toBe(0); + }); +}); + +describe("실패 격리", () => { + it("하나가 터져도 나머지는 돈다 — 확장이 저장 경로를 멈추게 두지 않는다", () => { + const seen: string[] = []; + onHook("file.save", () => { throw new Error("boom"); }, "bad"); + onHook("file.save", () => seen.push("after"), "good"); + const errs: string[] = []; + const ran = emitHook("file.save", {}, s => errs.push(s)); + expect(seen).toEqual(["after"]); + expect(errs).toEqual(["bad"]); + expect(ran).toBe(2); + }); + + it("보고 자체가 터져도 emit 은 성공한다", () => { + onHook("file.save", () => { throw new Error("boom"); }, "bad"); + expect(() => emitHook("file.save", {}, () => { throw new Error("보고 실패"); })).not.toThrow(); + }); + + it("onError 를 안 줘도 조용히 넘어간다", () => { + onHook("file.save", () => { throw new Error("boom"); }, "bad"); + expect(() => emitHook("file.save", {})).not.toThrow(); + }); +}); + +describe("도는 중 변경", () => { + it("emit 도중에 건 훅은 이번 판에 안 불린다", () => { + let late = 0; + onHook("run.start", () => { onHook("run.start", () => late++, "x"); }, "x"); + emitHook("run.start", {}); + expect(late).toBe(0); + emitHook("run.start", {}); + expect(late).toBe(1); + }); + + it("emit 도중 스스로 해제해도 그 판은 끝까지 돈다", () => { + const seen: string[] = []; + let off2: (() => void) | null = null; + onHook("run.end", () => { seen.push("a"); off2?.(); }, "x"); + off2 = onHook("run.end", () => seen.push("b"), "y"); + emitHook("run.end", {}); + expect(seen).toEqual(["a", "b"]); + emitHook("run.end", {}); + expect(seen).toEqual(["a", "b", "a"]); + }); +}); + +describe("정리와 상한", () => { + it("확장별로 걷어낸다", () => { + onHook("file.save", () => { /* */ }, "x"); + onHook("file.save", () => { /* */ }, "y"); + clearHooks("x"); + expect(hookCount("file.save")).toBe(1); + }); + + it("인자 없이 부르면 전부 걷어낸다 — 확장 리로드", () => { + onHook("file.save", () => { /* */ }, "x"); + onHook("file.open", () => { /* */ }, "y"); + clearHooks(); + expect(hookCount()).toBe(0); + }); + + it("한 확장이 상한을 넘게 달면 거절한다", () => { + for (let i = 0; i < MAX_PER_SOURCE + 5; i++) onHook("file.save", () => { /* */ }, "x"); + expect(hookCount()).toBe(MAX_PER_SOURCE); + onHook("file.save", () => { /* */ }, "y"); // 다른 확장은 영향 없다 + expect(hookCount()).toBe(MAX_PER_SOURCE + 1); + }); +}); + +describe("사건 이름", () => { + it("공개 목록만 참이다", () => { + for (const e of HOOK_EVENTS) expect(isHookEvent(e)).toBe(true); + expect(isHookEvent("file.saved")).toBe(false); + expect(isHookEvent("")).toBe(false); + }); + it("이름이 중복되지 않는다", () => { + expect(new Set(HOOK_EVENTS).size).toBe(HOOK_EVENTS.length); + }); +}); diff --git a/ide/src/ext/hooks.ts b/ide/src/ext/hooks.ts new file mode 100644 index 0000000..728c198 --- /dev/null +++ b/ide/src/ext/hooks.ts @@ -0,0 +1,81 @@ +/** + * 확장이 IDE 안에서 벌어지는 일을 **알 수 있게** 하는 자리. + * + * 지금까지 확장 API 는 명령 등록·패널 표시·토스트뿐이었다. 확장이 먼저 말을 걸 수는 + * 있어도, 사용자가 파일을 열거나 저장하거나 에이전트가 편집을 수락하는 것은 알 방법이 + * 없었다 — 그래서 "저장할 때마다 무언가 하는" 종류의 확장은 아예 쓸 수 없었다. + * + * 관찰 전용이다. 훅은 무엇도 막거나 바꾸지 못한다. 그 계약을 나중에 넓힐 수는 있지만, + * 지금 열어 두면 확장 하나가 저장을 영영 붙잡을 수 있다. + */ + +export type HookEvent = + /** 프로젝트를 열었다. { root } */ + | "workspace.open" + /** 파일을 열었다(탭으로). { rel } */ + | "file.open" + /** 파일을 디스크에 저장했다. { rel } */ + | "file.save" + /** 에이전트 제안을 수락/거절했다. { rel, agent } */ + | "proposal.accept" + | "proposal.reject" + /** 에이전트 턴이 시작/종료했다. { agent } */ + | "run.start" + | "run.end"; + +export const HOOK_EVENTS: HookEvent[] = [ + "workspace.open", "file.open", "file.save", + "proposal.accept", "proposal.reject", "run.start", "run.end", +]; + +export type HookHandler = (payload: Record) => void; + +interface Entry { ev: HookEvent; fn: HookHandler; source: string } + +/** 확장 하나가 같은 사건에 이만큼 넘게 달면 실수다 — 조용히 새는 대신 거절한다. */ +export const MAX_PER_SOURCE = 32; + +let entries: Entry[] = []; + +export function isHookEvent(x: string): x is HookEvent { + return (HOOK_EVENTS as string[]).includes(x); +} + +/** 구독. 해제 함수를 돌려준다(확장 리로드 때 호출측이 부른다). 모르는 사건이면 no-op. */ +export function onHook(ev: string, fn: HookHandler, source: string): () => void { + if (!isHookEvent(ev) || typeof fn !== "function") return () => { /* 아무것도 안 걺 */ }; + if (entries.filter(e => e.source === source).length >= MAX_PER_SOURCE) return () => { /* 상한 초과 */ }; + const entry: Entry = { ev, fn, source }; + entries.push(entry); + return () => { entries = entries.filter(e => e !== entry); }; +} + +/** 이 확장이 건 것을 전부 뗀다 — 확장을 끄거나 다시 읽을 때. */ +export function clearHooks(source?: string): void { + entries = source === undefined ? [] : entries.filter(e => e.source !== source); +} + +export function hookCount(ev?: HookEvent): number { + return ev ? entries.filter(e => e.ev === ev).length : entries.length; +} + +/** + * 사건을 알린다. **어떤 핸들러가 터져도 나머지는 계속 돈다** — 확장 하나가 IDE 의 + * 저장 경로를 멈추게 두면 안 된다. 터진 것은 onError 로 넘겨 호출측이 보고한다. + * + * 등록 순서대로 부른다. 스냅숏을 떠서 도는 중에 구독/해제가 일어나도 이번 판은 흔들리지 않는다. + */ +export function emitHook( + ev: HookEvent, + payload: Record, + onError?: (source: string, err: unknown) => void, +): number { + let ran = 0; + for (const e of entries.slice()) { + if (e.ev !== ev) continue; + ran++; + try { e.fn(payload); } + catch (err) { try { onError?.(e.source, err); } catch { /* 보고마저 실패 — 삼킨다 */ } } + } + return ran; +} diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index b4b9bbf..1ff3cea 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "src/ai/retry.test.ts", "src/keymap.test.ts", "src/overlays.test.ts", + "src/ext/hooks.test.ts", "src/theme.test.ts", "src/opening/**/*.test.ts", "src/uiMode.test.ts", From 7aa3571f14ebf0fe4a90be652fb9327f94d1c116 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Fri, 31 Jul 2026 18:35:19 +0900 Subject: [PATCH 04/52] =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=B7=B0=20=EC=A0=84=EC=9A=A9=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EA=B1=B7=EC=96=B4?= =?UTF-8?q?=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preload 가 window.schutz 를 조건 없이 노출하므로, `!window.schutz` 뒤에 있던 것은 데스크톱에서 한 번도 실행되지 않았다. 그런데도 그 코드가 값을 치렀다 — 계획 패널과 변경 개요를 채우는 유일한 구현이 거기 있어서, 실제 AI 를 붙이면 두 패널이 늘 비어 있었다. "만들었는데 닿지 않는" 패턴의 원본이다. 지운 것: startRun(대본 재생 97줄), renderDemoBody, freshDocs·hunkDefs 와 그 문서 조작 헬퍼들(updDoc·insertAfter·patchLine·addFile), resolveHunk·resolveFile· resolveAll, diffRows, 하드코딩된 데모 파일 트리, 그리고 renderReview 의 도달 불가 꼬리 80여 줄(첫 줄에서 늘 renderProposals 로 갈라졌다). 살아 있던 세 곳은 지운 게 아니라 다시 썼다. - 초기 탭이 데모 파일(TM)을 열어 둔 채 시작했다 → 빈 탭. - stopRun 이 "검토할 게 남았나" 를 데모 문서의 줄 상태로 판정했다 → 제안 목록으로. - 에디터 대체 화면이 renderDemoBody 였다 → 빈 편집 창 안내. 덤으로 둘. 브라우저에서 4분할로 시작하던 것은 데모 시각화용이었고, 이제 어디서나 단일 그룹이다. renderTree 는 워크스페이스가 없을 때 데스크톱만 빈 상태를 그리고 브라우저에서는 아무것도 안 돌려줬다 — 두 경우가 이제 같다. data.ts 는 절반이 데모였다(224 → 80줄). AGDEF·ENGINE_CREDIT·MENUS·PROJECTS 는 데스크톱이 쓰므로 남는다. --- ide/src/App.tsx | 426 ++++---------------------------------------- ide/src/ide/data.ts | 138 +------------- 2 files changed, 33 insertions(+), 531 deletions(-) diff --git a/ide/src/App.tsx b/ide/src/App.tsx index ebbf0e4..0807a6a 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -1,8 +1,7 @@ import React from "react"; import { - AGDEF, MENUS, TM, TY, MD, ENGINE_CREDIT, - freshDocs, hunkDefs, - DocLine, AgentState, PlanItem, ToolItem, ReviewFile, ChatMsg, + AGDEF, MENUS, ENGINE_CREDIT, + AgentState, PlanItem, ToolItem, ReviewFile, ChatMsg, } from "./ide/data"; import { GitBranchIcon, SearchIcon, @@ -258,7 +257,6 @@ interface S { plan: PlanItem[]; tools: ToolItem[]; files: ReviewFile[]; - docs: Record; chips: Record; /** 슬롯별 열린 탭 (rel 목록). 길이 = layout */ tabs: string[][]; @@ -386,7 +384,7 @@ interface S { * window.confirm 을 쓰지 않는 이유: 파일별 사유를 한 줄도 못 싣는다. */ undoAsk: { runId: string; plan: UndoVerdict[]; busy: boolean } | null; agents: Record; - /** 실제로 연 프로젝트 폴더 (Electron 전용). null이면 데모 모드 */ + /** 실제로 연 프로젝트 폴더. null 이면 아직 아무 프로젝트도 열지 않았다. */ workspace: SchutzWorkspaceTree | null; paneDirty: Record; /** Claude의 실파일 편집 제안 */ @@ -652,8 +650,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { state: S = { statusKey: "idle", running: false, runProgress: 0, messages: [], input: "", - plan: [], tools: [], files: [], docs: window.schutz ? {} : freshDocs(), chips: {}, // 데스크톱엔 데모 문서를 심지 않는다(실제 파일을 가림) - tabs: [[TM]], active: [TM], leftTab: "flow", expanded: null, + plan: [], tools: [], files: [], chips: {}, + tabs: [[]], active: [""], leftTab: "flow", expanded: null, breakpoints: {}, debug: null, debugConsole: [], watches: [], watchInput: "", extCommands: [], extList: [], extErrors: [], extLimited: [], extPanel: null, extThemes: [], extIconThemes: [], iconVer: 0, extSearch: "", extResults: [], extBusy: false, extInstalling: [], extDetail: null, extDetailBusy: false, git: null, gitMsg: "", gitBusy: false, gitError: "", @@ -675,8 +673,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { layout: (() => { const m = /[?&]layout=(\d)/.exec(window.location.search); if (m) { const v = parseInt(m[1], 10); return v === 2 ? 2 : v === 4 ? 4 : 1; } - // 데스크톱은 단일 그룹(탭 스택)으로 시작, 웹 데모는 4분할 시각화 - return window.schutz ? 1 : 4; + // 단일 그룹(탭 스택)으로 시작한다. 4분할은 데모 시각화 전용이었다. + return 1; })(), cliAgents: {}, cliBusy: false, cliModel: "", oauthPasteFor: null, oauthPasteVal: "", oauthWait: false, oauthMsg: "", oauthTick: 0, @@ -818,7 +816,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // ── 토스트 ── /** 토스트 전용 타이머. 탭 닫기와 같은 이유로 _timers 풀 밖에 둔다 — - * clearTimers()(startRun/stopRun)가 이 타이머를 지우면 토스트가 제거되지 않고 + * clearTimers()(stopRun)가 이 타이머를 지우면 토스트가 제거되지 않고 * 좀비로 남는다(leaving 상태로 opacity 0 인 채 마운트된 유령). */ private _toastTimers = new Map>(); @@ -1202,7 +1200,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { this._lastTabsRef = restored.tabs; this._lastActiveRef = restored.active; this._lastCollapsedRef = restored.collapsed; this.setState(s => ({ workspace: tree, leftTab: "tree", tabs: restored.tabs, active: restored.active, layout: restored.layout, messages: [], - docs: window.schutz ? {} : freshDocs(), files: [], plan: [], tools: [], chips: {}, + files: [], plan: [], tools: [], chips: {}, expanded: null, paneDirty: {}, statusKey: "idle", running: false, // 프로젝트마다 모드를 따로 기억한다 — 설정이 없으면 전역 기본값으로 떨어진다 uiMode: getUiMode(tree.root), @@ -2142,15 +2140,6 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { qt(fn: () => void, at: number) { this._timers.push(setTimeout(fn, at)); } clearTimers() { this._timers.forEach(clearTimeout); this._timers = []; } - updDoc(path: string, fn: (arr: DocLine[]) => DocLine[]) { - this.setState(s => ({ docs: { ...s.docs, [path]: fn(s.docs[path].slice()) } })); - } - insertAfter(path: string, afterId: string, ln: DocLine) { - this.updDoc(path, arr => { const i = arr.findIndex(l => l.id === afterId); arr.splice(i + 1, 0, ln); return arr; }); - } - patchLine(path: string, id: string, patch: Partial) { - this.updDoc(path, arr => arr.map(l => l.id === id ? { ...l, ...patch } : l)); - } setMsg(id: string, patch: Partial) { this.setState(s => ({ messages: s.messages.map(m => m.id === id ? { ...m, ...patch } : m) })); } @@ -2174,107 +2163,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { return { agents: { ...s.agents, [id]: { ...a, tin: a.tin + tin, tout: a.tout + tout } } }; }); } - addFile(path: string, add: number, del: number, agent: string) { - this.setState(s => ({ files: [...s.files, { path, add, del, agent, status: "pending" }] })); - } - - startRun(text: string) { - this.clearTimers(); - const speed = Math.max(0.2, TYPING_SPEED); - this.setState(s => ({ - running: true, statusKey: "thinking", - plan: [], tools: [], files: [], chips: {}, - // 현재 레이아웃 슬롯 수에 맞춰 구성 (4 하드코딩 제거) - docs: freshDocs(), - tabs: Array.from({ length: s.layout || 1 }, (_, i) => (i === 0 ? [TM] : [])), - active: Array.from({ length: s.layout || 1 }, (_, i) => (i === 0 ? TM : "")), - expanded: null, - agents: this.freshAgents(), - messages: [...s.messages, { id: "u" + (this._uid++), role: "user" as const, text }], - input: "", - })); - let at = 400; - const q = (d: number, fn: () => void) => { at += d; this.qt(fn, at); }; - - q(0, () => this.setAgent("claude", { status: "plan" })); - const aiId = "a" + (this._uid++); - const reply = "요청을 분석해 작업을 분배합니다. 제가 token-manager.ts의 스케줄러 구현을 맡고, GPT에 타입 정의를, Grok에 문서 갱신을 위임합니다. 세 파일은 락으로 격리되어 동시에 진행돼도 충돌하지 않습니다."; - q(500, () => this.setState(s => ({ messages: [...s.messages, { id: aiId, role: "ai" as const, who: "Claude · 관리자", text: "", streaming: true }] }))); - for (let i = 3; i <= reply.length + 2; i += 3) { - const cut = Math.min(i, reply.length); - q(22, () => this.setMsg(aiId, { text: reply.slice(0, cut) })); - } - q(50, () => { this.setMsg(aiId, { streaming: false }); this.bumpAgent("claude", 2140, 96); }); - - const planItems = [ - { label: "TokenManager 구조 파악", agent: "claude" }, - { label: "자동 갱신 스케줄러 구현", agent: "claude" }, - { label: "옵션 타입 정의 반영", agent: "gpt" }, - { label: "문서 갱신", agent: "grok" }, - ]; - q(250, () => this.setState({ plan: planItems.map((p, i) => ({ id: "p" + i, ...p, st: i === 0 ? "active" as const : "pending" as const })) })); - - q(350, () => { this.setState({ statusKey: "tool" }); this.setAgent("claude", { status: "edit", file: TM }); this.addTool("t1", "claude", "읽기", TM); }); - q(600, () => { this.setTool("t1", { st: "done", note: "2.1 KB" }); this.bumpAgent("claude", 5240, 0); }); - q(250, () => this.addTool("t2", "claude", "읽기", TY)); - q(450, () => { this.setTool("t2", { st: "done", note: "0.6 KB" }); this.bumpAgent("claude", 1830, 0); this.setPlan(0, "done"); }); - - // 위임: 페인 분할·락·동시 타이핑 체인 - q(400, () => { - this.setState({ tabs: [[TM], [TY], [MD], []], active: [TM, TY, MD, ""] }); - this.setPlan(1, "active"); this.setPlan(2, "active"); this.setPlan(3, "active"); - this.setAgent("gpt", { status: "edit", file: TY }); - this.setAgent("grok", { status: "edit", file: MD }); - this.addTool("t3", "claude", "편집", TM); - this.addTool("t4", "gpt", "편집", TY); - this.addTool("t5", "grok", "편집", MD); - }); - - const defs = hunkDefs(); - const typeChain = (cur: { at: number }, hk: string) => { - const def = defs[hk]; - const cq = (d: number, fn: () => void) => { cur.at += d; this.qt(fn, cur.at); }; - const cqa = (d: number, fn: () => void) => { this.qt(fn, cur.at + d); }; - if (def.removeId) cq(140, () => this.patchLine(def.path, def.removeId!, { kind: "removed", hunk: hk })); - let prev = def.afterId; - const ids: string[] = []; - def.lines.forEach((full, li) => { - const id = hk + "n" + li; - ids.push(id); - const pid = prev; - cq(70, () => this.insertAfter(def.path, pid, { id, text: "", full, kind: "typing", hunk: hk })); - for (let c = 2; c < full.length + 2; c += 3) { - const cut = Math.min(c, full.length); - cq(Math.round(26 / speed), () => this.patchLine(def.path, id, { text: full.slice(0, cut) })); - if (c % 12 === 2) cqa(0, () => this.bumpAgent(def.agent, 0, 9)); - } - cq(20, () => this.patchLine(def.path, id, { text: full, kind: "fresh" })); - prev = id; - }); - if (def.chip && SHOW_REASONS) { - cq(150, () => this.setState(s => ({ chips: { ...s.chips, [hk]: { text: def.chip, op: 1 } } }))); - cqa(3200, () => this.setState(s => s.chips[hk] ? { chips: { ...s.chips, [hk]: { ...s.chips[hk], op: 0 } } } as any : null)); - cqa(4400, () => this.setState(s => { const c = { ...s.chips }; delete c[hk]; return { chips: c }; })); - } - cq(600, () => this.updDoc(def.path, arr => arr.map(l => ids.includes(l.id) && l.kind === "fresh" ? { ...l, kind: "pending" } : l))); - }; - const t0 = at + 500; - const curC = { at: t0 }, curG = { at: t0 + 900 }, curK = { at: t0 + 1600 }; - typeChain(curC, "A"); typeChain(curC, "C"); typeChain(curC, "B"); - this.qt(() => { this.setTool("t3", { st: "done", note: "+13 −1" }); this.addFile(TM, 13, 1, "claude"); this.setPlan(1, "done"); this.setAgent("claude", { status: "review", file: null }); this.bumpAgent("claude", 0, 262); }, curC.at += 250); - typeChain(curG, "T"); - this.qt(() => { this.setTool("t4", { st: "done", note: "+4 −0" }); this.addFile(TY, 4, 0, "gpt"); this.setPlan(2, "done"); this.setAgent("gpt", { status: "review", file: null }); this.bumpAgent("gpt", 1620, 96); }, curG.at += 250); - typeChain(curK, "D"); - this.qt(() => { this.setTool("t5", { st: "done", note: "+6 −0" }); this.addFile(MD, 6, 0, "grok"); this.setPlan(3, "done"); this.setAgent("grok", { status: "review", file: null }); this.bumpAgent("grok", 980, 118); }, curK.at += 250); - - const endAt = Math.max(curC.at, curG.at, curK.at) + 500; - const doneId = "a" + (this._uid++); - this.qt(() => this.setState(s => ({ - running: false, statusKey: "review", - messages: [...s.messages, { id: doneId, role: "ai" as const, who: "Claude · 관리자", text: "세 에이전트의 작업이 모두 끝났습니다. 3개 파일 +23 −1, 충돌 없음. 변경 검토에서 파일별 diff를 확인하고 처리해 주세요." }], - })), endAt); - } /** 특정 에이전트만 중지 */ stopAgent(id: string) { @@ -2311,48 +2200,16 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { running: false, statusKey: "stopped", plan: stopPlan(s.plan), tools: s.tools.map(t => t.st === "run" ? { ...t, st: "stopped" as const, note: "중지" } : t), - docs: Object.fromEntries(Object.entries(s.docs).map(([k, arr]) => [k, arr.map(l => (l.kind === "typing" || l.kind === "fresh") ? { ...l, kind: "pending" as const } : l)])), messages: s.messages.map(m => m.streaming ? { ...m, streaming: false } : m), agents: Object.fromEntries(Object.entries(s.agents).map(([k, a]) => [k, (a.status === "edit" || a.status === "plan") ? { ...a, status: "stop" as const, file: null } : a])), })); + // 검토할 것이 남았는지는 제안 목록이 안다 — 예전엔 데모 문서의 줄 상태를 세어 판정했다. this.qt(() => this.setState(s => { if (s.statusKey !== "stopped") return null; - const any = Object.values(s.docs).some(arr => arr.some(l => l.kind === "pending" || l.kind === "removed")); - return { statusKey: any ? "review" : "idle" } as any; + return { statusKey: s.proposals.some(p => p.status === "pending") ? "review" : "idle" } as any; }), 1800); } - resolveHunk(path: string, hk: string, accept: boolean) { - this.updDoc(path, arr => { - const out: DocLine[] = []; - for (const l of arr) { - if (l.hunk !== hk) { out.push(l); continue; } - if (l.kind === "removed") { - if (accept) continue; - out.push({ ...l, kind: "base", hunk: null }); - continue; - } - if (accept) out.push({ ...l, kind: "accepted" }); - } - return out; - }); - if (accept) this.qt(() => this.updDoc(path, arr => arr.map(l => l.hunk === hk ? { ...l, kind: "base", hunk: null } : l)), 900); - this.qt(() => this.setState(s => { - const doc = s.docs[path] || []; - const open = doc.some(l => l.hunk && (l.kind === "pending" || l.kind === "removed" || l.kind === "typing" || l.kind === "fresh")); - if (open) return null; - return { files: s.files.map(f => f.path === path && f.status === "pending" ? { ...f, status: (accept ? "accepted" : "rejected") as ReviewFile["status"] } : f) } as any; - }), 40); - } - - openHunks(path: string): string[] { - const doc = this.state.docs[path] || []; - const set = new Set(); - doc.forEach(l => { if (l.hunk && (l.kind === "pending" || l.kind === "removed")) set.add(l.hunk); }); - return [...set]; - } - resolveFile(path: string, accept: boolean) { this.openHunks(path).forEach(hk => this.resolveHunk(path, hk, accept)); } - resolveAll(accept: boolean) { this.state.files.filter(f => f.status === "pending").forEach(f => this.resolveFile(f.path, accept)); } /** 파일을 포커스된 슬롯의 탭으로 연다 (이미 어느 슬롯에 열려 있으면 그 슬롯을 활성화) */ /** 편집 위치 기록. "방금 어디 있었더라" 를 되짚는 길이 없어서, 정의로 점프하고 나면 @@ -2765,7 +2622,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { this._closedTabs = [...this._closedTabs.filter(c => c.rel !== rel), { slot, rel }].slice(-20); } // 탭을 곧바로 제거하지 않고 szTabOut 재생 후 언마운트. - // 전용 타이머 — clearTimers()(startRun/stopRun) 가 이 닫기 타이머를 지워 좀비탭(닫힘 상태 영구 고착)이 되지 않도록 _timers 풀 밖에 둔다. + // 전용 타이머 — clearTimers()(stopRun) 가 이 닫기 타이머를 지워 좀비탭(닫힘 상태 영구 고착)이 되지 않도록 _timers 풀 밖에 둔다. this.setState(s => ({ closingTabs: [...s.closingTabs, key] })); const tid = setTimeout(() => { this._closeTabTimers.delete(key); @@ -3061,10 +2918,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // runReal/runCliTurn 이 조용히 return 해서, 사용자가 모아둔 첨부가 통째로 날아갔다. if (this.state.running || this.state.cliBusy) { this.toast("info", t2("chat.busyHint")); return; } const hasAttach = this.state.attach.length > 0; - // 데스크톱: 빈 입력 + 첨부 없음이면 아무것도 보내지 않는다 (데모 프롬프트가 실제 AI로 나가는 것 방지). - // 데모 문자열은 웹 프리뷰 자동재생 전용. - if (!rawIn && !hasAttach && window.schutz) return; - const t = rawIn || (hasAttach ? "첨부한 컨텍스트를 참고해서 진행해줘." : "TokenManager에 자동 갱신을 추가하고, 타입과 문서도 같이 맞춰줘"); + // 빈 입력 + 첨부 없음이면 아무것도 보내지 않는다. + if (!rawIn && !hasAttach) return; + const t = rawIn || "첨부한 컨텍스트를 참고해서 진행해줘."; const { block, summary, images } = await this.consumeAttachments(); const display = summary ? t + "\n📎 " + summary : t; // 1순위: 앱 내 연결된 계정(OAuth) 또는 API 키 — Schutz 통합 에이전트 루프 @@ -3075,8 +2931,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (ca.claude?.ok) { this.runCliTurn("claude", t + block); return; } if (ca.codex?.ok) { this.runCliTurn("codex", t + block); return; } } - // 데스크톱 앱: 데모 대신 연결 안내 (데모는 웹 프리뷰 전용) - if (window.schutz) { + // 붙은 에이전트가 없다 — 무엇을 해야 하는지 말해 준다. + { this.setState(s => ({ input: "", messages: [...s.messages, @@ -3085,9 +2941,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // (이 문구가 마지막까지 한국어로 굳어 있던 이유가 그거였다.) { id: "a" + (this._uid++), role: "ai" as const, who: "Schutz", text: t2("sc3.noAiConnected") }], }), () => this.saveSession()); - return; } - this.startRun(t); } /** 설정된 프로바이더 id 목록 */ @@ -3301,8 +3155,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (!ws || !window.schutz) return "오류: 워크스페이스가 열려 있지 않습니다."; const toolId = "rt" + (this._uid++); try { - // 계획 패널을 채우는 유일한 실제 경로. 이게 없던 동안 패널은 데모 스크립트 - // (startRun)에서만 찼고, 실제 AI 가 붙으면 영원히 빈 칸이었다 — README 가 + // 계획 패널을 채우는 유일한 경로. 예전엔 브라우저 프리뷰의 데모 스크립트에서만 + // 찼고, 실제 AI 가 붙으면 영원히 빈 칸이었다 — README 가 // "지금 하는 일과 다음" 을 보여준다고 적어 둔 바로 그 패널이다. if (call.name === "set_plan") { const steps = normalizeSteps(call.input?.steps); @@ -5540,16 +5394,6 @@ ${(r.output || "").slice(0, 2000)}`; } this.onChatScroll(); // 버튼 노출은 스크롤 위치 하나로 판단 (도착·스크롤 공통) } - this.allOpen().forEach(path => { - const el = this._paneRefs[path]; - if (!el) return; - const doc = this.state.docs[path] || []; - const i = doc.findIndex(l => l.kind === "typing"); - if (i >= 0) { - const y = Math.max(0, i * 20 - el.clientHeight * 0.55); - if (Math.abs(el.scrollTop - y) > 10) el.scrollTop = y; - } - }); } /** 신택스 하이라이트 (프로토타입 hl 포팅) */ @@ -5585,35 +5429,6 @@ ${(r.output || "").slice(0, 2000)}`; return f ? this.agDef(f.agent).color : null; } - diffRows(path: string) { - const doc = this.state.docs[path] || []; - const marked = doc.map(l => !!(l.hunk && ["pending", "removed", "typing", "fresh", "accepted"].includes(l.kind))); - if (!marked.some(Boolean)) return []; - const include = doc.map((_, i) => { - for (let j = Math.max(0, i - 2); j <= Math.min(doc.length - 1, i + 2); j++) if (marked[j]) return true; - return false; - }); - const rows: any[] = []; - let oldN = 0, newN = 0, prevIncluded = true; - doc.forEach((l, i) => { - const isOld = l.kind === "base" || l.kind === "removed"; - const isNew = l.kind !== "removed"; - if (isOld) oldN++; - if (isNew) newN++; - if (!include[i]) { prevIncluded = false; return; } - if (!prevIncluded && rows.length) rows.push({ key: "sep" + i, sep: true }); - prevIncluded = true; - let sign = " ", bg = "transparent", color = "var(--fg-sub2)", signColor = "transparent"; - if (l.kind === "removed") { sign = "−"; bg = "rgba(201,123,123,.1)"; color = "var(--err)"; signColor = "var(--err-hi)"; } - else if (marked[i]) { sign = "+"; bg = "color-mix(in srgb, var(--ok) 9%, transparent)"; color = "#B7CBBA"; signColor = "var(--ok)"; } - rows.push({ - key: "d" + i, sep: false, - oldN: isOld ? String(oldN) : "", newN: isNew ? String(newN) : "", - sign, signColor, bg, color, text: l.text || " ", - }); - }); - return rows; - } render() { const s = this.state; @@ -6514,8 +6329,8 @@ ${(r.output || "").slice(0, 2000)}`; renderTree() { const s = this.state; - // 데스크톱 앱 + 워크스페이스 없음 → 빈 상태 (데모 트리는 웹 프리뷰 전용) - if (window.schutz && !s.workspace) { + // 아직 프로젝트를 안 열었다 + if (!s.workspace) { return (
{t("flowtree.noProject")} @@ -6524,8 +6339,7 @@ ${(r.output || "").slice(0, 2000)}`;
); } - // 실제 워크스페이스가 열려 있으면 실파일 트리 - if (s.workspace) { + { const ws = s.workspace; const collapsed = s.collapsed; // 접힘 판정 — 항목의 조상 경로만 검사(O(depth))해 O(N·M) 전체 스캔 회피 @@ -6606,53 +6420,6 @@ ${(r.output || "").slice(0, 2000)}`; ); } - const lockOf = (path: string) => AGDEF.find(d => s.agents[d.id].file === path); - const pendOf = (path: string) => s.files.find(f => f.path === path && f.status === "pending"); - const fileRow = (path: string, name: string, pad: number) => { - const lock = lockOf(path); - const pend = pendOf(path); - const inPane = this.isOpen(path, s); - return ( -
this.openFile(path)} - style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: `0 16px 0 ${pad}px`, cursor: "pointer", background: inPane ? "rgba(125,145,131,.08)" : "transparent" }}> - - {name} -
- {lock && ( - - {lock.name} - - )} - {!lock && pend && } -
- ); - }; - const dirRow = (key: string, name: string, pad: number) => ( -
- - {name} -
- ); - const plainRow = (key: string, name: string, pad: number) => ( -
- - {name} -
- ); - return ( -
-
SCHUTZ-CORE
- {dirRow("d1", "src", 16)} - {dirRow("d2", "auth", 30)} - {fileRow(TM, "token-manager.ts", 44)} - {fileRow(TY, "types.ts", 44)} - {plainRow("f1", "client.ts", 44)} - {plainRow("f2", "index.ts", 44)} - {dirRow("d3", "docs", 16)} - {fileRow(MD, "auth.md", 30)} - {plainRow("f3", "package.json", 16)} -
- ); } /** 현재 탭에 보여줄 메시지. 레거시(agent 없음)는 전체 탭에서만 보인다 — @@ -7545,7 +7312,7 @@ ${(r.output || "").slice(0, 2000)}`; ); } const diffMeta = this.parseDiffKey(activeRel); - const realFile = !!(s.workspace && !s.docs[activeRel] && !diffMeta); + const realFile = !!(s.workspace && !diffMeta); const isImg = realFile && isImage(activeRel); const isMdPrev = realFile && activeRel.endsWith(".md") && !!s.mdPreview[activeRel]; const isReal = realFile && !isImg && !isMdPrev; @@ -7584,7 +7351,12 @@ ${(r.output || "").slice(0, 2000)}`; onToggleBreakpoint={this.toggleBreakpoint} gitVer={s.gitVer} /> - ) : this.renderDemoBody(activeRel)} + ) : ( +
+ + {t("misc.emptyEditorHint")} +
+ )}
); }); @@ -7738,63 +7510,6 @@ ${(r.output || "").slice(0, 2000)}`; ); } - /** 데모(웹 프리뷰)·에이전트 diff 문서 본문 렌더 */ - renderDemoBody(path: string) { - const s = this.state; - const lineColors = this._lineColors; - const doc = s.docs[path] || []; - const isMd = path.endsWith(".md"); - const agColor = this.agentColorFor(path) || "#7D9183"; - const hunkInfo: Record = {}; - doc.forEach(l => { - if (!l.hunk) return; - const h = hunkInfo[l.hunk] || (hunkInfo[l.hunk] = { active: false, open: false }); - if (l.kind === "typing" || l.kind === "fresh") h.active = true; - if (l.kind === "pending" || l.kind === "removed") h.open = true; - }); - const headSeen: Record = {}; - let num = 0; - return ( -
-
{ this._paneRefs[path] = el; }} style={{ position: "absolute", inset: 0, overflow: "auto", padding: "10px 0 50px", fontFamily: MONO }}> - {doc.map(l => { - const removed = l.kind === "removed"; - if (!removed) num++; - const colorDef = lineColors[l.kind] || lineColors.base; - const rowBg = colorDef[0]; - const barColor = (l.kind === "typing" || l.kind === "pending") ? agColor : colorDef[1]; - const marked = ["pending", "removed", "fresh", "typing"].includes(l.kind); - const isHead = l.hunk && marked && !headSeen[l.hunk]; - if (isHead) headSeen[l.hunk!] = true; - const chip = isHead && s.chips[l.hunk!] ? s.chips[l.hunk!] : null; - const info = l.hunk ? hunkInfo[l.hunk] : null; - const actions = !!(isHead && info && info.open && !info.active); - const hk = l.hunk!; - return ( -
-
{removed ? "" : String(num)}
-
-
- {this.hl(l.text, isMd)} - {l.kind === "typing" && } - {chip && ( - {chip.text} - )} -
- {actions && ( -
- -
- -
- )} -
- ); - })} -
-
- ); - } // ── 우 패널: 에이전트 ── renderAgents() { @@ -8352,86 +8067,9 @@ ${(r.output || "").slice(0, 2000)}`; // ── 우 패널: 변경 검토 ── renderReview() { - const s = this.state; - if (s.workspace || window.schutz) return this.renderProposals(); - const fstMap: Record = { pending: [t("sc5.reviewPending"), "var(--warn)"], accepted: [t("sc5.reviewAccepted"), "var(--ok)"], rejected: [t("sc5.reviewRejected"), "var(--err)"] }; - const pendingFiles = s.files.filter(f => f.status === "pending").length; - return ( -
-
this.setState(st => ({ reviewOpen: !st.reviewOpen }))} - style={{ flex: "none", height: 36, display: "flex", alignItems: "center", gap: 8, padding: "0 16px", cursor: "pointer", userSelect: "none" }}> - {s.reviewOpen ? "▾" : "▸"} - {t("agent.review")} - {s.files.length > 0 && {s.files.length}} -
- {s.reviewOpen && ( -
- {s.files.length === 0 &&
{t("sc5.reviewEmpty")}
} - {pendingFiles > 0 && ( -
- - -
- )} - {s.files.map(f => { - const [sl, sc] = fstMap[f.status]; - const d = this.agDef(f.agent); - const seg = f.path.split("/"); const name = seg.pop(); - const tot = Math.max(f.add + f.del, 1); - const expanded = s.expanded === f.path; - return ( -
- -
this.setState(st => ({ expanded: st.expanded === f.path ? null : f.path }))} style={{ padding: "11px 13px 11px 16px", cursor: "pointer" }}> -
- {expanded ? "▾" : "▸"} - {name} - {seg.join("/") + "/"} -
- {d.name} -
-
- +{f.add} - −{f.del} -
- - - -
- {sl} -
-
- {expanded && ( -
- {this.diffRows(f.path).map(dd => dd.sep - ?
· · ·
- : ( -
- {dd.oldN} - {dd.newN} - {dd.sign} - {dd.text} -
- ))} -
- -
- {f.status === "pending" && ( - <> - - - - )} -
-
- )} -
- ); - })} -
- )} -
- ); + // 예전엔 이 아래로 데모용 파일 카드 격자가 80여 줄 더 있었다. 첫 줄에서 늘 + // 갈라져 나가 **데스크톱에서는 한 번도 그려지지 않는** 코드였다. + return this.renderProposals(); } // ── 터미널 독 (xterm 멀티 탭 + AI 로그) ── diff --git a/ide/src/ide/data.ts b/ide/src/ide/data.ts index 29f0715..1c056d5 100644 --- a/ide/src/ide/data.ts +++ b/ide/src/ide/data.ts @@ -1,11 +1,7 @@ -/** Schutz IDE — 상태 모델·데모 데이터 (디자인 핸드오프 프로토타입의 상태 모델을 그대로 포팅) */ +/** Schutz IDE — 상태 모델과 정적 표(에이전트 정의·메뉴·프로젝트 목록). */ import { t } from "../i18n"; -export const TM = "src/auth/token-manager.ts"; -export const TY = "src/auth/types.ts"; -export const MD = "docs/auth.md"; - export interface AgentDef { id: string; name: string; @@ -21,16 +17,6 @@ export const AGDEF: AgentDef[] = [ { id: "glm", name: "GLM", model: "4.6", mgr: false, color: "#A99BC0" }, ]; -export type LineKind = "base" | "typing" | "fresh" | "pending" | "removed" | "accepted"; - -export interface DocLine { - id: string; - text: string; - full?: string; - kind: LineKind; - hunk: string | null; -} - export interface AgentState { status: "idle" | "plan" | "edit" | "review" | "stop"; file: string | null; @@ -79,128 +65,6 @@ export interface ChatMsg { streaming?: boolean; } -export interface HunkDef { - path: string; - agent: string; - afterId: string; - removeId?: string; - lines: string[]; - chip: string; -} - -const mk = (pref: string, arr: string[]): DocLine[] => - arr.map((t, i) => ({ id: pref + i, text: t, kind: "base", hunk: null })); - -export function freshDocs(): Record { - return { - [TM]: mk("tm", [ - 'import { AuthClient } from "./client";', - 'import type { TokenPair, TokenManagerOptions } from "./types";', - "", - "/** Manages access/refresh token lifecycle. */", - "export class TokenManager {", - " private tokens: TokenPair | null = null;", - "", - " constructor(", - " private readonly client: AuthClient,", - " private readonly options: TokenManagerOptions = {},", - " ) {}", - "", - " async getAccessToken(): Promise {", - " if (!this.tokens || this.isExpired()) {", - " this.tokens = await this.client.refresh();", - " }", - " return this.tokens.accessToken;", - " }", - "", - " private isExpired(): boolean {", - " return this.tokens !== null && Date.now() >= this.tokens.expiresAt;", - " }", - "}", - ]), - [TY]: mk("ty", [ - "export interface TokenPair {", - " accessToken: string;", - " refreshToken: string;", - " expiresAt: number;", - "}", - "", - "export interface TokenManagerOptions {", - " storage?: TokenStorage;", - "}", - ]), - [MD]: mk("md", [ - "# Auth", - "", - "## TokenManager", - "", - "액세스/리프레시 토큰의 수명 주기를 관리합니다.", - "", - "### Options", - "", - "| Option | Description |", - "| ------ | ----------- |", - "| `storage` | 커스텀 토큰 저장소 백엔드 |", - ]), - }; -} - -export function hunkDefs(): Record { - return { - A: { - path: TM, agent: "claude", afterId: "tm5", - lines: [" private refreshTimer: ReturnType | null = null;"], - chip: t("data.chipAddRefreshTimer"), - }, - C: { - path: TM, agent: "claude", removeId: "tm14", afterId: "tm14", - lines: [ - " this.tokens = await this.client.refresh();", - " this.scheduleRefresh();", - ], - chip: t("data.chipWireRefreshSchedule"), - }, - B: { - path: TM, agent: "claude", afterId: "tm17", - lines: [ - "", - " /**", - " * Schedules a token refresh shortly before expiry.", - " * Threshold: `options.refreshThreshold` (default 60_000 ms).", - " */", - " private scheduleRefresh(): void {", - " const threshold = this.options.refreshThreshold ?? 60_000;", - " const delay = this.tokens!.expiresAt - Date.now() - threshold;", - " this.refreshTimer = setTimeout(() => void this.getAccessToken(), Math.max(delay, 0));", - " }", - ], - chip: t("data.chipAutoRefreshScheduler"), - }, - T: { - path: TY, agent: "gpt", afterId: "ty7", - lines: [ - " /** Ms before expiry to trigger auto refresh. Default: 60_000. */", - " refreshThreshold?: number;", - " /** Invoked after every successful refresh. */", - " onRefresh?: (tokens: TokenPair) => void;", - ], - chip: t("data.chipExtendOptionsType"), - }, - D: { - path: MD, agent: "grok", afterId: "md10", - lines: [ - "| `refreshThreshold` | 만료 전 자동 갱신 시점(ms), 기본 60초 |", - "| `onRefresh` | 갱신 성공 시 호출되는 콜백 |", - "", - "### Auto refresh", - "", - "토큰은 만료 60초 전에 자동으로 갱신됩니다.", - ], - chip: t("data.chipUpdateDocs"), - }, - }; -} - /** 무엇 위에 서 있는지. 정보 창과 오프닝이 **같은 문자열**을 쓴다 — * 두 군데에 적어두면 하나만 고치고 다른 하나가 옛말이 된다. */ export const ENGINE_CREDIT = "Electron · Monaco · React"; From 8add282ef51516b804625234744499a17b9e12c8 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 3 Aug 2026 09:54:37 +0900 Subject: [PATCH 05/52] =?UTF-8?q?=EC=9D=B4=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A5=BC=20=EC=8B=A4=ED=96=89=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(Ctrl+F5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 단위 실행(package.json 스크립트)과 파이썬 디버그는 있는데, 파일 하나를 그냥 돌려보는 길이 없었다. .c 하나를 확인하려고 터미널을 열어 gcc 를 직접 쳐야 했다. **출력 패널이 아니라 진짜 터미널에서 돈다.** Code Runner 류의 가장 흔한 불만이 출력 채널에는 표준입력이 없어 input() 이 멈추는 것인데, 여기서는 작업 실행이 쓰는 그 PTY 를 그대로 쓴다 — 입력·색·Ctrl+C 가 공짜로 따라온다. - 없는 도구는 셸 오류를 그대로 토해내지 않는다. 먼저 확인하고 "gcc 을(를) 찾지 못했습니다" 라고 말한다. 없는 걸 눌렀을 때의 경험이 있는 걸 눌렀을 때만큼 중요하다. - 미저장이면 저장하고 실행한다. 안 그러면 방금 고친 게 아니라 옛 파일이 돈다. - 컴파일 산출물은 임시 폴더에 만든다. 프로젝트 폴더에 두면 트리에 뜨고 커밋에 섞인다. - 모르는 확장자는 명령을 지어내지 않고 모른다고 말한다. - 자동 실행은 없다. 남이 준 저장소를 열어보는 일이 흔하고, 키 하나로 임의 코드가 도는 것과 폴더를 여는 것만으로 도는 것은 전혀 다른 이야기다. - F5 는 디버그다. 실행은 VS Code 관례대로 Ctrl+F5. 기본 표는 gcc·python 처럼 가장 흔한 이름을 쓴다. clang 을 쓰거나 python3 여야 하는 환경에서 기능을 통째로 못 쓰게 되는 대신 설정에서 언어별로 명령을 갈아끼운다. 명령 조립은 engine/runFile.ts 에 순수 함수로 뒀다 — 공백 있는 경로, 산출물 이름, 셸별 따옴표가 조용히 틀리는 자리라서 테스트가 붙을 곳에 둔다. --- ide/electron/main.cjs | 17 +++++ ide/electron/preload.cjs | 3 + ide/src/App.tsx | 62 +++++++++++++++++ ide/src/engine/runFile.test.ts | 124 +++++++++++++++++++++++++++++++++ ide/src/engine/runFile.ts | 109 +++++++++++++++++++++++++++++ ide/src/i18n/dict/key.ts | 1 + ide/src/i18n/dict/runfile.ts | 10 +++ ide/src/i18n/messages.ts | 3 +- ide/src/keymap.ts | 4 +- ide/src/schutz.d.ts | 3 + ide/src/settings.ts | 35 ++++++++++ 11 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 ide/src/engine/runFile.test.ts create mode 100644 ide/src/engine/runFile.ts create mode 100644 ide/src/i18n/dict/runfile.ts diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index 8b9b589..ceee6fe 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -796,6 +796,23 @@ const runProcs = new Map(); // id → child const RUN_TIMEOUT_MS = 120_000; const RUN_OUTPUT_CAP = 20_000; // 모델 컨텍스트를 잡아먹지 않도록 +/** 실행에 필요한 도구가 있는지, 그리고 임시 폴더가 어디인지. + * 없는 도구를 눌렀을 때 셸 오류를 그대로 토해내는 대신 "gcc 를 못 찾았습니다" 라고 + * 말하려면 미리 알아야 한다. 실행이 아니라 조회라 승인 게이트를 태우지 않는다. */ +ipcMain.handle("schutz:whichTool", async (_e, name) => { + const n = String(name || "").trim(); + if (!n || !/^[A-Za-z0-9_.+-]+$/.test(n)) return { ok: false }; // 셸로 넘어가는 값이라 좁게 막는다 + const { execFile } = require("child_process"); + const win = process.platform === "win32"; + return await new Promise((res) => { + execFile(win ? "where" : "which", [n], { timeout: 4000, windowsHide: true }, (err, out) => { + const path = String(out || "").trim().split(String.fromCharCode(10))[0].trim(); + res(err || !path ? { ok: false } : { ok: true, path }); + }); + }); +}); + +ipcMain.handle("schutz:tmpDir", () => require("os").tmpdir()); ipcMain.handle("schutz:runCommand", async (e, opts) => { const root = String(opts?.cwd || ""); const command = String(opts?.command || "").trim(); diff --git a/ide/electron/preload.cjs b/ide/electron/preload.cjs index bc73541..0d6a818 100644 --- a/ide/electron/preload.cjs +++ b/ide/electron/preload.cjs @@ -160,6 +160,9 @@ contextBridge.exposeInMainWorld("schutz", { /** 에이전트 명령 실행 (워크스페이스 안, 타임아웃·출력 상한 있음) */ runCommand: (opts) => ipcRenderer.invoke("schutz:runCommand", opts), + /** 실행 도구가 PATH 에 있는지 — 없는 걸 눌렀을 때 셸 오류 대신 안내를 하려고. */ + whichTool: (name) => ipcRenderer.invoke("schutz:whichTool", name), + tmpDir: () => ipcRenderer.invoke("schutz:tmpDir"), runStop: (id) => ipcRenderer.send("schutz:runStop", id), onRunOutput: (cb) => { const h = (_e, line) => cb(line); diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 0807a6a..2a04140 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -30,6 +30,8 @@ import { } from "./engine/checkpoints"; import { resolveRenameTarget, isMove } from "./engine/movePath"; import { applyProposal } from "./engine/editApply"; +import { planRun, langFor, LANGS as RUN_LANGS } from "./engine/runFile"; +import { getRunOverride, getRunOverrides, setRunOverride } from "./settings"; import { emptyNav, push as navPush, back as navBack, forward as navForward, current as navCurrent, dropMissing as navDropMissing, type NavState } from "./engine/navHistory"; import { shouldProbeQuota } from "./engine/quotaPoll"; import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction } from "./overlays"; @@ -1288,6 +1290,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { const cmds: Command[] = [ { id: "newFile", label: t("sc1.cmd_new_file"), hint: kb("file.new"), run: () => void this.newFileAt("") }, { id: "save", label: t("sc1.cmd_save"), hint: kb("file.save"), run: () => void this.saveActive() }, + { id: "runFile", label: t("runfile.cmdRun"), hint: kb("file.run"), run: () => void this.runActiveFile() }, { id: "saveAll", label: t("sc1.cmd_save_all"), hint: kb("file.saveAll"), run: () => void this.saveAll() }, { id: "settings", label: t("sc1.cmd_open_settings"), hint: kb("settings.open"), run: () => this.openO({ settingsOpen: true }) }, { id: "term", label: t("sc1.cmd_toggle_terminal"), hint: kb("terminal.toggle"), run: () => this.toggleTerm() }, @@ -1576,6 +1579,46 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { } catch { this.setState({ tasks: [] }); } } + /** 지금 열려 있는 파일 하나를 실행한다. + * + * 출력 패널이 아니라 **진짜 터미널**에서 돌린다. Code Runner 류가 출력 채널을 쓰다가 + * stdin 이 없어 input() 이 멈추는 게 가장 흔한 불만인데, 여기서는 작업 실행이 쓰는 + * 그 PTY 를 그대로 쓰므로 입력·색·Ctrl+C 가 공짜로 따라온다. + * + * 자동 실행은 없다 — 남이 준 저장소를 열어보는 일이 흔하고, 키 하나로 임의 코드가 + * 도는 것과 폴더를 여는 것만으로 도는 것은 전혀 다른 이야기다. */ + async runActiveFile() { + const ws = this.state.workspace; + const rel = this.state.active[this._focusSlot]; + if (!ws || !window.schutz || !rel || this.parseDiffKey(rel) || this.parsePreviewKey(rel)) { + this.toast("info", t("runfile.noFile")); return; + } + const lang = langFor(rel); + if (!lang) { this.toast("info", t("runfile.unsupported", { ext: rel.split(".").pop() ?? "" })); return; } + + // 저장하지 않은 채 실행하면 방금 고친 게 아니라 옛 파일이 돈다 — 조용히 저장한다. + if (this.isDirtyRel(rel)) { + this.toast("info", t("runfile.saveFirst")); + const pane = paneRegistry.panes.get(rel); + if (pane) await pane.save(); else await this.saveAllDirtyModels(true); + } + + // 없는 도구를 눌렀을 때 셸 오류를 그대로 토해내지 않는다. + try { + const found = await window.schutz.whichTool(lang.requires); + if (!found.ok) { this.toast("error", t("runfile.missingTool", { tool: lang.requires })); return; } + } catch { /* 조회 실패는 막지 않는다 — 실행해 보고 셸이 말하게 둔다 */ } + + const tmp = await window.schutz.tmpDir().catch(() => ""); + // 루트가 역슬래시를 쓰면(윈도) 이어 붙이는 쪽도 맞춘다 — copyPath 와 같은 규칙이다. + const win = ws.root.includes("\\"); + const abs = win ? ws.root.replace(/[\\/]+$/, "") + "\\" + rel.replace(/\//g, "\\") + : ws.root.replace(/\/+$/, "") + "/" + rel; + const r = planRun({ absFile: abs, platform: win ? "win32" : "posix", tmpDir: tmp, override: getRunOverride(rel) }); + if (r.ok === false) { this.toast("info", t("runfile.unsupported", { ext: r.ext })); return; } + this.addTerm(r.plan.command); + } + /** 작업 실행 — 새 터미널에서 `npm run `. 터미널에 남으므로 출력·중지가 그대로 된다. */ runTask(name: string) { this.addTerm(`npm run ${name}`); @@ -4810,6 +4853,8 @@ ${(r.output || "").slice(0, 2000)}`; case "nav.back": prevent(); this.navGo(-1); return; case "nav.forward": prevent(); this.navGo(1); return; + case "file.run": prevent(); void this.runActiveFile(); return; + // 글자 크기 — 설정 모달을 열지 않고. wrap·minimap 팔레트 명령과 같은 경로다. case "editor.fontUp": prevent(); this.bumpFontSize(1); return; case "editor.fontDown": prevent(); this.bumpFontSize(-1); return; @@ -10648,6 +10693,23 @@ ${(r.output || "").slice(0, 2000)}`;
{t("settings.editorNote")}
+ {/* ── 실행 명령 ── 기본표는 gcc·python 같은 가장 흔한 이름을 쓴다. clang 을 쓰거나 + python3 여야 하는 환경에서 기능을 통째로 못 쓰게 되는 대신 여기서 갈아끼운다. */} +
+
{t("runfile.settingsTitle")}
+
{t("runfile.settingsNote")}
+
+ {RUN_LANGS.map(l => ( +
+ {l.label} + { setRunOverride(l.ext[0], e.target.value); this.forceUpdate(); }} + style={{ flex: 1, minWidth: 0, height: 26, background: "var(--bg-root)", border: "1px solid var(--w10)", borderRadius: 6, padding: "0 9px", color: "var(--fg)", fontFamily: MONO, fontSize: 11, outline: "none" }} /> +
+ ))} +
+
+ {/* ── 테마 ── */}
{t("settings.theme")}
diff --git a/ide/src/engine/runFile.test.ts b/ide/src/engine/runFile.test.ts new file mode 100644 index 0000000..c868d05 --- /dev/null +++ b/ide/src/engine/runFile.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { planRun, langFor, extOf, quote, artifactPath, LANGS } from "./runFile"; + +const WIN = { platform: "win32" as const, tmpDir: "C:\\Temp" }; +const NIX = { platform: "posix" as const, tmpDir: "/tmp" }; + +describe("확장자 판별", () => { + it("경로에서 확장자만 뽑는다", () => { + expect(extOf("src/a.ts")).toBe("ts"); + expect(extOf("C:\\x\\y\\main.CPP")).toBe("cpp"); + expect(extOf("Makefile")).toBe(""); + expect(extOf(".gitignore")).toBe(""); // 앞의 점은 확장자가 아니다 + expect(extOf("a.b.c.py")).toBe("py"); + }); + + it("아는 언어를 찾는다", () => { + expect(langFor("x.c")?.label).toBe("C"); + expect(langFor("x.cxx")?.label).toBe("C++"); + expect(langFor("x.mjs")?.label).toBe("Node"); + expect(langFor("x.txt")).toBeNull(); + }); + + it("한 확장자가 두 언어에 걸리지 않는다", () => { + const all = LANGS.flatMap(l => l.ext); + expect(new Set(all).size).toBe(all.length); + }); +}); + +describe("따옴표 — 공백 있는 경로가 조용히 깨지던 자리", () => { + it("Windows 는 큰따옴표", () => { + expect(quote("C:\\Program Files\\a.c", "win32")).toBe('"C:\\Program Files\\a.c"'); + }); + it("POSIX 는 작은따옴표", () => { + expect(quote("/home/my code/a.c", "posix")).toBe("'/home/my code/a.c'"); + }); + it("POSIX 경로 안의 작은따옴표를 탈출시킨다", () => { + expect(quote("/tmp/it's.c", "posix")).toBe("'/tmp/it'\\''s.c'"); + }); + it("Windows 경로 안의 큰따옴표를 겹쳐 쓴다", () => { + expect(quote('C:\\a"b.c', "win32")).toBe('"C:\\a""b.c"'); + }); +}); + +describe("산출물 경로", () => { + it("프로젝트 폴더가 아니라 임시 폴더에 만든다", () => { + expect(artifactPath("/proj/src/main.c", "/tmp", "posix")).toBe("/tmp/schutz-run-main"); + }); + it("Windows 는 .exe 를 붙이고 역슬래시를 쓴다", () => { + expect(artifactPath("C:\\p\\main.c", "C:\\Temp", "win32")).toBe("C:\\Temp\\schutz-run-main.exe"); + }); + it("tmpDir 끝의 구분자를 두 번 넣지 않는다", () => { + expect(artifactPath("/p/a.c", "/tmp/", "posix")).toBe("/tmp/schutz-run-a"); + }); + it("이름에 쓸 수 없는 글자는 밑줄로 — 한글·공백 파일명이 흔하다", () => { + // 공백도 대상이다 — 산출물 이름에 공백이 남으면 && 뒤의 실행이 다시 깨진다 + expect(artifactPath("/p/내 코드.c", "/tmp", "posix")).toBe("/tmp/schutz-run-____"); + }); +}); + +describe("planRun", () => { + it("스크립트 언어는 한 줄로 끝난다", () => { + const r = planRun({ absFile: "/p/a.py", ...NIX }); + expect(r).toMatchObject({ ok: true }); + if (r.ok) { + expect(r.plan.command).toBe("python '/p/a.py'"); + expect(r.plan.artifact).toBeNull(); + expect(r.plan.spec.requires).toBe("python"); + } + }); + + it("컴파일 언어는 빌드하고 바로 실행한다", () => { + const r = planRun({ absFile: "/p/main.c", ...NIX }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.plan.command).toBe("gcc '/p/main.c' -o '/tmp/schutz-run-main' && '/tmp/schutz-run-main'"); + expect(r.plan.artifact).toBe("/tmp/schutz-run-main"); + expect(r.plan.spec.compiled).toBe(true); + } + }); + + it("공백 있는 경로가 통째로 따옴표 안에 들어간다", () => { + const r = planRun({ absFile: "C:\\Program Files\\my app\\main.cpp", ...WIN }); + if (!r.ok) throw new Error("계획을 못 세움"); + expect(r.plan.command).toBe( + 'g++ "C:\\Program Files\\my app\\main.cpp" -o "C:\\Temp\\schutz-run-main.exe" && "C:\\Temp\\schutz-run-main.exe"', + ); + }); + + it("모르는 확장자는 거절한다 — 아무 명령이나 지어내지 않는다", () => { + const r = planRun({ absFile: "/p/notes.txt", ...NIX }); + expect(r).toEqual({ ok: false, reason: "unsupported", ext: "txt" }); + }); + + it("확장자가 아예 없어도 거절한다", () => { + expect(planRun({ absFile: "/p/Makefile", ...NIX })).toMatchObject({ ok: false, ext: "" }); + }); + + it("설정에서 덮어쓴 명령을 쓴다 — clang·python3 을 쓰는 사람이 있다", () => { + const r = planRun({ absFile: "/p/a.c", ...NIX, override: "clang ${file} -O2 -o ${out} && ${out}" }); + if (!r.ok) throw new Error("계획을 못 세움"); + expect(r.plan.command).toBe("clang '/p/a.c' -O2 -o '/tmp/schutz-run-a' && '/tmp/schutz-run-a'"); + }); + + it("덮어쓴 값이 공백뿐이면 기본으로 되돌아간다", () => { + const r = planRun({ absFile: "/p/a.py", ...NIX, override: " " }); + if (!r.ok) throw new Error("계획을 못 세움"); + expect(r.plan.command).toBe("python '/p/a.py'"); + }); + + it("같은 파일은 늘 같은 명령을 만든다 — 실행할 때마다 달라지면 못 믿는다", () => { + const a = planRun({ absFile: "/p/a.c", ...NIX }); + const b = planRun({ absFile: "/p/a.c", ...NIX }); + expect(a).toEqual(b); + }); + + it("모든 언어가 파일 경로를 실제로 쓴다 — 템플릿 오타 방지", () => { + for (const l of LANGS) { + const r = planRun({ absFile: "/p/x." + l.ext[0], ...NIX }); + if (!r.ok) throw new Error(l.label + ": 계획 실패"); + expect(r.plan.command, l.label).toContain("'/p/x." + l.ext[0] + "'"); + if (l.compiled) expect(r.plan.command, l.label).toContain("schutz-run-x"); + } + }); +}); diff --git a/ide/src/engine/runFile.ts b/ide/src/engine/runFile.ts new file mode 100644 index 0000000..7828305 --- /dev/null +++ b/ide/src/engine/runFile.ts @@ -0,0 +1,109 @@ +/** + * "지금 이 파일 한 번 돌려봐" — 확장자에서 실행 명령을 만든다. + * + * 프로젝트 단위 실행(package.json 스크립트)과 파이썬 디버그는 이미 있는데, 파일 하나를 + * 그냥 돌려보는 길만 없었다. `.c` 하나를 확인하려고 터미널을 열어 gcc 를 직접 쳐야 했다. + * + * 여기서 조용히 틀리는 것은 늘 같은 셋이다: 공백 있는 경로, 산출물 이름, 그리고 + * 셸마다 다른 연결자. 그래서 문자열 조립만 떼어 테스트를 붙인다. 실행은 하지 않는다 — + * 이 모듈은 무엇을 칠지만 정한다. + */ + +export type Platform = "win32" | "posix"; + +export interface RunSpec { + /** 이 언어를 부르는 이름(UI 표시용). */ + label: string; + /** 있어야 실행되는 실행 파일. 없으면 "gcc 를 못 찾았다" 고 말해 줄 수 있다. */ + requires: string; + /** 컴파일이 필요한가 — 산출물을 만들고 지운다. */ + compiled: boolean; +} + +export interface RunPlan { + /** 셸에 그대로 넣을 한 줄. */ + command: string; + spec: RunSpec; + /** 컴파일 산출물의 경로(있으면). 정리 대상이자 실행 대상이다. */ + artifact: string | null; +} + +/** 확장자 → 어떻게 돌리나. 사용자가 설정에서 덮어쓸 수 있게 명령을 템플릿으로 둔다. + * `${file}` 은 파일 절대경로, `${out}` 은 산출물 경로로 바뀐다(둘 다 따옴표가 붙는다). */ +export interface LangDef { + ext: string[]; + label: string; + requires: string; + /** 컴파일 언어면 [빌드, 실행] 두 단계. 스크립트 언어면 한 단계. */ + template: string; + compiled: boolean; +} + +export const LANGS: LangDef[] = [ + { ext: ["py"], label: "Python", requires: "python", template: "python ${file}", compiled: false }, + { ext: ["js", "mjs", "cjs"], label: "Node", requires: "node", template: "node ${file}", compiled: false }, + { ext: ["ts"], label: "TypeScript", requires: "npx", template: "npx tsx ${file}", compiled: false }, + { ext: ["sh"], label: "Shell", requires: "bash", template: "bash ${file}", compiled: false }, + { ext: ["rb"], label: "Ruby", requires: "ruby", template: "ruby ${file}", compiled: false }, + { ext: ["go"], label: "Go", requires: "go", template: "go run ${file}", compiled: false }, + { ext: ["rs"], label: "Rust", requires: "rustc", template: "rustc ${file} -o ${out} && ${out}", compiled: true }, + { ext: ["c"], label: "C", requires: "gcc", template: "gcc ${file} -o ${out} && ${out}", compiled: true }, + { ext: ["cc", "cpp", "cxx"], label: "C++", requires: "g++", template: "g++ ${file} -o ${out} && ${out}", compiled: true }, + { ext: ["java"], label: "Java", requires: "java", template: "java ${file}", compiled: false }, +]; + +export function extOf(rel: string): string { + const base = rel.replace(/\\/g, "/").split("/").pop() ?? ""; + const i = base.lastIndexOf("."); + return i > 0 ? base.slice(i + 1).toLowerCase() : ""; +} + +export function langFor(rel: string): LangDef | null { + const e = extOf(rel); + return LANGS.find(l => l.ext.includes(e)) ?? null; +} + +/** 셸에 넣을 수 있게 감싼다. + * Windows(cmd)는 작은따옴표를 모르고, POSIX 는 작은따옴표 안이 가장 안전하다. */ +export function quote(p: string, platform: Platform): string { + if (platform === "win32") return '"' + p.replace(/"/g, '""') + '"'; + return "'" + p.replace(/'/g, "'\\''") + "'"; +} + +/** 컴파일 산출물 경로. 같은 폴더에 두면 프로젝트가 지저분해지고 트리에도 뜬다 — + * 임시 폴더에 파일명을 살려 넣는다(오류 메시지에서 무엇을 돌렸는지 읽히게). */ +export function artifactPath(absFile: string, tmpDir: string, platform: Platform): string { + const base = (absFile.replace(/\\/g, "/").split("/").pop() ?? "out").replace(/\.[^.]*$/, ""); + const safe = base.replace(/[^A-Za-z0-9_.-]/g, "_") || "out"; + const sep = platform === "win32" ? "\\" : "/"; + return tmpDir.replace(/[\\/]+$/, "") + sep + "schutz-run-" + safe + (platform === "win32" ? ".exe" : ""); +} + +export interface PlanInput { + /** 파일 절대 경로. */ + absFile: string; + platform: Platform; + tmpDir: string; + /** 설정에서 덮어쓴 템플릿(확장자별). 없으면 기본표. */ + override?: string | null; +} + +export type PlanResult = + | { ok: true; plan: RunPlan } + | { ok: false; reason: "unsupported"; ext: string }; + +export function planRun(i: PlanInput): PlanResult { + const lang = langFor(i.absFile); + if (!lang) return { ok: false, reason: "unsupported", ext: extOf(i.absFile) }; + + const artifact = lang.compiled ? artifactPath(i.absFile, i.tmpDir, i.platform) : null; + const tpl = (i.override && i.override.trim()) || lang.template; + const command = tpl + .replace(/\$\{file\}/g, quote(i.absFile, i.platform)) + .replace(/\$\{out\}/g, artifact ? quote(artifact, i.platform) : ""); + + return { + ok: true, + plan: { command, artifact, spec: { label: lang.label, requires: lang.requires, compiled: lang.compiled } }, + }; +} diff --git a/ide/src/i18n/dict/key.ts b/ide/src/i18n/dict/key.ts index bdb0e1c..6fd192a 100644 --- a/ide/src/i18n/dict/key.ts +++ b/ide/src/i18n/dict/key.ts @@ -12,6 +12,7 @@ export const dict: Record = { + "runfile.cmdRun": { ko: "이 파일 실행", en: "Run this file", de: "Diese Datei ausführen", ja: "このファイルを実行" }, + "runfile.noFile": { ko: "실행할 파일이 열려 있지 않습니다.", en: "No file is open to run.", de: "Keine Datei zum Ausführen geöffnet.", ja: "実行するファイルが開かれていません。" }, + "runfile.unsupported": { ko: "'.{ext}' 는 실행할 방법을 모릅니다. 설정에서 명령을 지정할 수 있습니다.", en: "Don't know how to run '.{ext}'. You can set a command in Settings.", de: "Für '.{ext}' ist kein Ausführungsbefehl bekannt. In den Einstellungen festlegbar.", ja: "'.{ext}' の実行方法が不明です。設定でコマンドを指定できます。" }, + "runfile.missingTool": { ko: "{tool} 을(를) 찾지 못했습니다. 설치하거나 PATH 에 추가한 뒤 다시 시도해 주세요.", en: "Could not find {tool}. Install it or add it to PATH, then try again.", de: "{tool} wurde nicht gefunden. Installieren oder zu PATH hinzufügen.", ja: "{tool} が見つかりません。インストールするか PATH に追加してください。" }, + "runfile.settingsTitle": { ko: "실행 명령", en: "Run commands", de: "Ausführungsbefehle", ja: "実行コマンド" }, + "runfile.settingsNote": { ko: "${file} 은 파일 경로, ${out} 은 빌드 산출물로 바뀝니다. 비워 두면 기본값을 씁니다.", en: "${file} becomes the file path, ${out} the build artifact. Leave empty to use the default.", de: "${file} wird zum Dateipfad, ${out} zum Build-Artefakt. Leer lassen für den Standard.", ja: "${file} はファイルパス、${out} はビルド成果物に置き換わります。空なら既定値。" }, + "runfile.saveFirst": { ko: "저장한 뒤 실행합니다.", en: "Saving before running.", de: "Wird vor dem Ausführen gespeichert.", ja: "保存してから実行します。" }, +}; diff --git a/ide/src/i18n/messages.ts b/ide/src/i18n/messages.ts index 2ada712..fbc8da5 100644 --- a/ide/src/i18n/messages.ts +++ b/ide/src/i18n/messages.ts @@ -12,6 +12,7 @@ import { dict as d_oai } from "./dict/oai"; import { dict as d_reg } from "./dict/reg"; import { dict as d_key } from "./dict/key"; import { dict as d_gitp } from "./dict/gitp"; +import { dict as d_runfile } from "./dict/runfile"; import { dict as d_flowtree } from "./dict/flowtree"; import { dict as d_dbg } from "./dict/dbg"; import { dict as d_mcpui } from "./dict/mcpui"; @@ -40,7 +41,7 @@ export type Msg = { ko: string; en: string; de: string; ja: string }; export const MESSAGES: Record = { ...d_dap, ...d_data, ...d_exth, ...d_mcpc, ...d_media, ...d_model, ...d_mono, ...d_oai, ...d_reg, ...d_key, - ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, + ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, ...d_sc1, ...d_sc2, ...d_sc3, ...d_sc4, ...d_sc5, ...d_eng, ...d_plug, ...d_review, ...d_cloud, // ── 공통 ──────────────────────────────────────────────── "common.next": { ko: "다음", en: "Next", de: "Weiter", ja: "次へ" }, diff --git a/ide/src/keymap.ts b/ide/src/keymap.ts index 05024d4..ec46bbb 100644 --- a/ide/src/keymap.ts +++ b/ide/src/keymap.ts @@ -21,7 +21,7 @@ export type ActionId = | "find.next" | "find.prev" | "search.nextHit" | "search.prevHit" | "tabs.close" | "tabs.reopen" | "editor.fontUp" | "editor.fontDown" | "view.sidebar" - | "nav.back" | "nav.forward"; + | "nav.back" | "nav.forward" | "file.run"; /** i18n 키로 라벨을 만든다 — 표가 언어별 문자열을 들고 있지 않게. */ export interface Binding { id: ActionId; def: string; labelKey: string } @@ -52,6 +52,8 @@ export const BINDINGS: Binding[] = [ // 방금 어디 있었더라 — 정의로 점프한 뒤 원래 자리로 돌아오는 길. VS Code 와 같은 화음. { id: "nav.back", def: "Alt+Left", labelKey: "key.navBack" }, { id: "nav.forward", def: "Alt+Right", labelKey: "key.navForward" }, + // F5 는 디버그다. VS Code 관례대로 실행은 Ctrl+F5. + { id: "file.run", def: "Mod+F5", labelKey: "key.runFile" }, { id: "tabs.mru", def: "Mod+Tab", labelKey: "key.cycleTabs" }, { id: "tabs.mruBack", def: "Mod+Shift+Tab", labelKey: "key.cycleTabsBack" }, { id: "editor.fontUp", def: "Mod+=", labelKey: "key.fontUp" }, diff --git a/ide/src/schutz.d.ts b/ide/src/schutz.d.ts index 7a2911c..ef293c1 100644 --- a/ide/src/schutz.d.ts +++ b/ide/src/schutz.d.ts @@ -155,6 +155,9 @@ interface SchutzApi { } | null>; cpRestore(root: string, runId: string, actions: { rel: string; action: "restore" | "delete" }[]): Promise<{ done: string[]; failed: { rel: string; why: string }[] }>; + /** 실행 도구가 PATH 에 있는가. 없으면 { ok: false }. */ + whichTool(name: string): Promise<{ ok: boolean; path?: string }>; + tmpDir(): Promise; cpBeat(root: string, runId: string): Promise; cpDrop(root: string, runId: string): Promise; /** 끌어다 놓은 File 의 실제 경로 (Electron 32+ 에서 File.path 가 사라졌다). 못 얻으면 "" */ diff --git a/ide/src/settings.ts b/ide/src/settings.ts index c7eedd3..1571131 100644 --- a/ide/src/settings.ts +++ b/ide/src/settings.ts @@ -156,3 +156,38 @@ export function getActiveIconTheme(): ActiveIconTheme | null { export function setActiveIconTheme(v: ActiveIconTheme | null): void { try { v ? localStorage.setItem(ICON_THEME_KEY, JSON.stringify(v)) : localStorage.removeItem(ICON_THEME_KEY); } catch { /* ignore */ } } + +/* ── "이 파일 실행" 명령 재정의 ──────────────────────────────────────────── + * 기본 표(engine/runFile.ts)는 gcc·python 처럼 가장 흔한 이름을 쓴다. 그런데 + * clang 을 쓰는 사람도, python3 여야 하는 환경도 있다 — 그럴 때 기능을 통째로 + * 못 쓰게 되는 대신 명령만 갈아끼울 수 있어야 한다. 확장자별로 저장한다. */ +const RUN_KEY = "schutz.runCommands"; + +export function getRunOverrides(): Record { + try { + const raw = localStorage.getItem(RUN_KEY); + if (!raw) return {}; + const o = JSON.parse(raw); + if (!o || typeof o !== "object") return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(o)) if (typeof v === "string" && v.trim()) out[k.toLowerCase()] = v; + return out; + } catch { return {}; } +} + +/** 이 파일에 쓸 재정의 명령. 없으면 null(기본표를 쓴다). */ +export function getRunOverride(rel: string): string | null { + const base = rel.split(/[\\/]/).pop() ?? ""; + const i = base.lastIndexOf("."); + if (i <= 0) return null; + return getRunOverrides()[base.slice(i + 1).toLowerCase()] ?? null; +} + +export function setRunOverride(ext: string, command: string | null): void { + try { + const cur = getRunOverrides(); + const k = ext.toLowerCase(); + if (command && command.trim()) cur[k] = command.trim(); else delete cur[k]; + localStorage.setItem(RUN_KEY, JSON.stringify(cur)); + } catch { /* storage 불가 환경 */ } +} From 85ad61e0b769ca2242efdf9cc7e1154904617336 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 3 Aug 2026 10:17:19 +0900 Subject: [PATCH 06/52] =?UTF-8?q?=EC=96=B4=EB=8A=90=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=9F=AC=EB=A5=BC=20=EC=8B=A4=EC=97=88=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EB=A1=9C=20=EB=82=A8=EA=B8=B0=EA=B3=A0,=20di?= =?UTF-8?q?st=20=EB=A5=BC=20=EB=91=90=20=EB=B2=88=20=EC=8B=A3=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `electron .` 은 dev 서버가 떠 있으면 dist 가 아니라 그쪽을 문다. 조용히 일어나는 일이라, 빌드본을 보고 있다고 착각한 채 개발 전용 동작을 배포본 문제로 오진하기 딱 좋다 — 실제로 그렇게 오진했다. StrictMode 의 마운트 이중 호출(개발 전용)을 배포 빌드의 결함으로 잘못 짚었고, 원인은 남아 있던 vite 서버였다. 이제 무엇을 실었는지 한 줄 남긴다. 그리고 그 로그 덕에 하나 더 보였다 — did-fail-load 와 loadURL 의 catch 가 둘 다 불려서 dev 서버가 없을 때 dist 를 두 번 싣고 있었다. 한 번만 싣는다. --- ide/electron/main.cjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index ceee6fe..ff98da2 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -57,13 +57,23 @@ function createWindow(layout) { const search = "win=" + winId + (layout ? "&layout=" + layout : ""); const q = "?" + search; - const loadDist = () => - win.loadFile(path.join(__dirname, "..", "dist", "index.html"), { search }); + // 무엇을 실었는지 남긴다. 개발 중에 dev 서버가 떠 있으면 `electron .` 은 dist 가 아니라 + // 그쪽을 문다 — 조용히 일어나서, 빌드본을 보고 있다고 착각한 채 개발 전용 동작 + // (StrictMode 이중 마운트 등)을 배포본 문제로 오진하기 딱 좋다. + // did-fail-load 와 loadURL 의 catch 가 둘 다 불려서 dist 를 두 번 실었다. + let distLoaded = false; + const loadDist = () => { + if (distLoaded) return; + distLoaded = true; + console.log("[schutz] renderer: dist"); + return win.loadFile(path.join(__dirname, "..", "dist", "index.html"), { search }); + }; if (isDev) { // dev 서버가 죽어 있으면 빈 창 대신 빌드본으로 폴백 win.webContents.once("did-fail-load", (_e, code) => { if (code === -102 /* CONNECTION_REFUSED */ || code === -105 || code === -106) void loadDist(); }); + console.log("[schutz] renderer: " + DEV_URL + " (dev server)"); win.loadURL(DEV_URL + q).catch(() => void loadDist()); } else { void loadDist(); From f2376ea27cf9a11222a512c5c67a8ac49ce244f6 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 3 Aug 2026 11:41:00 +0900 Subject: [PATCH 07/52] =?UTF-8?q?Monaco=20=EC=9C=84=EC=A0=AF=EC=9D=B4=20?= =?UTF-8?q?=EC=95=B1=20=ED=85=8C=EB=A7=88=EB=A5=BC=20=EB=94=B0=EB=A5=B4?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 찾기 위젯이 Feldgrau 에서 #252526, Paper 에서 #F3F3F3 로 떴다. 앱 팝업은 #1A1F1C / #FFFFFF 다. 테마를 셋이나 만들어 놓고 에디터 안쪽만 남의 옷을 입고 있었다 — 자동완성·우클릭 메뉴·호버·Peek·줄 이동도 전부 같았다. 색 키를 앱 토큰에서 만든다(editor/monacoColors.ts). CSS 로 덮어쓰지 않은 이유는 Monaco 내부 클래스 이름이 버전마다 바뀌는 반면 색 키는 공개 계약이기 때문이다. 모서리 둥글기·그림자처럼 색이 아닌 것만 CSS 가 맡는다. **정작 고쳐야 할 곳은 다른 데였다.** 내장 테마(monacoSetup)에 색을 아무리 채워도 화면은 그대로였다 — 문법 확장이 하나라도 있으면 에디터는 TextMate 테마로 갈아타는데, 그쪽은 색을 넷(본문·줄번호)만 정하고 나머지를 Monaco 기본값에 맡기고 있었다. 실제로 적용되는 테마가 거기라서, 두 곳 모두에 같은 위젯 색을 넣는다. 앱 테마를 바꾸면 TextMate 테마도 다시 정의해 위젯 색이 따라가게 했다. 덤으로 Graphite 가 Feldgrau 의 monaco 테마를 빌려 쓰던 것도 떼어 냈다 — 팝업 바탕이 한 톤 어긋나 있었다. 물결 밑줄(오류·경고)은 0.1.0 에서 만든 시맨틱 토큰을 그대로 쓴다. 색을 또 만들지 않는다. --- ide/src/App.tsx | 5 +- ide/src/editor/monacoColors.test.ts | 93 +++++++++++++++++++++ ide/src/editor/monacoColors.ts | 121 ++++++++++++++++++++++++++++ ide/src/editor/monacoSetup.ts | 45 +++++++++++ ide/src/ext/textmate.ts | 19 ++++- ide/src/global.css | 32 ++++++++ ide/src/theme.ts | 2 +- ide/vitest.config.ts | 1 + 8 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 ide/src/editor/monacoColors.test.ts create mode 100644 ide/src/editor/monacoColors.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 2a04140..5d7860c 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -1399,7 +1399,10 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { applyEditorTheme(themes: vscodeExt.ImportedTheme[] = this.state.extThemes) { const vsx = getActiveVsxTheme(); if (vsx && themes.some(t => t.id === vsx)) { monaco.editor.setTheme(vsx); return; } - if (textmate.isTextMateWired()) monaco.editor.setTheme(textmate.tmThemeId()); + if (textmate.isTextMateWired()) { + textmate.defineTmTheme(); // 위젯 색이 지금 테마를 따라가도록 다시 정의한 뒤 적용 + monaco.editor.setTheme(textmate.tmThemeId()); + } else monaco.editor.setTheme(monacoThemeOf(getThemeId())); } /** 가져온 VS Code 에디터 테마 선택 + 영속화 */ diff --git a/ide/src/editor/monacoColors.test.ts b/ide/src/editor/monacoColors.test.ts new file mode 100644 index 0000000..e2da22f --- /dev/null +++ b/ide/src/editor/monacoColors.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { monacoWidgetColors, alpha } from "./monacoColors"; +import { THEME_TOKENS } from "../theme"; +import { contrast, AA_TEXT } from "../contrast"; + +describe("alpha", () => { + it("hex 뒤에 알파 두 자리를 붙인다", () => { + expect(alpha("#8FA893", 1)).toBe("#8FA893FF"); + expect(alpha("#8FA893", 0)).toBe("#8FA89300"); + expect(alpha("#8FA893", 0.5)).toBe("#8FA89380"); + }); + it("이미 알파가 붙어 있으면 갈아 끼운다 — 두 번 붙어 10자리가 되면 Monaco 가 무시한다", () => { + expect(alpha("#8FA893FF", 0.2)).toBe("#8FA89333"); + }); + it("범위를 벗어난 값은 잘라 낸다", () => { + expect(alpha("#000000", 2)).toBe("#000000FF"); + expect(alpha("#000000", -1)).toBe("#00000000"); + }); +}); + +describe("Monaco 위젯 색", () => { + const themes = Object.entries(THEME_TOKENS); + + it("모든 테마에서 값이 전부 hex 다 — rgba() 를 넣으면 Monaco 가 통째로 무시한다", () => { + for (const [id, t] of themes) { + for (const [k, v] of Object.entries(monacoWidgetColors(t))) { + expect(v, `${id}.${k} = ${v}`).toMatch(/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/); + } + } + }); + + it("눈에 띄던 자리를 실제로 덮는다", () => { + for (const [id, t] of themes) { + const c = monacoWidgetColors(t); + for (const k of [ + "editorWidget.background", "input.background", "focusBorder", + "editorSuggestWidget.background", "menu.background", "editorHoverWidget.background", + "quickInput.background", "editor.findMatchBackground", + ]) expect(c[k], `${id}.${k}`).toBeTruthy(); + } + }); + + it("떠 있는 것들의 바탕이 앱 팝업과 같다 — 이게 어긋나서 남의 옷처럼 보였다", () => { + for (const [id, t] of themes) { + const c = monacoWidgetColors(t); + expect(c["editorWidget.background"], id).toBe(t.bgPopup); + expect(c["editorSuggestWidget.background"], id).toBe(t.bgPopup); + expect(c["menu.background"], id).toBe(t.bgPopup); + expect(c["editorHoverWidget.background"], id).toBe(t.bgPopup); + } + }); + + it("VS Code 기본값이 하나도 남지 않는다", () => { + const stock = ["#252526", "#3C3C3C", "#F3F3F3", "#616161", "#007ACC", "#0E639C", "#094771"]; + for (const [id, t] of themes) { + const vals = Object.values(monacoWidgetColors(t)).map(v => v.slice(0, 7).toUpperCase()); + for (const s of stock) expect(vals, `${id} 에 ${s} 가 남았다`).not.toContain(s); + } + }); + + it("위젯 위의 글자가 읽힌다 — 팝업 바탕 대비 AA", () => { + for (const [id, t] of themes) { + const c = monacoWidgetColors(t); + for (const k of ["editorWidget.foreground", "editorSuggestWidget.foreground", "menu.foreground", "editorHoverWidget.foreground"]) { + const r = contrast(c[k]!.slice(0, 7), t.bgPopup); + expect(r, `${id}.${k} = ${r.toFixed(2)}:1`).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }); + + it("입력칸 글자도 입력칸 바탕에서 읽힌다", () => { + for (const [id, t] of themes) { + const c = monacoWidgetColors(t); + const r = contrast(c["input.foreground"]!.slice(0, 7), c["input.background"]!.slice(0, 7)); + expect(r, `${id} input = ${r.toFixed(2)}:1`).toBeGreaterThanOrEqual(AA_TEXT); + } + }); + + it("물결 밑줄은 0.1.0 의 시맨틱 토큰을 쓴다 — 색을 또 만들지 않는다", () => { + for (const [id, t] of themes) { + const c = monacoWidgetColors(t); + expect(c["editorError.foreground"], id).toBe(t.err); + expect(c["editorWarning.foreground"], id).toBe(t.warn); + } + }); + + it("밝은 테마와 어두운 테마가 실제로 다른 값을 낸다", () => { + const paper = monacoWidgetColors(THEME_TOKENS.paper!); + const dark = monacoWidgetColors(THEME_TOKENS.feldgrau!); + expect(paper["editorWidget.background"]).not.toBe(dark["editorWidget.background"]); + expect(paper["input.foreground"]).not.toBe(dark["input.foreground"]); + }); +}); diff --git a/ide/src/editor/monacoColors.ts b/ide/src/editor/monacoColors.ts new file mode 100644 index 0000000..b968c36 --- /dev/null +++ b/ide/src/editor/monacoColors.ts @@ -0,0 +1,121 @@ +import type { ThemeTokens } from "../theme"; + +/** + * Monaco 위젯 색을 앱 테마 토큰에서 만든다. + * + * 지금까지 테마는 에디터 **본문** 색만 정하고 위젯 색은 비워 뒀다. 그러면 Monaco 가 + * 자기 기본값(vs / vs-dark)으로 떨어진다 — 찾기 위젯이 Feldgrau 에서 #252526, + * Paper 에서 #F3F3F3 로 떴다. 앱 팝업은 #1A1F1C / #FFFFFF 인데, 에디터 안쪽만 + * 남의 옷을 입고 있었다. 자동완성 목록·우클릭 메뉴·호버·Peek 도 전부 같았다. + * + * CSS 로 덮어쓰지 않고 **테마 색 키를 채우는** 쪽을 고른 이유: Monaco 의 내부 클래스 + * 이름은 버전마다 바뀌지만 색 키는 공개 계약이다. 모서리 둥글기처럼 색이 아닌 것만 + * CSS 가 맡는다. + */ + +/** "#RRGGBB" + 알파(0~1) → "#RRGGBBAA". Monaco 는 8자리 hex 를 받는다. */ +export function alpha(hex: string, a: number): string { + const h = hex.trim().slice(0, 7); + const v = Math.round(Math.max(0, Math.min(1, a)) * 255); + return h + v.toString(16).padStart(2, "0").toUpperCase(); +} + +/** + * 앱 토큰 → Monaco 색 키. + * + * rgba() 형태의 헤어라인 토큰(w06 등)은 쓰지 않는다 — Monaco 는 hex 만 받는다. + * 필요한 반투명은 hex 토큰에서 alpha() 로 만든다. + */ +export function monacoWidgetColors(t: ThemeTokens): Record { + const light = !!t.light; + /** 목록에서 고른 줄 — 밝은 테마는 액센트를 옅게, 어두운 테마는 조금 진하게. */ + const sel = alpha(t.accent, light ? 0.16 : 0.22); + const hover = alpha(t.fg, light ? 0.06 : 0.07); + + return { + // 떠 있는 것들의 바탕 — 앱의 팝업과 같은 종이를 쓴다 + "editorWidget.background": t.bgPopup, + "editorWidget.foreground": t.fg, + "editorWidget.border": t.bdPopup, + "editorWidget.resizeBorder": t.accent, + "widget.shadow": alpha(light ? "#283223" : "#000000", light ? 0.16 : 0.55), + + // 찾기/바꾸기 입력칸 — 여기가 가장 크게 어긋나 있었다 + "input.background": t.bgRoot, + "input.foreground": t.fg, + "input.border": t.bdPopup, + "input.placeholderForeground": t.fgDim2, + "inputOption.activeBackground": alpha(t.accent, 0.18), + "inputOption.activeBorder": t.accent, + "inputOption.activeForeground": t.fg, + "inputValidation.errorBackground": t.errSoft.startsWith("#") ? t.errSoft : alpha(t.err, 0.14), + "inputValidation.errorBorder": t.err, + "inputValidation.errorForeground": t.fg, + "focusBorder": t.accent, + + // 찾은 것 — 액센트 계열로. 기본값(주황)은 이 팔레트에 없는 색이다. + "editor.findMatchBackground": alpha(t.accentHi, 0.38), + "editor.findMatchHighlightBackground": alpha(t.accent, 0.22), + "editor.findRangeHighlightBackground": alpha(t.accent, 0.1), + + // 자동완성 + "editorSuggestWidget.background": t.bgPopup, + "editorSuggestWidget.border": t.bdPopup, + "editorSuggestWidget.foreground": t.fgSub, + "editorSuggestWidget.selectedBackground": sel, + "editorSuggestWidget.selectedForeground": t.fg, + "editorSuggestWidget.highlightForeground": t.accentHi, + "editorSuggestWidget.focusHighlightForeground": t.accentHi, + + // 목록 일반(자동완성·빠른 입력·Peek 결과가 공유한다) + "list.hoverBackground": hover, + "list.focusBackground": sel, + "list.focusForeground": t.fg, + "list.activeSelectionBackground": sel, + "list.activeSelectionForeground": t.fg, + "list.inactiveSelectionBackground": alpha(t.accent, 0.1), + "list.highlightForeground": t.accentHi, + + // 호버 툴팁 + "editorHoverWidget.background": t.bgPopup, + "editorHoverWidget.border": t.bdPopup, + "editorHoverWidget.foreground": t.fgSub, + "editorHoverWidget.statusBarBackground": t.bgCard, + + // 우클릭 메뉴 + "menu.background": t.bgPopup, + "menu.foreground": t.fgSub, + "menu.border": t.bdPopup, + "menu.selectionBackground": sel, + "menu.selectionForeground": t.fg, + "menu.separatorBackground": t.bdPopup, + + // 위젯 안의 버튼(바꾸기 확인 등) + "button.background": t.accent, + "button.foreground": t.onAccent, + "button.hoverBackground": t.accentHi, + + // 줄 이동(Ctrl+G) 같은 빠른 입력 + "quickInput.background": t.bgPopup, + "quickInput.foreground": t.fg, + "quickInputList.focusBackground": sel, + "quickInputList.focusForeground": t.fg, + + // 정의 미리보기(Peek) + "peekView.border": t.accent, + "peekViewEditor.background": t.bgEditor, + "peekViewResult.background": t.bgPanel, + "peekViewResult.selectionBackground": sel, + "peekViewTitle.background": t.bgCard, + "peekViewTitleLabel.foreground": t.fg, + "peekViewTitleDescription.foreground": t.fgDim, + + // 물결 밑줄 — 0.1.0 에서 만든 시맨틱 토큰을 그대로 쓴다 + "editorError.foreground": t.err, + "editorWarning.foreground": t.warn, + "editorInfo.foreground": t.accent, + + // 이름 바꾸기 입력 + "editorGhostText.foreground": t.fgDim2, + }; +} diff --git a/ide/src/editor/monacoSetup.ts b/ide/src/editor/monacoSetup.ts index d1d9383..5b4c803 100644 --- a/ide/src/editor/monacoSetup.ts +++ b/ide/src/editor/monacoSetup.ts @@ -5,6 +5,8 @@ import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; import { parseJsonc } from "../engine/jsonc"; +import { THEME_TOKENS } from "../theme"; +import { monacoWidgetColors } from "./monacoColors"; (self as any).MonacoEnvironment = { getWorker(_: unknown, label: string) { @@ -123,6 +125,48 @@ monaco.editor.defineTheme("feldgrau", { { token: "delimiter", foreground: "AEB9AF" }, ], colors: { + // 위젯 색을 먼저 깐다 — 아래 본문 색이 그 위를 덮으므로 지금까지의 에디터 모습은 그대로다. + ...monacoWidgetColors(THEME_TOKENS.feldgrau!), + "editor.background": "#0F1211", + "editor.foreground": "#D8DFD8", + "editorLineNumber.foreground": "#606A62", + "editorLineNumber.activeForeground": "#B4BEB5", + "editorCursor.foreground": "#A9C4AD", + "editor.selectionBackground": "#7D918355", + "editor.lineHighlightBackground": "#181D1B99", + "editorIndentGuide.background1": "#232826", + "editorWidget.background": "#181C1A", + "editorWidget.border": "#2A302C", + "scrollbarSlider.background": "#FFFFFF16", + "scrollbarSlider.hoverBackground": "#FFFFFF2A", + }, +}); + +/** Graphite — 신택스는 Feldgrau 와 같고 위젯 색만 자기 토큰을 쓴다. + * 예전엔 monaco 테마를 feldgrau 와 공유해서, 팝업 바탕이 한 톤 어긋났다. */ +monaco.editor.defineTheme("graphite", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "", foreground: "D8DFD8" }, + { token: "keyword", foreground: "E4B67E" }, + { token: "string", foreground: "9DD3A6" }, + { token: "number", foreground: "D8A9C8" }, + { token: "regexp", foreground: "9DD3A6" }, + { token: "type", foreground: "A6D6C8" }, + { token: "class", foreground: "A6D6C8" }, + { token: "function", foreground: "E9E2AC" }, + { token: "variable", foreground: "D8DFD8" }, + { token: "constant", foreground: "D8A9C8" }, + { token: "identifier", foreground: "D8DFD8" }, + { token: "tag", foreground: "E4B67E" }, + { token: "attribute.name", foreground: "A6D6C8" }, + { token: "comment", foreground: "72806F", fontStyle: "italic" }, + { token: "delimiter", foreground: "AEB9AF" }, + ], + colors: { + // 위젯 색을 먼저 깐다 — 아래 본문 색이 그 위를 덮으므로 지금까지의 에디터 모습은 그대로다. + ...monacoWidgetColors(THEME_TOKENS.graphite!), "editor.background": "#0F1211", "editor.foreground": "#D8DFD8", "editorLineNumber.foreground": "#606A62", @@ -150,6 +194,7 @@ monaco.editor.defineTheme("schutz-paper", { { token: "comment", foreground: "8A8D86", fontStyle: "italic" }, ], colors: { + ...monacoWidgetColors(THEME_TOKENS.paper!), "editor.background": "#FFFFFF", "editor.foreground": "#232823", "editorLineNumber.foreground": "#BEC3B6", diff --git a/ide/src/ext/textmate.ts b/ide/src/ext/textmate.ts index 98ae66a..cb2bfe0 100644 --- a/ide/src/ext/textmate.ts +++ b/ide/src/ext/textmate.ts @@ -5,7 +5,8 @@ import { loadWASM } from "onigasm"; import { wireTmGrammars } from "monaco-editor-textmate"; import monaco from "../editor/monacoSetup"; import onigasmWasmUrl from "onigasm/lib/onigasm.wasm?url"; -import { getThemeId, isLightTheme } from "../theme"; +import { getThemeId, isLightTheme, THEME_TOKENS } from "../theme"; +import { monacoWidgetColors } from "../editor/monacoColors"; let wasmReady: Promise | null = null; function ensureWasm(): Promise { @@ -59,11 +60,25 @@ function tmRules(p: TmPalette): monaco.editor.ITokenThemeRule[] { ]; } -function defineTmTheme() { +/** TextMate 테마를 (다시) 정의한다. + * + * **위젯 색을 같이 넣는 게 핵심이다.** 문법 확장이 하나라도 있으면 에디터는 여기서 + * 정의한 테마로 갈아탄다 — 그런데 예전엔 색을 넷(본문·줄번호)만 정해서, 찾기 위젯· + * 자동완성·우클릭 메뉴가 전부 Monaco 기본값(#252526 / #3C3C3C)으로 떨어졌다. + * monacoSetup 의 내장 테마에 아무리 색을 채워도 소용이 없던 이유가 이것이다. + * + * 앱 테마가 바뀌면 다시 불러야 한다 — 위젯 색이 지금 테마의 토큰을 따라가야 하므로. */ +export function defineTmTheme(): void { + const tok = THEME_TOKENS[getThemeId()] ?? THEME_TOKENS.feldgrau!; for (const [id, p] of [["schutz-tm-dark", TM_DARK], ["schutz-tm-light", TM_LIGHT]] as [string, TmPalette][]) { + // 어두운 id 에는 어두운 토큰을, 밝은 id 에는 밝은 토큰을 준다. 지금 고른 테마의 + // 명암이 그 id 와 맞을 때만 그 토큰을 쓰고, 아니면 같은 명암의 기본 테마를 쓴다. + const wantLight = id.endsWith("-light"); + const t = !!tok.light === wantLight ? tok : (wantLight ? THEME_TOKENS.paper! : THEME_TOKENS.feldgrau!); monaco.editor.defineTheme(id, { base: p.base, inherit: true, rules: tmRules(p), colors: { + ...monacoWidgetColors(t), "editor.background": p.bg, "editor.foreground": p.editorFg, "editorLineNumber.foreground": p.lineNum, "editorLineNumber.activeForeground": p.lineNumActive, }, diff --git a/ide/src/global.css b/ide/src/global.css index c30276b..b00e7d0 100644 --- a/ide/src/global.css +++ b/ide/src/global.css @@ -254,3 +254,35 @@ button:disabled { opacity: .55; cursor: default !important; } /* 캡처되는 동안 스스로 움직이는 요소가 있으면 스냅샷이 흔들려 툭툭 끊겨 보인다. :active-view-transition 은 전환이 도는 동안만 참이라 따로 뒷정리할 게 없다. */ html:active-view-transition .szMoving { animation-play-state: paused !important; transition: none !important; } + +/* ── Monaco 위젯의 모양 ──────────────────────────────────────────────────── + * 색은 테마 색 키가 정한다(editor/monacoColors.ts). 여기는 색이 아닌 것만 — + * 앱의 다른 팝업은 전부 둥근 모서리에 그림자를 쓰는데 에디터 안쪽 위젯만 + * 각진 상자로 떠서, 색을 맞춰도 여전히 "붙여 넣은 것" 처럼 보였다. */ +.monaco-editor .find-widget, +.monaco-editor .suggest-widget, +.monaco-editor .monaco-hover, +.monaco-editor .parameter-hints-widget, +.monaco-editor .rename-box, +.monaco-menu-container { + border-radius: 10px !important; + box-shadow: var(--shadow-pop) !important; + overflow: hidden; +} + +/* 찾기 위젯은 상단에 붙어 있어 위쪽 모서리를 둥글릴 이유가 없다 — 잘린 것처럼 보인다. */ +.monaco-editor .find-widget { + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; +} + +/* 위젯 안 입력칸도 앱 입력칸과 같은 둥글기로. */ +.monaco-editor .find-widget .monaco-inputbox, +.monaco-editor .rename-box .monaco-inputbox { + border-radius: 6px !important; +} + +/* 자동완성 목록의 고른 줄 — 색은 테마가 정하고, 모서리만 앱의 목록과 맞춘다. */ +.monaco-editor .suggest-widget .monaco-list .monaco-list-row { + border-radius: 6px; +} diff --git a/ide/src/theme.ts b/ide/src/theme.ts index 8e13700..00dafe4 100644 --- a/ide/src/theme.ts +++ b/ide/src/theme.ts @@ -69,7 +69,7 @@ export const THEME_TOKENS: Record = { w10: "rgba(255,255,255,.1)", w12: "rgba(255,255,255,.12)", w14: "rgba(255,255,255,.14)", shadowPop: "0 12px 32px rgba(0,0,0,.55)", shadowSoft: "0 5px 16px rgba(0,0,0,.5)", accentSoft: "rgba(154,163,178,.16)", - monaco: "feldgrau", + monaco: "graphite", }, paper: { name: "Paper", diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index 1ff3cea..6196892 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ "src/overlays.test.ts", "src/ext/hooks.test.ts", "src/theme.test.ts", + "src/editor/monacoColors.test.ts", "src/opening/**/*.test.ts", "src/uiMode.test.ts", "src/agentTimeline.test.ts", From 0175a63e3484acc13c6dbbfd384c9fa78faaeae7 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 3 Aug 2026 12:14:01 +0900 Subject: [PATCH 08/52] =?UTF-8?q?=EB=AA=A8=EB=93=9C=EB=A5=BC=20=EB=B0=94?= =?UTF-8?q?=EA=BF=94=EB=8F=84=20=EC=9D=BD=EB=8D=98=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=ED=95=84=ED=84=B0=EA=B0=80=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=EB=90=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에디터 ↔ 에이전트를 오가면 채팅이 엉뚱한 자리에 떨어졌다. 두 모드는 대화를 아주 다르게 그린다 — 여백 16px↔28px, 간격 10↔24, 폭도 사이드바↔52rem 중앙 정렬이라 scrollHeight 가 통째로 달라진다. 그런데 스크롤 판정 서명에 uiMode 가 없어서 "바뀐 게 없다" 로 읽혔고, 아무 보정도 없이 옛 픽셀 위치가 남았다. 픽셀은 두 모드 사이에서 뜻이 없다. 화면 맨 위에 걸친 메시지를 앵커로 잡아 전환 뒤 같은 높이에 다시 놓는다. 하단에 붙어 있었으면 하단으로. switchChatTab 이 탭 전환에 쓰던 규칙("픽셀이 아니라 하단을 기억한다")을 모드 전환에도 세운 것이다. 전환은 View Transition 안에서 일어나 그 시점 레이아웃이 아직 앉지 않았다 — 지금 한 번, 다음 프레임에 한 번 더 맞춘다. 상대 이동이라 두 번 불러도 수렴한다. 그리고 에이전트 모드는 탭을 숨기지만 필터는 그대로 걸려 있다(renderAgentRows 도 visibleMessages 를 쓴다). 표시가 없으면 대화 절반이 사라진 걸 모른 채 "왜 답이 없지" 가 된다 — 걸려 있을 때만 한 줄로 알리고 해제 버튼을 단다. --- ide/src/App.tsx | 67 +++++++++++++++++++++++++++++++++------ ide/src/i18n/dict/mode.ts | 2 ++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 5d7860c..7d7cbeb 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -5391,14 +5391,27 @@ ${(r.output || "").slice(0, 2000)}`; private _lastTabsRef: string[][] | null = null; private _lastActiveRef: string[] | null = null; private _lastCollapsedRef: Record | null = null; - /** 렌더 커밋 직전의 채팅 스크롤 상태 — 하단 추적 판정은 반드시 갱신 전 값으로 해야 한다 */ - getSnapshotBeforeUpdate(): { chatAtBottom: boolean } | null { + /** 렌더 커밋 직전의 채팅 스크롤 상태 — 하단 추적 판정은 반드시 갱신 전 값으로 해야 한다. + * + * 앵커(화면 맨 위에 걸친 메시지)도 같이 잡는다. 모드를 바꾸면 채팅이 통째로 다시 + * 그려진다 — 여백 16px↔28px, 간격 10↔24, 폭도 사이드바↔52rem 중앙 정렬이라 + * scrollHeight 가 달라진다. 픽셀 scrollTop 은 그 사이에서 아무 뜻이 없다. */ + getSnapshotBeforeUpdate(): { chatAtBottom: boolean; anchorId: string | null; anchorTop: number } | null { const el = this._chat; if (!el) return null; - return { chatAtBottom: el.scrollHeight - el.scrollTop - el.clientHeight < 60 }; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60; + let anchorId: string | null = null, anchorTop = 0; + if (!atBottom) { + const top = el.getBoundingClientRect().top; + for (const n of Array.from(el.querySelectorAll("[data-mid]"))) { + const d = n.getBoundingClientRect().top - top; + if (d >= -n.offsetHeight) { anchorId = n.dataset.mid ?? null; anchorTop = d; break; } + } + } + return { chatAtBottom: atBottom, anchorId, anchorTop }; } - componentDidUpdate(_pp?: any, _ps?: any, snap?: { chatAtBottom: boolean } | null) { + componentDidUpdate(_pp?: any, _ps?: any, snap?: { chatAtBottom: boolean; anchorId: string | null; anchorTop: number } | null) { // 도움말 → 오프닝 다시 보기는 해시만 바꾼다. App 은 재마운트되지 않으므로 // componentDidMount 가 다시 돌지 않는다 — prop 이 켜지는 순간을 여기서 잡는다. if (this.props.playOpening && !_pp?.playOpening && this.state.openingPhase === "off") { @@ -5434,16 +5447,41 @@ ${(r.output || "").slice(0, 2000)}`; // 지금 탭에 보이는 것만 기준으로 — 안 보이는 탭이 자라도 끌어내리지 않는다. // 그리고 사용자가 위로 올려 읽는 중이면 건드리지 않는다(예전엔 매 토큰 하단으로 잡아당겼다). const el = this._chat; + // 모드가 바뀌면 읽던 자리를 되돌린다. 서명(sig)에 uiMode 가 없어서 "바뀐 게 없다" 로 + // 읽히는데, 실제로는 채팅이 통째로 다시 그려져 픽셀 위치가 무의미해진다 — 그래서 + // 아무 보정도 없이 엉뚱한 자리에 떨어졌다. switchChatTab 이 탭에 대해 쓰는 규칙과 같다. + // + // 전환은 View Transition 안에서 일어나 이 시점의 레이아웃은 아직 앉지 않았다. + // 지금 한 번, 다음 프레임에 한 번 더 맞춘다(상대 이동이라 두 번 불러도 수렴한다). + if (_ps && _ps.uiMode !== this.state.uiMode && snap) { + this.restoreChatScroll(snap); + requestAnimationFrame(() => this.restoreChatScroll(snap)); + } // text 가 없는 손상된 항목이 하나라도 있으면 여기서 터져 화면 전체가 하얘졌다 — 방어 - const sig = this.state.chatTab + "|" + this.visibleMessages().map(m => (m.text ?? "").length).join(","); + const sig = this.state.uiMode + "|" + this.state.chatTab + "|" + this.visibleMessages().map(m => (m.text ?? "").length).join(","); if (sig !== this._chatSig) { + const modeOnly = !!_ps && _ps.uiMode !== this.state.uiMode; this._chatSig = sig; - if (snap ? snap.chatAtBottom : true) el.scrollTop = el.scrollHeight; + // 모드 전환은 위에서 이미 자리를 잡았다 — 여기서 또 하단으로 끌지 않는다. + if (!modeOnly && (snap ? snap.chatAtBottom : true)) el.scrollTop = el.scrollHeight; } this.onChatScroll(); // 버튼 노출은 스크롤 위치 하나로 판단 (도착·스크롤 공통) } } + /** 모드 전환 전의 읽던 자리로 되돌린다. 하단에 있었으면 하단, 아니면 보고 있던 + * 메시지를 같은 높이에. */ + private restoreChatScroll(snap: { chatAtBottom: boolean; anchorId: string | null; anchorTop: number }) { + const el = this._chat; + if (!el) return; + if (snap.chatAtBottom) { el.scrollTop = el.scrollHeight; return; } + if (!snap.anchorId) return; + const n = el.querySelector(`[data-mid="${CSS.escape(snap.anchorId)}"]`); + // offsetTop 은 가장 가까운 positioned 조상 기준이라 스크롤러와 어긋난다 — + // 실제 좌표 차이로 옮긴다(레이아웃이 통째로 바뀐 뒤라 이게 유일하게 맞다). + if (n) el.scrollTop += (n.getBoundingClientRect().top - el.getBoundingClientRect().top) - snap.anchorTop; + } + /** 신택스 하이라이트 (프로토타입 hl 포팅) */ hl(text: string, md: boolean): React.ReactNode { if (!text) return ""; @@ -6750,7 +6788,7 @@ ${(r.output || "").slice(0, 2000)}`; private renderAgentMsg(m: ChatMsg) { if (m.role === "user") { return ( -
+
+
{/* 여러 에이전트가 도는 앱이라 이름은 남긴다 — 다만 아주 조용히, 문단 위에 얹는다 */} {m.who &&
{m.who}
}
+
{m.role === "user" ? "◆" : ""} {/* 복사 — 긴 답변을 드래그로 긁어내는 건 사실상 불가능했다. hover 시에만 나타난다 */} +
+ )}
{s.chatAway && ( + +
+
+
+ ); + } + renderAskRun() { const a = this.state.askRun; if (!a) return null; @@ -7441,6 +7497,7 @@ ${(r.output || "").slice(0, 2000)}`; rel={activeRel} onDirtyChange={this.handleDirtyChange} onSaved={this.notifySaved} + onConfirm={this.askConfirmProp} onStatus={this.handleStatus} onInlineEdit={this.handleInlineEdit} breakpoints={s.breakpoints[activeRel]} @@ -8816,6 +8873,7 @@ ${(r.output || "").slice(0, 2000)}`; case "undoAsk": if (!s.undoAsk?.busy) this.setState({ undoAsk: null }); return; case "commitView": this.setState({ commitView: null }); return; case "mcpb": if (!s.mcpb?.busy) void this.closeBundle(); return; + case "confirmAsk": this.answerConfirm(false); return; // Esc = 취소 case "import": this.closeImport(); return; case "tour": this.endTour(); return; } diff --git a/ide/src/editor/MonacoPane.tsx b/ide/src/editor/MonacoPane.tsx index 6dd528f..3d42e31 100644 --- a/ide/src/editor/MonacoPane.tsx +++ b/ide/src/editor/MonacoPane.tsx @@ -14,6 +14,8 @@ interface Props { onDirtyChange?: (rel: string, dirty: boolean) => void; /** 디스크에 실제로 쓴 뒤. 확장 훅(file.save)이 여기서 나간다 — dirty=false 는 저장 말고도 생긴다. */ onSaved?: (rel: string) => void; + /** 되돌리기 어려운 일 직전의 확인. window.confirm 은 렌더러를 얼리고 테마도 안 따른다. */ + onConfirm?: (o: { title: string; body: string; okLabel: string; danger?: boolean }) => Promise; onStatus?: (info: { rel: string; lang: string; line: number; col: number }) => void; /** Ctrl+K 인라인 편집 — 선택 영역과 지시를 App으로 전달 */ onInlineEdit?: (rel: string, selection: string, instruction: string, range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }) => void; @@ -62,7 +64,7 @@ export const paneRegistry: { panes: Map; focused: PaneApi | nul type LoadState = "loading" | "ready" | "error"; /** 실제 파일을 여는 Monaco 편집 페인 (Electron 전용 — window.schutz 필요) */ -function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onStatus, onInlineEdit, breakpoints, stoppedLine, onToggleBreakpoint, gitVer }: Props) { +function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onConfirm, onStatus, onInlineEdit, breakpoints, stoppedLine, onToggleBreakpoint, gitVer }: Props) { // React.memo 가 부모의 forceUpdate 를 막으므로 언어는 여기서 직접 구독한다. const langTick = useLang(); const hostRef = useRef(null); @@ -154,7 +156,12 @@ function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onStatus, onInlineE const text = editorRef.current.getValue(); // 내가 편집하는 사이 디스크가 바뀌었으면 그냥 덮어쓰지 않는다 — 외부 편집이 조용히 사라지던 자리 const ext = projectModels.externalChangeOf(rel); - if (ext !== null && ext !== text && !window.confirm(t("sc1.externalChangedOverwrite", { rel }))) return; + if (ext !== null && ext !== text) { + const ok = onConfirm + ? await onConfirm({ title: t("confirm.overwriteTitle"), body: t("sc1.externalChangedOverwrite", { rel }), okLabel: t("confirm.overwriteOk"), danger: true }) + : false; // 확인 창구가 없으면 덮어쓰지 않는다 — 조용히 남의 편집을 지우느니 저장을 미룬다 + if (!ok) return; + } try { await window.schutz.writeFile(root, rel, text); projectModels.markSaved(root, rel, text); diff --git a/ide/src/i18n/dict/confirm.ts b/ide/src/i18n/dict/confirm.ts new file mode 100644 index 0000000..4c8dbd8 --- /dev/null +++ b/ide/src/i18n/dict/confirm.ts @@ -0,0 +1,16 @@ +// confirm 도메인 — 인앱 확인 모달의 공통 문구 +export const dict: Record = { + "confirm.cancel": { ko: "취소", en: "Cancel", de: "Abbrechen", ja: "キャンセル" }, + "confirm.deleteTitle": { ko: "삭제할까요?", en: "Delete?", de: "Löschen?", ja: "削除しますか?" }, + "confirm.deleteOk": { ko: "삭제", en: "Delete", de: "Löschen", ja: "削除" }, + "confirm.discardTitle": { ko: "변경을 버릴까요?", en: "Discard changes?", de: "Änderungen verwerfen?", ja: "変更を破棄しますか?" }, + "confirm.discardOk": { ko: "버리기", en: "Discard", de: "Verwerfen", ja: "破棄" }, + "confirm.stashDropTitle": { ko: "감춰둔 변경을 버릴까요?", en: "Drop this stash?", de: "Stash verwerfen?", ja: "スタッシュを破棄しますか?" }, + "confirm.stashDropOk": { ko: "버리기", en: "Drop", de: "Verwerfen", ja: "破棄" }, + "confirm.amendTitle": { ko: "이미 올라간 커밋을 고칩니다", en: "Amending a pushed commit", de: "Bereits gepushten Commit ändern", ja: "プッシュ済みのコミットを修正します" }, + "confirm.amendOk": { ko: "계속", en: "Continue", de: "Fortfahren", ja: "続行" }, + "confirm.replaceTitle": { ko: "전체 치환할까요?", en: "Replace across all files?", de: "In allen Dateien ersetzen?", ja: "すべて置換しますか?" }, + "confirm.replaceOk": { ko: "치환", en: "Replace", de: "Ersetzen", ja: "置換" }, + "confirm.overwriteTitle": { ko: "디스크가 그 사이 바뀌었습니다", en: "The file changed on disk", de: "Die Datei wurde auf der Festplatte geändert", ja: "ディスク上でファイルが変更されました" }, + "confirm.overwriteOk": { ko: "덮어쓰기", en: "Overwrite", de: "Überschreiben", ja: "上書き" }, +}; diff --git a/ide/src/i18n/messages.ts b/ide/src/i18n/messages.ts index fbc8da5..2ea1d8a 100644 --- a/ide/src/i18n/messages.ts +++ b/ide/src/i18n/messages.ts @@ -13,6 +13,7 @@ import { dict as d_reg } from "./dict/reg"; import { dict as d_key } from "./dict/key"; import { dict as d_gitp } from "./dict/gitp"; import { dict as d_runfile } from "./dict/runfile"; +import { dict as d_confirm } from "./dict/confirm"; import { dict as d_flowtree } from "./dict/flowtree"; import { dict as d_dbg } from "./dict/dbg"; import { dict as d_mcpui } from "./dict/mcpui"; @@ -41,7 +42,7 @@ export type Msg = { ko: string; en: string; de: string; ja: string }; export const MESSAGES: Record = { ...d_dap, ...d_data, ...d_exth, ...d_mcpc, ...d_media, ...d_model, ...d_mono, ...d_oai, ...d_reg, ...d_key, - ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, + ...d_confirm, ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, ...d_sc1, ...d_sc2, ...d_sc3, ...d_sc4, ...d_sc5, ...d_eng, ...d_plug, ...d_review, ...d_cloud, // ── 공통 ──────────────────────────────────────────────── "common.next": { ko: "다음", en: "Next", de: "Weiter", ja: "次へ" }, diff --git a/ide/src/overlays.ts b/ide/src/overlays.ts index 1e97352..65a0c3d 100644 --- a/ide/src/overlays.ts +++ b/ide/src/overlays.ts @@ -67,6 +67,9 @@ export const OVERLAYS: OverlaySpec[] = [ { id: "undoAsk", flag: "undoAsk", z: 230, escapable: true }, { id: "commitView", flag: "commitView", z: 230, escapable: true }, { id: "mcpb", flag: "mcpb", z: 232, escapable: true }, + // 되돌리기 어려운 일 직전의 확인. 다른 모달 위에서 뜰 수 있어야 한다 + // (설정·검색 안에서도 삭제·치환을 부른다). + { id: "confirmAsk", flag: "confirmAsk", z: 233, escapable: true }, { id: "import", flag: "impOpen", z: 240, escapable: true }, { id: "tour", flag: "tourOpen", z: 245, escapable: true }, From d6fc40d92342a2f4bd778af6c73060e88c229c12 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 09:57:16 +0900 Subject: [PATCH 10/52] =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EA=B0=80=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=ED=95=9C=20=EA=B2=80=EC=82=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 워크플로가 릴리스 하나뿐이었고 v* 태그에만 걸렸다. push 에도 PR 에도 아무 검사가 없었다. 그래서 테스트 685개와 `npm run typecheck` 를 갖춰 놓고도 아무도 돌리지 않았고, 엄격 설정으로 따로 세운 src/engine 검사는 **25줄 실패한 채 남아 있었다.** 아무도 안 보는 검사는 있으나 마나다. CI 를 붙이고(typecheck·test·build), 빨간불이던 것을 고친다. 실제 코드 셋은 전부 **런타임 가드는 이미 있는데 타입이 그걸 못 읽던** 자리였다 — 잠복 버그는 없었다. 가드를 타입으로 표현하도록 고쳤다. jsonc.ts j < n 으로 막아 둔 인덱스 — ?? "" 로 (빈 문자열은 \s 에 안 걸린다) mcpb.ts 정규식 그룹이 있을 때만 담는다 subagents.ts 빈 목록은 위에서 걸렀다. 반환 타입도 그걸 알게 ?? null 테스트 쪽은 표를 인덱스로 집는 자리다. `!` 를 붙였다 — "그 자리에 있어야 한다" 는 주장이고, 없으면 그 줄에서 죽는 게 맞다. checkpoints 의 only() 는 헬퍼 하나만 고쳐 쓰는 곳 전부가 따라온다. README 의 기여 안내도 고쳤다. "릴리스 태그마다 CI 가 build 한다" 고 적혀 있었는데, 정작 PR 에는 아무것도 안 돌고 있었다. --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++ README.md | 7 ++++- ide/src/engine/changeset.test.ts | 8 +++--- ide/src/engine/checkpoints.test.ts | 8 +++--- ide/src/engine/jsonc.ts | 2 +- ide/src/engine/mcpb.test.ts | 2 +- ide/src/engine/mcpb.ts | 2 +- ide/src/engine/plan.test.ts | 12 ++++---- ide/src/engine/subagents.ts | 2 +- 9 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b80d851 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +# 저장소가 스스로 정의한 검사를 **누군가는 돌려야 한다.** +# +# 지금까지 워크플로는 릴리스 하나뿐이었고 v* 태그에만 걸렸다. 그래서 685개 테스트와 +# `npm run typecheck` 가 있는데도 push·PR 에서는 아무것도 돌지 않았고, 엄격 설정으로 +# 따로 세운 `src/engine/tsconfig.json` 검사가 25줄 실패한 채 조용히 남아 있었다. +# 아무도 안 보는 검사는 있으나 마나다. +on: + push: + branches: [main, develop] + pull_request: + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ide/package-lock.json + + - name: Install dependencies + working-directory: ide + run: npm ci + + # 두 단계다: 앱 전체(느슨) + src/engine 의 엄격한 섬(strict·noUncheckedIndexedAccess). + # 두 번째가 이 저장소가 일부러 만든 안전망이라 반드시 같이 돈다. + - name: Typecheck + working-directory: ide + run: npm run typecheck + + - name: Test + working-directory: ide + run: npm test + + # 렌더러가 실제로 묶이는지까지 본다 — 타입이 맞아도 번들이 깨질 수 있다. + # electron-builder(설치본)는 여기서 돌리지 않는다. 그건 릴리스 워크플로의 일이다. + - name: Build renderer + working-directory: ide + run: npm run build diff --git a/README.md b/README.md index a592972..7e2d6d5 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,12 @@ npm run dist:linux # Linux build ## Contributing -Issues and pull requests are welcome. Run `npm run build` in `ide/` before opening a PR — it type-checks and builds the renderer, which is what CI does on every release tag. +Issues and pull requests are welcome. CI runs on every push and pull request: `npm run typecheck`, +`npm test`, and `npm run build` in `ide/`. Running those three locally before opening a PR gets you +the same answer without the wait. + +Note that `typecheck` is two passes — the whole app, then the stricter island under `src/engine`, +which turns on `strict` and `noUncheckedIndexedAccess` for code that is meant to be provably total. See [CHANGELOG.md](CHANGELOG.md) for release history and [docs/DESIGN.md](docs/DESIGN.md) for design notes. diff --git a/ide/src/engine/changeset.test.ts b/ide/src/engine/changeset.test.ts index 17304b6..abb8d8d 100644 --- a/ide/src/engine/changeset.test.ts +++ b/ide/src/engine/changeset.test.ts @@ -66,7 +66,7 @@ describe("summarizeChanges", () => { P({ rel: "a.ts", status: "accepted" }), P({ rel: "a.ts", status: "pending" }), ]); - expect(r[0].status).toBe("pending"); + expect(r[0]!.status).toBe("pending"); }); it("전부 수락이면 accepted", () => { @@ -74,7 +74,7 @@ describe("summarizeChanges", () => { P({ rel: "a.ts", status: "accepted" }), P({ rel: "a.ts", status: "accepted" }), ]); - expect(r[0].status).toBe("accepted"); + expect(r[0]!.status).toBe("accepted"); }); it("실패가 섞이면 실패가 이긴다 — 조용히 성공으로 보이면 안 된다", () => { @@ -82,7 +82,7 @@ describe("summarizeChanges", () => { P({ rel: "a.ts", status: "accepted" }), P({ rel: "a.ts", status: "failed" }), ]); - expect(r[0].status).toBe("failed"); + expect(r[0]!.status).toBe("failed"); }); it("같은 파일을 두 에이전트가 건드리면 둘 다 기록한다", () => { @@ -91,7 +91,7 @@ describe("summarizeChanges", () => { P({ rel: "a.ts", agent: "gpt" }), P({ rel: "a.ts", agent: "claude" }), ]); - expect(r[0].agents).toEqual(["claude", "gpt"]); + expect(r[0]!.agents).toEqual(["claude", "gpt"]); }); it("빈 입력은 빈 목록", () => { diff --git a/ide/src/engine/checkpoints.test.ts b/ide/src/engine/checkpoints.test.ts index 75dcc53..b19ef3d 100644 --- a/ide/src/engine/checkpoints.test.ts +++ b/ide/src/engine/checkpoints.test.ts @@ -10,7 +10,7 @@ const E = (o: Partial): CheckpointEntry => ({ }); const D = (o: Partial): DiskState => ({ exists: true, hash: "H1", ...o }); const disk = (rel: string, d: Partial) => new Map([[rel, D(d)]]); -const only = (entries: CheckpointEntry[], m: Map) => planUndo(entries, m)[0]; +const only = (entries: CheckpointEntry[], m: Map) => planUndo(entries, m)[0]!; describe("planUndo — 되돌려도 되는 경우", () => { it("우리가 쓴 그대로면 되돌린다", () => { @@ -111,7 +111,7 @@ describe("mergeCapture — 처음 것이 원본", () => { let es = mergeCapture([], E({ rel: "a.ts", beforeHash: "처음" })); es = mergeCapture(es, E({ rel: "a.ts", beforeHash: "나중" })); expect(es).toHaveLength(1); - expect(es[0].beforeHash).toBe("처음"); + expect(es[0]!.beforeHash).toBe("처음"); }); it("다른 파일은 더한다", () => { let es = mergeCapture([], E({ rel: "a.ts" })); @@ -129,8 +129,8 @@ describe("applyAfter", () => { it("해당 파일의 afterHash 만 갱신한다", () => { const es = [E({ rel: "a.ts", afterHash: "옛것" }), E({ rel: "b.ts", afterHash: "그대로" })]; const r = applyAfter(es, "a.ts", "새것"); - expect(r[0].afterHash).toBe("새것"); - expect(r[1].afterHash).toBe("그대로"); + expect(r[0]!.afterHash).toBe("새것"); + expect(r[1]!.afterHash).toBe("그대로"); }); it("없는 파일은 만들지 않는다 — 캡처 안 된 것을 되돌릴 수는 없다", () => { const r = applyAfter([E({ rel: "a.ts" })], "없는파일.ts", "H"); diff --git a/ide/src/engine/jsonc.ts b/ide/src/engine/jsonc.ts index f8244b0..ac8ce28 100644 --- a/ide/src/engine/jsonc.ts +++ b/ide/src/engine/jsonc.ts @@ -59,7 +59,7 @@ function dropTrailingCommas(text: string): string { } if (c === ",") { let j = i + 1; - while (j < n && /\s/.test(text[j])) j++; + while (j < n && /\s/.test(text[j] ?? "")) j++; // 빈 문자열은 \s 에 안 걸려 여기서 멈춘다 if (text[j] === "}" || text[j] === "]") { i++; continue; } // 버린다 } out += c; i++; diff --git a/ide/src/engine/mcpb.test.ts b/ide/src/engine/mcpb.test.ts index b8f4c50..c0418fb 100644 --- a/ide/src/engine/mcpb.test.ts +++ b/ide/src/engine/mcpb.test.ts @@ -106,7 +106,7 @@ describe("user_config", () => { const f = okOf(withUc).userConfig; expect(f.map(x => x.key)).toEqual(["api_key", "units", "dirs"]); expect(f[0]).toMatchObject({ required: true, sensitive: true, title: "API 키" }); - expect(f[1].default).toBe("metric"); + expect(f[1]!.default).toBe("metric"); expect(f[2]).toMatchObject({ title: "dirs", multiple: true }); }); it("기본값으로 입력칸을 채운다", () => { diff --git a/ide/src/engine/mcpb.ts b/ide/src/engine/mcpb.ts index 015d600..4652c22 100644 --- a/ide/src/engine/mcpb.ts +++ b/ide/src/engine/mcpb.ts @@ -156,7 +156,7 @@ export function resolveServer(m: McpbManifest, vars: TemplateVars): ResolvedServ const unresolved: string[] = []; const scan = (s: string) => { - for (const mt of s.matchAll(/\$\{([^}]+)\}/g)) unresolved.push(mt[1].trim()); + for (const mt of s.matchAll(/\$\{([^}]+)\}/g)) { const g = mt[1]; if (g) unresolved.push(g.trim()); } }; scan(command); args.forEach(scan); Object.values(env).forEach(scan); return { command, args, env, unresolved: [...new Set(unresolved)] }; diff --git a/ide/src/engine/plan.test.ts b/ide/src/engine/plan.test.ts index 2390161..605422d 100644 --- a/ide/src/engine/plan.test.ts +++ b/ide/src/engine/plan.test.ts @@ -43,24 +43,24 @@ describe("mergePlan", () => { it("같은 라벨은 id 를 유지한다 — 안 그러면 스피너가 껌뻑인다", () => { const first = mergePlan([], [S("a"), S("b")], "claude"); const second = mergePlan(first, [S("a", true), S("b")], "claude"); - expect(second[0].id).toBe(first[0].id); - expect(second[1].id).toBe(first[1].id); + expect(second[0]!.id).toBe(first[0]!.id); + expect(second[1]!.id).toBe(first[1]!.id); expect(second.map(x => x.st)).toEqual(["done", "active"]); }); it("처음 올린 에이전트를 기억한다 — 나중 호출이 주인을 바꾸지 않는다", () => { const first = mergePlan([], [S("a")], "claude"); const second = mergePlan(first, [S("a", true)], "gpt"); - expect(second[0].agent).toBe("claude"); + expect(second[0]!.agent).toBe("claude"); }); it("새로 끼워 넣은 단계는 새 id 를 받는다", () => { const first = mergePlan([], [S("a"), S("c")], "claude"); const second = mergePlan(first, [S("a", true), S("b"), S("c")], "claude"); expect(second.map(x => x.label)).toEqual(["a", "b", "c"]); - expect(second[0].id).toBe(first[0].id); // 유지 - expect(second[2].id).toBe(first[1].id); // c 도 유지 — 순서가 밀려도 라벨로 찾는다 - expect(second[1].id).not.toBe(first[1].id); // b 는 새것 + expect(second[0]!.id).toBe(first[0]!.id); // 유지 + expect(second[2]!.id).toBe(first[1]!.id); // c 도 유지 — 순서가 밀려도 라벨로 찾는다 + expect(second[1]!.id).not.toBe(first[1]!.id); // b 는 새것 }); it("사라진 단계는 결과에서 빠진다 — 모델이 보낸 것이 전부다", () => { diff --git a/ide/src/engine/subagents.ts b/ide/src/engine/subagents.ts index 292a6aa..11c8d31 100644 --- a/ide/src/engine/subagents.ts +++ b/ide/src/engine/subagents.ts @@ -63,7 +63,7 @@ export function providerFor( const mapped = alias[want] ?? (configured.includes(want) ? want : ""); if (mapped && configured.includes(mapped)) return mapped; if (configured.includes(caller)) return caller; - return configured[0]; + return configured[0] ?? null; // 위에서 빈 목록을 걸렀지만, 타입도 그걸 알아야 한다 } /** From 4b3bfff895dbf89aaa3c23fccb1137c1b84b7d57 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 3 Aug 2026 14:03:25 +0900 Subject: [PATCH 11/52] =?UTF-8?q?README=20=EB=A5=BC=20=EC=A7=80=EA=B8=88?= =?UTF-8?q?=20=EB=82=98=EA=B0=80=EB=8A=94=20=EA=B2=83=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.1.0 이후 들어간 것들이 문서에 없었다. 단축키 표는 다섯 줄에서 멈춰 있었고 (Ctrl+F5·Alt+←/→·Ctrl+W·Ctrl+B·Ctrl+Shift+M 이 빠져 있었다), 기능 목록에는 파일 실행·위치 이동·트리 git 색·확장 이벤트 훅이 없었다. 로드맵 Phase 3 은 "생태계" 를 아직 예정으로 적어 두고 있었는데, 정작 기능 목록에는 VS Code 확장·스킬·MCP·.mcpb 가 이미 들어 있다. 같은 문서 안에서 앞뒤가 어긋난다 — 끝난 것은 끝났다고 적고, 대신 **아직 얇은 곳**을 이름으로 적는다: 셰임이 덮는 VS Code API 범위. 활성 에디터를 읽는 확장은 지금 아무것도 못 받는다. 문서가 앱보다 잘해 보이면 안 되지만, 못해 보여도 안 된다. --- README.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7e2d6d5..0291a89 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,24 @@ The spotlight tour covers the rest, and can be replayed any time from the Help m | `Ctrl+Shift+P` | Command palette | | `Ctrl+Shift+F` | Search across the project | | `Ctrl+T` | Search workspace symbols | +| `Ctrl+F5` | Run the file you are looking at | +| `Alt+←` / `Alt+→` | Back and forward through edit locations | +| `Ctrl+W` / `Ctrl+Shift+T` | Close a tab, reopen the last closed one | +| `Ctrl+B` | Hide the sidebar | +| `Ctrl+Shift+M` | Switch between editor and agent mode | + +Every binding is editable in Settings → Keybindings, and the list there is generated from the +same table the app dispatches from, so it cannot drift from what actually happens. ## Features -**Editor** — Tabbed editing with 1/2/4 split groups, unsaved-changes guards, project-wide search and replace, TypeScript intelligence, a problems panel, command palette, and symbol outline. Additional languages are supported through LSP (Python via pyright, plus a bridge for custom servers) with formatting, code actions, folding, highlights, and inlay hints. +**Editor** — Tabbed editing with 1/2/4 split groups, unsaved-changes guards, project-wide search and replace, TypeScript intelligence, a problems panel, command palette, and symbol outline. Additional languages are supported through LSP (Python via pyright, plus a bridge for custom servers) with formatting, code actions, folding, highlights, and inlay hints. `Alt+←` and `Alt+→` walk back and forward through where you have been — the position, not just the file. + +**Run a file** — `Ctrl+F5` runs whatever you are looking at: Python, C, C++, Rust, Go, Node, TypeScript, Ruby, Java, shell. It runs in a real terminal rather than an output panel, so `input()` works and `Ctrl+C` stops it. A missing toolchain is reported as a missing toolchain instead of a shell error, and the per-language command is editable in Settings. **AI** — Claude and Codex accounts, chat with file and selection context, inline edit, per-project conversation history, and per-agent stop control. The autonomy policy decides which low-risk changes apply on their own and which wait for review. Every run is checkpointed, so you can undo everything one turn touched — and a file you edited afterwards is reported, never silently overwritten. Subagents defined in `.claude/agents` become delegation targets with their own instructions and tool limits. -**Git** — Stage, commit, and push from the source control panel; side-by-side diff against `HEAD`; gutter change markers; branch and ahead/behind status; blame and stash. +**Git** — Stage, commit, and push from the source control panel; side-by-side diff against `HEAD`; gutter change markers; branch and ahead/behind status; blame and stash. The file tree colours what changed, so you can see it without opening the panel. **Terminal** — A real PTY terminal (xterm.js + node-pty) with ANSI color, scrollback, and multiple tabs, alongside a log tab showing live agent activity. @@ -83,7 +93,7 @@ The spotlight tour covers the rest, and can be replayed any time from the Help m **Debugging** — Breakpoints, call stack, variables, and stepping via DAP (Python/debugpy today). -**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. +**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. Schutz-native extensions can register commands, show panels, and subscribe to what happens in the IDE — files opened and saved, proposals accepted or rejected, agent turns starting and ending. **Localization** — The full UI ships in Korean, English, German, and Japanese. @@ -112,7 +122,12 @@ npm run dist:linux # Linux build - **Phase 1** — validate the core experience *(done)* - **Phase 2** — deepen renderer-level visual effects at the editor core *(in progress)* -- **Phase 3** — more providers (Gemini, local/OpenAI-compatible endpoints), codebase indexing, ecosystem +- **Phase 3** — more providers (Gemini, local/OpenAI-compatible endpoints) and codebase indexing + +The ecosystem work that used to sit in Phase 3 has shipped: VS Code extensions, Claude Code +skills and subagents, MCP servers over stdio and HTTP, and `.mcpb` bundles are all in the app +today. What is still thin is how much of the VS Code extension API the shim covers — an +extension that reads the active editor currently gets nothing back. ## Contributing From ca5ee5162964ddf7c84d3ea4d7a7a6e6370d8059 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 3 Aug 2026 14:24:45 +0900 Subject: [PATCH 12/52] =?UTF-8?q?README=20=ED=81=B4=EB=A6=BD=EC=9D=84=20?= =?UTF-8?q?=EC=B0=8D=EB=8A=94=20=EB=8F=84=EA=B5=AC=EB=A5=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EC=97=90=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demo.gif 를 어떻게 뽑았는지는 docs/record-demo.md 에 산문으로 남아 있는데, 정작 도구(compose.cjs·녹화 스크립트)는 저장소 밖에 있어서 사라졌다. 산문만으로는 다시 못 만든다 — 이번엔 같이 둔다. 이미지 라이브러리가 하나도 없다(sharp·pngjs·canvas 전부 없음). 그런데 우리는 이미 Chromium 을 CDP 로 몰고 있으므로 PNG 디코드·축소·둥근 모서리·그림자는 canvas 가 해 준다. 그래서 프레임을 Node 로 꺼내지 않고 렌더러 안에서 합성·양자화·차분·LZW 까지 끝낸 뒤 완성된 GIF 하나만 돌려받는다 — 프레임을 왕복시키면 클립 하나에 수십 MB 가 오간다. 의존성은 0 이다. 인코딩에서 값을 하는 건 하나다: 전역 팔레트 + 투명 인덱스 차분(dispose 1). IDE 화면은 프레임 사이가 99% 같아서 안 바뀐 픽셀을 투명으로 두면 LZW 가 긴 반복을 통째로 먹는다. 실측 56~333KB — 히어로(2.8MB)와 달리 기능 클립은 README 예산 안에 들어온다. 클립 자체는 아직 안 넣는다. 찍어 보니 지금 시나리오들이 약했다 — 대화가 비어 있는 모드 전환은 빈 화면 둘을 오갈 뿐이고, 터미널에 글자가 찍히는 건 이 앱의 특징이 아니다. 정작 보여 줄 값이 있는 것(실행 되돌리기·줄 단위 수락·편집 애니메이션)은 실제 에이전트 턴이 있어야 화면에 나온다. --- docs/record-demo.md | 12 ++- scripts/gifkit.js | 234 ++++++++++++++++++++++++++++++++++++++++ scripts/record-gif.mjs | 236 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 scripts/gifkit.js create mode 100644 scripts/record-gif.mjs diff --git a/docs/record-demo.md b/docs/record-demo.md index b370fa0..bb3714f 100644 --- a/docs/record-demo.md +++ b/docs/record-demo.md @@ -1,4 +1,14 @@ -# Recording `docs/assets/demo.gif` +# Recording the README clips + +> **Tooling lives in [`scripts/`](../scripts).** `node scripts/record-gif.mjs --list` shows the +> scenes; `node scripts/record-gif.mjs ` drives the app over CDP and writes +> `docs/assets/.gif`. Set `SCHUTZ_GIF_DEBUG=1` to also drop the last captured frame +> as a PNG, which is how you check that a scene actually shows what you meant. +> +> The first version of this pipeline lived outside the repository and was lost, leaving only +> the prose below. Keep the tool and the prose together. + +## The hero clip — `docs/assets/demo.gif` The README demo is captured from the running app, not mocked up. Re-record it whenever the UI changes enough that the GIF starts lying about what Schutz looks like. diff --git a/scripts/gifkit.js b/scripts/gifkit.js new file mode 100644 index 0000000..fd91e1d --- /dev/null +++ b/scripts/gifkit.js @@ -0,0 +1,234 @@ +/* eslint-disable */ +/** + * GIF 만들기 — **브라우저 안에서** 돈다. + * + * 이 저장소에는 이미지 라이브러리가 없다(sharp·pngjs·canvas 전부 없음). 그런데 우리는 + * 이미 Chromium 을 CDP 로 몰고 있으므로, PNG 디코드·축소·둥근 모서리·그림자는 canvas 가 + * 공짜로 해 준다. 그래서 프레임을 Node 로 도로 꺼내지 않고 여기서 끝까지 만든 뒤 + * **완성된 GIF 하나만** 돌려준다 — 프레임을 왕복시키면 클립 하나에 수십 MB 가 오간다. + * + * 인코딩에서 값을 하는 것은 하나다: 전역 팔레트 + 투명 인덱스 차분(dispose 1). + * IDE 화면은 프레임 사이가 99% 같아서, 안 바뀐 픽셀을 투명으로 두면 LZW 가 긴 반복을 + * 통째로 먹는다. docs/record-demo.md 에 적힌 2054KB → 121KB 가 이 한 줄에서 나온다. + * + * 쓰는 쪽은 scripts/record-gif.mjs. + */ +(function () { + "use strict"; + + /** 프레임을 모아 두는 곳. addFrame 이 채우고 finish 가 비운다. */ + const state = { frames: [], w: 0, h: 0 }; + + /** 캡처 한 장을 "창처럼" 만든다 — 배경 위에 여백을 두고, 모서리를 둥글리고, 그림자를 깐다. + * 생 캡처는 화면 사진처럼 읽힌다. 배경은 앱보다 **밝아야** 창이 물체로 보인다. */ + function compose(img, o) { + const scale = o.width / img.width; + const winW = o.width, winH = Math.round(img.height * scale); + const W = winW + o.margin * 2, H = winH + o.margin * 2; + + const c = document.createElement("canvas"); + c.width = W; c.height = H; + const g = c.getContext("2d", { willReadFrequently: true }); + + const grad = g.createLinearGradient(0, 0, W, H); + grad.addColorStop(0, o.bg[0]); grad.addColorStop(1, o.bg[1]); + g.fillStyle = grad; g.fillRect(0, 0, W, H); + + g.save(); + g.shadowColor = o.shadow; g.shadowBlur = o.margin * 0.9; g.shadowOffsetY = o.margin * 0.28; + g.fillStyle = "#000"; + roundRect(g, o.margin, o.margin, winW, winH, o.radius); g.fill(); + g.restore(); + + g.save(); + roundRect(g, o.margin, o.margin, winW, winH, o.radius); g.clip(); + g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high"; + g.drawImage(img, o.margin, o.margin, winW, winH); + g.restore(); + + return g.getImageData(0, 0, W, H); + } + + function roundRect(g, x, y, w, h, r) { + g.beginPath(); + g.moveTo(x + r, y); + g.arcTo(x + w, y, x + w, y + h, r); + g.arcTo(x + w, y + h, x, y + h, r); + g.arcTo(x, y + h, x, y, r); + g.arcTo(x, y, x + w, y, r); + g.closePath(); + } + + /** 전역 팔레트 — 미디언 컷. 프레임마다 팔레트를 새로 쓰면 차분을 못 쓴다(전부 새 색이 된다). */ + function buildPalette(frames, want) { + const seen = new Map(); + for (const f of frames) { + const d = f.data; + // 전 프레임 전 픽셀을 세면 느리다 — 고르게 성기게 뽑아도 색 분포는 같다. + for (let i = 0; i < d.length; i += 4 * 17) { + const k = (d[i] >> 2 << 12) | (d[i + 1] >> 2 << 6) | (d[i + 2] >> 2); + seen.set(k, (seen.get(k) || 0) + 1); + } + } + let box = []; + for (const [k, n] of seen) box.push([(k >> 12 & 63) << 2, (k >> 6 & 63) << 2, (k & 63) << 2, n]); + let boxes = [box]; + while (boxes.length < want) { + // 가장 넓은 상자를 가장 긴 축에서 가른다 + let bi = -1, bs = -1; + for (let i = 0; i < boxes.length; i++) { + const b = boxes[i]; + if (b.length < 2) continue; + const s = spread(b); + if (s.range > bs) { bs = s.range; bi = i; } + } + if (bi < 0) break; + const b = boxes[bi], ax = spread(b).axis; + b.sort((p, q) => p[ax] - q[ax]); + boxes.splice(bi, 1, b.slice(0, b.length >> 1), b.slice(b.length >> 1)); + } + const pal = []; + for (const b of boxes) { + let r = 0, g = 0, bl = 0, n = 0; + for (const p of b) { r += p[0] * p[3]; g += p[1] * p[3]; bl += p[2] * p[3]; n += p[3]; } + pal.push(n ? [Math.round(r / n), Math.round(g / n), Math.round(bl / n)] : [0, 0, 0]); + } + while (pal.length < want) pal.push([0, 0, 0]); + return pal.slice(0, want); + } + + function spread(b) { + let lo = [255, 255, 255], hi = [0, 0, 0]; + for (const p of b) for (let a = 0; a < 3; a++) { if (p[a] < lo[a]) lo[a] = p[a]; if (p[a] > hi[a]) hi[a] = p[a]; } + let axis = 0, range = -1; + for (let a = 0; a < 3; a++) { const d = hi[a] - lo[a]; if (d > range) { range = d; axis = a; } } + return { axis, range }; + } + + /** 색 → 팔레트 인덱스. 8비트로 잘라 캐시한다(같은 색이 수만 번 나온다). */ + function makeMapper(pal) { + const cache = new Map(); + return (r, g, b) => { + const k = (r >> 1 << 14) | (g >> 1 << 7) | (b >> 1); + const hit = cache.get(k); + if (hit !== undefined) return hit; + let best = 0, bd = 1e9; + for (let i = 0; i < pal.length; i++) { + const p = pal[i], dr = p[0] - r, dg = p[1] - g, db = p[2] - b; + const d = dr * dr * 3 + dg * dg * 6 + db * db; // 눈이 초록에 민감하다 + if (d < bd) { bd = d; best = i; } + } + cache.set(k, best); + return best; + }; + } + + // ── GIF 바이트 ─────────────────────────────────────────────────────────── + function lzw(indices, minCodeSize) { + const out = []; + let cur = 0, curBits = 0; + const put = (code, bits) => { + cur |= code << curBits; curBits += bits; + while (curBits >= 8) { out.push(cur & 255); cur >>= 8; curBits -= 8; } + }; + const clear = 1 << minCodeSize, eoi = clear + 1; + let dict = new Map(), next = eoi + 1, size = minCodeSize + 1; + const reset = () => { dict = new Map(); next = eoi + 1; size = minCodeSize + 1; }; + put(clear, size); reset(); + let prefix = indices[0]; + for (let i = 1; i < indices.length; i++) { + const k = indices[i], key = prefix * 4096 + k; + const found = dict.get(key); + if (found !== undefined) { prefix = found; continue; } + put(prefix, size); + dict.set(key, next++); + if (next - 1 === (1 << size) && size < 12) size++; + if (next >= 4096) { put(clear, size); reset(); } + prefix = k; + } + put(prefix, size); put(eoi, size); + if (curBits > 0) out.push(cur & 255); + return out; + } + + function bytes(str) { const a = []; for (let i = 0; i < str.length; i++) a.push(str.charCodeAt(i)); return a; } + function u16(n) { return [n & 255, (n >> 8) & 255]; } + + function encode(frames, pal, transIdx, w, h, loop) { + const out = []; + const push = a => { for (const b of a) out.push(b); }; + // 팔레트는 2의 거듭제곱이어야 한다 + let bits = 1; while ((1 << bits) < pal.length) bits++; + push(bytes("GIF89a")); + push(u16(w)); push(u16(h)); + push([0xF0 | (bits - 1), 0, 0]); + for (let i = 0; i < (1 << bits); i++) { const p = pal[i] || [0, 0, 0]; push([p[0], p[1], p[2]]); } + // 무한 반복 + push([0x21, 0xFF, 11]); push(bytes("NETSCAPE2.0")); push([3, 1]); push(u16(loop)); push([0]); + + for (const f of frames) { + const delay = Math.max(2, Math.round(f.delay / 10)); // 1/100초 + // dispose 1 = 그대로 두기. 투명 인덱스를 쓰려면 이래야 앞 프레임이 비쳐 보인다. + push([0x21, 0xF9, 4, (1 << 2) | (f.trans ? 1 : 0), delay & 255, (delay >> 8) & 255, f.trans ? transIdx : 0, 0]); + push([0x2C]); push(u16(0)); push(u16(0)); push(u16(w)); push(u16(h)); push([0]); + const min = Math.max(2, bits); + push([min]); + const data = lzw(f.idx, min); + for (let i = 0; i < data.length; i += 255) { + const chunk = data.slice(i, i + 255); + push([chunk.length]); push(chunk); + } + push([0]); + } + push([0x3B]); + return out; + } + + // ── 공개 API ───────────────────────────────────────────────────────────── + window.__gifkit = { + reset() { state.frames = []; state.w = 0; state.h = 0; }, + + /** 캡처 한 장을 더한다. dataUrl 은 screencast 가 준 PNG. */ + async add(dataUrl, delay, opts) { + const img = new Image(); + img.src = dataUrl; + await img.decode(); + const data = compose(img, opts); + state.w = data.width; state.h = data.height; + state.frames.push({ data: data.data, delay }); + return state.frames.length; + }, + + /** 팔레트를 만들고 차분해 GIF 를 짓는다. base64 로 돌려준다. */ + finish(colors) { + const fs = state.frames; + if (!fs.length) return null; + const want = Math.min(255, colors || 200); // 한 칸은 투명용으로 남긴다 + const pal = buildPalette(fs, want); + const transIdx = pal.length; // 팔레트 바로 뒤 + const map = makeMapper(pal); + const n = state.w * state.h; + const out = []; + let prev = null; + for (let fi = 0; fi < fs.length; fi++) { + const d = fs[fi].data; + const idx = new Uint8Array(n); + let anyTrans = false; + for (let i = 0, p = 0; i < n; i++, p += 4) { + const v = map(d[p], d[p + 1], d[p + 2]); + if (prev && prev[i] === v && fi > 0) { idx[i] = transIdx; anyTrans = true; } + else idx[i] = v; + } + // 다음 프레임의 기준은 **실제로 보이는 것** — 투명 자리는 앞 값이 그대로 남는다 + const shown = new Uint8Array(n); + for (let i = 0; i < n; i++) shown[i] = idx[i] === transIdx ? prev[i] : idx[i]; + prev = shown; + out.push({ idx, delay: fs[fi].delay, trans: anyTrans }); + } + const bin = encode(out, pal, transIdx, state.w, state.h, 0); + let s = ""; + for (let i = 0; i < bin.length; i += 8192) s += String.fromCharCode.apply(null, bin.slice(i, i + 8192)); + return btoa(s); + }, + }; +})(); diff --git a/scripts/record-gif.mjs b/scripts/record-gif.mjs new file mode 100644 index 0000000..4b8f823 --- /dev/null +++ b/scripts/record-gif.mjs @@ -0,0 +1,236 @@ +/** + * 기능 GIF 녹화 — 실행 중인 앱을 CDP 로 찍어 docs/assets/*.gif 를 만든다. + * + * docs/record-demo.md 가 히어로 GIF 를 어떻게 뽑았는지 산문으로 남겼는데, 정작 도구는 + * 저장소 밖에 있어서 사라졌다. 다시 잃지 않도록 이번엔 커밋한다. + * + * node scripts/record-gif.mjs <시나리오이름> + * node scripts/record-gif.mjs --list + * + * 프레임을 Node 로 꺼내지 않는다. screencast 로 받은 PNG 를 렌더러에 도로 넣고 + * scripts/gifkit.js 가 합성·양자화·차분·인코딩까지 끝내 GIF 하나만 돌려준다 — + * 이 저장소에는 이미지 라이브러리가 없고, 프레임을 왕복시키면 클립당 수십 MB 가 오간다. + */ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, ".."); +const IDE = path.join(ROOT, "ide"); +const OUT = path.join(ROOT, "docs", "assets"); +const PORT = 9333; + +/** 히어로 GIF 와 달리 기능 클립은 작아야 한다. README 는 볼 때마다 전부 내려받는다. */ +const LOOK = { + width: 900, // 창 폭(px). 히어로는 1744(2×), 기능 클립은 1×로 충분하다 + margin: 34, + radius: 12, + bg: ["#A9BCA9", "#7E9583"], // 세이지 — 앱보다 밝아야 창이 물체로 보인다 + shadow: "rgba(24,32,22,.45)", +}; +const FPS = 12; // UI 클립에 28fps 는 낭비다. 예산은 용량에 쓴다 +const COLORS = 200; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +/* ── 시나리오 ───────────────────────────────────────────────────────────── + * 각 시나리오는 준비(setup)와 연기(act)로 나뉜다. 캡처는 act 동안에만 돈다 — + * 준비 장면이 클립에 들어가면 무엇을 보라는 건지 흐려진다. */ +const SCENES = { + "nav-history": { + title: "Alt+← / Alt+→ 로 자리 되짚기", + files: { + "token.ts": Array.from({ length: 40 }, (_, i) => `export const token${i} = ${i};`).join("\n") + "\n", + "types.ts": Array.from({ length: 30 }, (_, i) => `export type T${i} = { v: ${i} };`).join("\n") + "\n", + }, + async setup(d) { + await d.open("token.ts"); await sleep(700); + await d.gotoLine(32); await sleep(600); + await d.open("types.ts"); await sleep(900); + }, + async act(d) { + await sleep(600); + await d.key("ArrowLeft", { alt: true, vk: 37 }); await sleep(1400); + await d.key("ArrowRight", { alt: true, vk: 39 }); await sleep(1400); + }, + }, + "mode-switch": { + title: "에디터 ↔ 에이전트 전환", + files: { "a.ts": "export const a = 1;\n" }, + async setup(d) { await d.open("a.ts"); await sleep(900); }, + async act(d) { + await sleep(500); + await d.key("m", { ctrl: true, shift: true, vk: 77 }); await sleep(2000); + await d.key("m", { ctrl: true, shift: true, vk: 77 }); await sleep(1800); + }, + }, + "tree-git": { + title: "트리에서 바뀐 파일이 보인다", + git: true, + files: { "kept.ts": "export const kept = 1;\n", "edited.ts": "export const edited = 1;\n" }, + async setup(d) { await d.open("edited.ts"); await sleep(900); }, + async act(d) { + await d.type(" // touched"); await sleep(700); + await d.key("s", { ctrl: true, vk: 83 }); await sleep(2600); + }, + }, +}; + +/* ── CDP ─────────────────────────────────────────────────────────────────── */ +function makeDriver(ws, send, ev) { + const key = async (k, o = {}) => { + const mod = (o.alt ? 1 : 0) | (o.ctrl ? 2 : 0) | (o.shift ? 8 : 0); + await send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: k, code: o.code ?? k, modifiers: mod, windowsVirtualKeyCode: o.vk, nativeVirtualKeyCode: o.vk }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: o.code ?? k, modifiers: mod }); + await sleep(220); + }; + return { + key, + ev, + async type(text) { await send("Input.insertText", { text }); }, + async open(name) { + await key("p", { code: "KeyP", ctrl: true, vk: 80 }); + await sleep(350); + await ev("(function(){var i=document.querySelector('input[data-szfocus]');" + + "var s=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;" + + "s.call(i," + JSON.stringify(name) + ");i.dispatchEvent(new Event('input',{bubbles:true}));return 1})()"); + await sleep(500); + await key("Enter", { code: "Enter", vk: 13 }); + await sleep(900); + }, + async gotoLine(n) { + await key("g", { code: "KeyG", ctrl: true, vk: 71 }); + await sleep(400); + await send("Input.insertText", { text: String(n) }); + await sleep(250); + await key("Enter", { code: "Enter", vk: 13 }); + await sleep(400); + }, + }; +} + +async function connect() { + for (let i = 0; i < 60; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + const page = list.find(t => t.type === "page" && t.webSocketDebuggerUrl); + if (page) return page.webSocketDebuggerUrl; + } catch { /* 아직 안 떴다 */ } + await sleep(500); + } + throw new Error("CDP 대상이 안 뜬다"); +} + +async function record(name) { + const scene = SCENES[name]; + if (!scene) throw new Error(`모르는 시나리오: ${name}`); + + const proj = path.join(os.tmpdir(), "schutz-gif-" + name); + fs.rmSync(proj, { recursive: true, force: true }); + fs.mkdirSync(proj, { recursive: true }); + for (const [f, body] of Object.entries(scene.files)) fs.writeFileSync(path.join(proj, f), body); + if (scene.git) { + const { execSync } = await import("node:child_process"); + execSync('git init -q && git add -A && git -c user.email=a@b -c user.name=a commit -qm init', { cwd: proj, stdio: "ignore" }); + } + + const el = spawn(path.join(IDE, "node_modules/electron/dist/electron.exe"), + [IDE, `--remote-debugging-port=${PORT}`], { cwd: IDE, stdio: "ignore" }); + + let ws, id = 0; + const pend = new Map(); + const frames = []; + const send = (m, p = {}) => new Promise((res, rej) => { const i = ++id; pend.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method: m, params: p })); }); + const ev = async e => { + const r = await send("Runtime.evaluate", { expression: e, awaitPromise: true, returnByValue: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval 실패"); + return r.result.value; + }; + + try { + ws = new WebSocket(await connect()); + await new Promise(r => ws.addEventListener("open", r)); + ws.addEventListener("message", e => { + const m = JSON.parse(e.data); + if (m.id && pend.has(m.id)) { const { res, rej } = pend.get(m.id); pend.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); return; } + if (m.method === "Page.screencastFrame") { + frames.push({ data: m.params.data, t: Date.now() }); + void send("Page.screencastFrameAck", { sessionId: m.params.sessionId }).catch(() => {}); + } + }); + + await send("Runtime.enable"); + await send("Page.enable"); + // 배경 창은 한 프레임도 안 그린다 — 이걸 빼면 screencast 가 0장을 돌려준다 + await send("Emulation.setFocusEmulationEnabled", { enabled: true }); + await send("Page.bringToFront"); + await sleep(1200); + + await ev(`localStorage.setItem("schutz.lastRoot", ${JSON.stringify(proj)});` + + `localStorage.setItem("schutz.lang","en");localStorage.setItem("schutz.theme","feldgrau");` + + `localStorage.setItem("schutz.uiMode","editor");1`); + await send("Page.reload"); + await sleep(7000); + await send("Page.bringToFront"); + + const d = makeDriver(ws, send, ev); + await scene.setup(d); + + await send("Page.startScreencast", { format: "png", quality: 90, everyNthFrame: 1 }); + const t0 = Date.now(); + await scene.act(d); + await send("Page.stopScreencast"); + console.log(` 캡처: ${frames.length}장 / ${((Date.now() - t0) / 1000).toFixed(1)}초`); + if (!frames.length) throw new Error("프레임이 0장이다 — 창이 앞에 없다"); + + // 12fps 로 솎는다. UI 클립에 28fps 는 용량만 먹는다. + const step = 1000 / FPS; + const picked = []; + let want = frames[0].t; + for (const f of frames) { if (f.t >= want) { picked.push(f); want += step; } } + if (picked[picked.length - 1] !== frames[frames.length - 1]) picked.push(frames[frames.length - 1]); + console.log(` 솎음: ${picked.length}장`); + + // 장면을 짜는 사람이 "무엇이 찍혔나" 를 눈으로 봐야 한다 — 마지막 장을 따로 남긴다. + if (process.env.SCHUTZ_GIF_DEBUG) { + fs.mkdirSync(OUT, { recursive: true }); + fs.writeFileSync(path.join(OUT, name + ".last.png"), Buffer.from(picked[picked.length - 1].data, "base64")); + console.log(" 마지막 장: docs/assets/" + name + ".last.png"); + } + const kit = fs.readFileSync(path.join(HERE, "gifkit.js"), "utf8"); + await ev(kit + ";1"); + await ev("window.__gifkit.reset();1"); + for (let i = 0; i < picked.length; i++) { + const delay = i + 1 < picked.length ? picked[i + 1].t - picked[i].t : step; + await ev(`window.__gifkit.add("data:image/png;base64,${picked[i].data}", ${Math.round(delay)}, ${JSON.stringify(LOOK)})`); + } + const b64 = await ev(`window.__gifkit.finish(${COLORS})`); + if (!b64) throw new Error("인코딩이 빈 결과를 냈다"); + + fs.mkdirSync(OUT, { recursive: true }); + const dest = path.join(OUT, name + ".gif"); + fs.writeFileSync(dest, Buffer.from(b64, "base64")); + const kb = Math.round(fs.statSync(dest).size / 1024); + console.log(` 저장: docs/assets/${name}.gif ${kb} KB`); + if (kb > 500) console.log(" ⚠ 500KB 를 넘었다 — 길이를 줄이거나 폭을 낮출 것"); + } finally { + try { ws?.close(); } catch { /* */ } + el.kill(); + await sleep(400); + } +} + +const arg = process.argv[2]; +if (!arg || arg === "--list") { + console.log("시나리오:"); + for (const [k, v] of Object.entries(SCENES)) console.log(` ${k.padEnd(14)} ${v.title}`); + process.exit(0); +} +const names = arg === "--all" ? Object.keys(SCENES) : [arg]; +for (const n of names) { + console.log(`\n== ${n} — ${SCENES[n]?.title ?? "?"}`); + await record(n); +} From 5b743163f224094cb279f6bb8fab99a9809d2989 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 3 Aug 2026 17:09:32 +0900 Subject: [PATCH 13/52] =?UTF-8?q?=EB=85=B9=ED=99=94=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=EA=B0=80=20=EC=98=A4=EC=97=BC=EB=90=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A1=9C=20=EC=B0=8D=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 되돌리기 클립을 두 번 찍어 보고 둘 다 버렸다. 원인이 도구에 있었다. 체크포인트는 프로젝트 폴더가 아니라 **경로 해시로 userData 에** 남는다. 그래서 녹화 때마다 폴더를 새로 만들어도 지난 녹화의 "Undoable runs" 카드가 그대로 떠 있었고, 그걸 "준비 끝" 신호로 읽는 바람에 턴이 아직 도는 중에 촬영이 시작됐다 — 클립이 "Planning…" 화면으로 끝났다. 찍기 전에 그 경로의 체크포인트를 비운다. 준비 완료 판정도 바꿨다. 카드가 뜨는 것과 턴이 끝나는 것은 다른 사건이다. 이제 Stop 버튼이 사라질 때까지 기다린다. 그리고 무대가 제대로 서지 않으면 **찍지 않고 멈춘다.** 첫 시도가 딱 그 경우였다 — "실행 뒤에 손댄 파일" 을 만들려던 편집이 안 들어갔는데 그대로 찍혀서, 이 앱만 하는 말이 하나도 없는 클립이 나왔다. 무대가 틀리면 결과물은 조용히 무의미해진다. SCHUTZ_GIF_REUSE=1 을 두었다 — 에이전트 턴이 필요한 장면은 연기만 고쳐 다시 찍을 때 턴을 또 돌리지 않는다. 실제 API 호출이라 값이 나가는 일이다. --- scripts/record-gif.mjs | 99 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/scripts/record-gif.mjs b/scripts/record-gif.mjs index 4b8f823..de70c3e 100644 --- a/scripts/record-gif.mjs +++ b/scripts/record-gif.mjs @@ -40,6 +40,70 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); * 각 시나리오는 준비(setup)와 연기(act)로 나뉜다. 캡처는 act 동안에만 돈다 — * 준비 장면이 클립에 들어가면 무엇을 보라는 건지 흐려진다. */ const SCENES = { + // 이 앱만 하는 말이 화면에 나오는 유일한 장면이다: 실행 하나를 통째로 되돌리되, + // **그 뒤에 사람이 손댄 파일은 건드리지 않는다.** 실제 에이전트 턴이 필요하다. + "undo-run": { + title: "실행 단위 되돌리기 — 나중에 손댄 파일은 남긴다", + needsAgent: true, + files: { + "greet.ts": [ + "export function greet(name: string) {", + " return `Hello, ${name}`;", + "}", + ].join("\n") + "\n", + "notes.md": "# Notes\n\nNothing yet.\n", + }, + async setup(d) { + // 제안이 손을 안 타고 바로 적용되게 — 되돌릴 것이 있어야 찍을 게 생긴다 + await d.ev('localStorage.setItem("schutz.autonomy", JSON.stringify({ policy: "auto", rules: { docs: true, tests: true, deps: false }, reviewOnCommit: false, projectInstructions: false })); 1'); + await d.reload(); + + // 체크포인트는 프로젝트 폴더가 아니라 **경로 해시로 userData 에** 남는다. 폴더를 + // 새로 만들어도 지난 녹화의 카드가 그대로 떠 있어서, 그걸 준비 완료로 읽으면 + // 턴이 도는 중에 촬영이 시작된다(실제로 그랬다). 먼저 비운다. + if (!d.reused) { + await d.ev('(async()=>{const r=' + JSON.stringify(d.root) + ';const l=await window.schutz.cpList(r);' + + 'for(const c of l) await window.schutz.cpDrop(r,c.rootRunId);return l.length;})()'); + await d.reload(); + } + + await d.open("greet.ts"); + const already = await d.waitFor('document.body.innerText.indexOf("Undoable runs") >= 0', 4000); + if (!already) { + await d.prompt("Add a one-line JSDoc comment above greet() in greet.ts, and add one short line to notes.md about it. Keep both edits tiny."); + // 카드가 뜨는 것만으로는 부족하다 — 턴이 **끝나야** 한다. 도는 중에 찍으면 + // 클립이 계획 중인 화면으로 끝난다. + const done = await d.waitFor('(function(){var t=document.body.innerText;' + + 'return t.indexOf("Undoable runs") >= 0 && t.indexOf("Stop") < 0;})()', 240000); + if (!done) throw new Error("턴이 끝나지 않았다 — 찍지 않는다"); + } + await d.sleep(1500); + + // 이 클립의 요점: 실행 뒤에 사람이 손댄 파일은 되돌리기가 건드리지 않는다. + // 저장하지 않고 둔다 — 미저장 버퍼도 "건드리지 않음" 으로 잡히고, 저장 키는 + // 에디터 안에서 CDP 로 잘 안 먹는다. + await d.open("notes.md"); + await d.sleep(900); + await d.focusEditor(); + await d.key("End", { code: "End", vk: 35, ctrl: true }); + await d.sleep(300); + await d.type("\n\n(I edited this by hand)"); + const dirty = await d.waitFor('document.body.innerText.indexOf("I edited this by hand") >= 0', 6000); + if (!dirty) throw new Error("손편집이 안 들어갔다 — 이 장면의 요점이 빠지므로 찍지 않는다"); + await d.sleep(700); + }, + async act(d) { + await d.sleep(600); + await d.key("p", { code: "KeyP", ctrl: true, shift: true, vk: 80 }); + await d.sleep(500); + await d.type("undo"); + await d.sleep(900); + await d.key("Enter", { code: "Enter", vk: 13 }); + await d.sleep(2600); // 되돌리기 대화창 — 여기가 이 클립의 전부다 + await d.key("Enter", { code: "Enter", vk: 13 }); + await d.sleep(1600); + }, + }, "nav-history": { title: "Alt+← / Alt+→ 로 자리 되짚기", files: { @@ -90,7 +154,28 @@ function makeDriver(ws, send, ev) { return { key, ev, + sleep, async type(text) { await send("Input.insertText", { text }); }, + async reload() { await send("Page.reload"); await sleep(7000); await send("Page.bringToFront"); }, + /** 에디터 본문에 포커스 — Monaco 는 숨은 textarea 가 키를 받는다. */ + async focusEditor() { await ev("(function(){var t=document.querySelector('.monaco-editor textarea'); if(t) t.focus(); return !!t})()"); }, + /** 채팅으로 요청을 보낸다. **실제 에이전트 턴이 돈다.** */ + async prompt(text) { + await ev("(function(){var t=document.querySelector('textarea[placeholder]'); if(t) t.focus(); return !!t})()"); + await send("Input.insertText", { text }); + await sleep(300); + await key("Enter", { code: "Enter", vk: 13 }); + }, + /** 조건이 참이 될 때까지 기다린다. 모델이 무엇을 할지는 우리가 못 정하므로, + * 원하는 상태가 안 나오면 찍지 않고 멈추는 편이 낫다. */ + async waitFor(expr, ms) { + const until = Date.now() + ms; + while (Date.now() < until) { + try { if (await ev(expr)) return true; } catch { /* 렌더 중일 수 있다 */ } + await sleep(1000); + } + return false; + }, async open(name) { await key("p", { code: "KeyP", ctrl: true, vk: 80 }); await sleep(350); @@ -129,9 +214,16 @@ async function record(name) { if (!scene) throw new Error(`모르는 시나리오: ${name}`); const proj = path.join(os.tmpdir(), "schutz-gif-" + name); - fs.rmSync(proj, { recursive: true, force: true }); - fs.mkdirSync(proj, { recursive: true }); - for (const [f, body] of Object.entries(scene.files)) fs.writeFileSync(path.join(proj, f), body); + // SCHUTZ_GIF_REUSE=1 이면 그대로 둔다 — 에이전트 턴이 필요한 장면을 연기만 고쳐 + // 다시 찍을 때 토큰을 또 쓰지 않으려고. + const reuse = !!process.env.SCHUTZ_GIF_REUSE && fs.existsSync(proj); + if (!reuse) { + fs.rmSync(proj, { recursive: true, force: true }); + fs.mkdirSync(proj, { recursive: true }); + for (const [f, body] of Object.entries(scene.files)) fs.writeFileSync(path.join(proj, f), body); + } else { + console.log(" (재사용) 지난 실행의 프로젝트와 체크포인트를 그대로 쓴다"); + } if (scene.git) { const { execSync } = await import("node:child_process"); execSync('git init -q && git add -A && git -c user.email=a@b -c user.name=a commit -qm init', { cwd: proj, stdio: "ignore" }); @@ -177,6 +269,7 @@ async function record(name) { await send("Page.bringToFront"); const d = makeDriver(ws, send, ev); + d.root = proj; d.reused = reuse; await scene.setup(d); await send("Page.startScreencast", { format: "png", quality: 90, everyNthFrame: 1 }); From 0f572e241d65052df90e48f25bb531dd93234551 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 10:22:43 +0900 Subject: [PATCH 14/52] =?UTF-8?q?MCP=20=ED=95=B8=EB=93=9C=EC=85=B0?= =?UTF-8?q?=EC=9D=B4=ED=81=AC=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=93=B0?= =?UTF-8?q?=EA=B3=A0,=20=EC=84=B8=20=EA=B8=B0=EB=91=A5=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=20=EC=9D=BD=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialize 응답을 통째로 버리고 있었다. `await request(s, "initialize", …)` 의 반환값을 아무 데도 안 썼다. 그래서 세 가지를 몰랐다. - **협상된 개정판.** 2025-06-18 부터 HTTP 전송은 이후 요청마다 `MCP-Protocol-Version` 헤더를 요구하는데, 실을 값이 없어 아예 안 보냈다. 보내는 쪽도 "2024-11-05" 로 굳어 있었다 — 최초 개정판이다. - **서버 capabilities.** 무엇을 할 수 있는지 모르는 채 tools/list 를 무턱대고 불렀다. - **serverInfo.** 무엇에 붙었는지 화면에 쓸 수가 없었다. 이제 2025-06-18 을 먼저 부르고 **서버가 돌려준 것을 따른다** — 스펙이 그렇게 하라고 되어 있고, 그래야 옛 서버와 새 서버가 같이 붙는다. 그리고 MCP 를 도구로만 쓰고 있었다. 실제로 부르는 메서드가 tools/list·tools/call· notifications/initialized 셋뿐이었다 — 세 기둥 중 둘이 통째로 비어 있어서, 리소스를 내주는 서버(문서·DB 스키마 같은 것)를 붙여도 화면에 아무것도 안 나왔다. resources/list·resources/read·prompts/list·prompts/get 을 붙이고 IPC 로 연다. capabilities 를 광고하는 서버는 그 말을 믿고, 아예 안 주는 옛 서버는 예전처럼 그냥 물어본다 — 없다고 단정해 기능을 잠그는 것보다 낫다. MCP 패널도 "도구 N개" 대신 셋을 다 세고 협상된 개정판을 함께 적는다. 리소스만 내주는 서버가 "도구 0개" 로 보이던 자리다. --- ide/electron/mcp.cjs | 72 ++++++++++++++++++++++++++++++++----- ide/electron/preload.cjs | 5 +++ ide/src/App.tsx | 14 +++++++- ide/src/i18n/dict/mcpui.ts | 1 + ide/src/mcp/mcpClient.ts | Bin 5815 -> 6145 bytes ide/src/schutz.d.ts | 16 +++++++-- 6 files changed, 97 insertions(+), 11 deletions(-) diff --git a/ide/electron/mcp.cjs b/ide/electron/mcp.cjs index 6620442..7da2f4f 100644 --- a/ide/electron/mcp.cjs +++ b/ide/electron/mcp.cjs @@ -77,6 +77,9 @@ function pickRpc(text, id) { function httpHeaders(s) { const h = { "content-type": "application/json", accept: "application/json, text/event-stream", ...(s.headers || {}) }; if (s.sessionId) h["mcp-session-id"] = s.sessionId; // 서버가 세션을 쥐면 따라간다 + // 2025-06-18 개정부터 HTTP 전송은 협상된 버전을 매 요청에 실어야 한다. 예전엔 + // initialize 응답을 통째로 버려서 실을 값이 없었다 — 그래서 아예 안 보냈다. + if (s.protocolVersion) h["mcp-protocol-version"] = s.protocolVersion; return h; } async function httpRequest(s, method, params, timeoutMs) { @@ -141,17 +144,44 @@ function killServer(name) { * 핸드셰이크 도중 들어온 두 번째 startServer 가 **끝날 때까지 기다리게** 한다. * 예전엔 servers.set 이 핸드셰이크보다 먼저라, 그 사이의 호출이 아직 비어 있는 s.tools 로 * `{ok:true, tools:[]}` 를 돌려받았다 — 시작 버튼을 두 번 누르면 "연결됨, 도구 0개" 가 됐다. */ +/** 우리가 먼저 부르는 개정판. 서버가 다른 걸 돌려주면 **서버 것을 따른다** — + * 스펙이 그렇게 하라고 되어 있고, 그래야 옛 서버와 새 서버가 같이 붙는다. */ +const PREFERRED_PROTOCOL = "2025-06-18"; + function handshake(s, name) { s.starting = (async () => { try { - await request(s, "initialize", { - protocolVersion: "2024-11-05", capabilities: {}, + // 예전엔 이 응답을 통째로 버렸다. 그 바람에 협상된 버전도, 서버가 무엇을 할 수 + // 있는지도, 서버 이름조차도 몰랐다 — tools/list 를 무턱대고 부르고 있었다. + const init = await request(s, "initialize", { + protocolVersion: PREFERRED_PROTOCOL, capabilities: {}, clientInfo: { name: "Schutz", version: app.getVersion() }, - }, 20000); + }, 20000) || {}; + s.protocolVersion = typeof init.protocolVersion === "string" ? init.protocolVersion : PREFERRED_PROTOCOL; + s.caps = init.capabilities && typeof init.capabilities === "object" ? init.capabilities : {}; + s.info = init.serverInfo && typeof init.serverInfo === "object" ? init.serverInfo : null; sendRpc(s, "notifications/initialized", {}, true); - const listed = await request(s, "tools/list", {}, 15000).catch(() => ({ tools: [] })); + + // capabilities 를 광고하는 서버는 그 말을 믿는다. 아예 안 주는 서버(옛 구현)는 + // 예전처럼 그냥 물어본다 — 없다고 단정해 기능을 잠그는 것보다 낫다. + const can = (k) => !init.capabilities || s.caps[k] !== undefined; + const listed = can("tools") + ? await request(s, "tools/list", {}, 15000).catch(() => ({ tools: [] })) + : { tools: [] }; s.tools = Array.isArray(listed && listed.tools) ? listed.tools : []; - return { ok: true, tools: s.tools }; + + // 도구만 쓰던 자리. 리소스·프롬프트를 노출하는 서버를 붙여도 화면에 아무것도 + // 안 나왔다 — MCP 의 세 기둥 중 둘이 통째로 비어 있었다. + if (can("resources")) { + const r = await request(s, "resources/list", {}, 15000).catch(() => ({ resources: [] })); + s.resources = Array.isArray(r && r.resources) ? r.resources : []; + } else s.resources = []; + if (can("prompts")) { + const p = await request(s, "prompts/list", {}, 15000).catch(() => ({ prompts: [] })); + s.prompts = Array.isArray(p && p.prompts) ? p.prompts : []; + } else s.prompts = []; + + return { ok: true, tools: s.tools, resources: s.resources, prompts: s.prompts, protocolVersion: s.protocolVersion, info: s.info }; } catch (err) { killServer(name); // 맵에서 지운다 → 다시 시도할 수 있다 return { ok: false, reason: String(err && err.message || err) }; @@ -166,7 +196,7 @@ async function startServer(name) { if (servers.has(name)) { const s = servers.get(name); if (s.starting) return s.starting; // 아직 악수 중 — 빈 도구 목록을 성공으로 돌려주지 않는다 - return { ok: true, tools: s.tools }; + return { ok: true, tools: s.tools, resources: s.resources, prompts: s.prompts, protocolVersion: s.protocolVersion, info: s.info }; } let cfg; try { cfg = readCfg().mcpServers[name]; } catch { return { ok: false, reason: "설정 파일(mcp.json) 손상" }; } @@ -177,7 +207,7 @@ async function startServer(name) { let u; try { u = new URL(cfg.url); } catch { return { ok: false, reason: "잘못된 URL" }; } if (u.protocol !== "https:" && u.protocol !== "http:") return { ok: false, reason: "http(s) 주소만 지원합니다" }; - const hs = { kind: "http", url: u.toString(), headers: cfg.headers || {}, tools: [], seq: 0, pending: new Map(), name, starting: null }; + const hs = { kind: "http", url: u.toString(), headers: cfg.headers || {}, tools: [], resources: [], prompts: [], seq: 0, pending: new Map(), name, starting: null, protocolVersion: null, caps: {}, info: null }; servers.set(name, hs); return handshake(hs, name); } @@ -193,7 +223,7 @@ async function startServer(name) { }); } catch (err) { return { ok: false, reason: String(err && err.message || err) }; } - const s = { child, tools: [], seq: 0, pending: new Map(), name, starting: null }; + const s = { child, tools: [], resources: [], prompts: [], seq: 0, pending: new Map(), name, starting: null, protocolVersion: null, caps: {}, info: null }; const parser = makeLineParser((msg) => { if (msg.id != null && s.pending.has(msg.id)) { const p = s.pending.get(msg.id); s.pending.delete(msg.id); clearTimeout(p.timer); @@ -258,12 +288,38 @@ function init(ipcMain) { return { name, command: remote ? c.url : c.command, args: remote ? [] : (c.args || []), running: !!s, tools: s ? s.tools.length : 0, remote, + // 도구 개수만 보여 주던 자리 — 리소스·프롬프트를 내주는 서버가 "0개" 로 보였다. + resources: s ? s.resources.length : 0, + prompts: s ? s.prompts.length : 0, + protocolVersion: s ? s.protocolVersion : null, + serverName: s && s.info ? (s.info.name || "") : "", }; }); }); ipcMain.handle("schutz:mcpStart", (_e, name) => startServer(name)); ipcMain.handle("schutz:mcpStop", (_e, name) => { killServer(name); return { ok: true }; }); ipcMain.handle("schutz:mcpTools", (_e, name) => { const s = servers.get(name); return s ? s.tools : []; }); + /** 서버가 무엇을 내주는지 — 도구 말고도. 예전엔 이 둘이 아예 없어서, 리소스를 + * 노출하는 서버를 붙여도 화면에 아무것도 안 나왔다. */ + ipcMain.handle("schutz:mcpResources", (_e, name) => { const s = servers.get(name); return s ? s.resources : []; }); + ipcMain.handle("schutz:mcpPrompts", (_e, name) => { const s = servers.get(name); return s ? s.prompts : []; }); + /** 연결 상태 — 협상된 개정판과 서버가 스스로 밝힌 이름. 무엇에 붙었는지 말할 수 있게. */ + ipcMain.handle("schutz:mcpInfo", (_e, name) => { + const s = servers.get(name); + return s ? { protocolVersion: s.protocolVersion, info: s.info, caps: s.caps } : null; + }); + ipcMain.handle("schutz:mcpReadResource", async (_e, name, uri) => { + const s = servers.get(name); + if (!s) return { ok: false, error: "서버가 실행 중이 아닙니다" }; + try { return { ok: true, result: await request(s, "resources/read", { uri }, 60000) }; } + catch (err) { return { ok: false, error: String(err && err.message || err) }; } + }); + ipcMain.handle("schutz:mcpGetPrompt", async (_e, name, promptName, args) => { + const s = servers.get(name); + if (!s) return { ok: false, error: "서버가 실행 중이 아닙니다" }; + try { return { ok: true, result: await request(s, "prompts/get", { name: promptName, arguments: args || {} }, 60000) }; } + catch (err) { return { ok: false, error: String(err && err.message || err) }; } + }); ipcMain.handle("schutz:mcpAllTools", () => { const out = []; for (const [name, s] of servers) for (const t of s.tools) out.push({ server: name, ...t }); diff --git a/ide/electron/preload.cjs b/ide/electron/preload.cjs index 0d6a818..aed4584 100644 --- a/ide/electron/preload.cjs +++ b/ide/electron/preload.cjs @@ -105,6 +105,11 @@ contextBridge.exposeInMainWorld("schutz", { mcpStart: (name) => ipcRenderer.invoke("schutz:mcpStart", name), mcpStop: (name) => ipcRenderer.invoke("schutz:mcpStop", name), mcpTools: (name) => ipcRenderer.invoke("schutz:mcpTools", name), + mcpResources: (name) => ipcRenderer.invoke("schutz:mcpResources", name), + mcpPrompts: (name) => ipcRenderer.invoke("schutz:mcpPrompts", name), + mcpInfo: (name) => ipcRenderer.invoke("schutz:mcpInfo", name), + mcpReadResource: (name, uri) => ipcRenderer.invoke("schutz:mcpReadResource", name, uri), + mcpGetPrompt: (name, promptName, args) => ipcRenderer.invoke("schutz:mcpGetPrompt", name, promptName, args), mcpAllTools: () => ipcRenderer.invoke("schutz:mcpAllTools"), mcpCall: (name, tool, args) => ipcRenderer.invoke("schutz:mcpCall", name, tool, args), mcpAdd: (name, cfg) => ipcRenderer.invoke("schutz:mcpAdd", name, cfg), diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 35287a1..55a3bec 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -10370,7 +10370,19 @@ ${(r.output || "").slice(0, 2000)}`;
{sv.name}
{sv.command} {sv.args.join(" ")}
- {sv.running && {t("mcpui.toolCount", { n: sv.tools })}} + {/* 도구 개수만 보여 주던 자리. 리소스·프롬프트를 내주는 서버는 여기서 + "0개" 로 보였다 — 붙었는데 아무것도 없는 것처럼. 협상된 개정판도 같이 적어 + 무엇에 붙었는지 말할 수 있게 한다. */} + {sv.running && ( + + + {t("mcpui.offers", { t: sv.tools, r: sv.resources ?? 0, p: sv.prompts ?? 0 })} + + {sv.protocolVersion && ( + {sv.protocolVersion} + )} + + )} diff --git a/ide/src/i18n/dict/mcpui.ts b/ide/src/i18n/dict/mcpui.ts index e86c708..1476429 100644 --- a/ide/src/i18n/dict/mcpui.ts +++ b/ide/src/i18n/dict/mcpui.ts @@ -29,6 +29,7 @@ export const dict: RecordN`#$K^BDJ)qz(g(8yP5(}tyBOX-Sl$>7>?680CIg?yJ_SA z-X`?1!Qm9rbsr~-L!jA!0++mLk->1p|9|Ku;nY6haz%F|8ZPqrHoa{u*V4!@1HSu! zaW|ovgAZI=sj@^0%k0R8u#S(w=;#+hYS5OZ-nMrpg*cl%QNM>{AGnW#=WN9E%?_On zIV0Z_&9gUF&J$d^@ar&rm5_H&-su?d>y^9#Ur3=-FnFbHHq6$!kXF+)7-QM8X(Yt(2W!``@Bjb+ delta 18 ZcmZoP*si-llyR~+V }>; cliChatList(agent: string, headBytes: number): Promise<{ rows: { agent: string; file: string; head: string; bytes: number; updatedAt: number }[] }>; cliChatRead(agent: string, file: string, tailBytes: number): Promise<{ text?: string; bytes?: number; partial?: boolean; error?: string }>; - mcpList(): Promise<{ name: string; command: string; args: string[]; running: boolean; tools: number; remote?: boolean }[]>; - mcpStart(name: string): Promise<{ ok: boolean; tools?: McpTool[]; reason?: string }>; + mcpList(): Promise<{ name: string; command: string; args: string[]; running: boolean; tools: number; remote?: boolean; resources?: number; prompts?: number; protocolVersion?: string | null; serverName?: string }[]>; + mcpStart(name: string): Promise<{ ok: boolean; tools?: McpTool[]; resources?: McpResource[]; prompts?: McpPrompt[]; protocolVersion?: string; info?: { name?: string; version?: string } | null; reason?: string }>; mcpStop(name: string): Promise<{ ok: boolean }>; mcpTools(name: string): Promise; + /** MCP 의 나머지 두 기둥. 예전엔 도구만 읽어서, 리소스를 노출하는 서버는 붙여도 빈 칸이었다. */ + mcpResources(name: string): Promise; + mcpPrompts(name: string): Promise; + /** 협상된 개정판과 서버가 스스로 밝힌 이름 — 무엇에 붙었는지 말할 수 있게. */ + mcpInfo(name: string): Promise<{ protocolVersion: string | null; info: { name?: string; version?: string } | null; caps: Record } | null>; + mcpReadResource(name: string, uri: string): Promise<{ ok: boolean; result?: any; error?: string }>; + mcpGetPrompt(name: string, promptName: string, args?: Record): Promise<{ ok: boolean; result?: any; error?: string }>; mcpAllTools(): Promise<(McpTool & { server: string })[]>; mcpCall(name: string, tool: string, args: any): Promise<{ ok: boolean; result?: any; error?: string }>; mcpAdd(name: string, cfg: { command?: string; args?: string[]; env?: Record; cwd?: string; url?: string; headers?: Record; overwrite?: boolean }): Promise<{ ok: boolean; error?: string }>; From 6faef1e3557997d0950c006c1d490749267ffd0b Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 10:32:32 +0900 Subject: [PATCH 15/52] =?UTF-8?q?VS=20Code=20=EC=85=B0=EC=9E=84=EC=9D=84?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=EC=97=90=EB=94=94=ED=84=B0=EC=97=90=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확장이 로드되고 "성공" 으로 보고된 뒤 **조용히 아무것도 하지 않았다.** get activeTextEditor() { return undefined; } // 늘 visibleTextEditors: [] // 늘 workspaceFolders: undefined // 늘 openTextDocument: () => Promise.reject(...) 현재 파일을 읽는 확장은 전부 무동작이었다. 정작 앱은 그 정보를 다 갖고 있었다 — 모델도(projectModels), 열린 페인도(paneRegistry). 셰임만 안 연결돼 있었다. 이제 문서·편집기를 실제 Monaco 모델 위에 만든다. 값은 스냅숏이 아니라 게터로 그때 그때 읽는다 — 스냅숏을 들고 있으면 확장이 옛 내용을 보고 판단한다. document getText·lineAt·offsetAt·positionAt·getWordRangeAtPosition·lineCount· languageId·version·isDirty editor selection(읽기·쓰기)·visibleRanges·revealRange·edit() workspace textDocuments·workspaceFolders·getWorkspaceFolder·openTextDocument edit() 는 모델을 거친다. 디스크에 직접 쓰면 되돌릴 수도, 저장 기준선을 맞출 수도 없다. 모델로 가면 Ctrl+Z 가 그대로 듣는다. 이벤트도 아무도 안 쏘는 빈 EventEmitter 였다 — onDidSaveTextDocument 를 구독한 확장은 영원히 안 불렸다. 지난 판에서 만든 훅(file.open·file.save)을 그대로 흘려보낸다. 한 사건에 두 이름이 붙는 셈이라 새 배관이 생기지 않는다. 아직 못 하는 것: 열려 있지 않은 파일의 openTextDocument(모델이 없다), document.save(), showQuickPick·showInputBox. 앞의 둘은 reject·false 로 분명히 답한다 — 조용히 빈 값을 주는 것보다 낫다. --- ide/src/App.tsx | 4 ++ ide/src/ext/extHost.ts | 23 +++++- ide/src/ext/shimDoc.ts | 144 ++++++++++++++++++++++++++++++++++++++ ide/src/ext/vscodeShim.ts | 76 ++++++++++++++++---- 4 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 ide/src/ext/shimDoc.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 55a3bec..6ef89d9 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -5311,6 +5311,10 @@ ${(r.output || "").slice(0, 2000)}`; toast: (k, m) => this.toast(k, m), showPanel: (title, html) => this.openO({ extPanel: { title, html } }), getActiveFile: () => this.state.active[this._focusSlot] || null, + // 셰임이 진짜 문서·편집기를 만들 수 있게 — 이게 없어서 확장이 현재 파일을 + // 못 읽고 조용히 무동작했다. + workspaceRoot: () => this.state.workspace?.root ?? null, + openFiles: () => this.allOpen().filter(rel => !this.parseDiffKey(rel) && !this.parsePreviewKey(rel)), }); // VS Code 확장(선언형) — 테마·스니펫·언어설정 적용 const vres = await vscodeExt.loadVscodeExtensions(); diff --git a/ide/src/ext/extHost.ts b/ide/src/ext/extHost.ts index 8dc55eb..e87b579 100644 --- a/ide/src/ext/extHost.ts +++ b/ide/src/ext/extHost.ts @@ -3,6 +3,7 @@ // Schutz 네이티브(schutz API) + VS Code 프로그램형(vscode 셰임으로 activate 실행) 둘 다 지원. import { makeVscodeApi, disposeShimRegistrations } from "./vscodeShim"; import { onHook, clearHooks, emitHook, HOOK_EVENTS, type HookEvent } from "./hooks"; +import { editorEvents } from "./vscodeShim"; import { t } from "../i18n"; export interface ExtCommand { id: string; title: string; run: (...args: any[]) => any; source: string; } @@ -12,6 +13,10 @@ export interface HostDeps { toast: (kind: "ok" | "error" | "info", msg: string) => void; showPanel: (title: string, html: string) => void; getActiveFile: () => string | null; + /** vscode 셰임이 문서·편집기를 만들려면 이 둘이 필요하다. 없이 두었을 때 + * activeTextEditor 가 영원히 undefined 였다. */ + workspaceRoot: () => string | null; + openFiles: () => string[]; } let commands: ExtCommand[] = []; @@ -36,6 +41,22 @@ export function notifyExtensions(ev: HookEvent, payload: Record emitHook(ev, payload, (source, err) => { deps?.toast("error", t("exth.commandError", { source, msg: err instanceof Error ? err.message : String(err) })); }); + // vscode 확장은 같은 사건을 자기 이름으로 듣는다. 셰임의 이벤트들은 아무도 안 쏘는 + // 빈 EventEmitter 였다 — onDidSaveTextDocument 를 구독한 확장은 영원히 안 불렸다. + const rel = typeof payload.rel === "string" ? payload.rel : null; + if (!rel) return; + const doc = shimDocFor(rel); + if (!doc) return; + if (ev === "file.open") { editorEvents.docOpened.fire(doc); editorEvents.activeChanged.fire(shimEditorFor(rel)); } + else if (ev === "file.save") editorEvents.docSaved.fire(doc); +} + +/** 사건을 쏠 때 쓸 문서 통로. makeVscodeApi 가 확장마다 인덱스를 만들지만, + * 사건에는 확장과 무관한 문서 하나면 된다. */ +let shimDocFor: (rel: string) => any = () => null; +let shimEditorFor: (rel: string) => any = () => null; +export function setShimDocSource(d: (rel: string) => any, e: (rel: string) => any): void { + shimDocFor = d; shimEditorFor = e; } /** 등록된 확장 명령 실행 (vscode 셰임의 executeCommand 위임용). @@ -174,7 +195,7 @@ export async function loadExtensions(d: HostDeps): Promise<{ loaded: number; err else errors.push(ext.name + ": " + reason); continue; } - const vscode = makeVscodeApi({ toast: d.toast, showPanel: d.showPanel, getActiveFile: d.getActiveFile, registerCommand: addCommand }, ext); + const vscode = makeVscodeApi({ toast: d.toast, showPanel: d.showPanel, getActiveFile: d.getActiveFile, workspaceRoot: d.workspaceRoot, openFiles: d.openFiles, registerCommand: addCommand }, ext); const moduleObj = { exports: {} as any }; const require = makeHostRequire(vscode); const ctx = { diff --git a/ide/src/ext/shimDoc.ts b/ide/src/ext/shimDoc.ts new file mode 100644 index 0000000..4b56b3b --- /dev/null +++ b/ide/src/ext/shimDoc.ts @@ -0,0 +1,144 @@ +import monaco from "../editor/monacoSetup"; +import * as projectModels from "../editor/projectModels"; +import { paneRegistry } from "../editor/MonacoPane"; + +/** + * vscode 셰임의 문서·편집기 — **실제 Monaco 모델에 연결한다.** + * + * 예전 셰임은 `activeTextEditor` 가 늘 undefined 였고 `visibleTextEditors` 는 늘 빈 + * 배열이었다. 그래서 현재 파일을 읽는 확장은 로드되고 "성공" 으로 보고된 뒤 **조용히 + * 아무것도 하지 않았다.** 정작 앱은 그 정보를 다 갖고 있었다 — 모델도, 열린 페인도. + * + * 여기서 만드는 것은 얇은 겉면이다. 값은 늘 모델에서 그때그때 읽는다(스냅숏을 들고 + * 있으면 확장이 옛 내용을 보고 판단한다). + */ + +export interface DocDeps { + /** 워크스페이스 루트. 없으면 null — 그때는 문서를 만들 수 없다. */ + root: () => string | null; + /** 지금 활성 파일의 워크스페이스 상대 경로. */ + activeRel: () => string | null; + /** 지금 열려 있는 모든 파일의 상대 경로. */ + openRels: () => string[]; +} + +const abs = (root: string, rel: string) => root.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel; + +/** 모델 → vscode.TextDocument 모양. 게터로 두어 늘 지금 값을 읽는다. */ +export function makeDoc(root: string, rel: string, m: monaco.editor.ITextModel, Position: any, Range: any) { + const pos = (p: any) => ({ lineNumber: (p?.line ?? 0) + 1, column: (p?.character ?? 0) + 1 }); + return { + uri: monaco.Uri.file(abs(root, rel)), + get fileName() { return abs(root, rel); }, + get languageId() { return m.getLanguageId(); }, + get version() { return m.getVersionId(); }, + get isDirty() { return projectModels.isDirty(rel); }, + isUntitled: false, + isClosed: false, + get eol() { return m.getEOL() === "\r\n" ? 2 : 1; }, + get lineCount() { return m.getLineCount(); }, + getText(range?: any) { + if (!range) return m.getValue(); + const a = pos(range.start), b = pos(range.end); + return m.getValueInRange({ startLineNumber: a.lineNumber, startColumn: a.column, endLineNumber: b.lineNumber, endColumn: b.column }); + }, + lineAt(line: any) { + const n = typeof line === "number" ? line : (line?.line ?? 0); + const ln = Math.min(Math.max(1, n + 1), m.getLineCount()); + const text = m.getLineContent(ln); + const firstNonWs = Math.max(0, m.getLineFirstNonWhitespaceColumn(ln) - 1); + return { + lineNumber: ln - 1, text, + range: new Range(ln - 1, 0, ln - 1, text.length), + rangeIncludingLineBreak: new Range(ln - 1, 0, ln - 1, text.length), + firstNonWhitespaceCharacterIndex: firstNonWs, + isEmptyOrWhitespace: text.trim().length === 0, + }; + }, + offsetAt(p: any) { const q = pos(p); return m.getOffsetAt({ lineNumber: q.lineNumber, column: q.column }); }, + positionAt(off: number) { const p = m.getPositionAt(off); return new Position(p.lineNumber - 1, p.column - 1); }, + getWordRangeAtPosition(p: any) { + const q = pos(p); + const w = m.getWordAtPosition({ lineNumber: q.lineNumber, column: q.column }); + if (!w) return undefined; + return new Range(q.lineNumber - 1, w.startColumn - 1, q.lineNumber - 1, w.endColumn - 1); + }, + validateRange(r: any) { return r; }, + validatePosition(p: any) { return p; }, + /** 저장은 앱의 저장 경로를 타야 한다 — 확장이 직접 디스크에 쓰면 기준선이 어긋난다. */ + save(): Promise { return Promise.resolve(false); }, + }; +} + +/** 문서 + 커서/선택 + 편집. vscode.TextEditor 의 쓰이는 부분만. */ +export function makeEditor(doc: any, rel: string, Position: any, Selection: any, Range: any) { + const api = () => paneRegistry.panes.get(rel) ?? null; + const selFromEditor = () => { + const e = api()?.editor; + const s = e?.getSelection(); + if (!s) return new Selection(0, 0, 0, 0); + return new Selection(s.startLineNumber - 1, s.startColumn - 1, s.endLineNumber - 1, s.endColumn - 1); + }; + return { + document: doc, + get selection() { return selFromEditor(); }, + set selection(v: any) { + const e = api()?.editor; + if (!e) return; + e.setSelection({ startLineNumber: v.start.line + 1, startColumn: v.start.character + 1, endLineNumber: v.end.line + 1, endColumn: v.end.character + 1 }); + }, + get selections() { return [selFromEditor()]; }, + get visibleRanges() { + const r = api()?.editor.getVisibleRanges()?.[0]; + return r ? [new Range(r.startLineNumber - 1, 0, r.endLineNumber - 1, 0)] : []; + }, + viewColumn: 1, + options: {}, + /** 확장이 실제로 고칠 수 있게 한다. 모델을 거치므로 undo 스택에 남는다 — + * 디스크에 직접 쓰면 되돌릴 수도, 저장 기준선을 맞출 수도 없다. */ + edit(cb: (b: any) => void): Promise { + const model = projectModels.getByRel(rel); + if (!model) return Promise.resolve(false); + const ops: monaco.editor.IIdentifiedSingleEditOperation[] = []; + const toRange = (r: any) => ({ + startLineNumber: r.start.line + 1, startColumn: r.start.character + 1, + endLineNumber: r.end.line + 1, endColumn: r.end.character + 1, + }); + const builder = { + replace: (r: any, text: string) => { ops.push({ range: toRange(r), text }); }, + insert: (p: any, text: string) => { ops.push({ range: { startLineNumber: p.line + 1, startColumn: p.character + 1, endLineNumber: p.line + 1, endColumn: p.character + 1 }, text }); }, + delete: (r: any) => { ops.push({ range: toRange(r), text: "" }); }, + setEndOfLine: () => { /* 미지원 — 조용히 넘긴다 */ }, + }; + try { cb(builder); } catch { return Promise.resolve(false); } + if (!ops.length) return Promise.resolve(true); + model.pushEditOperations([], ops, () => null); + return Promise.resolve(true); + }, + revealRange(r: any) { + try { api()?.editor.revealRangeInCenter({ startLineNumber: r.start.line + 1, startColumn: 1, endLineNumber: r.end.line + 1, endColumn: 1 }); } catch { /* */ } + }, + setDecorations() { /* 데코레이션 타입이 no-op 이라 여기도 no-op */ }, + }; +} + +/** 지금 상태에서 문서/편집기를 만들어 준다. 값은 부를 때마다 새로 읽는다. */ +export function makeDocIndex(d: DocDeps, types: { Position: any; Range: any; Selection: any }) { + const docFor = (rel: string | null) => { + const root = d.root(); + if (!root || !rel) return undefined; + const m = projectModels.getByRel(rel); + if (!m || m.isDisposed()) return undefined; + return makeDoc(root, rel, m, types.Position, types.Range); + }; + const editorFor = (rel: string | null) => { + const doc = docFor(rel); + return doc && rel ? makeEditor(doc, rel, types.Position, types.Selection, types.Range) : undefined; + }; + return { + docFor, editorFor, + activeEditor: () => editorFor(d.activeRel()), + visibleEditors: () => d.openRels().map(editorFor).filter(Boolean), + documents: () => d.openRels().map(docFor).filter(Boolean), + }; +} diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts index d70d7a3..d70e471 100644 --- a/ide/src/ext/vscodeShim.ts +++ b/ide/src/ext/vscodeShim.ts @@ -3,12 +3,18 @@ // Node/네이티브 의존이나 미구현 API를 쓰는 확장은 실패(캐치되어 보고). "단순 확장"용. import monaco from "../editor/monacoSetup"; import { getLang } from "../i18n"; +import { makeDocIndex } from "./shimDoc"; +import { setShimDocSource } from "./extHost"; export interface ShimDeps { toast: (kind: "ok" | "error" | "info", msg: string) => void; showPanel: (title: string, html: string) => void; getActiveFile: () => string | null; registerCommand: (id: string, title: string, run: (...args: any[]) => any, source: string) => void; + /** 열려 있는 파일들과 워크스페이스 루트 — 문서·편집기를 만들려면 이 둘이 있어야 한다. + * 없이 두었더니 activeTextEditor 가 영원히 undefined 였다. */ + workspaceRoot: () => string | null; + openFiles: () => string[]; } const disposables: monaco.IDisposable[] = []; @@ -66,7 +72,44 @@ function langIdsFromSelector(sel: any): string[] { } /** vscode 모듈 셰임 인스턴스 생성 — 확장별로 만든다(구독/컨텍스트 격리). */ +/** 셰임이 쏘는 편집기 사건들. 앱이 fireShimEvent 로 밀어 준다 — 예전엔 전부 아무도 + * 안 쏘는 빈 EventEmitter 라, 저장·열기를 구독한 확장은 영원히 안 불렸다. */ +export const editorEvents = { + activeChanged: new EventEmitter(), + selectionChanged: new EventEmitter(), + docChanged: new EventEmitter(), + docOpened: new EventEmitter(), + docClosed: new EventEmitter(), + docSaved: new EventEmitter(), +}; + +/** 루트를 떼어 워크스페이스 상대 경로로. uri·경로·문서 아무거나 받는다. */ +function stripRoot(root: string, p: string): string { + const r = root.replace(/\\/g, "/").replace(/\/+$/, "").replace(/^\//, "") + "/"; + const q = String(p).replace(/\\/g, "/").replace(/^\//, ""); + return q.startsWith(r) ? q.slice(r.length) : q; +} +function relOfWith(root: string | null, arg: any): string | null { + if (!arg) return null; + const p = typeof arg === "string" ? arg : (arg.fsPath || arg.path || arg.uri?.fsPath || arg.uri?.path); + if (!p) return null; + return root ? stripRoot(root, p) : String(p); +} + export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) { + const docs = makeDocIndex( + { root: deps.workspaceRoot, activeRel: deps.getActiveFile, openRels: deps.openFiles }, + { Position, Range, Selection }, + ); + const relOf = (arg: any) => relOfWith(deps.workspaceRoot(), arg); + // 사건을 쏠 때 쓸 통로. 확장마다 덮어써도 같은 것을 가리키므로 문제 없다. + setShimDocSource((rel) => docs.docFor(rel), (rel) => docs.editorFor(rel)); + const folder = () => { + const r = deps.workspaceRoot(); + if (!r) return undefined; + const name = r.replace(/\\/g, "/").split("/").filter(Boolean).pop() || r; + return { uri: UriShim.file(r), name, index: 0 }; + }; const executeCommand = async (id: string, ...args: any[]) => { // 내장 명령 일부만 지원; 나머지는 등록된 확장 명령으로 위임(자기 네임스페이스 우선 해석) try { return (window as any).__schutzRunCommand?.(id, args, ext.id); } catch { /* */ } @@ -146,10 +189,13 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) createTextEditorDecorationType: () => ({ dispose() {}, key: "sz-deco" }), registerTreeDataProvider: () => noopDisposable, registerWebviewViewProvider: () => noopDisposable, - onDidChangeActiveTextEditor: new EventEmitter().event, - onDidChangeTextEditorSelection: new EventEmitter().event, - get activeTextEditor() { return undefined; }, - visibleTextEditors: [] as any[], + onDidChangeActiveTextEditor: editorEvents.activeChanged.event, + onDidChangeTextEditorSelection: editorEvents.selectionChanged.event, + // 예전엔 이 둘이 늘 undefined / 빈 배열이었다. 확장은 로드되고 "성공" 으로 보고된 + // 뒤 현재 파일을 못 읽어 조용히 아무것도 하지 않았다 — 앱은 그 정보를 다 갖고 있었다. + get activeTextEditor() { return docs.activeEditor(); }, + get visibleTextEditors() { return docs.visibleEditors(); }, + showTextDocument: (doc: any) => Promise.resolve(docs.editorFor(relOf(doc))), withProgress: (_opts: any, task: any) => Promise.resolve(task({ report() {} }, { isCancellationRequested: false, onCancellationRequested: new EventEmitter().event })), }; @@ -161,16 +207,22 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) inspect: () => undefined, }), onDidChangeConfiguration: new EventEmitter().event, - onDidChangeTextDocument: new EventEmitter().event, - onDidOpenTextDocument: new EventEmitter().event, - onDidCloseTextDocument: new EventEmitter().event, - onDidSaveTextDocument: new EventEmitter().event, + onDidChangeTextDocument: editorEvents.docChanged.event, + onDidOpenTextDocument: editorEvents.docOpened.event, + onDidCloseTextDocument: editorEvents.docClosed.event, + onDidSaveTextDocument: editorEvents.docSaved.event, onDidChangeWorkspaceFolders: new EventEmitter().event, - workspaceFolders: undefined as any, - getWorkspaceFolder: () => undefined, - getText: () => "", + // 폴더를 안 주면 대부분의 확장이 첫 줄에서 물러난다. + get workspaceFolders() { const f = folder(); return f ? [f] : undefined; }, + get textDocuments() { return docs.documents(); }, + getWorkspaceFolder: (_uri?: any) => folder(), createFileSystemWatcher: () => ({ onDidCreate: new EventEmitter().event, onDidChange: new EventEmitter().event, onDidDelete: new EventEmitter().event, dispose() {} }), - openTextDocument: () => Promise.reject(new Error("openTextDocument 미지원")), + openTextDocument: (arg?: any) => { + // 열려 있지 않은 파일은 아직 못 연다(모델이 없다). 그래도 reject 로 끝내던 + // 자리라, 최소한 열려 있는 파일에는 답한다. + const d = docs.docFor(relOf(arg)); + return d ? Promise.resolve(d) : Promise.reject(new Error("열려 있는 파일만 지원합니다")); + }, registerTextDocumentContentProvider: () => noopDisposable, fs: {}, name: undefined, From 05d45160ccc7b4bae89cc7409b63c9273a883026 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 10:39:24 +0900 Subject: [PATCH 16/52] =?UTF-8?q?README=20=EB=A1=9C=EB=93=9C=EB=A7=B5?= =?UTF-8?q?=EC=9D=84=20=EC=83=9D=ED=83=9C=EA=B3=84=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EB=92=A4=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로드맵에 "확장이 활성 에디터를 읽으면 아무것도 못 받는다" 고 적어 두었는데 방금 고쳤다. 문서가 앱보다 잘해 보이면 안 되지만, 못해 보여도 안 된다. 대신 **아직 얇은 곳을 이름으로** 적는다 — 셰임에 quick-pick·input box 가 없고, 탭에 없는 파일은 못 열고, document.save() 가 없다. 셋 다 조용히 빈 값을 주는 대신 오류로 답하므로, 그게 필요한 확장은 무동작이 아니라 소리 내며 실패한다. MCP 리소스·프롬프트는 읽고 패널에 보이지만 아직 에이전트 도구로는 안 준다. 기능 목록의 MCP·Extensions 두 줄도 실제와 맞춘다. --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0291a89..330839c 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,11 @@ same table the app dispatches from, so it cannot drift from what actually happen **Terminal** — A real PTY terminal (xterm.js + node-pty) with ANSI color, scrollback, and multiple tabs, alongside a log tab showing live agent activity. -**MCP** — A built-in Model Context Protocol host. Import existing servers, generate one from a program, or drop an `.mcpb` bundle onto the window — the install dialog shows the exact command before anything runs. Manage them from the title bar and expose their tools to the agent loop. +**MCP** — A built-in Model Context Protocol host, speaking revision 2025-06-18 and falling back to whatever a server negotiates. Import existing servers, generate one from a program, or drop an `.mcpb` bundle onto the window — the install dialog shows the exact command before anything runs. Tools, resources and prompts are all read; tools are exposed to the agent loop. **Debugging** — Breakpoints, call stack, variables, and stepping via DAP (Python/debugpy today). -**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. Schutz-native extensions can register commands, show panels, and subscribe to what happens in the IDE — files opened and saved, proposals accepted or rejected, agent turns starting and ending. +**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. The `vscode` shim gives them the active editor for real: reading the document, moving the selection, and editing through the model so `Ctrl+Z` still works. Schutz-native extensions can register commands, show panels, and subscribe to what happens in the IDE — files opened and saved, proposals accepted or rejected, agent turns starting and ending. **Localization** — The full UI ships in Korean, English, German, and Japanese. @@ -126,8 +126,14 @@ npm run dist:linux # Linux build The ecosystem work that used to sit in Phase 3 has shipped: VS Code extensions, Claude Code skills and subagents, MCP servers over stdio and HTTP, and `.mcpb` bundles are all in the app -today. What is still thin is how much of the VS Code extension API the shim covers — an -extension that reads the active editor currently gets nothing back. +today. The VS Code shim reads and edits the active document, and MCP speaks all three of its +pillars rather than tools alone. + +Where it is still thin, specifically: the shim has no quick-pick or input box, cannot open a +file that is not already in a tab, and does not implement `document.save()` — those return an +error rather than an empty value, so an extension that needs them fails loudly instead of +doing nothing. MCP resources and prompts are readable and shown in the panel, but are not yet +offered to the agent as tools. ## Contributing From 8a167fc373fa01a42c4bfad1b66b31bc7c62a44e Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 11:51:59 +0900 Subject: [PATCH 17/52] =?UTF-8?q?=ED=99=95=EC=9E=A5=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=EC=9D=B4=20id=20=EC=99=80=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B4=EB=A9=B4=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=8F=84=20=EB=AA=BB=20=EC=9D=BD=EB=8D=98=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan 은 VS Code 확장의 id 를 package.json 의 publisher.name 으로 만들고 실제 폴더 경로(dir)도 같이 돌려준다. 그런데 파일을 읽는 쪽(safeExtPath)은 그 dir 을 버리고 extensions// 를 다시 만들어 썼다. 둘이 어긋나면 엔트리·테마·아이콘· 문법 파일을 **전부** 못 읽는다. .vsix 로 설치하면 폴더 이름이 id 라 맞아떨어져서 안 보였다. 폴더를 직접 넣으면 어긋난다 — "확장 폴더 열기" 버튼이 그러라고 있는 자리다. 목록에는 멀쩡히 뜨고 활성화만 "엔트리 파일을 찾을 수 없음" 으로 실패하니, 확장이 잘못된 줄 안다. Schutz 네이티브 쪽도 같다(id 는 매니페스트에서 오고 폴더 이름과 무관하다). id 로 폴더를 찾을 때 이름이 같은 흔한 경우는 statSync 한 번으로 끝내고, 어긋난 것만 스캔해서 찾은 뒤 기억해 둔다. 못 찾으면 원래 자리를 그대로 돌려준다 — 조용히 다른 확장의 파일을 읽는 것보다 ENOENT 로 실패하는 편이 낫다. 경로 이탈 가드는 그대로 두되, 스캔이 준 폴더도 확장 디렉터리 안인지 다시 본다. --- ide/electron/extensions.cjs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/ide/electron/extensions.cjs b/ide/electron/extensions.cjs index 162d0ed..89b19f9 100644 --- a/ide/electron/extensions.cjs +++ b/ide/electron/extensions.cjs @@ -9,11 +9,35 @@ function stateFile(app) { return path.join(extDir(app), ".state.json"); } /** 확장 id 검증 — 경로 구분자·'..' 금지 (슬러그만 허용) */ function validExtId(id) { return typeof id === "string" && /^[A-Za-z0-9._-]+$/.test(id) && id !== "." && id !== ".."; } +/** id → 실제 폴더. 폴더 이름과 id 는 **대개** 같지만 반드시 같지는 않다. + * + * scan 은 VS Code 확장의 id 를 package.json 의 `publisher.name` 으로 만든다. + * .vsix 로 설치하면 폴더도 그 이름이라 맞아떨어지지만, 폴더를 직접 넣는 길이 열려 + * 있고("확장 폴더 열기" 버튼이 그러라고 있다) 그때는 어긋난다. 어긋나면 엔트리·테마· + * 아이콘·문법 **파일을 하나도 못 읽는다** — 목록에는 멀쩡히 뜨는데 "엔트리 파일을 + * 찾을 수 없음" 만 나온다. scan 이 이미 dir 을 알고 돌려주는데 읽기 쪽이 그걸 버리고 + * id 로 다시 만들고 있었다. + * + * 폴더 이름이 id 와 같은 흔한 경우는 statSync 한 번으로 끝나고, 어긋난 것만 스캔한다. */ +const folderMemo = new Map(); +function extFolder(app, id) { + const direct = path.resolve(extDir(app), id); + try { if (fs.statSync(direct).isDirectory()) return direct; } catch { /* 없다 — 아래에서 찾는다 */ } + const memo = folderMemo.get(id); + // 기억해 둔 것도 지워졌을 수 있다(확장 삭제). 그러면 다시 찾는다. + if (memo) { try { if (fs.statSync(memo).isDirectory()) return memo; } catch { folderMemo.delete(id); } } + for (const e of scan(app)) if (e.id === id) { folderMemo.set(id, e.dir); return e.dir; } + return direct; // 못 찾으면 원래 자리 — 읽기가 ENOENT 로 실패한다(조용히 다른 곳을 읽지 않는다) +} + /** 확장 디렉터리 내부로 경로를 안전하게 해석. 이탈 시 throw. * id 는 슬러그 검증, 최종 경로는 base 컨테인먼트(구분자 포함)로 확인. */ function safeExtPath(app, id, rel) { if (!validExtId(id)) throw new Error("잘못된 확장 id"); - const base = path.resolve(extDir(app), id); + const base = extFolder(app, id); + // scan 이 준 값이라도 확장 디렉터리 안인지 다시 본다 — 경로 가드는 한 곳에만 둔다. + const root = path.resolve(extDir(app)); + if (base !== root && !base.startsWith(root + path.sep)) throw new Error("경로 이탈"); const full = path.resolve(base, rel || ""); if (full !== base && !full.startsWith(base + path.sep)) throw new Error("경로 이탈"); return full; From 338eeda732ecdac9d096405e56aee86309442a0b Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 11:52:41 +0900 Subject: [PATCH 18/52] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9D=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=AC=BC=EC=96=B4=EB=B3=BC=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 셰임에서 묻는 함수는 셋인데 셋 다 묻지 않고 곧장 답했다. showQuickPick: () => Promise.resolve(undefined), showInputBox: () => Promise.resolve(undefined), showInformationMessage: (msg, ..._items) => { toast(...); return undefined; }, vscode 규약에서 undefined 는 "사용자가 취소했다" 다. 그래서 확장은 물음을 띄운 적도 없이 취소당한 줄 알고 흐름을 접었다. 오류도 토스트도 없다. 특히 세 번째가 고약한데, 토스트는 뜨니까 뭔가 일어난 것처럼 보인다 — 정작 `if (await showInformationMessage(m, "Reload") === "Reload")` 는 영원히 거짓이다. 세 물음을 실제 대화창으로 만든다. 고른 항목은 **확장이 넘긴 값 그대로** 돌려준다 (확장은 대개 자기가 붙인 필드를 보고 다음을 정한다). canPickMany, Promise 로 넘긴 항목 목록, matchOnDescription/Detail, password, validateInput 을 지원한다. 검사기는 열 때도 한 번 돌린다. 확장이 준 초기값이 이미 규칙에 어긋날 수 있는데, 그때 아무 표시가 없으면 멀쩡해 보이는 값에 확인을 눌러야 왜 안 되는지 알게 된다. 버튼 없이 부른 알림은 그대로 토스트다 — 알림은 흐름을 막을 일이 아니다. 물음은 늘 누가 묻는지 머리에 밝힌다. 확장이 띄운 창을 앱이 띄운 것으로 오해하면 확장을 지운 뒤에도 앱을 의심하게 된다. 고르는 로직(정규화·필터·커서·검증)은 순수 모듈로 빼서 26개 테스트로 덮는다. 곁들여, 오버레이 zIndex 를 표에서 읽어 가게 했다. "렌더 리터럴과 같아야 한다" 고 주석으로만 적어 뒀더니 실제로 어긋나 있었다 — 확인창은 표에서 233 인데 231 로 그려져 번들 설치(232) 아래였고, 투어는 245 인데 240 이라 가져오기와 같은 층이었다. Esc 는 표 순서대로 위엣것부터 닫는데 눈에 보이는 순서는 반대가 되는 자리다. --- ide/src/App.tsx | 226 ++++++++++++++++++++++++++++++++++-- ide/src/ext/extHost.ts | 4 +- ide/src/ext/prompt.test.ts | 126 ++++++++++++++++++++ ide/src/ext/prompt.ts | 113 ++++++++++++++++++ ide/src/ext/vscodeShim.ts | 53 ++++++++- ide/src/i18n/dict/extask.ts | 12 ++ ide/src/i18n/messages.ts | 3 +- ide/src/overlays.test.ts | 26 ++++- ide/src/overlays.ts | 20 ++++ ide/vitest.config.ts | 2 +- 10 files changed, 564 insertions(+), 21 deletions(-) create mode 100644 ide/src/ext/prompt.test.ts create mode 100644 ide/src/ext/prompt.ts create mode 100644 ide/src/i18n/dict/extask.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 6ef89d9..7904dc8 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -34,7 +34,8 @@ import { planRun, langFor, LANGS as RUN_LANGS } from "./engine/runFile"; import { getRunOverride, getRunOverrides, setRunOverride } from "./settings"; import { emptyNav, push as navPush, back as navBack, forward as navForward, current as navCurrent, dropMissing as navDropMissing, type NavState } from "./engine/navHistory"; import { shouldProbeQuota } from "./engine/quotaPoll"; -import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction } from "./overlays"; +import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction, overlayZ } from "./overlays"; +import { filterPicks, stepIndex, validateInput, type PromptReq, type NormPick } from "./ext/prompt"; import { targetIdOf, isSubagentTarget, findSubagent, providerFor, filterTools, rosterLines, personaSystem, type SubagentDef, @@ -338,6 +339,17 @@ interface S { /** 되돌릴 수 없는 일 — 확인 버튼을 오류 색으로. */ danger?: boolean; } | null; + /** 확장이 사용자에게 던진 물음. 셰임의 showQuickPick/showInputBox/showXMessage 가 + * 여기로 온다 — 예전엔 묻지도 않고 undefined(=취소)를 돌려줬다. */ + extAsk: PromptReq | null; + /** 물음 안의 입력값. 빠른 선택에서는 걸러내기 문자열, 입력창에서는 값 자체다. */ + extAskText: string; + /** 빠른 선택의 커서. **걸러낸 뒤 목록에서의 자리**다(원본 index 가 아니다). */ + extAskSel: number; + /** canPickMany 에서 체크한 항목 — 원본 index 를 담는다. 걸러도 선택이 유지돼야 한다. */ + extAskPicked: number[]; + /** validateInput 이 돌려준 문구. 있으면 확인을 막는다. */ + extAskErr: string | null; /** 실행 승인 대기 중인 명령 (수동 정책일 때) */ /** 승인 대기. okLabel/cancelLabel 은 자리에 맞는 문구가 있을 때만 채운다(없으면 기본 허용/거부). */ askRun: { command: string; rationale: string; agent: string; okLabel?: string; cancelLabel?: string } | null; @@ -671,7 +683,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { termOpen: false, termReady: false, termTab: "t1", chatTab: "all", chatAway: false, openDiffs: {}, openTools: {}, sheetOpen: false, convId: null, asideTab: "recents", impOpen: false, impRows: null, impThisOnly: true, impBusy: null, impAgent: "all", agentSideW: (() => { try { return Math.max(360, Math.min(1100, +(localStorage.getItem("schutz.agentSideW") || 620))); } catch { return 620; } })(), - agentAsideW: (() => { try { return Math.max(150, Math.min(480, +(localStorage.getItem("schutz.agentAsideW") || 216))); } catch { return 216; } })(), quota: {}, askRun: null, confirmAsk: null, terms: [{ id: "t1", n: 1 }], tasks: [], keyCapture: null, hunkSel: {}, checkpoints: [], undoAsk: null, gitAmend: false, commitView: null, mcpb: null, + agentAsideW: (() => { try { return Math.max(150, Math.min(480, +(localStorage.getItem("schutz.agentAsideW") || 216))); } catch { return 216; } })(), quota: {}, askRun: null, confirmAsk: null, extAsk: null, extAskText: "", extAskSel: 0, extAskPicked: [], extAskErr: null, terms: [{ id: "t1", n: 1 }], tasks: [], keyCapture: null, hunkSel: {}, checkpoints: [], undoAsk: null, gitAmend: false, commitView: null, mcpb: null, agents: this.freshAgents(), workspace: null, paneDirty: {}, proposals: [], reviewFindings: [], reviewBusy: false, paneVer: {}, @@ -1994,6 +2006,72 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { this.setState({ confirmAsk: { ...o, cancelLabel: o.cancelLabel ?? t("confirm.cancel") } }); }); } + private _extAskResolve: ((v: any) => void) | null = null; + + /** 확장의 물음을 띄우고 답을 기다린다. 취소면 undefined 로 푼다 — vscode 규약이다. + * + * 물음이 이미 떠 있으면 앞의 것을 **취소로** 닫는다. 겹쳐 쌓이면 어느 물음에 답한 + * 것인지 알 수 없고, 확장 쪽 await 는 둘 다 살아 있다(askConfirm 과 같은 규칙). */ + private askExtension(req: PromptReq): Promise { + this._extAskResolve?.(undefined); + return new Promise(resolve => { + this._extAskResolve = resolve; + const picked = req.kind === "pick" ? req.items.filter(i => i.picked).map(i => i.index) : []; + this.openO({ + extAsk: req, + extAskText: req.kind === "input" ? req.value : "", + extAskSel: 0, extAskPicked: picked, extAskErr: null, + }); + // 확장이 준 초기값이 이미 규칙에 어긋날 수 있다. 열자마자 검사하지 않으면 + // 멀쩡해 보이는 값에 확인을 눌러야 비로소 왜 안 되는지 알게 된다. + // + // 여기서는 state 를 보면 안 된다. 바로 위의 setState 는 아직 반영되지 않았을 수 + // 있어서(React 가 마이크로태스크에서 몰아 처리한다) `extAsk`·`extAskText` 둘 다 + // 옛 값이고, 그걸 "그 사이 다른 물음으로 바뀌었다" 로 읽어 조용히 지나갔다. + // **기다리는 resolve** 는 동기적으로 이미 정해져 있으니 그걸로 신원을 확인한다. + // + // 사용자가 그 사이 타자를 쳤다면 그쪽 onChange 가 더 최신 답을 덮어쓴다. + if (req.kind === "input") { + void validateInput(req.validate, req.value).then(m => { + if (this._extAskResolve === resolve) this.setState({ extAskErr: m }); + }); + } + }); + } + private answerExtension(v: any) { + const r = this._extAskResolve; + this._extAskResolve = null; + this.setState({ extAsk: null, extAskText: "", extAskPicked: [], extAskErr: null }, () => r?.(v)); + } + + /** 지금 걸러낸 목록. 렌더와 키 처리가 같은 것을 봐야 커서가 어긋나지 않는다. */ + private extAskVisible(): NormPick[] { + const a = this.state.extAsk; + if (a?.kind !== "pick") return []; + return filterPicks(a.items, this.state.extAskText, a.match); + } + + private async extAskSubmit() { + const a = this.state.extAsk; + if (!a) return; + if (a.kind === "input") { + const msg = await validateInput(a.validate, this.state.extAskText); + // 확인을 누른 뒤에도 다시 검사한다 — 타자 중 검사만 믿으면 마지막 글자가 빠진다. + if (msg) { this.setState({ extAskErr: msg }); return; } + this.answerExtension(this.state.extAskText); + return; + } + if (a.kind === "pick") { + if (a.many) { this.answerExtension(this.state.extAskPicked.slice().sort((x, y) => x - y)); return; } + const vis = this.extAskVisible(); + const it = vis[this.state.extAskSel]; + // 걸러낸 결과가 비었으면 고를 것이 없다. 여기서 취소로 닫으면 확장은 사용자가 + // 그만둔 줄 안다 — 실제로는 오타 하나였을 수 있으므로 아무것도 하지 않는다. + if (!it) return; + this.answerExtension(it.index); + } + } + private answerConfirm(ok: boolean) { const r = this._confirmResolve; this._confirmResolve = null; @@ -5315,6 +5393,7 @@ ${(r.output || "").slice(0, 2000)}`; // 못 읽고 조용히 무동작했다. workspaceRoot: () => this.state.workspace?.root ?? null, openFiles: () => this.allOpen().filter(rel => !this.parseDiffKey(rel) && !this.parsePreviewKey(rel)), + prompt: req => this.askExtension(req), }); // VS Code 확장(선언형) — 테마·스니펫·언어설정 적용 const vres = await vscodeExt.loadVscodeExtensions(); @@ -5653,6 +5732,7 @@ ${(r.output || "").slice(0, 2000)}`; {this.renderSearch()} {this.renderAskClose()} {this.renderConfirm()} + {this.renderExtAsk()} {this.renderAskRun()} {this.renderUndoAsk()} {this.renderCommitView()} @@ -6750,7 +6830,7 @@ ${(r.output || "").slice(0, 2000)}`; if (!a) return null; return (
this.answerConfirm(false)} - style={{ position: "fixed", inset: 0, zIndex: 231, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}> + style={{ position: "fixed", inset: 0, zIndex: overlayZ("confirmAsk"), background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 440, maxWidth: "92%", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 12, boxShadow: "var(--shadow-pop)", padding: 18 }}>
{a.title}
@@ -6769,6 +6849,127 @@ ${(r.output || "").slice(0, 2000)}`; ); } + /** 확장이 던진 물음 — 빠른 선택 / 입력 / 버튼. + * + * 세 모양이 한 함수에 있는 이유는 vscode 도 셋을 "확장이 사용자에게 묻는 한 가지 일" + * 로 묶기 때문이다. 어느 쪽이든 **누가 묻는지**를 머리에 밝힌다 — 확장이 띄운 창을 + * 앱이 띄운 것으로 오해하면, 확장을 지운 뒤에도 앱을 의심하게 된다. */ + renderExtAsk() { + const a = this.state.extAsk; + if (!a) return null; + const s = this.state; + const cancel = () => this.answerExtension(undefined); + const head = ( +
+ + {t("extask.from", { name: a.source })} +
+ ); + const inputStyle: React.CSSProperties = { + width: "100%", height: 30, boxSizing: "border-box", padding: "0 10px", fontSize: 12.5, fontFamily: "inherit", + color: "var(--fg)", background: "var(--bg-editor)", borderRadius: 7, outline: "none", + border: "1px solid " + (s.extAskErr ? "var(--err)" : "var(--w12)"), + }; + const btn = (label: string, primary: boolean, onClick: () => void, key?: string) => ( + + ); + + let body: React.ReactNode; + if (a.kind === "buttons") { + body = (<> +
{a.title}
+
+ {/* 첫 버튼이 권장값이라는 것이 vscode 관례다. isCloseAffordance 는 취소 자리로 둔다. */} + {a.buttons.map((b, i) => btn(b.label, i === 0 && !b.isClose, () => this.answerExtension(i), "b" + i))} +
+ ); + } else if (a.kind === "input") { + body = (<> + {a.title &&
{a.title}
} + { + const v = e.target.value; + this.setState({ extAskText: v, extAskErr: null }); + // 타자 중에도 검사한다. 늦게 오는 답이 그 사이 바뀐 값을 덮지 않게, 돌아왔을 + // 때 값이 그대로인지 확인한다. + void validateInput(a.validate, v).then(m => { + if (this.state.extAsk === a && this.state.extAskText === v) this.setState({ extAskErr: m }); + }); + }} + onKeyDown={e => { + if (e.key === "Enter") { e.preventDefault(); void this.extAskSubmit(); } + else if (e.key === "Escape") { e.preventDefault(); cancel(); } + }} /> + {s.extAskErr &&
{s.extAskErr}
} +
+ {btn(t("extask.cancel"), false, cancel)} + {btn(t("extask.ok"), true, () => void this.extAskSubmit())} +
+ ); + } else { + const vis = this.extAskVisible(); + const sel = Math.min(s.extAskSel, Math.max(0, vis.length - 1)); + const toggle = (idx: number) => this.setState(st => ({ + extAskPicked: st.extAskPicked.includes(idx) ? st.extAskPicked.filter(x => x !== idx) : [...st.extAskPicked, idx], + })); + body = (<> + this.setState({ extAskText: e.target.value, extAskSel: 0 })} + onKeyDown={e => { + if (e.key === "ArrowDown") { e.preventDefault(); this.setState({ extAskSel: stepIndex(sel, 1, vis.length) }); } + else if (e.key === "ArrowUp") { e.preventDefault(); this.setState({ extAskSel: stepIndex(sel, -1, vis.length) }); } + else if (e.key === "Enter") { e.preventDefault(); void this.extAskSubmit(); } + else if (e.key === "Escape") { e.preventDefault(); cancel(); } + // 여러 개 고르는 중에는 Space 가 체크다. 한 개짜리에서는 걸러내기에 쓰는 + // 평범한 글자여야 하므로 가로채지 않는다. + else if (e.key === " " && a.many && vis[sel]) { e.preventDefault(); toggle(vis[sel]!.index); } + }} /> +
+ {!vis.length &&
{t("extask.none")}
} + {vis.map((it, i) => ( +
this.setState({ extAskSel: i })} + onClick={() => { if (a.many) toggle(it.index); else this.answerExtension(it.index); }} + style={{ display: "flex", alignItems: "center", gap: 9, padding: "7px 11px", cursor: "pointer", + background: i === sel ? "var(--w08)" : "transparent" }}> + {a.many && {s.extAskPicked.includes(it.index) ? "✓" : ""}} +
+
+ {it.label} + {it.description && {it.description}} +
+ {it.detail &&
{it.detail}
} +
+
+ ))} +
+
+ + {a.many ? (s.extAskPicked.length ? t("extask.selected", { n: s.extAskPicked.length }) : t("extask.pickMany")) : ""} + +
+ {btn(t("extask.cancel"), false, cancel)} + {a.many && btn(t("extask.ok"), true, () => void this.extAskSubmit())} +
+ ); + } + + return ( +
+
e.stopPropagation()} + style={{ width: 460, maxWidth: "92%", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 12, boxShadow: "var(--shadow-pop)", padding: 18, fontFamily: SUIT }}> + {head} + {body} +
+
+ ); + } + renderAskRun() { const a = this.state.askRun; if (!a) return null; @@ -6777,7 +6978,7 @@ ${(r.output || "").slice(0, 2000)}`; const d = this.agDef(a.agent); return (
this.answerRun(false)} - style={{ position: "fixed", inset: 0, zIndex: 230, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}> + style={{ position: "fixed", inset: 0, zIndex: overlayZ("askRun"), background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 460, maxWidth: "92%", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 12, boxShadow: "var(--shadow-pop)", padding: 18 }}>
@@ -6970,7 +7171,7 @@ ${(r.output || "").slice(0, 2000)}`; return (
+ style={{ position: "fixed", inset: 0, zIndex: overlayZ("import"), background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 620, maxWidth: "94%", height: 520, maxHeight: "86vh", display: "flex", flexDirection: "column", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 14, boxShadow: "var(--shadow-pop)" }}> @@ -8018,7 +8219,7 @@ ${(r.output || "").slice(0, 2000)}`; ); return (
{ if (!ask.busy) this.setState({ undoAsk: null }); }} - style={{ position: "fixed", inset: 0, zIndex: 230, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}> + style={{ position: "fixed", inset: 0, zIndex: overlayZ("undoAsk"), background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 480, maxWidth: "92%", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 12, boxShadow: "var(--shadow-pop)", padding: 18 }}>
{t("cp.title")}
@@ -8065,7 +8266,7 @@ ${(r.output || "").slice(0, 2000)}`; const label = { fontSize: 10.5, color: "var(--fg-dim)", marginBottom: 3 } as React.CSSProperties; return (
{ if (!b.busy) this.closeBundle(); }} - style={{ position: "fixed", inset: 0, zIndex: 232, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}> + style={{ position: "fixed", inset: 0, zIndex: overlayZ("mcpb"), background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 540, maxWidth: "94%", maxHeight: "86%", overflowY: "auto", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 12, boxShadow: "var(--shadow-pop)", padding: 18 }}>
{t("mcpb.title")}
@@ -8152,7 +8353,7 @@ ${(r.output || "").slice(0, 2000)}`; : ln.startsWith("diff --git") ? "var(--fg-sub)" : "var(--fg-sub2)"; return (
this.setState({ commitView: null })} - style={{ position: "fixed", inset: 0, zIndex: 230, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}> + style={{ position: "fixed", inset: 0, zIndex: overlayZ("commitView"), background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 820, maxWidth: "94%", height: "78%", display: "flex", flexDirection: "column", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 12, boxShadow: "var(--shadow-pop)", padding: 16 }}>
@@ -8357,7 +8558,7 @@ ${(r.output || "").slice(0, 2000)}`; if (list.length < 2) return null; const sel = s.mruSel % list.length; return ( -
+
{list.map((rel, i) => (
@@ -8399,7 +8600,7 @@ ${(r.output || "").slice(0, 2000)}`; const closeAsk = () => this.closeOverlay("askClose", { askClose: null }); return (
+ style={{ position: "fixed", inset: 0, zIndex: overlayZ("askClose"), background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 380, maxWidth: "90%", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 12, boxShadow: "var(--shadow-pop)", padding: "18px 20px" }}>
{t("misc.unsavedTitle")}
@@ -8878,6 +9079,7 @@ ${(r.output || "").slice(0, 2000)}`; case "commitView": this.setState({ commitView: null }); return; case "mcpb": if (!s.mcpb?.busy) void this.closeBundle(); return; case "confirmAsk": this.answerConfirm(false); return; // Esc = 취소 + case "extAsk": this.answerExtension(undefined); return; // 확장에는 "취소" 로 간다 case "import": this.closeImport(); return; case "tour": this.endTour(); return; } @@ -9501,7 +9703,7 @@ ${(r.output || "").slice(0, 2000)}`; const isLast = cur === TOUR_STEPS.length - 1; const tourBtn: React.CSSProperties = { padding: "5px 14px", fontSize: 11.5, borderRadius: 7, cursor: "pointer", fontFamily: SUIT }; return ( -
{ if (e.target === e.currentTarget) { /* 배경 클릭은 무시(오작동 방지) */ } }}> {rect ?
@@ -10645,7 +10847,7 @@ ${(r.output || "").slice(0, 2000)}`; const segBtn = (on: boolean): React.CSSProperties => ({ flex: 1, height: 30, fontSize: 11.5, fontWeight: 600, fontFamily: "inherit", cursor: "pointer", borderRadius: 7, color: on ? "var(--fg)" : "var(--fg-sub2)", background: on ? "var(--accent-soft)" : "transparent", border: `1px solid ${on ? "var(--accent)" : "var(--w10)"}` }); return (
+ style={{ position: "fixed", inset: 0, zIndex: overlayZ("settings"), background: "rgba(0,0,0,.55)", display: "flex", alignItems: "center", justifyContent: "center" }}>
e.stopPropagation()} style={{ width: 480, maxWidth: "92%", maxHeight: "88vh", overflowY: "auto", background: "var(--bg-card)", border: "1px solid var(--bd-popup)", borderRadius: 14, boxShadow: "var(--shadow-pop)", padding: "18px 20px" }}>
diff --git a/ide/src/ext/extHost.ts b/ide/src/ext/extHost.ts index e87b579..98992ad 100644 --- a/ide/src/ext/extHost.ts +++ b/ide/src/ext/extHost.ts @@ -17,6 +17,8 @@ export interface HostDeps { * activeTextEditor 가 영원히 undefined 였다. */ workspaceRoot: () => string | null; openFiles: () => string[]; + /** 확장이 사용자에게 묻는 통로. 없으면 셰임이 곧장 취소로 답한다. */ + prompt: (req: any) => Promise; } let commands: ExtCommand[] = []; @@ -195,7 +197,7 @@ export async function loadExtensions(d: HostDeps): Promise<{ loaded: number; err else errors.push(ext.name + ": " + reason); continue; } - const vscode = makeVscodeApi({ toast: d.toast, showPanel: d.showPanel, getActiveFile: d.getActiveFile, workspaceRoot: d.workspaceRoot, openFiles: d.openFiles, registerCommand: addCommand }, ext); + const vscode = makeVscodeApi({ toast: d.toast, showPanel: d.showPanel, getActiveFile: d.getActiveFile, workspaceRoot: d.workspaceRoot, openFiles: d.openFiles, prompt: d.prompt, registerCommand: addCommand }, ext); const moduleObj = { exports: {} as any }; const require = makeHostRequire(vscode); const ctx = { diff --git a/ide/src/ext/prompt.test.ts b/ide/src/ext/prompt.test.ts new file mode 100644 index 0000000..41deb60 --- /dev/null +++ b/ide/src/ext/prompt.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { normalizePicks, matchPick, filterPicks, stepIndex, normalizeButtons, validateInput } from "./prompt"; + +describe("normalizePicks", () => { + it("문자열 배열을 라벨로 받는다", () => { + const r = normalizePicks(["a", "b"]); + expect(r.map(x => x.label)).toEqual(["a", "b"]); + expect(r.map(x => x.index)).toEqual([0, 1]); + }); + + it("원래 값을 그대로 들고 있는다 — 확장이 넘긴 다른 필드가 결과로 돌아가야 한다", () => { + const item = { label: "열기", uri: "file:///x", run: () => 1 }; + const r = normalizePicks([item]); + expect(r[0]!.raw).toBe(item); + }); + + it("description·detail·picked 를 읽는다", () => { + const r = normalizePicks([{ label: "L", description: "D", detail: "T", picked: true }]); + expect(r[0]).toMatchObject({ label: "L", description: "D", detail: "T", picked: true }); + }); + + it("label 이 없으면 자리 번호로 채운다 — 빈 줄이면 무엇인지 알 수 없다", () => { + expect(normalizePicks([{ description: "x" } as any])[0]!.label).toBe("(1)"); + }); + + it("배열이 아니면 빈 목록", () => { + expect(normalizePicks(null)).toEqual([]); + expect(normalizePicks(undefined)).toEqual([]); + expect(normalizePicks("nope" as any)).toEqual([]); + }); +}); + +describe("matchPick", () => { + const [it0] = normalizePicks([{ label: "Open File", description: "workspace", detail: "src/App.tsx" }]); + + it("빈 질의는 전부 통과", () => expect(matchPick(it0!, "")).toBe(true)); + it("대소문자를 무시한다", () => expect(matchPick(it0!, "OPEN")).toBe(true)); + + it("조각이 전부 들어 있어야 한다 — 순서는 상관없다", () => { + expect(matchPick(it0!, "file open")).toBe(true); + expect(matchPick(it0!, "open zzz")).toBe(false); + }); + + it("description·detail 은 기본으로 안 본다", () => { + expect(matchPick(it0!, "workspace")).toBe(false); + expect(matchPick(it0!, "workspace", { matchOnDescription: true })).toBe(true); + expect(matchPick(it0!, "App.tsx", { matchOnDetail: true })).toBe(true); + }); +}); + +describe("filterPicks", () => { + const items = normalizePicks(["alpha", "beta", "alphabet"]); + + it("맞는 것만 남기고 원래 자리를 유지한다", () => { + const r = filterPicks(items, "alpha"); + expect(r.map(x => x.label)).toEqual(["alpha", "alphabet"]); + expect(r.map(x => x.index)).toEqual([0, 2]); + }); + + it("확장이 정해 둔 순서를 흔들지 않는다 — 첫 항목이 대개 권장값이다", () => { + expect(filterPicks(items, "a").map(x => x.label)).toEqual(["alpha", "beta", "alphabet"]); + }); +}); + +describe("stepIndex", () => { + it("끝에서 반대편으로 돈다", () => { + expect(stepIndex(2, 1, 3)).toBe(0); + expect(stepIndex(0, -1, 3)).toBe(2); + }); + it("가운데선 그냥 움직인다", () => expect(stepIndex(1, 1, 3)).toBe(2)); + it("빈 목록이면 0", () => expect(stepIndex(5, 1, 0)).toBe(0)); + it("범위 밖 커서도 안전하다", () => expect(stepIndex(99, 1, 3)).toBe(1)); +}); + +describe("normalizeButtons", () => { + it("문자열을 버튼으로", () => { + expect(normalizeButtons(["Yes", "No"]).map(b => b.label)).toEqual(["Yes", "No"]); + }); + + it("MessageItem 의 title 을 읽고 isCloseAffordance 를 표시한다", () => { + const r = normalizeButtons([{ title: "Reload" }, { title: "Later", isCloseAffordance: true }]); + expect(r.map(b => [b.label, b.isClose])).toEqual([["Reload", false], ["Later", true]]); + }); + + it("첫 인자로 끼워 넣는 옵션 객체는 버튼이 아니다", () => { + expect(normalizeButtons([{ modal: true }, "OK"]).map(b => b.label)).toEqual(["OK"]); + }); + + it("고른 결과로 원래 값을 돌려줄 수 있게 raw 를 들고 있는다", () => { + const mi = { title: "Reload", id: 7 }; + expect(normalizeButtons([mi])[0]!.raw).toBe(mi); + }); + + it("항목이 없으면 빈 목록 — 그때는 알림이지 물음이 아니다", () => { + expect(normalizeButtons([])).toEqual([]); + expect(normalizeButtons(undefined)).toEqual([]); + }); +}); + +describe("validateInput", () => { + it("검사기가 없으면 통과", async () => { + expect(await validateInput(undefined, "x")).toBeNull(); + }); + + it("문자열을 오류 문구로 돌려준다", async () => { + expect(await validateInput((v: string) => (v ? null : "비어 있습니다"), "")).toBe("비어 있습니다"); + expect(await validateInput((v: string) => (v ? null : "비어 있습니다"), "a")).toBeNull(); + }); + + it("빈 문자열은 통과로 본다", async () => { + expect(await validateInput(() => "", "x")).toBeNull(); + }); + + it("{ message } 모양도 받는다", async () => { + expect(await validateInput(() => ({ message: "안 됨", severity: 3 }), "x")).toBe("안 됨"); + }); + + it("Promise 를 기다린다", async () => { + expect(await validateInput(async () => "늦은 오류", "x")).toBe("늦은 오류"); + }); + + it("확장이 던진 예외로 물음이 죽지 않는다 — 통과로 본다", async () => { + expect(await validateInput(() => { throw new Error("boom"); }, "x")).toBeNull(); + expect(await validateInput(async () => { throw new Error("boom"); }, "x")).toBeNull(); + }); +}); diff --git a/ide/src/ext/prompt.ts b/ide/src/ext/prompt.ts new file mode 100644 index 0000000..0604aa4 --- /dev/null +++ b/ide/src/ext/prompt.ts @@ -0,0 +1,113 @@ +/** + * 확장이 **사용자에게 무언가를 묻는** 경로. + * + * 셰임에서 묻는 함수는 셋인데 셋 다 이랬다: + * + * showQuickPick: () => Promise.resolve(undefined), + * showInputBox: () => Promise.resolve(undefined), + * showInformationMessage: (msg, ..._items) => { toast(...); return Promise.resolve(undefined); }, + * + * vscode 규약에서 `undefined` 는 **"사용자가 취소했다"** 다. 그래서 확장은 물음을 띄운 + * 적도 없이 "취소당했다" 고 판단하고 흐름을 접었다. 오류도 안 나고 토스트도 안 뜬다 — + * activeTextEditor 때와 같은 종류의 조용한 무동작이다. 특히 세 번째가 고약한데, 토스트는 + * 뜨니까 **뭔가 일어난 것처럼 보인다.** 정작 `"Reload"` 버튼을 누를 방법이 없다. + * + * 여기 있는 것은 그 물음의 순수한 부분이다 — 항목 정규화, 필터, 커서 이동, 검증. + * React 도 monaco 도 모른다. + */ + +/** 확장이 넘기는 항목. 문자열이거나 vscode.QuickPickItem 모양이다. */ +export type PickChoice = string | { label?: string; description?: string; detail?: string; picked?: boolean; [k: string]: any }; + +export interface NormPick { + label: string; + description: string; + detail: string; + picked: boolean; + /** 원래 값. 고른 결과로 **이것을** 돌려준다 — 확장이 넘긴 객체의 다른 필드 + * (`id`, `uri`, 핸들러 등)를 그대로 되받아야 뒤 흐름이 이어진다. */ + raw: PickChoice; + /** 원본 배열에서의 자리. 필터한 뒤에도 어느 항목인지 잃지 않는다. */ + index: number; +} + +const str = (v: any) => (v == null ? "" : String(v)); + +export function normalizePicks(items: readonly PickChoice[] | null | undefined): NormPick[] { + if (!Array.isArray(items)) return []; + return items.map((raw, index) => { + if (typeof raw === "string") return { label: raw, description: "", detail: "", picked: false, raw, index }; + return { + // label 이 없는 객체를 넘기는 확장이 있다. 빈 줄로 두면 고를 수는 있는데 뭔지 + // 알 수 없으니, 최소한 자리 번호라도 보인다. + label: str(raw?.label) || `(${index + 1})`, + description: str(raw?.description), + detail: str(raw?.detail), + picked: raw?.picked === true, + raw, + index, + }; + }); +} + +export interface MatchOpts { + matchOnDescription?: boolean; + matchOnDetail?: boolean; +} + +/** 공백으로 끊은 모든 조각이 들어 있어야 맞는 것으로 본다. 대소문자는 무시한다. + * vscode 의 퍼지 점수까지 흉내 내지는 않는다 — 순서를 흔들면 확장이 정해 둔 + * 우선순위(대개 첫 항목이 권장값)가 무너진다. */ +export function matchPick(it: NormPick, query: string, o: MatchOpts = {}): boolean { + const parts = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!parts.length) return true; + let hay = it.label.toLowerCase(); + if (o.matchOnDescription) hay += " " + it.description.toLowerCase(); + if (o.matchOnDetail) hay += " " + it.detail.toLowerCase(); + return parts.every(p => hay.includes(p)); +} + +export function filterPicks(items: readonly NormPick[], query: string, o: MatchOpts = {}): NormPick[] { + return items.filter(it => matchPick(it, query, o)); +} + +/** 위/아래 키. 끝에서 반대편으로 돈다 — 목록이 비면 0. */ +export function stepIndex(cur: number, delta: number, len: number): number { + if (len <= 0) return 0; + const c = Number.isFinite(cur) ? Math.trunc(cur) : 0; + return ((c + delta) % len + len) % len; +} + +/** showInformationMessage(msg, ...items) 의 버튼들. + * 항목은 문자열이거나 vscode.MessageItem(`{ title, isCloseAffordance }`) 이다. */ +export interface MsgButton { label: string; raw: any; isClose: boolean } + +export function normalizeButtons(items: readonly any[] | null | undefined): MsgButton[] { + if (!Array.isArray(items)) return []; + return items + // 옵션 객체(`{ modal: true }`)를 첫 인자로 끼워 넣는 호출이 있다. 버튼이 아니다. + .filter(x => typeof x === "string" || (x && typeof x === "object" && ("title" in x))) + .map(x => typeof x === "string" + ? { label: x, raw: x, isClose: false } + : { label: str(x.title), raw: x, isClose: x.isCloseAffordance === true }); +} + +/** validateInput 을 돌린다. 문자열/`{message}`/null/Promise/예외를 모두 받는다. + * 돌려주는 것은 보여 줄 오류 문구이거나 null(통과). 확장이 던진 예외로 물음이 + * 통째로 죽으면 안 되므로, 예외는 "통과" 로 본다. */ +export async function validateInput(fn: any, value: string): Promise { + if (typeof fn !== "function") return null; + try { + const r = await fn(value); + if (r == null) return null; + if (typeof r === "string") return r || null; + if (typeof r === "object" && "message" in r) return str((r as any).message) || null; + return null; + } catch { return null; } +} + +/** 셰임 → 앱으로 넘어가는 물음 하나. 앱은 이 모양만 알면 된다. */ +export type PromptReq = + | { kind: "pick"; source: string; title: string; items: NormPick[]; many: boolean; match: MatchOpts } + | { kind: "input"; source: string; title: string; detail: string; value: string; password: boolean; validate: any } + | { kind: "buttons"; source: string; title: string; tone: "info" | "warn" | "error"; buttons: MsgButton[] }; diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts index d70e471..4e9e880 100644 --- a/ide/src/ext/vscodeShim.ts +++ b/ide/src/ext/vscodeShim.ts @@ -4,6 +4,7 @@ import monaco from "../editor/monacoSetup"; import { getLang } from "../i18n"; import { makeDocIndex } from "./shimDoc"; +import { normalizePicks, normalizeButtons, type PromptReq } from "./prompt"; import { setShimDocSource } from "./extHost"; export interface ShimDeps { @@ -15,6 +16,10 @@ export interface ShimDeps { * 없이 두었더니 activeTextEditor 가 영원히 undefined 였다. */ workspaceRoot: () => string | null; openFiles: () => string[]; + /** 사용자에게 묻는다. 취소면 undefined 로 풀린다 — 그게 vscode 규약이다. + * 이 통로가 없던 동안 showQuickPick·showInputBox 는 **묻지도 않고** undefined 를 + * 돌려줬고, 확장은 사용자가 취소한 줄 알고 흐름을 접었다. */ + prompt: (req: PromptReq) => Promise; } const disposables: monaco.IDisposable[] = []; @@ -170,10 +175,24 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) setLanguageConfiguration() { return noopDisposable; }, }; + /** 알림인가 물음인가를 인자로 가른다. vscode 도 이 한 함수로 둘 다 한다. */ + const msgOrAsk = async (tone: "info" | "warn" | "error", msg: string, items: any[]) => { + const buttons = normalizeButtons(items); + if (!buttons.length) { + deps.toast(tone === "error" ? "error" : "info", ext.name + (tone === "warn" ? " ⚠ " : ": ") + msg); + return undefined; + } + const got = await deps.prompt({ kind: "buttons", source: ext.name, title: String(msg), tone, buttons }); + return got == null ? undefined : buttons[got as number]!.raw; + }; + const window_ = { - showInformationMessage: (msg: string, ..._items: any[]) => { deps.toast("info", ext.name + ": " + msg); return Promise.resolve(undefined); }, - showWarningMessage: (msg: string, ..._items: any[]) => { deps.toast("info", ext.name + " ⚠ " + msg); return Promise.resolve(undefined); }, - showErrorMessage: (msg: string, ..._items: any[]) => { deps.toast("error", ext.name + ": " + msg); return Promise.resolve(undefined); }, + // 버튼 없이 부르면 알림이다(토스트). 버튼을 주면 **물음**이다 — 예전엔 둘 다 + // 토스트로 흘리고 undefined 를 돌려줘, `if (await showInformationMessage(m, "Reload") === "Reload")` + // 같은 흔한 흐름이 영원히 거짓이었다. 뭔가 뜨긴 하니 더 알아채기 어려웠다. + showInformationMessage: (msg: string, ...items: any[]) => msgOrAsk("info", msg, items), + showWarningMessage: (msg: string, ...items: any[]) => msgOrAsk("warn", msg, items), + showErrorMessage: (msg: string, ...items: any[]) => msgOrAsk("error", msg, items), setStatusBarMessage: (_msg: string) => noopDisposable, createOutputChannel: (name: string) => { let buf = ""; @@ -184,8 +203,32 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) }; }, createStatusBarItem: () => ({ text: "", tooltip: "", command: "", show() {}, hide() {}, dispose() {} }), - showQuickPick: () => Promise.resolve(undefined), - showInputBox: () => Promise.resolve(undefined), + showQuickPick: async (items: any, options?: any) => { + // 확장은 배열을 Promise 로 넘기기도 한다(파일 목록을 읽어 오는 흐름). + const list = normalizePicks(await Promise.resolve(items)); + if (!list.length) return undefined; + const many = options?.canPickMany === true; + const got = await deps.prompt({ + kind: "pick", source: ext.name, + title: String(options?.placeHolder ?? options?.title ?? ""), + items: list, many, + match: { matchOnDescription: options?.matchOnDescription === true, matchOnDetail: options?.matchOnDetail === true }, + }); + if (got == null) return undefined; + // 넘겨받은 값 그대로 돌려준다 — 확장은 대개 자기가 붙인 필드를 보고 다음을 정한다. + return many ? (got as number[]).map(i => list[i]!.raw) : list[got as number]!.raw; + }, + showInputBox: async (options?: any) => { + const got = await deps.prompt({ + kind: "input", source: ext.name, + title: String(options?.prompt ?? options?.title ?? ""), + detail: String(options?.placeHolder ?? ""), + value: String(options?.value ?? ""), + password: options?.password === true, + validate: options?.validateInput, + }); + return got == null ? undefined : String(got); + }, createTextEditorDecorationType: () => ({ dispose() {}, key: "sz-deco" }), registerTreeDataProvider: () => noopDisposable, registerWebviewViewProvider: () => noopDisposable, diff --git a/ide/src/i18n/dict/extask.ts b/ide/src/i18n/dict/extask.ts new file mode 100644 index 0000000..7a65b68 --- /dev/null +++ b/ide/src/i18n/dict/extask.ts @@ -0,0 +1,12 @@ +// extask 도메인 — 확장이 사용자에게 묻는 물음(빠른 선택·입력·버튼) +export const dict: Record = { + // 누가 묻는지 늘 밝힌다. 확장이 띄운 창을 앱이 띄운 것으로 오해하면 안 된다. + "extask.from": { ko: "{name} 확장", en: "{name} extension", de: "Erweiterung {name}", ja: "{name} 拡張機能" }, + "extask.pickPlaceholder": { ko: "항목 선택", en: "Select an item", de: "Element auswählen", ja: "項目を選択" }, + "extask.filter": { ko: "걸러내기", en: "Filter", de: "Filtern", ja: "絞り込み" }, + "extask.none": { ko: "맞는 항목이 없습니다", en: "No matching items", de: "Keine passenden Einträge", ja: "一致する項目がありません" }, + "extask.pickMany": { ko: "여러 개를 고를 수 있습니다 — Space 로 선택", en: "Pick several — Space to toggle", de: "Mehrfachauswahl — Leertaste zum Umschalten", ja: "複数選択できます — Space で切り替え" }, + "extask.ok": { ko: "확인", en: "OK", de: "OK", ja: "OK" }, + "extask.cancel": { ko: "취소", en: "Cancel", de: "Abbrechen", ja: "キャンセル" }, + "extask.selected": { ko: "{n}개 선택함", en: "{n} selected", de: "{n} ausgewählt", ja: "{n} 件選択" }, +}; diff --git a/ide/src/i18n/messages.ts b/ide/src/i18n/messages.ts index 2ea1d8a..9e0f905 100644 --- a/ide/src/i18n/messages.ts +++ b/ide/src/i18n/messages.ts @@ -14,6 +14,7 @@ import { dict as d_key } from "./dict/key"; import { dict as d_gitp } from "./dict/gitp"; import { dict as d_runfile } from "./dict/runfile"; import { dict as d_confirm } from "./dict/confirm"; +import { dict as d_extask } from "./dict/extask"; import { dict as d_flowtree } from "./dict/flowtree"; import { dict as d_dbg } from "./dict/dbg"; import { dict as d_mcpui } from "./dict/mcpui"; @@ -42,7 +43,7 @@ export type Msg = { ko: string; en: string; de: string; ja: string }; export const MESSAGES: Record = { ...d_dap, ...d_data, ...d_exth, ...d_mcpc, ...d_media, ...d_model, ...d_mono, ...d_oai, ...d_reg, ...d_key, - ...d_confirm, ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, + ...d_confirm, ...d_extask, ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, ...d_sc1, ...d_sc2, ...d_sc3, ...d_sc4, ...d_sc5, ...d_eng, ...d_plug, ...d_review, ...d_cloud, // ── 공통 ──────────────────────────────────────────────── "common.next": { ko: "다음", en: "Next", de: "Weiter", ja: "次へ" }, diff --git a/ide/src/overlays.test.ts b/ide/src/overlays.test.ts index 48f8d15..3d92bbb 100644 --- a/ide/src/overlays.test.ts +++ b/ide/src/overlays.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { OVERLAYS, OVERLAY_KEY, overlayById, topOverlay, suppressesAction } from "./overlays"; +import { OVERLAYS, OVERLAY_KEY, overlayById, topOverlay, suppressesAction, overlayZ } from "./overlays"; describe("표 불변 조건", () => { it("id 가 유일하다", () => { @@ -126,3 +126,27 @@ describe("suppressesAction", () => { } }); }); + +describe("overlayZ", () => { + it("표의 z 를 그대로 내준다 — 렌더가 이걸 읽어 간다", () => { + for (const o of OVERLAYS) expect(overlayZ(o.id), o.id).toBe(o.z); + }); + + it("표에 없는 id 는 조용히 0 을 주지 않고 던진다", () => { + // 렌더가 zIndex: 0 으로 그려지면 모달이 뒤에 깔려 **보이지 않는 채로** 키를 먹는다. + // 그 모양은 디버깅이 어렵다 — 차라리 화면이 안 뜨고 오류가 나는 편이 낫다. + expect(() => overlayZ("없는것")).toThrow(); + }); + + it("확장이 던진 물음은 앱의 확인창보다 위다", () => { + expect(overlayZ("extAsk")).toBeGreaterThan(overlayZ("confirmAsk")); + }); + + it("확인창은 번들 설치창보다 위다 — 예전엔 표만 그랬고 렌더는 반대였다", () => { + expect(overlayZ("confirmAsk")).toBeGreaterThan(overlayZ("mcpb")); + }); + + it("투어는 가져오기창보다 위다 — 예전엔 렌더에서 둘 다 240 이라 DOM 순서가 정했다", () => { + expect(overlayZ("tour")).toBeGreaterThan(overlayZ("import")); + }); +}); diff --git a/ide/src/overlays.ts b/ide/src/overlays.ts index 65a0c3d..dd55db7 100644 --- a/ide/src/overlays.ts +++ b/ide/src/overlays.ts @@ -70,6 +70,9 @@ export const OVERLAYS: OverlaySpec[] = [ // 되돌리기 어려운 일 직전의 확인. 다른 모달 위에서 뜰 수 있어야 한다 // (설정·검색 안에서도 삭제·치환을 부른다). { id: "confirmAsk", flag: "confirmAsk", z: 233, escapable: true }, + // 확장이 던진 물음. 확인창보다 위다 — 확장 명령이 앱의 확인을 거쳐 시작될 수 있고, + // 그때 위에 뜨는 것은 확장 쪽이다. Esc 는 확장에 "취소" 로 전달된다. + { id: "extAsk", flag: "extAsk", z: 234, escapable: true }, { id: "import", flag: "impOpen", z: 240, escapable: true }, { id: "tour", flag: "tourOpen", z: 245, escapable: true }, @@ -78,6 +81,23 @@ export const OVERLAYS: OverlaySpec[] = [ const BY_ID = new Map(OVERLAYS.map(o => [o.id, o])); export function overlayById(id: string): OverlaySpec | null { return BY_ID.get(id) ?? null; } +/** + * 렌더가 쓸 zIndex. **표에서 읽어 간다.** + * + * "렌더 쪽 리터럴과 같아야 한다" 고 적어 두기만 했더니 실제로 어긋나 있었다: + * 확인창은 표에서 233 인데 231 로 그려졌고(번들 설치 232 **아래**), 투어는 표에서 + * 245 인데 240 으로 그려져 가져오기(240)와 같은 층이었다. 둘 다 Esc 는 표 순서대로 + * 위엣것부터 닫는데 **눈에 보이는 순서는 반대**가 된다 — 사용자는 보이는 창을 닫으려 + * Esc 를 누르고, 답은 뒤에 가려진 창으로 간다. + * + * 지켜야 할 규칙을 주석으로 적는 대신 한 곳에서 값을 내주면 어긋날 자리가 없다. + */ +export function overlayZ(id: string): number { + const o = BY_ID.get(id); + if (!o) throw new Error(`overlayZ: 표에 없는 오버레이 "${id}"`); + return o.z; +} + /** 지금 열린 것들 중 **가장 위**. 같은 z 면 표에서 뒤에 적힌 것이 이긴다. */ export function topOverlay(openIds: readonly string[]): OverlaySpec | null { const open = new Set(openIds); diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index 6196892..ede96a4 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ "src/ai/retry.test.ts", "src/keymap.test.ts", "src/overlays.test.ts", - "src/ext/hooks.test.ts", + "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/theme.test.ts", "src/editor/monacoColors.test.ts", "src/opening/**/*.test.ts", From 9bd89db77d5a45afacf356c28d485dd35ba6adda Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 11:55:30 +0900 Subject: [PATCH 19/52] =?UTF-8?q?README=20=EC=97=90=EC=84=9C=20quick-pick?= =?UTF-8?q?=C2=B7input=20box=20=EB=A5=BC=20"=EC=96=87=EC=9D=80=20=EA=B3=B3?= =?UTF-8?q?"=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20=EB=BA=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지난 판에 "확인할 수 있게 이름으로 적자" 고 해 놓고 적었는데, 그중 둘이 이번에 채워졌다. 이름으로 적었으니 낡았는지도 바로 보인다. 기능 목록에도 확장이 사용자에게 물을 수 있다는 것을 한 줄 넣는다. --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 330839c..1ff521e 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ same table the app dispatches from, so it cannot drift from what actually happen **Debugging** — Breakpoints, call stack, variables, and stepping via DAP (Python/debugpy today). -**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. The `vscode` shim gives them the active editor for real: reading the document, moving the selection, and editing through the model so `Ctrl+Z` still works. Schutz-native extensions can register commands, show panels, and subscribe to what happens in the IDE — files opened and saved, proposals accepted or rejected, agent turns starting and ending. +**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. The `vscode` shim gives them the active editor for real: reading the document, moving the selection, and editing through the model so `Ctrl+Z` still works. Extensions can ask you things too — quick picks, input boxes with validation, and messages with buttons, each labelled with the extension doing the asking. Schutz-native extensions can register commands, show panels, and subscribe to what happens in the IDE — files opened and saved, proposals accepted or rejected, agent turns starting and ending. **Localization** — The full UI ships in Korean, English, German, and Japanese. @@ -129,11 +129,11 @@ skills and subagents, MCP servers over stdio and HTTP, and `.mcpb` bundles are a today. The VS Code shim reads and edits the active document, and MCP speaks all three of its pillars rather than tools alone. -Where it is still thin, specifically: the shim has no quick-pick or input box, cannot open a -file that is not already in a tab, and does not implement `document.save()` — those return an -error rather than an empty value, so an extension that needs them fails loudly instead of -doing nothing. MCP resources and prompts are readable and shown in the panel, but are not yet -offered to the agent as tools. +Where it is still thin, specifically: the shim cannot open a file that is not already in a +tab, and does not implement `document.save()` — both return an error rather than an empty +value, so an extension that needs them fails loudly instead of doing nothing. MCP resources +and prompts are readable and shown in the panel, but are not yet offered to the agent as +tools. ## Contributing From 5552737a1a8b4fdcbeffc9d77af9b5c045457b9f Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 12:31:00 +0900 Subject: [PATCH 20/52] =?UTF-8?q?=EC=96=B8=EC=96=B4=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EC=9D=B4=20=EB=82=B4=EB=86=93=EB=8A=94=20=EA=B2=83=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=ED=99=94=EB=A9=B4=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 셰임의 languages 네임스페이스는 자동완성·호버만 진짜였고 바로 아래 넉 줄이 등록만 받고 결과를 버렸다. registerDefinitionProvider() { return noopDisposable; }, registerCodeActionsProvider() { return noopDisposable; }, registerDocumentFormattingEditProvider() { return noopDisposable; }, createDiagnosticCollection(name) { return { set() {}, delete() {}, clear() {}, ... }; }, disposable 을 돌려주니 확장은 등록에 성공한 줄 안다. 그중 진단이 제일 크다 — 린터 확장은 파일을 다 읽고 문제를 찾아 set() 에 넘기고, 그 값이 그대로 사라진다. 밑줄도 없고 문제 패널도 비어 있다. 정의·서식·진단을 Monaco 로 연결한다. 진단은 확장마다 owner 를 나눈다(한 이름을 나눠 쓰면 나중에 set 하는 확장이 앞 확장 것을 지운다). 마커를 올려 둔 모델을 들고 있다가 clear/dispose 에서 되짚어 지운다 — 안 들고 있으면 "지웠다" 고만 하고 화면에는 옛 문제가 남는다. get/has/forEach 도 채운다. 심각도 표는 새로 만든다. vscode 는 **Error 가 0** 이고(Error 0·Warning 1· Information 2·Hint 3) LSP 는 Error 가 1 이다. lspConverters 의 표를 그대로 쓰면 한 칸씩 밀려 오류가 경고로 내려앉는다. 그리고 프로바이더가 받는 첫 인자를 고친다. vscode 는 TextDocument 를 준다고 약속하는데 Monaco 모델을 그대로 넘기고 있었다. 모델에는 getText·lineAt·fileName 이 없으니, 그걸 부르는 프로바이더는 첫 줄에서 던지고 바깥 try/catch 가 예외를 삼켰다 — 확장 쪽에서는 "결과가 없다" 와 구별되지 않는다. 진작 붙어 있던 자동완성·호버가 등록만 되고 아무것도 안 나오던 이유이기도 하다. 코드 액션은 그대로 둔다. WorkspaceEdit 를 셰임이 아직 안 내주므로, 편집을 담은 액션은 확장이 만들 수조차 없다 — 반쪽을 붙이면 되는 것과 안 되는 것을 구별하기만 더 어려워진다. --- ide/src/ext/shimLang.test.ts | 128 +++++++++++++++++++++++++++++++++++ ide/src/ext/shimLang.ts | 114 +++++++++++++++++++++++++++++++ ide/src/ext/vscodeShim.ts | 110 ++++++++++++++++++++++++++++-- ide/vitest.config.ts | 2 +- 4 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 ide/src/ext/shimLang.test.ts create mode 100644 ide/src/ext/shimLang.ts diff --git a/ide/src/ext/shimLang.test.ts b/ide/src/ext/shimLang.test.ts new file mode 100644 index 0000000..a651faa --- /dev/null +++ b/ide/src/ext/shimLang.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { markerSeverity, toMonacoRange, toMarker, toMarkers, toLocations, toEdits, M_HINT, M_INFO, M_WARN, M_ERROR } from "./shimLang"; + +describe("markerSeverity", () => { + it("vscode 는 Error 가 0 이다 — LSP 표(Error=1)를 쓰면 한 칸씩 밀린다", () => { + expect(markerSeverity(0)).toBe(M_ERROR); + expect(markerSeverity(1)).toBe(M_WARN); + expect(markerSeverity(2)).toBe(M_INFO); + expect(markerSeverity(3)).toBe(M_HINT); + }); + + it("안 준 경우는 오류로 본다 — 힌트로 낮추면 문제 패널에서 사라진다", () => { + expect(markerSeverity(undefined)).toBe(M_ERROR); + expect(markerSeverity(null)).toBe(M_ERROR); + expect(markerSeverity("error")).toBe(M_ERROR); + }); +}); + +describe("toMonacoRange", () => { + it("0-기반을 1-기반으로 옮긴다", () => { + expect(toMonacoRange({ start: { line: 0, character: 0 }, end: { line: 2, character: 5 } })) + .toEqual({ startLineNumber: 1, startColumn: 1, endLineNumber: 3, endColumn: 6 }); + }); + + it("배열 꼴 [start, end] 도 받는다", () => { + expect(toMonacoRange([{ line: 1, character: 2 }, { line: 1, character: 4 }])) + .toEqual({ startLineNumber: 2, startColumn: 3, endLineNumber: 2, endColumn: 5 }); + }); + + it("끝이 없으면 시작과 같은 자리 — 한 점을 가리키는 흔한 꼴이다", () => { + expect(toMonacoRange({ start: { line: 3, character: 7 } })) + .toEqual({ startLineNumber: 4, startColumn: 8, endLineNumber: 4, endColumn: 8 }); + }); + + it("거꾸로 들어온 범위는 뒤집는다 — 그대로 두면 아무것도 안 그려진다", () => { + expect(toMonacoRange({ start: { line: 5, character: 2 }, end: { line: 1, character: 0 } })) + .toEqual({ startLineNumber: 2, startColumn: 1, endLineNumber: 6, endColumn: 3 }); + }); + + it("빠지거나 이상한 값은 문서 첫 자리로 떨어진다", () => { + expect(toMonacoRange(undefined)).toEqual({ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }); + expect(toMonacoRange({ start: { line: NaN, character: "x" } }).startLineNumber).toBe(1); + }); +}); + +describe("toMarker", () => { + const d = { range: { start: { line: 0, character: 4 }, end: { line: 0, character: 9 } }, message: "쓰지 않는 변수", severity: 1, source: "eslint", code: "no-unused-vars" }; + + it("진단 하나를 마커로 옮긴다", () => { + expect(toMarker(d)).toEqual({ + startLineNumber: 1, startColumn: 5, endLineNumber: 1, endColumn: 10, + severity: M_WARN, message: "쓰지 않는 변수", code: "no-unused-vars", source: "eslint", + }); + }); + + it("code 가 { value, target } 객체여도 읽는다", () => { + expect(toMarker({ ...d, code: { value: "E123", target: "https://x" } }).code).toBe("E123"); + }); + + it("code·source 가 없으면 키 자체를 넣지 않는다", () => { + const m = toMarker({ range: d.range, message: "m", severity: 0 }); + expect("code" in m).toBe(false); + expect("source" in m).toBe(false); + }); + + it("메시지가 없어도 빈 문자열로 살아남는다 — 마커를 통째로 버리지 않는다", () => { + expect(toMarker({ range: d.range, severity: 0 }).message).toBe(""); + }); +}); + +describe("toMarkers", () => { + it("배열이 아니면 빈 목록", () => { + expect(toMarkers(null)).toEqual([]); + expect(toMarkers({ message: "x" })).toEqual([]); + }); + it("빈 항목은 걸러낸다", () => { + expect(toMarkers([null, { range: { start: { line: 0, character: 0 } }, message: "a", severity: 0 }])).toHaveLength(1); + }); +}); + +describe("toLocations", () => { + const R = { start: { line: 2, character: 0 }, end: { line: 2, character: 3 } }; + + it("Location 하나를 받는다", () => { + expect(toLocations({ uri: "u", range: R })).toEqual([{ uri: "u", range: { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 4 } }]); + }); + + it("Location 배열을 받는다", () => { + expect(toLocations([{ uri: "a", range: R }, { uri: "b", range: R }]).map(l => l.uri)).toEqual(["a", "b"]); + }); + + it("LocationLink 의 targetUri/targetRange 도 읽는다 — 이 이름을 안 보면 링크 꼴만 조용히 실패한다", () => { + expect(toLocations([{ targetUri: "t", targetRange: R }])).toEqual([{ uri: "t", range: { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 4 } }]); + }); + + it("targetSelectionRange 가 있으면 그쪽이 더 좁으므로 우선한다", () => { + const sel = { start: { line: 2, character: 1 }, end: { line: 2, character: 2 } }; + expect(toLocations([{ targetUri: "t", targetRange: R, targetSelectionRange: sel }])[0]!.range.startColumn).toBe(2); + }); + + it("uri 나 range 가 빠진 항목은 버린다", () => { + expect(toLocations([{ uri: "a" }, { range: R }, null, undefined])).toEqual([]); + }); + + it("결과가 없으면 빈 목록", () => { + expect(toLocations(null)).toEqual([]); + expect(toLocations([])).toEqual([]); + }); +}); + +describe("toEdits", () => { + it("TextEdit 를 옮긴다", () => { + expect(toEdits([{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 2 } }, newText: "ab" }])) + .toEqual([{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 3 }, text: "ab" }]); + }); + + it("newText 가 없으면 빈 문자열 — 삭제로 본다", () => { + expect(toEdits([{ range: { start: { line: 0, character: 0 } } }])[0]!.text).toBe(""); + }); + + it("range 없는 항목은 버린다", () => { + expect(toEdits([{ newText: "x" }])).toEqual([]); + }); + + it("배열이 아니면 빈 목록", () => { + expect(toEdits(undefined)).toEqual([]); + }); +}); diff --git a/ide/src/ext/shimLang.ts b/ide/src/ext/shimLang.ts new file mode 100644 index 0000000..8a93d6c --- /dev/null +++ b/ide/src/ext/shimLang.ts @@ -0,0 +1,114 @@ +/** + * 언어 확장이 **내놓는 것**을 Monaco 가 아는 모양으로 옮긴다. + * + * 셰임의 languages 네임스페이스는 자동완성과 호버만 진짜였고, 바로 아래 넉 줄이 + * 이랬다: + * + * registerDefinitionProvider() { return noopDisposable; }, + * registerCodeActionsProvider() { return noopDisposable; }, + * registerDocumentFormattingEditProvider() { return noopDisposable; }, + * createDiagnosticCollection(name) { return { set() {}, delete() {}, clear() {}, ... }; }, + * + * 등록은 성공하고(disposable 을 돌려주니 확장은 잘된 줄 안다) 결과만 사라진다. + * 그중 진단이 제일 크다 — 린터 확장은 파일을 읽고 문제를 다 찾아낸 뒤 `set()` 에 + * 넘기고, 그 값이 그대로 버려진다. 화면에는 밑줄 하나 없고 문제 패널도 비어 있다. + * + * 순수 모듈이다. monaco 의 **상수 값**만 쓰고 monaco 자체는 부르지 않는다. + */ + +/** Monaco MarkerSeverity — Hint 1 · Info 2 · Warning 4 · Error 8. */ +export const M_HINT = 1, M_INFO = 2, M_WARN = 4, M_ERROR = 8; + +/** + * vscode DiagnosticSeverity → Monaco MarkerSeverity. + * + * vscode 는 **Error 가 0** 이다(Error 0 · Warning 1 · Information 2 · Hint 3). + * LSP 쪽(lspConverters.ts)은 Error 가 1 인 다른 체계라 그 표를 그대로 쓰면 한 칸씩 + * 밀린다 — 오류가 경고로, 경고가 정보로 내려앉는다. + */ +export function markerSeverity(sev: unknown): number { + switch (sev) { + case 0: return M_ERROR; + case 1: return M_WARN; + case 2: return M_INFO; + case 3: return M_HINT; + // 안 준 경우는 오류로 본다. 진단을 내놓았다는 것 자체가 "봐 달라" 는 뜻이고, + // 힌트로 낮추면 문제 패널에서 조용히 사라진다. + default: return M_ERROR; + } +} + +export interface MonacoRange { + startLineNumber: number; startColumn: number; + endLineNumber: number; endColumn: number; +} + +const n = (v: unknown, d = 0) => (typeof v === "number" && Number.isFinite(v) ? v : d); + +/** vscode Range(0-기반) → Monaco Range(1-기반). 배열 꼴 `[start, end]` 도 받는다. */ +export function toMonacoRange(r: any): MonacoRange { + const s = Array.isArray(r) ? r[0] : r?.start; + const e = Array.isArray(r) ? r[1] : r?.end; + const sl = n(s?.line) + 1, sc = n(s?.character) + 1; + // 끝을 안 주면 시작과 같은 자리로 둔다(길이 0). 확장이 한 점만 가리키는 흔한 꼴이다. + const el = e ? n(e.line) + 1 : sl, ec = e ? n(e.character) + 1 : sc; + // 거꾸로 들어온 범위는 뒤집어 준다 — Monaco 는 그대로 받으면 아무것도 안 그린다. + if (el < sl || (el === sl && ec < sc)) return { startLineNumber: el, startColumn: ec, endLineNumber: sl, endColumn: sc }; + return { startLineNumber: sl, startColumn: sc, endLineNumber: el, endColumn: ec }; +} + +export interface MarkerData extends MonacoRange { + severity: number; message: string; code?: string; source?: string; +} + +/** vscode Diagnostic → Monaco IMarkerData. */ +export function toMarker(d: any): MarkerData { + const code = d?.code; + return { + ...toMonacoRange(d?.range), + severity: markerSeverity(d?.severity), + message: String(d?.message ?? ""), + // code 는 `{ value, target }` 객체일 수도 있다. + ...(code != null ? { code: String(typeof code === "object" ? (code.value ?? "") : code) } : {}), + ...(d?.source ? { source: String(d.source) } : {}), + }; +} + +export function toMarkers(list: any): MarkerData[] { + return Array.isArray(list) ? list.filter(Boolean).map(toMarker) : []; +} + +export interface NormLocation { uri: any; range: MonacoRange } + +/** + * 정의 이동 결과를 한 모양으로 편다. + * + * 확장은 넷 중 아무 꼴로나 돌려준다: `Location`, `Location[]`, `LocationLink`, + * `LocationLink[]`. LocationLink 는 `uri`/`range` 대신 `targetUri`/`targetRange` 를 + * 쓴다 — 그 이름을 안 보면 링크 꼴로 돌려주는 확장만 조용히 아무 데도 못 간다. + */ +export function toLocations(res: any): NormLocation[] { + const arr = res == null ? [] : (Array.isArray(res) ? res : [res]); + const out: NormLocation[] = []; + for (const it of arr) { + if (!it) continue; + const uri = it.targetUri ?? it.uri; + const range = it.targetSelectionRange ?? it.targetRange ?? it.range; + if (!uri || !range) continue; + out.push({ uri, range: toMonacoRange(range) }); + } + return out; +} + +export interface NormEdit { range: MonacoRange; text: string } + +/** vscode TextEdit[] → Monaco 편집 목록. */ +export function toEdits(edits: any): NormEdit[] { + if (!Array.isArray(edits)) return []; + const out: NormEdit[] = []; + for (const e of edits) { + if (!e?.range) continue; + out.push({ range: toMonacoRange(e.range), text: String(e.newText ?? "") }); + } + return out; +} diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts index 4e9e880..7557607 100644 --- a/ide/src/ext/vscodeShim.ts +++ b/ide/src/ext/vscodeShim.ts @@ -3,8 +3,10 @@ // Node/네이티브 의존이나 미구현 API를 쓰는 확장은 실패(캐치되어 보고). "단순 확장"용. import monaco from "../editor/monacoSetup"; import { getLang } from "../i18n"; +import * as projectModels from "../editor/projectModels"; import { makeDocIndex } from "./shimDoc"; import { normalizePicks, normalizeButtons, type PromptReq } from "./prompt"; +import { toMarkers, toLocations, toEdits } from "./shimLang"; import { setShimDocSource } from "./extHost"; export interface ShimDeps { @@ -107,6 +109,23 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) { Position, Range, Selection }, ); const relOf = (arg: any) => relOfWith(deps.workspaceRoot(), arg); + /** 프로바이더에 넘길 문서. + * + * vscode 는 프로바이더의 첫 인자로 **TextDocument** 를 준다고 약속한다. 그런데 + * 여기서는 Monaco 모델을 그대로 넘기고 있었다. 모델에는 `getText`·`lineAt`· + * `fileName` 이 없으므로, 그걸 부르는 프로바이더는 첫 줄에서 던지고 바깥 + * try/catch 가 그 예외를 삼켰다 — 확장 입장에서는 "결과가 없다" 와 구별되지 않는다. + * 자동완성과 호버가 등록은 되는데 아무것도 안 나오던 이유다. + * + * 워크스페이스 밖 모델(diff·미리보기)은 상대 경로가 없어 문서를 못 만든다. 그때는 + * 예전처럼 모델을 넘긴다 — 없는 것보다는 낫다. */ + const docForModel = (model: monaco.editor.ITextModel): any => { + try { + const rel = projectModels.relFor(model.uri.toString()); + if (rel) { const d = docs.docFor(rel); if (d) return d; } + } catch { /* */ } + return model; + }; // 사건을 쏠 때 쓸 통로. 확장마다 덮어써도 같은 것을 가리키므로 문제 없다. setShimDocSource((rel) => docs.docFor(rel), (rel) => docs.editorFor(rel)); const folder = () => { @@ -129,7 +148,7 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) triggerCharacters: triggers, async provideCompletionItems(model, position) { try { - const items = await provider.provideCompletionItems(model, new Position(position.lineNumber - 1, position.column - 1), { triggerKind: 0 }, null); + const items = await provider.provideCompletionItems(docForModel(model), new Position(position.lineNumber - 1, position.column - 1), { triggerKind: 0 }, null); const list = Array.isArray(items) ? items : (items?.items ?? []); const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, startColumn: word.startColumn, endLineNumber: position.lineNumber, endColumn: word.endColumn }; @@ -157,7 +176,7 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) const d = monaco.languages.registerHoverProvider(lang, { async provideHover(model, position) { try { - const h = await provider.provideHover(model, new Position(position.lineNumber - 1, position.column - 1), null); + const h = await provider.provideHover(docForModel(model), new Position(position.lineNumber - 1, position.column - 1), null); if (!h) return undefined; const contents = (h.contents || []).map((c: any) => ({ value: typeof c === "string" ? c : (c?.value ?? "") })); return { contents }; @@ -168,10 +187,91 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) } return { dispose() { for (const d of created) { try { d.dispose(); } catch { /* */ } const i = disposables.indexOf(d); if (i >= 0) disposables.splice(i, 1); } } }; }, - registerDefinitionProvider() { return noopDisposable; }, + registerDefinitionProvider(selector: any, provider: any) { + const created: monaco.IDisposable[] = []; + for (const lang of langIdsFromSelector(selector)) { + const d = monaco.languages.registerDefinitionProvider(lang, { + async provideDefinition(model, position) { + try { + const res = await provider.provideDefinition(docForModel(model), new Position(position.lineNumber - 1, position.column - 1), null); + // uri 는 확장이 만든 것(우리 Uri 셰임이거나 문자열)이라 monaco.Uri 로 다시 세운다. + return toLocations(res).map(l => ({ uri: monaco.Uri.parse(String(l.uri?.toString?.() ?? l.uri)), range: l.range })); + } catch { return []; } + }, + }); + disposables.push(d); created.push(d); + } + return { dispose() { for (const d of created) { try { d.dispose(); } catch { /* */ } const i = disposables.indexOf(d); if (i >= 0) disposables.splice(i, 1); } } }; + }, registerCodeActionsProvider() { return noopDisposable; }, - registerDocumentFormattingEditProvider() { return noopDisposable; }, - createDiagnosticCollection(name?: string) { return { set() {}, delete() {}, clear() {}, dispose() {}, name }; }, + registerDocumentFormattingEditProvider(selector: any, provider: any) { + const created: monaco.IDisposable[] = []; + for (const lang of langIdsFromSelector(selector)) { + const d = monaco.languages.registerDocumentFormattingEditProvider(lang, { + async provideDocumentFormattingEdits(model) { + try { + const edits = await provider.provideDocumentFormattingEdits(docForModel(model), { tabSize: 2, insertSpaces: true }, null); + return toEdits(edits); + } catch { return []; } + }, + }); + disposables.push(d); created.push(d); + } + return { dispose() { for (const d of created) { try { d.dispose(); } catch { /* */ } const i = disposables.indexOf(d); if (i >= 0) disposables.splice(i, 1); } } }; + }, + /** 린터가 찾아낸 문제를 실제로 화면에 올린다. + * + * 예전엔 set/delete/clear 가 전부 빈 함수였다. 린터 확장은 파일을 다 읽고 문제를 + * 찾아 넘긴 뒤 아무 일도 일어나지 않는 것을 봤다 — 밑줄도, 문제 패널의 한 줄도 없다. + * + * owner 를 확장마다 나눠 둔다. 한 이름을 나눠 쓰면 나중에 set 하는 확장이 앞 + * 확장의 진단을 지운다. */ + createDiagnosticCollection(name?: string) { + const owner = "ext:" + ext.id + ":" + (name || "default"); + // 이 컬렉션이 마커를 올려 둔 모델들. clear/dispose 때 되짚어 지우려면 필요하다 — + // 안 들고 있으면 "지웠다" 고 하고 옛 문제가 화면에 남는다. + const touched = new Set(); + const modelFor = (uri: any) => { + const key = String(uri?.toString?.() ?? uri ?? ""); + if (!key) return null; + try { return monaco.editor.getModel(monaco.Uri.parse(key)); } catch { return null; } + }; + const put = (uri: any, list: any) => { + const m = modelFor(uri); + if (!m) return; // 안 열린 파일 — 열릴 때 확장이 다시 진단한다 + const markers = toMarkers(list); + monaco.editor.setModelMarkers(m, owner, markers); + if (markers.length) touched.add(m.uri.toString()); else touched.delete(m.uri.toString()); + }; + const clearAll = () => { + for (const key of touched) { + try { const m = monaco.editor.getModel(monaco.Uri.parse(key)); if (m) monaco.editor.setModelMarkers(m, owner, []); } catch { /* 사라진 모델 */ } + } + touched.clear(); + }; + const coll = { + name: name || "default", + // vscode 는 set(uri, diags) 와 set(entries[]) 둘 다 받는다. + set(a: any, b?: any) { + if (Array.isArray(a) && b === undefined) { for (const [u, list] of a) put(u, list); return; } + put(a, b); + }, + delete(uri: any) { put(uri, []); }, + clear: clearAll, + dispose() { clearAll(); }, + // 확장이 자기 진단을 되읽는 흐름이 있다. 빈 함수로 두면 "없다" 로 오해한다. + get(uri: any) { const m = modelFor(uri); return m ? monaco.editor.getModelMarkers({ owner, resource: m.uri }) : []; }, + has(uri: any) { const m = modelFor(uri); return !!m && touched.has(m.uri.toString()); }, + forEach(cb: (uri: any, diags: any[]) => void) { + for (const key of touched) { + try { const u = monaco.Uri.parse(key); cb(u, monaco.editor.getModelMarkers({ owner, resource: u })); } catch { /* */ } + } + }, + }; + // 확장이 dispose 를 잊어도 재로드 때 정리된다 — 안 그러면 옛 진단이 계속 남는다. + disposables.push({ dispose: clearAll }); + return coll; + }, setLanguageConfiguration() { return noopDisposable; }, }; diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index ede96a4..1770904 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ "src/ai/retry.test.ts", "src/keymap.test.ts", "src/overlays.test.ts", - "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", + "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", "src/theme.test.ts", "src/editor/monacoColors.test.ts", "src/opening/**/*.test.ts", From 2148642c9a4d3496fdeceaa0f75fcd889b905780 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 13:42:05 +0900 Subject: [PATCH 21/52] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9D=B4=20=EC=9E=90?= =?UTF-8?q?=EA=B8=B0=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=9D=BD=EA=B3=A0=20?= =?UTF-8?q?=EB=B0=94=EA=BF=80=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getConfiguration 이 이랬다. get: (_key, def) => def, // 늘 인자로 준 기본값 has: () => false, // 늘 "없다" update: () => Promise.resolve(), // 아무 데도 안 쓴다 inspect: () => undefined, get(key, def) 만 보면 그럴듯한데, 확장은 **기본값을 인자로 안 준다.** 자기 package.json 의 contributes.configuration 에 이미 적어 뒀기 때문이다. 그래서 cfg.get("material-icon-theme.folders.theme") 가 vscode 에서는 "specific" 인데 여기서는 undefined 였다. 확장은 설정이 꺼져 있다고 읽고 기능을 접거나, undefined 를 그대로 쓰다 뒤에서 터진다. has() 가 늘 거짓인 것도 같은 종류의 거짓말이다. 선언된 기본값을 읽고, 사용자 값을 localStorage 에 확장별로 저장한다. 값은 사용자 → 선언 → 호출자 인자 순으로 고른다. update(k, undefined) 는 저장을 지워 선언 기본값으로 되돌린다(vscode 규약) — 그대로 저장하면 사용자가 undefined 를 골랐다는 뜻이 되어 기본값이 영영 안 돌아온다. contributes.configuration 은 객체 하나일 수도 배열일 수도 있다. 한쪽만 처리하면 절반의 확장이 조용히 기본값을 잃는다. default 를 안 적은 항목은 타입별 빈 값으로 채운다 — 없는 키로 두면 "설정 안 됨" 과 구별되지 않는다. 설정 객체는 값을 속성으로도 노출한다(cfg.enable). 그렇게 읽는 확장이 흔하다. onDidChangeConfiguration 도 실제로 쏜다. affectsConfiguration 은 접두사로 본다 — a.b 가 바뀌면 a 도 영향받은 것이다. 워크스페이스별 설정은 아직 없다. inspect().workspaceValue 는 undefined 로 둔다 — "이 층엔 값이 없다" 는 정확한 답이고, 거짓으로 채우는 것보다 낫다. --- ide/src/ext/config.test.ts | 131 +++++++++++++++++++++++++++++++++++++ ide/src/ext/config.ts | 131 +++++++++++++++++++++++++++++++++++++ ide/src/ext/vscodeShim.ts | 48 +++++++++++--- ide/vitest.config.ts | 2 +- 4 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 ide/src/ext/config.test.ts create mode 100644 ide/src/ext/config.ts diff --git a/ide/src/ext/config.test.ts b/ide/src/ext/config.test.ts new file mode 100644 index 0000000..6db7dec --- /dev/null +++ b/ide/src/ext/config.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { flattenDefaults, fullKey, keysInSection, readValue, hasValue, inspectValue, sectionValues, affects } from "./config"; + +describe("flattenDefaults", () => { + it("contributes.configuration 이 객체 하나인 꼴", () => { + expect(flattenDefaults({ configuration: { properties: { "a.b": { type: "string", default: "x" } } } })) + .toEqual({ "a.b": "x" }); + }); + + it("배열 꼴도 받는다 — 한쪽만 처리하면 절반의 확장이 기본값을 잃는다", () => { + expect(flattenDefaults({ configuration: [ + { properties: { "a.b": { default: 1 } } }, + { properties: { "a.c": { default: 2 } } }, + ] })).toEqual({ "a.b": 1, "a.c": 2 }); + }); + + it("default 를 안 적은 항목은 타입별 빈 값으로 채운다", () => { + expect(flattenDefaults({ configuration: { properties: { + b: { type: "boolean" }, n: { type: "number" }, s: { type: "string" }, + a: { type: "array" }, o: { type: "object" }, u: {}, + } } })).toEqual({ b: false, n: 0, s: "", a: [], o: {}, u: null }); + }); + + it("타입이 배열이면 첫 번째를 쓴다", () => { + expect(flattenDefaults({ configuration: { properties: { p: { type: ["string", "null"] } } } })).toEqual({ p: "" }); + }); + + it("null 을 기본값으로 **적었으면** 그대로 둔다", () => { + expect(flattenDefaults({ configuration: { properties: { p: { type: "string", default: null } } } })).toEqual({ p: null }); + }); + + it("설정 기여가 없으면 빈 객체", () => { + expect(flattenDefaults(undefined)).toEqual({}); + expect(flattenDefaults({ commands: [] })).toEqual({}); + expect(flattenDefaults({ configuration: { properties: null } })).toEqual({}); + }); +}); + +describe("fullKey / keysInSection", () => { + it("섹션을 앞에 붙인다", () => expect(fullKey("a.b", "c")).toBe("a.b.c")); + it("섹션이 없으면 키 그대로", () => { + expect(fullKey(undefined, "c")).toBe("c"); + expect(fullKey(" ", "c")).toBe("c"); + }); + it("섹션에 속한 키만 짧은 이름으로", () => { + expect(keysInSection({ "a.b": 1, "a.c": 2, "z.d": 3 }, "a")).toEqual(["b", "c"]); + }); + it("섹션이 없으면 전부", () => { + expect(keysInSection({ "a.b": 1, z: 2 })).toEqual(["a.b", "z"]); + }); + it("접두사가 겹치는 다른 섹션을 끌어오지 않는다", () => { + expect(keysInSection({ "ab.x": 1, "a.y": 2 }, "a")).toEqual(["y"]); + }); +}); + +describe("readValue", () => { + const src = { defaults: { "a.b": "선언값" }, stored: {} as Record }; + + it("확장이 선언한 기본값을 돌려준다 — 예전엔 늘 인자 기본값이라 undefined 였다", () => { + expect(readValue(src, "a.b")).toBe("선언값"); + }); + + it("사용자 값이 선언값을 이긴다", () => { + expect(readValue({ ...src, stored: { "a.b": "내값" } }, "a.b")).toBe("내값"); + }); + + it("아무 데도 없으면 호출자가 준 기본값", () => { + expect(readValue(src, "없음", "폴백")).toBe("폴백"); + expect(readValue(src, "없음")).toBeUndefined(); + }); + + it("사용자가 false·0·빈 문자열로 바꾼 것도 값이다 — falsy 라고 선언값으로 되돌아가면 안 된다", () => { + expect(readValue({ defaults: { k: true }, stored: { k: false } }, "k")).toBe(false); + expect(readValue({ defaults: { k: 5 }, stored: { k: 0 } }, "k")).toBe(0); + expect(readValue({ defaults: { k: "x" }, stored: { k: "" } }, "k")).toBe(""); + }); + + it("사용자가 명시적으로 null·undefined 로 둔 것도 존중한다", () => { + expect(readValue({ defaults: { k: "x" }, stored: { k: null } }, "k")).toBeNull(); + expect(readValue({ defaults: { k: "x" }, stored: { k: undefined } }, "k")).toBeUndefined(); + }); +}); + +describe("hasValue", () => { + it("선언만 돼 있어도 있는 것이다 — 늘 false 는 '설정이 없는 환경' 이라는 거짓말이다", () => { + expect(hasValue({ defaults: { k: 1 }, stored: {} }, "k")).toBe(true); + }); + it("사용자 값만 있어도 있는 것", () => { + expect(hasValue({ defaults: {}, stored: { k: 1 } }, "k")).toBe(true); + }); + it("모르는 키는 없는 것", () => { + expect(hasValue({ defaults: {}, stored: {} }, "k")).toBe(false); + }); +}); + +describe("inspectValue", () => { + it("어느 층에서 온 값인지 나눠 준다", () => { + expect(inspectValue({ defaults: { k: "d" }, stored: { k: "g" } }, "k")) + .toEqual({ key: "k", defaultValue: "d", globalValue: "g", workspaceValue: undefined }); + }); + it("사용자가 안 바꿨으면 globalValue 는 undefined", () => { + expect(inspectValue({ defaults: { k: "d" }, stored: {} }, "k")!.globalValue).toBeUndefined(); + }); + it("모르는 키는 undefined — vscode 도 그렇다", () => { + expect(inspectValue({ defaults: {}, stored: {} }, "k")).toBeUndefined(); + }); +}); + +describe("sectionValues", () => { + it("섹션의 값들을 짧은 이름으로 얹는다", () => { + expect(sectionValues({ defaults: { "e.a": 1, "e.b": 2 }, stored: { "e.b": 9 } }, "e")) + .toEqual({ a: 1, b: 9 }); + }); + it("점이 더 있는 키는 속성으로 얹지 않는다(get 으로 읽는다)", () => { + expect(sectionValues({ defaults: { "e.a.b": 1, "e.c": 2 }, stored: {} }, "e")).toEqual({ c: 2 }); + }); + it("다른 섹션은 안 섞인다", () => { + expect(sectionValues({ defaults: { "e.a": 1, "z.a": 2 }, stored: {} }, "e")).toEqual({ a: 1 }); + }); +}); + +describe("affects", () => { + it("정확히 같은 키", () => expect(affects(["a.b"], "a.b")).toBe(true)); + it("접두사도 영향받은 것으로 본다 — vscode 규약", () => expect(affects(["a.b.c"], "a.b")).toBe(true)); + it("접두사가 겹치기만 하는 다른 키는 아니다", () => expect(affects(["ab.c"], "a")).toBe(false)); + it("상관없는 키", () => expect(affects(["z"], "a.b")).toBe(false)); + it("빈 질의는 뭐라도 바뀌었으면 참", () => { + expect(affects(["z"], "")).toBe(true); + expect(affects([], "")).toBe(false); + }); +}); diff --git a/ide/src/ext/config.ts b/ide/src/ext/config.ts new file mode 100644 index 0000000..9dd0a63 --- /dev/null +++ b/ide/src/ext/config.ts @@ -0,0 +1,131 @@ +/** + * 확장 설정 — `vscode.workspace.getConfiguration()`. + * + * 예전 셰임은 이랬다: + * + * getConfiguration: (_section?) => ({ + * get: (_key, def) => def, // 늘 인자로 준 기본값 + * has: () => false, // 늘 "없다" + * update: () => Promise.resolve(), // 아무 데도 안 쓴다 + * inspect: () => undefined, + * }) + * + * `get(key, def)` 만 보면 그럴듯한데, 확장은 **기본값을 인자로 안 준다.** 자기 + * package.json 의 `contributes.configuration` 에 이미 적어 뒀기 때문이다. 그래서 + * `cfg.get("material-icon-theme.folders.theme")` 는 vscode 에서 `"specific"` 인데 + * 여기서는 `undefined` 였다. 확장은 설정이 꺼져 있다고 판단하고 기능을 접거나, + * undefined 를 그대로 쓰다 뒤에서 터진다. + * + * `has()` 가 늘 거짓인 것도 같은 종류다 — "설정이 하나도 없는 환경" 이라는 거짓말이다. + * + * 순수 모듈이다. + */ + +/** package.json 의 contributes.configuration 에서 `키 → 기본값` 을 모은다. + * + * configuration 은 객체 하나일 수도, 배열일 수도 있다(vscode 가 둘 다 받는다). + * 배열만 처리하거나 객체만 처리하면 절반의 확장이 조용히 기본값을 잃는다. */ +export function flattenDefaults(contributes: any): Record { + const out: Record = {}; + const cfg = contributes?.configuration; + const blocks = Array.isArray(cfg) ? cfg : (cfg ? [cfg] : []); + for (const b of blocks) { + const props = b?.properties; + if (!props || typeof props !== "object") continue; + for (const [key, spec] of Object.entries(props)) { + // default 를 안 적은 항목도 있다. 그때 vscode 는 타입별 빈 값을 준다 — + // 없는 키로 두면 `get(k)` 가 undefined 라 "설정 안 됨" 과 구별되지 않는다. + out[key] = "default" in (spec || {}) ? spec.default : emptyFor(spec?.type); + } + } + return out; +} + +function emptyFor(type: any): any { + const t = Array.isArray(type) ? type[0] : type; + switch (t) { + case "boolean": return false; + case "number": case "integer": return 0; + case "string": return ""; + case "array": return []; + case "object": return {}; + default: return null; + } +} + +/** `getConfiguration("a.b").get("c")` → `"a.b.c"`. 섹션이 없으면 키 그대로. */ +export function fullKey(section: string | undefined, key: string): string { + const s = (section || "").trim(); + return s ? s + "." + key : key; +} + +/** 이 섹션에 속하는 키들을 섹션을 뗀 이름으로 돌려준다. + * vscode 의 설정 객체는 값을 **속성으로도** 노출한다(`cfg.folders.theme` 처럼 + * 점 없는 첫 조각까지). 여기서는 평평한 키만 다룬다. */ +export function keysInSection(all: Record, section?: string): string[] { + const s = (section || "").trim(); + if (!s) return Object.keys(all); + const p = s + "."; + return Object.keys(all).filter(k => k.startsWith(p)).map(k => k.slice(p.length)); +} + +export interface ConfigSource { + /** package.json 이 선언한 기본값. */ + defaults: Record; + /** 사용자가 바꾼 값. */ + stored: Record; +} + +/** 값 하나. 사용자 값 → 선언된 기본값 → 호출자가 준 기본값 순. */ +export function readValue(src: ConfigSource, key: string, callerDefault?: any): any { + if (Object.prototype.hasOwnProperty.call(src.stored, key)) return src.stored[key]; + if (Object.prototype.hasOwnProperty.call(src.defaults, key)) return src.defaults[key]; + return callerDefault; +} + +/** 이 키를 우리가 아는가. 사용자가 안 바꿨어도 **선언돼 있으면 있는 것**이다. */ +export function hasValue(src: ConfigSource, key: string): boolean { + return Object.prototype.hasOwnProperty.call(src.stored, key) + || Object.prototype.hasOwnProperty.call(src.defaults, key); +} + +export interface Inspected { + key: string; + defaultValue: any; + globalValue: any; + workspaceValue: undefined; +} + +/** vscode 의 inspect(). 어느 층에서 온 값인지 확장이 구분하게 해 준다. */ +export function inspectValue(src: ConfigSource, key: string): Inspected | undefined { + if (!hasValue(src, key)) return undefined; + return { + key, + defaultValue: src.defaults[key], + globalValue: Object.prototype.hasOwnProperty.call(src.stored, key) ? src.stored[key] : undefined, + // 워크스페이스별 설정은 아직 없다. undefined 로 두면 "이 층엔 값이 없다" 는 + // 정확한 답이 된다 — 거짓으로 채우는 것보다 낫다. + workspaceValue: undefined, + }; +} + +/** 섹션 하나를 평평한 `{ 짧은키: 값 }` 으로. 설정 객체에 속성으로 얹을 때 쓴다. */ +export function sectionValues(src: ConfigSource, section?: string): Record { + const out: Record = {}; + const seen = new Set([...Object.keys(src.defaults), ...Object.keys(src.stored)]); + for (const short of keysInSection(Object.fromEntries([...seen].map(k => [k, 1])), section)) { + // 첫 조각만 얹는다. `folders.theme` 은 `cfg.folders` 가 객체여야 하는데, + // 그건 확장이 `get("folders.theme")` 으로도 읽을 수 있어 굳이 만들지 않는다. + if (short.includes(".")) continue; + out[short] = readValue(src, fullKey(section, short)); + } + return out; +} + +/** 설정 하나가 바뀌었을 때 `affectsConfiguration(x)` 가 참이어야 하는가. + * vscode 는 **접두사**로 본다 — `a.b` 가 바뀌면 `a` 도 영향을 받은 것이다. */ +export function affects(changedKeys: readonly string[], query: string): boolean { + const q = (query || "").trim(); + if (!q) return changedKeys.length > 0; + return changedKeys.some(k => k === q || k.startsWith(q + ".")); +} diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts index 7557607..dc00fdc 100644 --- a/ide/src/ext/vscodeShim.ts +++ b/ide/src/ext/vscodeShim.ts @@ -7,6 +7,7 @@ import * as projectModels from "../editor/projectModels"; import { makeDocIndex } from "./shimDoc"; import { normalizePicks, normalizeButtons, type PromptReq } from "./prompt"; import { toMarkers, toLocations, toEdits } from "./shimLang"; +import { flattenDefaults, fullKey, readValue, hasValue, inspectValue, sectionValues, affects } from "./config"; import { setShimDocSource } from "./extHost"; export interface ShimDeps { @@ -103,7 +104,7 @@ function relOfWith(root: string | null, arg: any): string | null { return root ? stripRoot(root, p) : String(p); } -export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) { +export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string; contributes?: any }) { const docs = makeDocIndex( { root: deps.workspaceRoot, activeRel: deps.getActiveFile, openRels: deps.openFiles }, { Position, Range, Selection }, @@ -342,14 +343,45 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string }) withProgress: (_opts: any, task: any) => Promise.resolve(task({ report() {} }, { isCancellationRequested: false, onCancellationRequested: new EventEmitter().event })), }; + // ── 설정 ── + // 확장은 자기 기본값을 package.json 의 contributes.configuration 에 적어 두고 + // `get(key)` 를 인자 없이 부른다. 예전 셰임은 그 선언을 아예 안 읽어서 그런 호출이 + // 전부 undefined 였다 — 확장은 "설정이 꺼져 있다" 로 읽고 기능을 접었다. + const cfgDefaults = flattenDefaults(ext.contributes); + const CFG_NS = "schutz.extconfig." + ext.id; + const readStored = (): Record => { + try { return JSON.parse(localStorage.getItem(CFG_NS) || "{}"); } catch { return {}; } + }; + const writeStored = (o: Record) => { + try { localStorage.setItem(CFG_NS, JSON.stringify(o)); } catch { /* 용량초과 */ } + }; + const cfgChanged = new EventEmitter(); + + const getConfiguration = (section?: string) => { + const src = { defaults: cfgDefaults, stored: readStored() }; + const key = (k: string) => fullKey(section, k); + // vscode 의 설정 객체는 값을 **속성으로도** 노출한다(`cfg.enable`). 그렇게 읽는 + // 확장이 흔해서, 아래 메서드보다 먼저 얹고 메서드가 덮어쓰게 둔다. + return Object.assign(sectionValues(src, section), { + get: (k: string, def?: any) => readValue(src, key(k), def), + has: (k: string) => hasValue(src, key(k)), + inspect: (k: string) => inspectValue(src, key(k)), + update: (k: string, value: any) => { + const full = key(k); + const next = readStored(); + // undefined 는 "기본값으로 되돌린다" 는 뜻이다(vscode 규약). 그대로 저장하면 + // 사용자가 undefined 를 골랐다는 뜻이 되어 선언 기본값이 영영 안 돌아온다. + if (value === undefined) delete next[full]; else next[full] = value; + writeStored(next); + cfgChanged.fire({ affectsConfiguration: (q: string) => affects([full], q) }); + return Promise.resolve(); + }, + }); + }; + const workspace = { - getConfiguration: (_section?: string) => ({ - get: (_key: string, def?: any) => def, - has: () => false, - update: () => Promise.resolve(), - inspect: () => undefined, - }), - onDidChangeConfiguration: new EventEmitter().event, + getConfiguration, + onDidChangeConfiguration: cfgChanged.event, onDidChangeTextDocument: editorEvents.docChanged.event, onDidOpenTextDocument: editorEvents.docOpened.event, onDidCloseTextDocument: editorEvents.docClosed.event, diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index 1770904..bb66a97 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ "src/ai/retry.test.ts", "src/keymap.test.ts", "src/overlays.test.ts", - "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", + "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", "src/ext/config.test.ts", "src/theme.test.ts", "src/editor/monacoColors.test.ts", "src/opening/**/*.test.ts", From 519657c9722b9da74ad3c8849c9420e8f65ab1c6 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 13:49:06 +0900 Subject: [PATCH 22/52] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9D=B4=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=B0=94=EC=97=90=20=EC=98=AC=EB=A6=B0=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=97=AC=20=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createStatusBarItem: () => ({ text: "", tooltip: "", command: "", show() {}, hide() {}, dispose() {} }), setStatusBarMessage: (_msg) => noopDisposable, text 는 그냥 객체의 속성이라 대입이 성공한다. show() 도 성공한다. 그런데 그 객체를 읽는 곳이 어디에도 없었다. "빌드 중…", "3 problems", 로그인 상태 같은 알림이 통째로 사라졌고, 실패한 흔적조차 없다. 앱 상태바에 확장 항목 자리를 만든다. 우선순위·정렬은 vscode 규칙 그대로다 — 왼쪽 그룹은 큰 값이 더 왼쪽, 오른쪽 그룹은 큰 값이 더 오른쪽. 우리 상태바는 왼쪽부터 그리므로 오른쪽 그룹은 뒤집어야 눈에 보이는 순서가 같아진다. 항목은 Proxy 로 감싼다. 확장은 show() 뒤에도 text 를 계속 갈아 끼운다(진행 표시가 그렇게 쓰인다). 만들 때 한 번만 읽으면 첫 글자에서 멈춘 채로 남는다. codicon 표기($(sync~spin))는 걷어낸다. 그 폰트를 안 실으므로 그대로 두면 사용자에게 "$(sync~spin)" 이 글자로 보인다. 걷어낸 뒤 아무것도 안 남는 항목(아이콘만 올리는 경우)에는 점 하나를 남긴다 — 빈 글자는 폭이 0 이라 누를 수 있는 것이 화면에서 사라진다. 툴팁에 확장 이름을 함께 싣는다. 상태바는 앱이 말하는 자리로 읽히므로, 누가 올린 글자인지 밝히지 않으면 확장을 지운 뒤에도 앱을 의심하게 된다. 확장을 다시 읽을 때 상태바를 먼저 비운다. 안 그러면 방금 끈 확장의 "빌드 중…" 이 아무도 갱신하지 않은 채 영원히 남는다. --- ide/src/App.tsx | 28 +++++++++++- ide/src/ext/extHost.ts | 5 ++- ide/src/ext/statusBar.test.ts | 84 +++++++++++++++++++++++++++++++++++ ide/src/ext/statusBar.ts | 79 ++++++++++++++++++++++++++++++++ ide/src/ext/vscodeShim.ts | 47 +++++++++++++++++++- ide/vitest.config.ts | 2 +- 6 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 ide/src/ext/statusBar.test.ts create mode 100644 ide/src/ext/statusBar.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 7904dc8..d1f0810 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -36,6 +36,7 @@ import { emptyNav, push as navPush, back as navBack, forward as navForward, curr import { shouldProbeQuota } from "./engine/quotaPoll"; import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction, overlayZ } from "./overlays"; import { filterPicks, stepIndex, validateInput, type PromptReq, type NormPick } from "./ext/prompt"; +import { upsert as sbUpsert, remove as sbRemove, ordered as sbOrdered, ALIGN_LEFT, ALIGN_RIGHT, type StatusItem } from "./ext/statusBar"; import { targetIdOf, isSubagentTarget, findSubagent, providerFor, filterTools, rosterLines, personaSystem, type SubagentDef, @@ -339,6 +340,8 @@ interface S { /** 되돌릴 수 없는 일 — 확인 버튼을 오류 색으로. */ danger?: boolean; } | null; + /** 확장이 상태바에 올린 항목. 예전엔 셰임이 받아 두기만 하고 읽는 곳이 없었다. */ + extStatus: StatusItem[]; /** 확장이 사용자에게 던진 물음. 셰임의 showQuickPick/showInputBox/showXMessage 가 * 여기로 온다 — 예전엔 묻지도 않고 undefined(=취소)를 돌려줬다. */ extAsk: PromptReq | null; @@ -683,7 +686,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { termOpen: false, termReady: false, termTab: "t1", chatTab: "all", chatAway: false, openDiffs: {}, openTools: {}, sheetOpen: false, convId: null, asideTab: "recents", impOpen: false, impRows: null, impThisOnly: true, impBusy: null, impAgent: "all", agentSideW: (() => { try { return Math.max(360, Math.min(1100, +(localStorage.getItem("schutz.agentSideW") || 620))); } catch { return 620; } })(), - agentAsideW: (() => { try { return Math.max(150, Math.min(480, +(localStorage.getItem("schutz.agentAsideW") || 216))); } catch { return 216; } })(), quota: {}, askRun: null, confirmAsk: null, extAsk: null, extAskText: "", extAskSel: 0, extAskPicked: [], extAskErr: null, terms: [{ id: "t1", n: 1 }], tasks: [], keyCapture: null, hunkSel: {}, checkpoints: [], undoAsk: null, gitAmend: false, commitView: null, mcpb: null, + agentAsideW: (() => { try { return Math.max(150, Math.min(480, +(localStorage.getItem("schutz.agentAsideW") || 216))); } catch { return 216; } })(), quota: {}, askRun: null, confirmAsk: null, extStatus: [], extAsk: null, extAskText: "", extAskSel: 0, extAskPicked: [], extAskErr: null, terms: [{ id: "t1", n: 1 }], tasks: [], keyCapture: null, hunkSel: {}, checkpoints: [], undoAsk: null, gitAmend: false, commitView: null, mcpb: null, agents: this.freshAgents(), workspace: null, paneDirty: {}, proposals: [], reviewFindings: [], reviewBusy: false, paneVer: {}, @@ -2006,6 +2009,22 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { this.setState({ confirmAsk: { ...o, cancelLabel: o.cancelLabel ?? t("confirm.cancel") } }); }); } + /** 상태바 항목의 등록 순서. 우선순위가 같을 때 자리를 고정한다. */ + private _sbSeq = 0; + + /** 확장이 올린 상태바 항목 하나. 누르면 그 확장이 붙여 둔 명령이 돈다. */ + private renderExtStatus(align: number) { + const items = sbOrdered(this.state.extStatus, align); + if (!items.length) return null; + return <>{items.map(it => ( + + ))}; + } + private _extAskResolve: ((v: any) => void) | null = null; /** 확장의 물음을 띄우고 답을 기다린다. 취소면 undefined 로 푼다 — vscode 규약이다. @@ -5385,6 +5404,9 @@ ${(r.output || "").slice(0, 2000)}`; async reloadExtensions() { if (!window.schutz) return; + // 상태바를 먼저 비운다. 항목은 확장이 올린 것뿐이라 전부 지워도 되고, 안 지우면 + // 방금 끈 확장의 "빌드 중…" 이 아무도 갱신하지 않은 채 영원히 남는다. + if (this.state.extStatus.length) this.setState({ extStatus: [] }); const res = await extHost.loadExtensions({ toast: (k, m) => this.toast(k, m), showPanel: (title, html) => this.openO({ extPanel: { title, html } }), @@ -5394,6 +5416,8 @@ ${(r.output || "").slice(0, 2000)}`; workspaceRoot: () => this.state.workspace?.root ?? null, openFiles: () => this.allOpen().filter(rel => !this.parseDiffKey(rel) && !this.parsePreviewKey(rel)), prompt: req => this.askExtension(req), + statusSet: item => this.setState(st => ({ extStatus: sbUpsert(st.extStatus, { ...item, seq: this._sbSeq++ }) })), + statusRemove: id => this.setState(st => ({ extStatus: sbRemove(st.extStatus, id) })), }); // VS Code 확장(선언형) — 테마·스니펫·언어설정 적용 const vres = await vscodeExt.loadVscodeExtensions(); @@ -6107,7 +6131,9 @@ ${(r.output || "").slice(0, 2000)}`; {(() => { const c = (s.git?.staged.length ?? 0) + (s.git?.unstaged.length ?? 0) + (s.git?.untracked.length ?? 0); return c > 0 ? {t("status.changes", { n: c })} : 0 ? "var(--dirty)" : "var(--fg-dim)" }}>{pendingFiles > 0 ? t("status.pendingReview", { n: pendingFiles }) : t("status.noChanges")}; })()} + {this.renderExtStatus(ALIGN_LEFT)}
+ {this.renderExtStatus(ALIGN_RIGHT)} {t("status.agentsActive", { active: AGDEF.filter(d => ["edit", "plan"].includes(s.agents[d.id].status)).length, total: AGDEF.length })} {quotaSummary && (() => { const left = this.quotaTightest(getManagerId()) ?? this.quotaTightest("claude") ?? this.quotaTightest("gpt") ?? 100; diff --git a/ide/src/ext/extHost.ts b/ide/src/ext/extHost.ts index 98992ad..cd86a28 100644 --- a/ide/src/ext/extHost.ts +++ b/ide/src/ext/extHost.ts @@ -19,6 +19,9 @@ export interface HostDeps { openFiles: () => string[]; /** 확장이 사용자에게 묻는 통로. 없으면 셰임이 곧장 취소로 답한다. */ prompt: (req: any) => Promise; + /** 상태바 항목 올리기/내리기. */ + statusSet: (item: any) => void; + statusRemove: (id: string) => void; } let commands: ExtCommand[] = []; @@ -197,7 +200,7 @@ export async function loadExtensions(d: HostDeps): Promise<{ loaded: number; err else errors.push(ext.name + ": " + reason); continue; } - const vscode = makeVscodeApi({ toast: d.toast, showPanel: d.showPanel, getActiveFile: d.getActiveFile, workspaceRoot: d.workspaceRoot, openFiles: d.openFiles, prompt: d.prompt, registerCommand: addCommand }, ext); + const vscode = makeVscodeApi({ toast: d.toast, showPanel: d.showPanel, getActiveFile: d.getActiveFile, workspaceRoot: d.workspaceRoot, openFiles: d.openFiles, prompt: d.prompt, statusSet: d.statusSet, statusRemove: d.statusRemove, registerCommand: addCommand }, ext); const moduleObj = { exports: {} as any }; const require = makeHostRequire(vscode); const ctx = { diff --git a/ide/src/ext/statusBar.test.ts b/ide/src/ext/statusBar.test.ts new file mode 100644 index 0000000..6467585 --- /dev/null +++ b/ide/src/ext/statusBar.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { cleanText, upsert, remove, removeSource, ordered, ALIGN_LEFT, ALIGN_RIGHT, type StatusItem } from "./statusBar"; + +const mk = (o: Partial & { id: string }): StatusItem => ({ + source: "확장", text: "t", tooltip: "", alignment: ALIGN_LEFT, priority: 0, seq: 0, ...o, +}); + +describe("cleanText", () => { + it("codicon 표기를 걷어낸다 — 안 걷으면 $(sync~spin) 이 글자로 보인다", () => { + expect(cleanText("$(sync~spin) 빌드 중")).toBe("빌드 중"); + expect(cleanText("$(check) 3 problems $(x)")).toBe("3 problems"); + }); + + it("아이콘만 있는 항목은 점 하나를 남긴다 — 빈 글자는 폭이 0 이라 눌 수 없다", () => { + expect(cleanText("$(bell)")).toBe("•"); + expect(cleanText("")).toBe("•"); + expect(cleanText(undefined)).toBe("•"); + }); + + it("빈칸을 정리한다", () => expect(cleanText(" a b ")).toBe("a b")); + it("닫는 괄호가 없으면 건드리지 않는다", () => expect(cleanText("$(broken")).toBe("$(broken")); +}); + +describe("upsert", () => { + it("없으면 뒤에 붙인다", () => { + expect(upsert([], mk({ id: "a" })).map(x => x.id)).toEqual(["a"]); + }); + + it("같은 id 면 갈아 끼운다", () => { + const r = upsert([mk({ id: "a", text: "옛" })], mk({ id: "a", text: "새" })); + expect(r).toHaveLength(1); + expect(r[0]!.text).toBe("새"); + }); + + it("갈아 끼울 때 seq 는 처음 것을 지킨다 — 글자만 바꿨는데 옆으로 튀면 안 된다", () => { + const r = upsert([mk({ id: "a", seq: 1 }), mk({ id: "b", seq: 2 })], mk({ id: "a", seq: 99, text: "새" })); + expect(r[0]!.seq).toBe(1); + }); + + it("원래 배열을 건드리지 않는다", () => { + const orig = [mk({ id: "a" })]; + upsert(orig, mk({ id: "b" })); + expect(orig).toHaveLength(1); + }); +}); + +describe("remove / removeSource", () => { + const list = [mk({ id: "a", source: "X" }), mk({ id: "b", source: "Y" }), mk({ id: "c", source: "X" })]; + it("id 하나를 뺀다", () => expect(remove(list, "b").map(x => x.id)).toEqual(["a", "c"])); + it("없는 id 는 아무 일도 없다", () => expect(remove(list, "z")).toHaveLength(3)); + it("확장 하나가 올린 것을 전부 걷는다", () => expect(removeSource(list, "X").map(x => x.id)).toEqual(["b"])); +}); + +describe("ordered", () => { + const list = [ + mk({ id: "l1", alignment: ALIGN_LEFT, priority: 1, seq: 0 }), + mk({ id: "l2", alignment: ALIGN_LEFT, priority: 9, seq: 1 }), + mk({ id: "r1", alignment: ALIGN_RIGHT, priority: 1, seq: 2 }), + mk({ id: "r2", alignment: ALIGN_RIGHT, priority: 9, seq: 3 }), + ]; + + it("왼쪽 그룹은 우선순위가 큰 것이 앞(= 더 왼쪽)", () => { + expect(ordered(list, ALIGN_LEFT).map(x => x.id)).toEqual(["l2", "l1"]); + }); + + it("오른쪽 그룹은 우선순위가 큰 것이 뒤(= 더 오른쪽) — 뒤집지 않으면 vscode 와 반대다", () => { + expect(ordered(list, ALIGN_RIGHT).map(x => x.id)).toEqual(["r1", "r2"]); + }); + + it("우선순위가 같으면 등록 순서를 지킨다", () => { + const same = [mk({ id: "b", seq: 5 }), mk({ id: "a", seq: 1 })]; + expect(ordered(same, ALIGN_LEFT).map(x => x.id)).toEqual(["a", "b"]); + }); + + it("다른 정렬의 항목은 안 섞인다", () => { + expect(ordered(list, ALIGN_LEFT).every(x => x.alignment === ALIGN_LEFT)).toBe(true); + }); + + it("원래 배열을 건드리지 않는다", () => { + const orig = list.slice(); + ordered(list, ALIGN_RIGHT); + expect(list).toEqual(orig); + }); +}); diff --git a/ide/src/ext/statusBar.ts b/ide/src/ext/statusBar.ts new file mode 100644 index 0000000..8ead5c7 --- /dev/null +++ b/ide/src/ext/statusBar.ts @@ -0,0 +1,79 @@ +/** + * 확장이 상태바에 올리는 항목. + * + * 예전 셰임은 이랬다: + * + * createStatusBarItem: () => ({ text: "", tooltip: "", command: "", show() {}, hide() {}, dispose() {} }), + * setStatusBarMessage: (_msg) => noopDisposable, + * + * `text` 는 그냥 객체의 속성이라 대입은 성공한다. `show()` 도 성공한다. 그런데 그 + * 객체를 읽는 곳이 어디에도 없다. 확장이 "빌드 중…", "3 problems", 로그인 상태 같은 + * 것을 상태바로 알리는 흐름이 통째로 사라졌다 — 실패한 흔적조차 없다. + * + * 순수 모듈이다. + */ + +export const ALIGN_LEFT = 1, ALIGN_RIGHT = 2; + +export interface StatusItem { + /** 확장·항목을 합친 고유 키. 같은 id 로 다시 오면 갈아 끼운다. */ + id: string; + /** 어느 확장이 올렸는가 — 툴팁에 밝힌다. */ + source: string; + text: string; + tooltip: string; + alignment: number; + /** 큰 값이 바깥쪽(왼쪽 그룹은 더 왼쪽, 오른쪽 그룹은 더 오른쪽). vscode 와 같다. */ + priority: number; + /** 눌렀을 때 실행할 것. 없으면 그냥 글자다. */ + run?: (() => void) | undefined; + /** 등록 순서 — 우선순위가 같을 때 순서를 고정한다. */ + seq: number; +} + +/** + * `$(icon)` 표기를 걷어낸다. + * + * vscode 는 상태바 글자에 codicon 을 `$(sync~spin) 빌드 중` 처럼 끼운다. 우리는 그 + * 아이콘 폰트를 안 싣는다. 그대로 두면 사용자에게 `$(sync~spin)` 이라는 글자가 그대로 + * 보이므로 걷어낸다. + * + * 걷어낸 뒤 아무것도 안 남는 경우가 있다(아이콘만 올리는 항목). 그때는 점 하나를 + * 남긴다 — 빈 글자는 폭이 0 이라 **누를 수 있는 것이 화면에서 사라진다.** + */ +export function cleanText(raw: unknown): string { + const s = String(raw ?? "").replace(/\$\([^)]*\)/g, " ").replace(/\s+/g, " ").trim(); + return s || "•"; +} + +/** 같은 id 면 갈아 끼우고, 없으면 뒤에 붙인다. 새 배열을 돌려준다. */ +export function upsert(list: readonly StatusItem[], item: StatusItem): StatusItem[] { + const i = list.findIndex(x => x.id === item.id); + if (i < 0) return [...list, item]; + const next = list.slice(); + // seq 는 처음 것을 지킨다 — 글자만 바꿨는데 항목이 옆으로 튀면 안 된다. + next[i] = { ...item, seq: list[i]!.seq }; + return next; +} + +export function remove(list: readonly StatusItem[], id: string): StatusItem[] { + return list.filter(x => x.id !== id); +} + +/** 한 확장이 올린 것을 전부 걷는다(확장 재로드·비활성화). */ +export function removeSource(list: readonly StatusItem[], source: string): StatusItem[] { + return list.filter(x => x.source !== source); +} + +/** + * 화면에 놓을 순서. + * + * 왼쪽 그룹은 우선순위가 **큰** 것이 더 왼쪽, 오른쪽 그룹은 우선순위가 큰 것이 더 + * 오른쪽 — vscode 가 그렇다. 우리 상태바는 왼쪽에서 오른쪽으로 그리므로, 오른쪽 + * 그룹은 뒤집어야 눈에 보이는 순서가 vscode 와 같아진다. + */ +export function ordered(list: readonly StatusItem[], alignment: number): StatusItem[] { + const g = list.filter(x => x.alignment === alignment); + g.sort((a, b) => (b.priority - a.priority) || (a.seq - b.seq)); + return alignment === ALIGN_RIGHT ? g.reverse() : g; +} diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts index dc00fdc..ce9d2ba 100644 --- a/ide/src/ext/vscodeShim.ts +++ b/ide/src/ext/vscodeShim.ts @@ -8,6 +8,7 @@ import { makeDocIndex } from "./shimDoc"; import { normalizePicks, normalizeButtons, type PromptReq } from "./prompt"; import { toMarkers, toLocations, toEdits } from "./shimLang"; import { flattenDefaults, fullKey, readValue, hasValue, inspectValue, sectionValues, affects } from "./config"; +import { cleanText, ALIGN_LEFT, ALIGN_RIGHT, type StatusItem } from "./statusBar"; import { setShimDocSource } from "./extHost"; export interface ShimDeps { @@ -23,6 +24,9 @@ export interface ShimDeps { * 이 통로가 없던 동안 showQuickPick·showInputBox 는 **묻지도 않고** undefined 를 * 돌려줬고, 확장은 사용자가 취소한 줄 알고 흐름을 접었다. */ prompt: (req: PromptReq) => Promise; + /** 상태바에 항목을 올리고 내린다. 없으면 확장이 올린 글자가 어디에도 안 보인다. */ + statusSet: (item: StatusItem) => void; + statusRemove: (id: string) => void; } const disposables: monaco.IDisposable[] = []; @@ -287,6 +291,7 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string; c return got == null ? undefined : buttons[got as number]!.raw; }; + let statusSeq = 0; const window_ = { // 버튼 없이 부르면 알림이다(토스트). 버튼을 주면 **물음**이다 — 예전엔 둘 다 // 토스트로 흘리고 undefined 를 돌려줘, `if (await showInformationMessage(m, "Reload") === "Reload")` @@ -294,7 +299,14 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string; c showInformationMessage: (msg: string, ...items: any[]) => msgOrAsk("info", msg, items), showWarningMessage: (msg: string, ...items: any[]) => msgOrAsk("warn", msg, items), showErrorMessage: (msg: string, ...items: any[]) => msgOrAsk("error", msg, items), - setStatusBarMessage: (_msg: string) => noopDisposable, + /** 잠깐 띄우는 한 줄. 시간을 주면 그때 스스로 내려간다. */ + setStatusBarMessage: (msg: string, hideAfter?: number) => { + const id = "sbmsg:" + ext.id + ":" + (++statusSeq); + deps.statusSet({ id, source: ext.name, text: cleanText(msg), tooltip: "", alignment: ALIGN_RIGHT, priority: -1, seq: 0 }); + const off = () => deps.statusRemove(id); + if (typeof hideAfter === "number" && hideAfter > 0) setTimeout(off, hideAfter); + return { dispose: off }; + }, createOutputChannel: (name: string) => { let buf = ""; return { @@ -303,7 +315,38 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string; c hide: () => {}, dispose: () => {}, replace: (s: string) => { buf = s; }, }; }, - createStatusBarItem: () => ({ text: "", tooltip: "", command: "", show() {}, hide() {}, dispose() {} }), + /** 상태바 항목. 예전엔 `text` 를 받아 두기만 하는 빈 객체였다 — 대입도 show() 도 + * 성공하는데 그 객체를 읽는 곳이 없어서, "빌드 중…" 같은 알림이 통째로 사라졌다. */ + createStatusBarItem: (...args: any[]) => { + // vscode 는 (alignment, priority) 와 (id, alignment, priority) 둘 다 받는다. + const withId = typeof args[0] === "string"; + const alignment = withId ? args[1] : args[0]; + const priority = withId ? args[2] : args[1]; + const id = "sb:" + ext.id + ":" + (++statusSeq); + let shown = false; + const item: any = { + id, text: "", tooltip: "", command: undefined, color: undefined, name: undefined, + alignment: alignment === ALIGN_LEFT ? ALIGN_LEFT : ALIGN_RIGHT, + priority: typeof priority === "number" ? priority : 0, + show() { shown = true; push(); }, + hide() { shown = false; deps.statusRemove(id); }, + dispose() { shown = false; deps.statusRemove(id); }, + }; + const push = () => { + if (!shown) return; + const cmd = typeof item.command === "string" ? item.command : item.command?.command; + deps.statusSet({ + id, source: ext.name, + text: cleanText(item.text), + // 어느 확장이 올린 글자인지 밝힌다 — 상태바는 앱의 것으로 읽히는 자리다. + tooltip: [String(item.tooltip ?? ""), ext.name].filter(Boolean).join(" — "), + alignment: item.alignment, priority: item.priority, seq: 0, + run: cmd ? () => { void executeCommand(cmd); } : undefined, + }); + }; + // 확장은 show() 뒤에도 text 를 계속 갈아 끼운다(진행 표시). 그때마다 올려 줘야 한다. + return new Proxy(item, { set(t, k, v) { (t as any)[k] = v; push(); return true; } }); + }, showQuickPick: async (items: any, options?: any) => { // 확장은 배열을 Promise 로 넘기기도 한다(파일 목록을 읽어 오는 흐름). const list = normalizePicks(await Promise.resolve(items)); diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index bb66a97..713fe36 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ "src/ai/retry.test.ts", "src/keymap.test.ts", "src/overlays.test.ts", - "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", "src/ext/config.test.ts", + "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", "src/ext/config.test.ts", "src/ext/statusBar.test.ts", "src/theme.test.ts", "src/editor/monacoColors.test.ts", "src/opening/**/*.test.ts", From 809ddd4f71b6ac8fff6b8aefd5d8319d615c7147 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 14:01:01 +0900 Subject: [PATCH 23/52] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9D=98=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B0=90=EC=8B=9C=EC=9E=90=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=94=94=EC=8A=A4=ED=81=AC=20=EB=B3=80=ED=99=94?= =?UTF-8?q?=EC=97=90=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createFileSystemWatcher: () => ({ onDidCreate: new EventEmitter().event, onDidChange: new EventEmitter().event, onDidDelete: new EventEmitter().event, dispose() {}, }), 아무도 쏘지 않는 이미터 셋이었다. 파일이 바뀌면 다시 읽는 확장 — 설정 파일 감시, 빌드 산출물 추적, 인덱스 갱신 — 이 한 번도 깨어나지 않았다. 정작 경로는 처음부터 있었다. 메인의 fs.watch(root, {recursive:true}) 는 바뀐 파일 이름을 콜백으로 받아 무시 규칙(node_modules, .schutz-tmp)에 쓰고는 그대로 버렸고, 렌더러에는 인자 없는 "뭔가 바뀌었다" 만 갔다. 그 이름을 디바운스 동안 모아 함께 보낸다(대량 변경에 대비해 2000개 상한). 이름만으로는 무슨 일이 있었는지 알 수 없다 — 만들어졌는지 지워졌는지 고쳐졌는지. 그래서 syncFromDisk 가 어차피 다시 읽는 트리와 앞뒤로 맞춰 판정한다. 워처가 알려 주지 않은 경로도 트리 비교로 잡히므로, 브랜치 전환처럼 개별 알림이 뭉개지는 자리에서도 만들어짐·지워짐은 놓치지 않는다. 트리가 상한에 걸려 잘렸을 때는 **워처가 이름을 준 경로만** 본다. 그때 "목록에 없다" 는 "지워졌다" 가 아니라 "안 읽었다" 일 수 있고, 그걸 지워짐으로 쏘면 그걸 받은 확장은 자기 인덱스에서 멀쩡한 파일을 실제로 지운다. 글롭은 vscode 문법 그대로다. ** 는 경로 구분자를 넘고 * 는 안 넘는다 — 둘을 같게 다루면 *.json 이 a/b/c.json 까지 잡아 확장이 엉뚱한 파일에 반응한다. `**/` 는 0단계도 뜻하므로 `**/*.ts` 가 최상위 a.ts 도 잡는다. 한 감시자가 던져도 나머지는 받는다. 남의 확장이 우리 확장을 막으면 안 된다. --- ide/electron/main.cjs | 15 ++++- ide/electron/preload.cjs | 4 +- ide/src/App.tsx | 24 ++++++- ide/src/ext/extHost.ts | 7 +- ide/src/ext/fsWatch.test.ts | 123 ++++++++++++++++++++++++++++++++++++ ide/src/ext/fsWatch.ts | 120 +++++++++++++++++++++++++++++++++++ ide/src/ext/vscodeShim.ts | 38 ++++++++++- ide/src/schutz.d.ts | 4 +- ide/vitest.config.ts | 2 +- 9 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 ide/src/ext/fsWatch.test.ts create mode 100644 ide/src/ext/fsWatch.ts diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index ff98da2..2e9a43a 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -1356,18 +1356,31 @@ ipcMain.on("schutz:watchStart", (e, root) => { try { assertRoot(root); } catch { return; } let timer = null; let dirty = false; + // 어떤 파일이 움직였는지 모아 둔다. fs.watch 는 처음부터 이름을 주는데, 예전엔 + // 무시 규칙에만 쓰고 버려서 렌더러에는 "뭔가 바뀌었다" 는 빈 신호만 갔다. + // 그래서 확장의 파일 감시자를 만들 수가 없었다. + let touched = new Set(); + const MAX_TOUCHED = 2000; // 대량 변경(브랜치 전환)에서 무한히 쌓이지 않게 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, "/")); } dirty = true; const cur = watchers.get(wid); if (cur) { clearTimeout(cur.timer); - cur.timer = setTimeout(() => { if (dirty && !e.sender.isDestroyed()) { dirty = false; e.sender.send("schutz:fsChange"); } }, 350); + cur.timer = setTimeout(() => { + if (dirty && !e.sender.isDestroyed()) { + dirty = false; + const rels = [...touched]; + touched = new Set(); + e.sender.send("schutz:fsChange", rels); + } + }, 350); } }) : null; if (watcher) { diff --git a/ide/electron/preload.cjs b/ide/electron/preload.cjs index aed4584..d5d441b 100644 --- a/ide/electron/preload.cjs +++ b/ide/electron/preload.cjs @@ -50,7 +50,9 @@ contextBridge.exposeInMainWorld("schutz", { watchStart: (root) => ipcRenderer.send("schutz:watchStart", root), watchStop: () => ipcRenderer.send("schutz:watchStop"), onFsChange: (cb) => { - const h = () => cb(); + // 바뀐 파일들의 상대 경로를 함께 넘긴다. 예전엔 인자 없이 불러서, 무엇이 + // 움직였는지 아는 쪽(메인)의 정보가 렌더러에 닿지 않았다. + const h = (_e, rels) => cb(Array.isArray(rels) ? rels : []); ipcRenderer.on("schutz:fsChange", h); return () => ipcRenderer.removeListener("schutz:fsChange", h); }, diff --git a/ide/src/App.tsx b/ide/src/App.tsx index d1f0810..ba648d3 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -37,6 +37,7 @@ import { shouldProbeQuota } from "./engine/quotaPoll"; import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction, overlayZ } from "./overlays"; import { filterPicks, stepIndex, validateInput, type PromptReq, type NormPick } from "./ext/prompt"; import { upsert as sbUpsert, remove as sbRemove, ordered as sbOrdered, ALIGN_LEFT, ALIGN_RIGHT, type StatusItem } from "./ext/statusBar"; +import { classify as fsClassify } from "./ext/fsWatch"; import { targetIdOf, isSubagentTarget, findSubagent, providerFor, filterTools, rosterLines, personaSystem, type SubagentDef, @@ -4347,19 +4348,40 @@ ${(r.output || "").slice(0, 2000)}`; private _fsTimer: ReturnType | null = null; /** 파일 워처 트리거 — 트리·모델·git을 디스크와 가볍게 동기화 (페인 리마운트 없이 커서 보존) */ - private onFsChange = () => { + /** 워처가 알려 준, 아직 처리하지 않은 경로들. 디바운스 동안 모았다가 트리를 + * 다시 읽은 뒤 만들어짐/고쳐짐/지워짐으로 나눈다. */ + private _fsTouched = new Set(); + private onFsChange = (rels?: string[]) => { + for (const r of rels ?? []) this._fsTouched.add(r); if (this._fsTimer) clearTimeout(this._fsTimer); this._fsTimer = setTimeout(() => void this.syncFromDisk(), 250); }; async syncFromDisk(opts?: { bulk?: boolean }) { const ws = this.state.workspace; if (!ws || !window.schutz) return; + const before = ws.entries.filter(e => !e.dir).map(e => e.rel); let tree: SchutzWorkspaceTree | null = null; try { tree = await window.schutz.readTree(ws.root); if (this.state.workspace !== ws) return; // 그 사이 워크스페이스 전환 → 스테일 트리로 새 repo를 덮지 않음 this.setState({ workspace: tree }); } catch { /* */ } + // 확장의 파일 감시자에게 알린다. 워처가 준 이름만으로는 무슨 일이 있었는지 알 수 + // 없으므로(만들어졌는지 지워졌는지 고쳐졌는지) 앞뒤 트리와 맞춰 판정한다. + const touched = [...this._fsTouched]; + this._fsTouched.clear(); + if (tree) { + const after = tree.entries.filter(e => !e.dir).map(e => e.rel); + // 트리가 잘렸으면 "목록에 없다" 가 "지워졌다" 가 아니라 "상한에 걸려 안 읽었다" + // 일 수 있다. 그때 트리 전체를 비교하면 멀쩡한 파일에 지워짐을 쏜다 — 그걸 받은 + // 확장은 인덱스에서 실제로 지운다. 그래서 **워처가 이름을 준 경로만** 본다. + const capped = tree.truncated || ws.truncated; + const t = new Set(touched); + const delta = capped + ? fsClassify(before.filter(r => t.has(r)), after.filter(r => t.has(r)), touched) + : fsClassify(before, after, touched); + if (delta.created.length || delta.changed.length || delta.deleted.length) extHost.notifyFsDelta(delta); + } // 사라진 파일(외부 삭제·브랜치 전환)의 stale 모델·진단·문제패널 항목 정리 — 트리 완전할 때만(truncated 면 실존 파일 오삭제 위험) if (tree && !(tree as any).truncated) { const present = new Set(tree.entries.filter(e => !e.dir).map(e => e.rel)); diff --git a/ide/src/ext/extHost.ts b/ide/src/ext/extHost.ts index cd86a28..7dd47ee 100644 --- a/ide/src/ext/extHost.ts +++ b/ide/src/ext/extHost.ts @@ -1,7 +1,7 @@ // 확장 호스트 (렌더러) — 활성 확장의 엔트리를 큐레이트 API로 로드. // 확장은 신뢰 코드로 간주(VS Code와 동일 모델)하되, 편의 API는 이 표면으로 한정한다. // Schutz 네이티브(schutz API) + VS Code 프로그램형(vscode 셰임으로 activate 실행) 둘 다 지원. -import { makeVscodeApi, disposeShimRegistrations } from "./vscodeShim"; +import { makeVscodeApi, disposeShimRegistrations, deliverFsDelta } from "./vscodeShim"; import { onHook, clearHooks, emitHook, HOOK_EVENTS, type HookEvent } from "./hooks"; import { editorEvents } from "./vscodeShim"; import { t } from "../i18n"; @@ -267,6 +267,11 @@ function memento(nsKey: string) { }; } +/** 앱이 알아낸 파일 변화를 확장의 감시자들에게 넘긴다. */ +export function notifyFsDelta(delta: { created: string[]; changed: string[]; deleted: string[] }): number { + return deliverFsDelta(delta); +} + /** 관리 UI용 목록 */ export async function listExtensions(): Promise { if (!window.schutz) return []; diff --git a/ide/src/ext/fsWatch.test.ts b/ide/src/ext/fsWatch.test.ts new file mode 100644 index 0000000..845052f --- /dev/null +++ b/ide/src/ext/fsWatch.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { globToRegExp, matchesGlob, classify, dispatch, type WatcherSpec } from "./fsWatch"; + +const m = (glob: string, rel: string) => matchesGlob(globToRegExp(glob), rel); + +describe("globToRegExp", () => { + it("* 는 경로 구분자를 넘지 않는다 — 넘으면 확장이 엉뚱한 파일에 반응한다", () => { + expect(m("*.json", "a.json")).toBe(true); + expect(m("*.json", "sub/a.json")).toBe(false); + }); + + it("** 는 넘는다", () => { + expect(m("**/*.ts", "a/b/c.ts")).toBe(true); + expect(m("**/*.ts", "a.ts")).toBe(true); // `**/` 는 0개 디렉터리도 뜻한다 + }); + + it("중간의 ** 도 0단계를 허용한다", () => { + expect(m("src/**/x.ts", "src/x.ts")).toBe(true); + expect(m("src/**/x.ts", "src/a/b/x.ts")).toBe(true); + expect(m("src/**/x.ts", "other/x.ts")).toBe(false); + }); + + it("? 는 한 글자, 구분자는 안 넘는다", () => { + expect(m("a?.ts", "ab.ts")).toBe(true); + expect(m("a?.ts", "abc.ts")).toBe(false); + expect(m("a?b", "a/b")).toBe(false); + }); + + it("{a,b} 묶음", () => { + expect(m("**/*.{ts,tsx}", "src/a.tsx")).toBe(true); + expect(m("**/*.{ts,tsx}", "src/a.js")).toBe(false); + }); + + it("문자 클래스", () => { + expect(m("v[0-9].ts", "v1.ts")).toBe(true); + expect(m("v[0-9].ts", "vx.ts")).toBe(false); + expect(m("a[b", "a[b")).toBe(true); // 닫는 괄호가 없으면 그냥 글자 + }); + + it("점은 글자 그대로다 — 정규식의 '아무 글자' 가 아니다", () => { + expect(m("a.json", "axjson")).toBe(false); + }); + + it("정규식 특수문자가 새지 않는다", () => { + expect(m("a+b.ts", "a+b.ts")).toBe(true); + expect(m("a+b.ts", "aab.ts")).toBe(false); + }); + + it("역슬래시 경로와 앞의 ./ 를 정규화한다", () => { + expect(m("./**/*.ts", "src\\a.ts")).toBe(true); + }); + + it("부분 일치가 아니라 전체 일치다", () => { + expect(m("a.ts", "xa.ts")).toBe(false); + expect(m("a.ts", "a.tsx")).toBe(false); + }); +}); + +describe("classify", () => { + it("새 트리에만 있으면 만들어짐", () => { + expect(classify(["a"], ["a", "b"], ["b"])).toEqual({ created: ["b"], changed: [], deleted: [] }); + }); + + it("옛 트리에만 있으면 지워짐", () => { + expect(classify(["a", "b"], ["a"], ["b"])).toEqual({ created: [], changed: [], deleted: ["b"] }); + }); + + it("양쪽에 다 있으면 고쳐짐", () => { + expect(classify(["a"], ["a"], ["a"])).toEqual({ created: [], changed: ["a"], deleted: [] }); + }); + + it("잠깐 생겼다 사라진 파일에는 아무것도 안 쏜다 — 빌드 임시 파일이 그렇다", () => { + expect(classify(["a"], ["a"], ["tmp"])).toEqual({ created: [], changed: [], deleted: [] }); + }); + + it("워처가 안 알려 준 것도 트리 비교로 잡는다 — 브랜치 전환에서 알림이 뭉개진다", () => { + expect(classify(["a"], ["b"], [])).toEqual({ created: ["b"], changed: [], deleted: ["a"] }); + }); + + it("같은 경로가 여러 번 와도 한 번만 쏜다", () => { + expect(classify(["a"], ["a"], ["a", "a", "a"]).changed).toEqual(["a"]); + }); + + it("역슬래시·앞 슬래시를 정규화한다", () => { + expect(classify([], ["a/b.ts"], ["a\\b.ts"]).created).toEqual(["a/b.ts"]); + }); + + it("빈 이름은 버린다", () => { + expect(classify([], [], ["", null as any])).toEqual({ created: [], changed: [], deleted: [] }); + }); +}); + +describe("dispatch", () => { + const mkW = (o: Partial & { id: string; glob: string; got: string[][] }): WatcherSpec => ({ + id: o.id, re: globToRegExp(o.glob), + ignoreCreate: o.ignoreCreate ?? false, ignoreChange: o.ignoreChange ?? false, ignoreDelete: o.ignoreDelete ?? false, + fire: (k, rel) => o.got.push([k, rel]), + }); + + it("글롭에 맞는 것만 보낸다", () => { + const got: string[][] = []; + dispatch([mkW({ id: "w", glob: "**/*.ts", got })], { created: ["a.ts", "a.js"], changed: [], deleted: [] }); + expect(got).toEqual([["create", "a.ts"]]); + }); + + it("무시 표시가 있으면 그 종류는 안 보낸다", () => { + const got: string[][] = []; + dispatch([mkW({ id: "w", glob: "**", got, ignoreChange: true })], { created: ["a"], changed: ["b"], deleted: ["c"] }); + expect(got.map(g => g[0])).toEqual(["create", "delete"]); + }); + + it("하나가 던져도 나머지는 받는다 — 남의 확장이 우리를 막으면 안 된다", () => { + const got: string[][] = []; + const bad: WatcherSpec = { id: "bad", re: globToRegExp("**"), ignoreCreate: false, ignoreChange: false, ignoreDelete: false, fire: () => { throw new Error("boom"); } }; + const n = dispatch([bad, mkW({ id: "ok", glob: "**", got })], { created: ["a"], changed: [], deleted: [] }); + expect(got).toEqual([["create", "a"]]); + expect(n).toBe(1); + }); + + it("감시자가 없으면 아무 일도 없다", () => { + expect(dispatch([], { created: ["a"], changed: [], deleted: [] })).toBe(0); + }); +}); diff --git a/ide/src/ext/fsWatch.ts b/ide/src/ext/fsWatch.ts new file mode 100644 index 0000000..bc3e9af --- /dev/null +++ b/ide/src/ext/fsWatch.ts @@ -0,0 +1,120 @@ +/** + * 확장의 파일 감시자 — `vscode.workspace.createFileSystemWatcher()`. + * + * 예전 셰임은 이랬다: + * + * createFileSystemWatcher: () => ({ + * onDidCreate: new EventEmitter().event, + * onDidChange: new EventEmitter().event, + * onDidDelete: new EventEmitter().event, + * dispose() {}, + * }), + * + * 아무도 쏘지 않는 이미터 셋이다. 파일이 바뀌면 다시 읽는 확장(설정 파일 감시, + * 빌드 산출물 추적, 인덱스 갱신)은 **한 번도 깨어나지 않았다.** + * + * 정작 경로는 처음부터 있었다. 메인의 `fs.watch(root, {recursive:true})` 는 바뀐 + * 파일 이름을 콜백으로 받아서 무시 규칙에 쓰고는 그대로 버렸고, 렌더러에는 "뭔가 + * 바뀌었다" 라는 빈 신호만 갔다. + * + * 순수 모듈이다. + */ + +/** vscode 의 GlobPattern 중 실제로 쓰이는 문법만 정규식으로 옮긴다. + * + * `**` 는 경로 구분자를 넘고, `*` 는 넘지 않는다 — 이 둘을 같게 다루면 + * `*.json` 이 `a/b/c.json` 까지 잡아 확장이 엉뚱한 파일에 반응한다. */ +export function globToRegExp(glob: string): RegExp { + const g = String(glob || "").replace(/\\/g, "/").replace(/^\.\//, ""); + let out = ""; + for (let i = 0; i < g.length; i++) { + const c = g[i]!; + if (c === "*") { + if (g[i + 1] === "*") { + i++; + // `**/` 는 "0개 이상의 디렉터리" 다. 슬래시까지 통째로 선택적이어야 + // `**/*.ts` 가 최상위의 `a.ts` 도 잡는다. + if (g[i + 1] === "/") { i++; out += "(?:.*/)?"; } + else out += ".*"; + } else out += "[^/]*"; + } else if (c === "?") out += "[^/]"; + else if (c === "{") out += "(?:"; + else if (c === "}") out += ")"; + else if (c === ",") out += "|"; + else if (c === "[") { + // 문자 클래스는 그대로 넘긴다. 닫는 괄호가 없으면 평범한 글자로 본다. + const end = g.indexOf("]", i + 1); + if (end < 0) out += "\\["; + else { out += g.slice(i, end + 1); i = end; } + } else out += c.replace(/[.+^$()|\\]/g, "\\$&"); + } + return new RegExp("^" + out + "$"); +} + +export function matchesGlob(re: RegExp, rel: string): boolean { + return re.test(String(rel || "").replace(/\\/g, "/").replace(/^\/+/, "")); +} + +export interface FsDelta { + created: string[]; + changed: string[]; + deleted: string[]; +} + +/** + * 바뀐 경로들을 만들어짐/고쳐짐/지워짐으로 나눈다. + * + * `touched` 는 워처가 알려 준 이름들이다. 그 이름만으로는 무슨 일이 있었는지 알 수 + * 없으므로(만들어졌는지 지워졌는지 고쳐졌는지), 앞뒤 트리에 있는지로 판정한다. + * + * 워처가 알려 주지 않은 경로도 트리 비교로 잡는다 — 대량 변경(브랜치 전환)에서 + * 개별 알림이 뭉개져도 만들어짐·지워짐은 놓치지 않는다. + */ +export function classify(prev: readonly string[], next: readonly string[], touched: readonly string[]): FsDelta { + const P = new Set(prev), N = new Set(next); + const created: string[] = [], changed: string[] = [], deleted: string[] = []; + const seen = new Set(); + + for (const rel of touched) { + const r = String(rel || "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (!r || seen.has(r)) continue; + seen.add(r); + if (N.has(r) && !P.has(r)) created.push(r); + else if (!N.has(r) && P.has(r)) deleted.push(r); + // 양쪽에 다 있으면 내용이 바뀐 것. 양쪽에 다 없으면 우리가 모르는 파일이 + // 잠깐 생겼다 사라진 것이므로(빌드 임시 파일 등) 아무것도 쏘지 않는다. + else if (N.has(r) && P.has(r)) changed.push(r); + } + for (const r of N) if (!P.has(r) && !seen.has(r)) { seen.add(r); created.push(r); } + for (const r of P) if (!N.has(r) && !seen.has(r)) { seen.add(r); deleted.push(r); } + + return { created, changed, deleted }; +} + +export interface WatcherSpec { + id: string; + re: RegExp; + ignoreCreate: boolean; + ignoreChange: boolean; + ignoreDelete: boolean; + fire: (kind: "create" | "change" | "delete", rel: string) => void; +} + +/** 델타를 등록된 감시자들에게 나눠 준다. + * 하나가 던져도 나머지는 받아야 한다 — 남의 확장이 우리 확장을 막으면 안 된다. */ +export function dispatch(watchers: Iterable, delta: FsDelta): number { + let sent = 0; + const send = (w: WatcherSpec, kind: "create" | "change" | "delete", rels: readonly string[], skip: boolean) => { + if (skip) return; + for (const rel of rels) { + if (!matchesGlob(w.re, rel)) continue; + try { w.fire(kind, rel); sent++; } catch { /* 확장이 던진 것 */ } + } + }; + for (const w of watchers) { + send(w, "create", delta.created, w.ignoreCreate); + send(w, "change", delta.changed, w.ignoreChange); + send(w, "delete", delta.deleted, w.ignoreDelete); + } + return sent; +} diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts index ce9d2ba..cac4533 100644 --- a/ide/src/ext/vscodeShim.ts +++ b/ide/src/ext/vscodeShim.ts @@ -9,8 +9,18 @@ import { normalizePicks, normalizeButtons, type PromptReq } from "./prompt"; import { toMarkers, toLocations, toEdits } from "./shimLang"; import { flattenDefaults, fullKey, readValue, hasValue, inspectValue, sectionValues, affects } from "./config"; import { cleanText, ALIGN_LEFT, ALIGN_RIGHT, type StatusItem } from "./statusBar"; +import { globToRegExp, dispatch as fsDispatch, type WatcherSpec, type FsDelta } from "./fsWatch"; import { setShimDocSource } from "./extHost"; +/** 지금 살아 있는 파일 감시자들. 확장을 다시 읽으면 disposeShimRegistrations 가 비운다. */ +const fsWatchers = new Map(); +let fsWatchSeq = 0; + +/** 앱이 파일 변화를 알아냈을 때 부른다. 맞는 감시자에게만 간다. */ +export function deliverFsDelta(delta: FsDelta): number { + return fsDispatch(fsWatchers.values(), delta); +} + export interface ShimDeps { toast: (kind: "ok" | "error" | "info", msg: string) => void; showPanel: (title: string, html: string) => void; @@ -434,7 +444,33 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string; c get workspaceFolders() { const f = folder(); return f ? [f] : undefined; }, get textDocuments() { return docs.documents(); }, getWorkspaceFolder: (_uri?: any) => folder(), - createFileSystemWatcher: () => ({ onDidCreate: new EventEmitter().event, onDidChange: new EventEmitter().event, onDidDelete: new EventEmitter().event, dispose() {} }), + /** 파일 감시자. 예전엔 아무도 쏘지 않는 이미터 셋이라, 파일이 바뀌면 다시 읽는 + * 확장이 한 번도 깨어나지 않았다. 정작 바뀐 경로는 메인이 처음부터 알고 있었다. */ + createFileSystemWatcher: (pattern: any, ignoreCreate?: boolean, ignoreChange?: boolean, ignoreDelete?: boolean) => { + // RelativePattern(`{ base, pattern }`) 도 온다. 우리는 워크스페이스 하나만 + // 다루므로 pattern 만 본다 — base 를 무시해도 루트 밖은 애초에 안 감시한다. + const glob = typeof pattern === "string" ? pattern : String(pattern?.pattern ?? "**/*"); + const onCreate = new EventEmitter(), onChange = new EventEmitter(), onDelete = new EventEmitter(); + const id = "fsw:" + ext.id + ":" + (++fsWatchSeq); + const root = deps.workspaceRoot(); + fsWatchers.set(id, { + id, re: globToRegExp(glob), + ignoreCreate: ignoreCreate === true, ignoreChange: ignoreChange === true, ignoreDelete: ignoreDelete === true, + fire: (kind, rel) => { + // 확장은 Uri 를 기대한다. 루트가 없으면 상대 경로로라도 준다. + const uri = UriShim.file(root ? root.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel : rel); + (kind === "create" ? onCreate : kind === "delete" ? onDelete : onChange).fire(uri); + }, + }); + const d = { dispose() { fsWatchers.delete(id); } }; + // 확장이 dispose 를 잊어도 재로드 때 정리된다. + disposables.push(d); + return { + onDidCreate: onCreate.event, onDidChange: onChange.event, onDidDelete: onDelete.event, + ignoreCreateEvents: ignoreCreate === true, ignoreChangeEvents: ignoreChange === true, ignoreDeleteEvents: ignoreDelete === true, + dispose: d.dispose, + }; + }, openTextDocument: (arg?: any) => { // 열려 있지 않은 파일은 아직 못 연다(모델이 없다). 그래도 reject 로 끝내던 // 자리라, 최소한 열려 있는 파일에는 답한다. diff --git a/ide/src/schutz.d.ts b/ide/src/schutz.d.ts index 0da77d0..64c0fb3 100644 --- a/ide/src/schutz.d.ts +++ b/ide/src/schutz.d.ts @@ -143,7 +143,9 @@ interface SchutzApi { readBinary(root: string, rel: string): Promise; watchStart(root: string): void; watchStop(): void; - onFsChange(cb: () => void): () => void; + /** 바뀐 파일들의 워크스페이스 상대 경로. 감시자가 이름을 못 준 변경도 있으므로 + * 이 목록은 완전하지 않다 — 트리 비교로 보완해서 쓴다. */ + onFsChange(cb: (rels: string[]) => void): () => void; mkdir(root: string, rel: string): Promise; /** 체크포인트 — 한 실행이 손댄 파일의 원본 바이트를 메인에 잡아 두고 통째로 되돌린다. * 해시는 전부 메인이 계산한다. 무엇을 되돌릴지는 engine/checkpoints.ts 가 정한다. */ diff --git a/ide/vitest.config.ts b/ide/vitest.config.ts index 713fe36..403edb3 100644 --- a/ide/vitest.config.ts +++ b/ide/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ "src/ai/retry.test.ts", "src/keymap.test.ts", "src/overlays.test.ts", - "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", "src/ext/config.test.ts", "src/ext/statusBar.test.ts", + "src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", "src/ext/config.test.ts", "src/ext/statusBar.test.ts", "src/ext/fsWatch.test.ts", "src/theme.test.ts", "src/editor/monacoColors.test.ts", "src/opening/**/*.test.ts", From bd1346d095dd66d291f979ddad9ea7d7daeb2140 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 4 Aug 2026 15:05:17 +0900 Subject: [PATCH 24/52] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9D=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=EC=97=90=20=EB=B7=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=99=EC=9D=BC=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerTreeDataProvider: () => noopDisposable, registerWebviewViewProvider: () => noopDisposable, 등록은 성공하는데 그 뷰가 놓일 자리가 앱에 아예 없었다. 확장은 매니페스트에 contributes.views 로 "여기 뷰를 하나 놓겠다" 고 선언하고 프로바이더까지 붙이는데, 화면 어디에도 나타나지 않는다. 레일에 "확장 뷰" 자리를 만들고 등록된 뷰를 그린다. 이름은 매니페스트에서 읽는다 — 프로바이더를 등록하는 쪽은 뷰 id 만 아는데, 사람에게 보여 줄 이름은 거기에만 있다. 안 읽으면 사이드바에 원시 id(myExt.viewA)가 그대로 뜬다. 어느 확장이 붙였는지도 함께 밝힌다. 안 밝히면 앱의 기능으로 읽힌다. 트리는 펼쳐 둔 줄만 자식을 물어본다. 전부 미리 물어보면 파일 트리를 내주는 확장에서 프로젝트 전체를 한 번에 읽게 된다. 확장이 던지면 그 뷰만 실패로 두고 나머지는 그대로 그린다. 웹뷰는 샌드박스 iframe 안에 둔다. 확장의 HTML 을 우리 문서에 그대로 붙이면 앱 DOM 을 자유롭게 만질 수 있다. 프레임 안에서는 스크립트가 돌되(웹뷰는 스크립트가 있어야 산다) 바깥과는 postMessage 로만 오간다. acquireVsCodeApi() 도 넣어 준다 — 거의 모든 웹뷰가 그 한 줄로 시작하고, 없으면 첫 줄에서 죽는다. HTML 만 그려 둔 "떠 있는데 아무 반응 없는 창" 은 아무것도 안 뜨는 것보다 나쁘다. 만들면서 걸린 것 둘: 트리 줄의 명령이 안 돌았다. 확장 명령은 `확장id:명령id` 로 저장되는데 확장이 항목에 적어 두는 것은 자기 이름(probe.pick)뿐이라, 누가 부르는지 같이 넘겨야 찾는다. 안 넘기면 줄을 눌러도 조용히 아무 일이 없다 — 이 판에서 고치려던 바로 그 모양이다. onDidChangeTreeData 구독을 뷰 id 로 기억했더니, 확장을 다시 읽어 프로바이더가 새로 만들어졌을 때 옛 이미터에 걸린 구독이 남았다. 확장은 계속 알리는데 아무도 안 듣고 트리는 처음 그린 모습에서 굳는다. 등록이 바뀔 때마다 전부 떼고 다시 건다. 봉투도 샌다. 앱이 웹뷰로 보내는 메시지는 __schutzToView 를 단 객체로 싸는데, 그게 확장의 리스너에까지 그대로 갔다. 캡처 단계에서 가로채 전파를 끊는다. TreeView.reveal 은 아직 못 한다. 조용히 성공한 척하지 않고 거부한다. --- ide/src/App.tsx | 182 ++++++++++++++++++++++++++++++++++- ide/src/ext/extHost.ts | 9 +- ide/src/ext/views.test.ts | 112 +++++++++++++++++++++ ide/src/ext/views.ts | 142 +++++++++++++++++++++++++++ ide/src/ext/vscodeShim.ts | 113 +++++++++++++++++++++- ide/src/i18n/dict/extview.ts | 11 +++ ide/src/i18n/messages.ts | 3 +- ide/vitest.config.ts | 2 +- 8 files changed, 564 insertions(+), 10 deletions(-) create mode 100644 ide/src/ext/views.test.ts create mode 100644 ide/src/ext/views.ts create mode 100644 ide/src/i18n/dict/extview.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index ba648d3..51762c5 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -38,6 +38,7 @@ import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction, overlayZ } from ". import { filterPicks, stepIndex, validateInput, type PromptReq, type NormPick } from "./ext/prompt"; import { upsert as sbUpsert, remove as sbRemove, ordered as sbOrdered, ALIGN_LEFT, ALIGN_RIGHT, type StatusItem } from "./ext/statusBar"; import { classify as fsClassify } from "./ext/fsWatch"; +import { rowKey, webviewDoc, type TreeRow } from "./ext/views"; import { targetIdOf, isSubagentTarget, findSubagent, providerFor, filterTools, rosterLines, personaSystem, type SubagentDef, @@ -269,7 +270,7 @@ interface S { closingTabs: string[]; /** 슬롯별 활성 rel ("" = 빈 슬롯). 길이 = layout */ active: string[]; - leftTab: "flow" | "tree" | "git" | "debug" | "ext"; + leftTab: "flow" | "tree" | "git" | "debug" | "ext" | "extview"; /** 확장: 로드된 커맨드 · 관리 목록 · 기여 패널 */ extCommands: import("./ext/extHost").ExtCommand[]; extList: import("./ext/extHost").ExtInfo[]; @@ -343,6 +344,14 @@ interface S { } | null; /** 확장이 상태바에 올린 항목. 예전엔 셰임이 받아 두기만 하고 읽는 곳이 없었다. */ extStatus: StatusItem[]; + /** 확장이 붙인 사이드바 뷰가 바뀔 때마다 올라가는 번호 — 목록은 셰임이 들고 있다. */ + extViewVer: number; + /** 펼쳐 둔 트리 줄(rowKey). 다시 그려도 접힘이 풀리지 않게 상태로 둔다. */ + extViewOpen: Record; + /** 그려 둔 트리 줄들. 확장에 물어보는 일이 비동기라 결과를 여기에 쌓는다. */ + extViewRows: Record; + /** 웹뷰가 돌려준 HTML. */ + extViewHtml: Record; /** 확장이 사용자에게 던진 물음. 셰임의 showQuickPick/showInputBox/showXMessage 가 * 여기로 온다 — 예전엔 묻지도 않고 undefined(=취소)를 돌려줬다. */ extAsk: PromptReq | null; @@ -687,7 +696,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { termOpen: false, termReady: false, termTab: "t1", chatTab: "all", chatAway: false, openDiffs: {}, openTools: {}, sheetOpen: false, convId: null, asideTab: "recents", impOpen: false, impRows: null, impThisOnly: true, impBusy: null, impAgent: "all", agentSideW: (() => { try { return Math.max(360, Math.min(1100, +(localStorage.getItem("schutz.agentSideW") || 620))); } catch { return 620; } })(), - agentAsideW: (() => { try { return Math.max(150, Math.min(480, +(localStorage.getItem("schutz.agentAsideW") || 216))); } catch { return 216; } })(), quota: {}, askRun: null, confirmAsk: null, extStatus: [], extAsk: null, extAskText: "", extAskSel: 0, extAskPicked: [], extAskErr: null, terms: [{ id: "t1", n: 1 }], tasks: [], keyCapture: null, hunkSel: {}, checkpoints: [], undoAsk: null, gitAmend: false, commitView: null, mcpb: null, + agentAsideW: (() => { try { return Math.max(150, Math.min(480, +(localStorage.getItem("schutz.agentAsideW") || 216))); } catch { return 216; } })(), quota: {}, askRun: null, confirmAsk: null, extStatus: [], extViewVer: 0, extViewOpen: {}, extViewRows: {}, extViewHtml: {}, extAsk: null, extAskText: "", extAskSel: 0, extAskPicked: [], extAskErr: null, terms: [{ id: "t1", n: 1 }], tasks: [], keyCapture: null, hunkSel: {}, checkpoints: [], undoAsk: null, gitAmend: false, commitView: null, mcpb: null, agents: this.freshAgents(), workspace: null, paneDirty: {}, proposals: [], reviewFindings: [], reviewBusy: false, paneVer: {}, @@ -4831,6 +4840,9 @@ ${(r.output || "").slice(0, 2000)}`; void lspClient.initLsp().then(() => { registerLspProviders(); return lspClient.syncOpenModels(); }); // 확장 로드 (커맨드 기여 → 팔레트) void this.reloadExtensions(); + // 확장이 뷰를 붙이거나 뗄 때 사이드바를 다시 맞춘다. + this._viewsOff = extHost.onExtViewsChanged(this.syncExtViews); + window.addEventListener("message", this.onViewMessage); // MCP 서버 시작 (Schutz 호스트) → 도구를 에이전트 루프에 노출 mcp.setMcpChangeHandler(() => this.forceUpdate()); void mcp.startAll(); @@ -4880,6 +4892,10 @@ ${(r.output || "").slice(0, 2000)}`; } } componentWillUnmount() { + this._viewsOff?.(); this._viewsOff = null; + for (const off of this._viewChangeOff.values()) off(); + this._viewChangeOff.clear(); + window.removeEventListener("message", this.onViewMessage); window.removeEventListener("dragover", this.onWindowDragOver); window.removeEventListener("drop", this.onWindowDrop, true); if (this._engineWatch) { clearTimeout(this._engineWatch); this._engineWatch = 0; } @@ -5440,6 +5456,10 @@ ${(r.output || "").slice(0, 2000)}`; prompt: req => this.askExtension(req), statusSet: item => this.setState(st => ({ extStatus: sbUpsert(st.extStatus, { ...item, seq: this._sbSeq++ }) })), statusRemove: id => this.setState(st => ({ extStatus: sbRemove(st.extStatus, id) })), + postToView: (viewId, msg) => { + const f = this._viewFrames.get(viewId); + try { f?.contentWindow?.postMessage({ __schutzToView: true, data: msg }, "*"); } catch { /* 사라진 프레임 */ } + }, }); // VS Code 확장(선언형) — 테마·스니펫·언어설정 적용 const vres = await vscodeExt.loadVscodeExtensions(); @@ -6067,6 +6087,9 @@ ${(r.output || "").slice(0, 2000)}`; +