diff --git a/README.md b/README.md
index 1ad7c69..2844b85 100644
--- a/README.md
+++ b/README.md
@@ -144,10 +144,18 @@ webview to the sidebar. MCP speaks all three of its pillars rather than tools al
MCP resources and prompts are offered to the agent, not just listed in a panel.
-Where it is still thin, specifically: a `WorkspaceEdit` cannot create, delete or rename
-files, and the shim has no custom editors and no debug adapter API. Those three fail loudly —
-`WorkspaceEdit` throws, and the other two are absent from the API object, so calling one is a
-`TypeError` rather than a quiet nothing.
+`WorkspaceEdit` creates, deletes and renames files, all-or-nothing: if one operation cannot be
+done, none are, and a file with unsaved edits is never deleted or overwritten. An extension can
+own a file type through `registerCustomEditorProvider` — the document stays a normal
+`TextDocument`, so edits go through the model and `Ctrl+Z` still works. Extensions can observe
+debugging: the active session, its start and end, the breakpoint list, and adding or removing
+breakpoints.
+
+Where it is still thin, specifically: a custom editor that owns the bytes itself
+(`CustomEditorProvider`, with its own save and backup) rather than a text document, and an
+extension supplying its own debugger (`registerDebugAdapterDescriptorFactory`). Both fail
+loudly — the first throws with a message naming what is missing, the second is absent from the
+API object, so calling it is a `TypeError` rather than a quiet nothing.
Decorations used to sit in that list as the one gap that answered successfully and drew
nothing. They now draw: `createTextEditorDecorationType` compiles the requested styling into
diff --git a/ide/src/App.tsx b/ide/src/App.tsx
index ba88aed..1f2f9dd 100644
--- a/ide/src/App.tsx
+++ b/ide/src/App.tsx
@@ -70,6 +70,8 @@ import { ImagePane, MarkdownPane, isImage, mdToHtml } from "./editor/MediaPane";
import monaco, { languageOf, applyTsPaths, revalidateTs } from "./editor/monacoSetup";
import * as projectModels from "./editor/projectModels";
import * as proposalDeco from "./editor/proposalDeco";
+import { CustomEditorPane } from "./editor/CustomEditorPane";
+import { editorFor as customEditorFor } from "./ext/customEditors";
import * as symbolIndex from "./editor/symbolIndex";
import * as vscodeShim from "./ext/vscodeShim";
import { missingFor as missingLspFor, shouldTell as shouldTellLsp, type ServerRow } from "./engine/lspHint";
@@ -8218,7 +8220,12 @@ ${(r.output || "").slice(0, 2000)}`;
const realFile = !!(s.workspace && !diffMeta);
const isImg = realFile && isImage(activeRel);
const isMdPrev = realFile && activeRel.endsWith(".md") && !!s.mdPreview[activeRel];
- const isReal = realFile && !isImg && !isMdPrev;
+ // 확장이 만든 편집기가 이 파일을 맡는가. 선언과 구현이 둘 다 있어야 한다 —
+ // 선언만 있으면 텍스트로 연다(빈 화면보다 낫다).
+ const custom = realFile
+ ? customEditorFor(activeRel, extHost.getCustomEditorDecls(), new Set(extHost.listExtEditors().map(e => e.viewType)))
+ : null;
+ const isReal = realFile && !isImg && !isMdPrev && !custom;
return (
{ this._focusSlot = si; }}
@@ -8240,6 +8247,12 @@ ${(r.output || "").slice(0, 2000)}`;
) : isMdPrev ? (
+ ) : custom ? (
+
) : isReal ? (
(null);
+ const [err, setErr] = useState("");
+ const frameRef = useRef(null);
+
+ useEffect(() => {
+ let dead = false;
+ const ed = extHost.extEditorFor(viewType);
+ if (!ed) { setErr(t("exth.customEditorGone", { viewType })); return; }
+ // 문서를 먼저 세운다 — 이 파일에는 모델이 없을 수 있고, 그러면 확장이
+ // document.getText() 첫 줄에서 던진다.
+ extHost.openDocFor(rel)
+ .then(doc => {
+ if (dead) return null;
+ if (!doc) throw new Error(t("exth.customEditorNoDoc", { rel }));
+ return ed.resolve(rel, doc);
+ })
+ .then(h => { if (!dead && h != null) setHtml(String(h)); })
+ // 확장이 던지면 빈 화면 대신 이유를 띄운다 — 안 그러면 파일이 안 열린 것처럼 보인다.
+ .catch(e => { if (!dead) setErr(e instanceof Error ? e.message : String(e)); });
+ return () => { dead = true; };
+ }, [viewType, rel]);
+
+ // 웹뷰가 보낸 말을 확장에게 넘긴다. 사이드바 웹뷰와 같은 봉투를 쓴다.
+ useEffect(() => {
+ const onMsg = (e: MessageEvent) => {
+ const d: any = e.data;
+ if (!d || d.__schutzView !== "editor:" + rel) return;
+ extHost.extEditorFor(viewType)?.post(d.data);
+ };
+ window.addEventListener("message", onMsg);
+ return () => window.removeEventListener("message", onMsg);
+ }, [viewType, rel]);
+
+ if (err) {
+ return ⚠️ {err}
;
+ }
+ if (html == null) {
+ return {t("exth.customEditorLoading")}
;
+ }
+ return (
+
+ );
+}
diff --git a/ide/src/ext/customEditors.test.ts b/ide/src/ext/customEditors.test.ts
new file mode 100644
index 0000000..557dd15
--- /dev/null
+++ b/ide/src/ext/customEditors.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from "vitest";
+import { parseCustomEditors, matchesPattern, editorFor, type CustomEditorDecl } from "./customEditors";
+
+describe("parseCustomEditors", () => {
+ it("viewType 과 파일 패턴을 읽는다", () => {
+ const r = parseCustomEditors({
+ customEditors: [{ viewType: "my.editor", selector: [{ filenamePattern: "*.draw" }] }],
+ }, "pub.ext");
+ expect(r).toEqual([{ viewType: "my.editor", extId: "pub.ext", patterns: ["*.draw"], optional: false }]);
+ });
+
+ it("selector 가 여럿이면 다 읽는다", () => {
+ const r = parseCustomEditors({
+ customEditors: [{ viewType: "v", selector: [{ filenamePattern: "*.a" }, { filenamePattern: "*.b" }] }],
+ }, "e");
+ expect(r[0]!.patterns).toEqual(["*.a", "*.b"]);
+ });
+
+ it("priority: option 은 자동으로 열지 않는 것으로 표시한다", () => {
+ const r = parseCustomEditors({
+ customEditors: [{ viewType: "v", selector: [{ filenamePattern: "*.a" }], priority: "option" }],
+ }, "e");
+ expect(r[0]!.optional).toBe(true);
+ });
+
+ // 선언이 깨졌다고 확장 전체가 죽으면 안 된다 — 그 항목만 버린다.
+ it("모양이 어긋난 항목은 버리고 나머지는 살린다", () => {
+ const r = parseCustomEditors({
+ customEditors: [
+ { viewType: "", selector: [{ filenamePattern: "*.a" }] },
+ { viewType: "ok", selector: [] },
+ { viewType: "good", selector: [{ filenamePattern: "*.c" }] },
+ ],
+ }, "e");
+ expect(r.map(d => d.viewType)).toEqual(["good"]);
+ });
+
+ it("customEditors 가 없거나 이상하면 빈 목록", () => {
+ expect(parseCustomEditors(undefined, "e")).toEqual([]);
+ expect(parseCustomEditors({}, "e")).toEqual([]);
+ expect(parseCustomEditors({ customEditors: "무엇" }, "e")).toEqual([]);
+ });
+
+ it("객체 하나만 준 경우도 받는다", () => {
+ const r = parseCustomEditors({ customEditors: { viewType: "v", selector: { filenamePattern: "*.a" } } }, "e");
+ expect(r).toHaveLength(1);
+ });
+});
+
+describe("matchesPattern", () => {
+ it("확장자 패턴", () => {
+ expect(matchesPattern("a.draw", "*.draw")).toBe(true);
+ expect(matchesPattern("deep/dir/a.draw", "*.draw")).toBe(true);
+ expect(matchesPattern("a.txt", "*.draw")).toBe(false);
+ });
+ it("경로가 붙은 패턴", () => {
+ expect(matchesPattern("src/a.draw", "src/*.draw")).toBe(true);
+ expect(matchesPattern("src/deep/a.draw", "src/*.draw")).toBe(false);
+ expect(matchesPattern("src/deep/a.draw", "src/**/*.draw")).toBe(true);
+ });
+ it("대소문자를 가리지 않는다", () => {
+ expect(matchesPattern("A.DRAW", "*.draw")).toBe(true);
+ });
+ it("역슬래시 경로도 받는다", () => {
+ expect(matchesPattern("src\\a.draw", "src/*.draw")).toBe(true);
+ });
+ it("깨진 패턴에 터지지 않는다", () => {
+ expect(matchesPattern("a.draw", "")).toBe(false);
+ expect(matchesPattern("", "*.draw")).toBe(false);
+ });
+});
+
+describe("editorFor", () => {
+ const decl = (viewType: string, patterns: string[], optional = false): CustomEditorDecl =>
+ ({ viewType, extId: "e", patterns, optional });
+
+ it("선언과 구현이 둘 다 있으면 고른다", () => {
+ expect(editorFor("a.draw", [decl("v", ["*.draw"])], new Set(["v"]))?.viewType).toBe("v");
+ });
+
+ // 선언만 있고 확장이 아직 안 떴으면 텍스트로 열려야 한다 — 빈 화면보다 낫다.
+ it("구현이 등록되지 않았으면 안 고른다", () => {
+ expect(editorFor("a.draw", [decl("v", ["*.draw"])], new Set())).toBeNull();
+ });
+
+ it("패턴이 안 맞으면 안 고른다", () => {
+ expect(editorFor("a.txt", [decl("v", ["*.draw"])], new Set(["v"]))).toBeNull();
+ });
+
+ it("option 인 것은 자동으로 열지 않는다", () => {
+ expect(editorFor("a.draw", [decl("v", ["*.draw"], true)], new Set(["v"]))).toBeNull();
+ });
+
+ it("여럿이면 먼저 선언된 것", () => {
+ const decls = [decl("first", ["*.draw"]), decl("second", ["*.draw"])];
+ expect(editorFor("a.draw", decls, new Set(["first", "second"]))?.viewType).toBe("first");
+ });
+
+ it("등록된 것만 있으면 그것을 고른다", () => {
+ const decls = [decl("first", ["*.draw"]), decl("second", ["*.draw"])];
+ expect(editorFor("a.draw", decls, new Set(["second"]))?.viewType).toBe("second");
+ });
+
+ it("아무것도 없으면 null", () => {
+ expect(editorFor("a.draw", [], new Set())).toBeNull();
+ });
+});
diff --git a/ide/src/ext/customEditors.ts b/ide/src/ext/customEditors.ts
new file mode 100644
index 0000000..d430405
--- /dev/null
+++ b/ide/src/ext/customEditors.ts
@@ -0,0 +1,91 @@
+// 확장이 만든 편집기 — 어떤 파일을 누가 열 것인가.
+//
+// `contributes.customEditors` 는 viewType 과 파일 패턴을 짝지어 선언하고,
+// `window.registerCustomEditorProvider` 가 그 viewType 에 실제 구현을 붙인다.
+// 둘이 다 있어야 그 파일이 그 편집기로 열린다 — 선언만 있고 구현이 없으면
+// 파일이 안 열리는 것처럼 보이고, 구현만 있고 선언이 없으면 아무도 안 부른다.
+//
+// 여기는 그 짝짓기만 한다. 화면에 그리는 일은 App 이, 등록은 셰임이 맡는다.
+
+export interface CustomEditorDecl {
+ viewType: string;
+ /** 확장 id — 같은 viewType 을 두 확장이 선언해도 갈라 두기 위해. */
+ extId: string;
+ /** 파일 이름 패턴들(glob). 하나라도 맞으면 이 편집기가 후보다. */
+ patterns: string[];
+ /** 사용자가 기본 편집기 대신 이걸 쓰길 원하는가. vscode 의 priority. */
+ optional: boolean;
+}
+
+/** manifest 의 contributes.customEditors 를 읽는다. 모양이 어긋나면 그 항목만 버린다. */
+export function parseCustomEditors(contributes: any, extId: string): CustomEditorDecl[] {
+ const raw = contributes?.customEditors;
+ const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
+ const out: CustomEditorDecl[] = [];
+ for (const it of list) {
+ const viewType = String(it?.viewType ?? "").trim();
+ if (!viewType) continue;
+ const sel = Array.isArray(it?.selector) ? it.selector : it?.selector ? [it.selector] : [];
+ const patterns = sel.map((s: any) => String(s?.filenamePattern ?? "").trim()).filter(Boolean);
+ if (!patterns.length) continue;
+ out.push({
+ viewType,
+ extId,
+ patterns,
+ // vscode 의 기본값은 "default"(이 편집기로 연다). "option" 이면 사용자가 골라야 한다.
+ optional: String(it?.priority ?? "default") === "option",
+ });
+ }
+ return out;
+}
+
+/** 아주 작은 glob → 정규식. `*` 는 구분자를 안 넘고 `**` 는 넘는다.
+ *
+ * 순서대로 replace 하면 안 된다 — `**\/` 를 `(?:.*\/)?` 로 바꾼 뒤 다시 `*` 를
+ * 치환하면 **방금 넣은 그 결과 안의 `*`** 까지 바뀐다. 실제로 그렇게 만들었다가
+ * `src/**\/*.draw` 가 아무것도 못 잡았다. 한 번에 훑는다. */
+export function globToRe(pattern: string): RegExp {
+ const p = String(pattern ?? "").replace(/\\/g, "/");
+ let body = "";
+ for (let i = 0; i < p.length; i++) {
+ const c = p[i]!;
+ if (c === "*") {
+ if (p[i + 1] === "*") {
+ // `**/` 는 폴더 0개 이상, 그냥 `**` 는 구분자까지 넘는 아무거나.
+ if (p[i + 2] === "/") { body += "(?:[^/]*/)*"; i += 2; }
+ else { body += ".*"; i += 1; }
+ } else {
+ body += "[^/]*";
+ }
+ continue;
+ }
+ if (c === "?") { body += "[^/]"; continue; }
+ body += /[.+^${}()|[\]\\]/.test(c) ? "\\" + c : c;
+ }
+ // 경로가 없는 패턴(`*.draw`)은 어느 폴더에 있어도 맞아야 한다.
+ const anywhere = !p.includes("/");
+ return new RegExp(anywhere ? "^(?:.*/)?" + body + "$" : "^" + body + "$", "i");
+}
+
+export function matchesPattern(rel: string, pattern: string): boolean {
+ try { return globToRe(pattern).test(String(rel ?? "").replace(/\\/g, "/")); } catch { return false; }
+}
+
+/**
+ * 이 파일을 열 편집기. 없으면 null 이고, 그때는 평소대로 텍스트 편집기가 연다.
+ *
+ * **구현이 등록된 것만** 고른다. 선언은 있는데 확장이 아직 activate 되지 않았으면
+ * 그 파일은 텍스트로 열려야 한다 — 빈 화면을 띄우는 것보다 낫다.
+ */
+export function editorFor(
+ rel: string,
+ decls: readonly CustomEditorDecl[],
+ registered: ReadonlySet,
+): CustomEditorDecl | null {
+ for (const d of decls) {
+ if (d.optional) continue; // 사용자가 고르는 것 — 자동으로 안 연다
+ if (!registered.has(d.viewType)) continue;
+ if (d.patterns.some(p => matchesPattern(rel, p))) return d;
+ }
+ return null;
+}
diff --git a/ide/src/ext/extHost.ts b/ide/src/ext/extHost.ts
index 964b1f0..041f436 100644
--- a/ide/src/ext/extHost.ts
+++ b/ide/src/ext/extHost.ts
@@ -1,10 +1,12 @@
// 확장 호스트 (렌더러) — 활성 확장의 엔트리를 큐레이트 API로 로드.
// 확장은 신뢰 코드로 간주(VS Code와 동일 모델)하되, 편의 API는 이 표면으로 한정한다.
// Schutz 네이티브(schutz API) + VS Code 프로그램형(vscode 셰임으로 activate 실행) 둘 다 지원.
-import { makeVscodeApi, disposeShimRegistrations, deliverFsDelta, listExtViews, onExtViewsChanged } from "./vscodeShim";
+import { makeVscodeApi, disposeShimRegistrations, deliverFsDelta, listExtViews, onExtViewsChanged, listExtEditors, extEditorFor } from "./vscodeShim";
import { onHook, clearHooks, emitHook, HOOK_EVENTS, type HookEvent } from "./hooks";
import { editorEvents } from "./vscodeShim";
import { paneRegistry } from "../editor/MonacoPane";
+import * as projectModels from "../editor/projectModels";
+import { parseCustomEditors, type CustomEditorDecl } from "./customEditors";
import { t } from "../i18n";
export interface ExtCommand { id: string; title: string; run: (...args: any[]) => any; source: string; }
@@ -58,6 +60,27 @@ function teardownExtensions() {
export function getExtCommands(): ExtCommand[] { return commands; }
+/** 매니페스트가 선언한 커스텀 편집기들. 구현 등록 여부는 셰임이 따로 안다. */
+let customEditorDecls: CustomEditorDecl[] = [];
+export function getCustomEditorDecls(): CustomEditorDecl[] { return customEditorDecls; }
+export { listExtEditors, extEditorFor };
+/** 커스텀 편집기에 넘길 TextDocument.
+ *
+ * 이 파일에는 모델이 없을 수 있다 — preload 는 TS 계열만 세우고, 커스텀 편집기가
+ * 맡은 파일은 Monaco 페인이 안 뜨므로 아무도 안 만든다. 그러면 확장에게 null 이
+ * 가고 `document.getText()` 가 첫 줄에서 던진다(실측: 빈 화면만 떴다).
+ * 그래서 없으면 여기서 읽어 세운다. */
+export async function openDocFor(rel: string): Promise {
+ const have = shimDocFor(rel);
+ if (have) return have;
+ const root = deps?.workspaceRoot();
+ if (!root || !deps) return null;
+ const text = await deps.readFile(rel);
+ if (text == null) return null;
+ try { projectModels.ensure(root, rel, text); } catch { return null; }
+ return shimDocFor(rel);
+}
+
/** IDE 쪽에서 사건을 알린다. 확장 핸들러가 터지면 토스트로 보고하되 흐름은 막지 않는다. */
export function notifyExtensions(ev: HookEvent, payload: Record): void {
emitHook(ev, payload, (source, err) => {
@@ -241,6 +264,13 @@ export async function loadExtensions(d: HostDeps): Promise<{ loaded: number; err
if (!window.schutz) return { loaded: 0, errors, limited };
let list: any[] = [];
try { list = await window.schutz.extList(); } catch { return { loaded: 0, errors: [t("exth.extListLoadFailed")], limited }; }
+ // 커스텀 편집기 선언은 활성화와 무관하게 먼저 모은다 — 어떤 파일이 어느
+ // viewType 으로 열릴 수 있는지는 매니페스트만 봐도 알 수 있고, 실제로 열지는
+ // 구현이 등록된 뒤에 정해진다(customEditors.editorFor 가 그 둘을 짝짓는다).
+ customEditorDecls = list
+ .filter(e => e.enabled)
+ .flatMap(e => parseCustomEditors(e.contributes, e.id));
+
let loaded = 0;
const activations: Promise[] = []; // activate 를 병렬 수집 — 한 확장의 느린/멈춘 activate 가 나머지를 막지 않게
for (const ext of list) {
diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts
index dcfe74b..43fe9c2 100644
--- a/ide/src/ext/vscodeShim.ts
+++ b/ide/src/ext/vscodeShim.ts
@@ -50,6 +50,19 @@ export interface RegisteredView {
/** 확장에게 메시지를 보낸다(앱 → 웹뷰는 App 이 iframe 에 직접 쏜다). */
}
+/** 확장이 만든 편집기. viewType → 그 파일을 여는 방법.
+ * 선언(contributes.customEditors)은 앱이 읽고, 구현은 여기 등록된다. */
+export interface RegisteredEditor {
+ viewType: string;
+ extId: string;
+ /** 파일 하나를 열어 HTML 을 돌려준다. 웹뷰가 보낸 말은 post 로 확장에 간다. */
+ resolve: (rel: string, doc: any) => Promise;
+ post: (msg: any) => void;
+}
+const extEditors = new Map();
+export function listExtEditors(): RegisteredEditor[] { return [...extEditors.values()]; }
+export function extEditorFor(viewType: string): RegisteredEditor | null { return extEditors.get(viewType) ?? null; }
+
const extViews = new Map();
const viewListeners = new Set<() => void>();
function viewsChanged() { for (const f of viewListeners) { try { f(); } catch { /* */ } } }
@@ -104,6 +117,7 @@ const decoTypesByExt = new Map();
export function disposeShimRegistrations() {
for (const d of disposables.splice(0)) { try { d.dispose(); } catch { /* */ } }
+ extEditors.clear();
for (const list of decoTypesByExt.values()) disposeAllDecos(list);
decoTypesByExt.clear();
}
@@ -753,6 +767,45 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string; c
dispose: d.dispose,
};
},
+ // 확장이 만든 편집기. 지금은 CustomTextEditorProvider 만 — 문서가 평범한
+ // TextDocument 라 이미 있는 것들(모델·WorkspaceEdit) 위에 그대로 얹힌다.
+ // 바이너리를 직접 들고 저장·백업까지 하는 CustomEditorProvider 는 아직 없다.
+ registerCustomEditorProvider: (viewType: string, provider: any, _options?: any) => {
+ const vt = String(viewType);
+ if (typeof provider?.resolveCustomTextEditor !== "function") {
+ // 조용히 등록해 두면 그 파일이 빈 화면으로 열린다. 지금 말해 주는 편이 낫다.
+ throw new Error("registerCustomEditorProvider: resolveCustomTextEditor 가 필요합니다(바이너리 커스텀 에디터는 아직 지원하지 않습니다)");
+ }
+ let onMsg: ((m: any) => void) | null = null;
+ extEditors.set(vt, {
+ viewType: vt, extId: ext.id,
+ resolve: async (rel: string, doc: any) => {
+ let html = "";
+ const webview: any = {
+ options: {}, cspSource: "schutz:",
+ get html() { return html; },
+ set html(v: string) { html = String(v ?? ""); },
+ onDidReceiveMessage: (fn: (m: any) => void) => { onMsg = fn; return { dispose() { onMsg = null; } }; },
+ postMessage: (m: any) => { deps.postToView("editor:" + rel, m); return Promise.resolve(true); },
+ asWebviewUri: (u: any) => u,
+ };
+ const panel: any = {
+ webview, viewType: vt, title: rel.split("/").pop() ?? rel,
+ visible: true, active: true,
+ onDidDispose: new EventEmitter().event,
+ onDidChangeViewState: new EventEmitter().event,
+ reveal: () => { /* 앱이 이미 보여 주고 있다 */ },
+ dispose: () => { /* 탭을 닫는 것은 앱이 한다 */ },
+ };
+ await provider.resolveCustomTextEditor(doc, panel, { isCancellationRequested: false, onCancellationRequested: new EventEmitter().event });
+ return html;
+ },
+ post: (msg: any) => { try { onMsg?.(msg); } catch { /* 확장이 던진 것 */ } },
+ });
+ const d = { dispose() { extEditors.delete(vt); } };
+ disposables.push(d);
+ return d;
+ },
registerWebviewViewProvider: (viewId: string, provider: any) => {
const id = String(viewId);
let onMsg: ((m: any) => void) | null = null;
diff --git a/ide/src/i18n/dict/exth.ts b/ide/src/i18n/dict/exth.ts
index 2156c3b..cc946ce 100644
--- a/ide/src/i18n/dict/exth.ts
+++ b/ide/src/i18n/dict/exth.ts
@@ -20,6 +20,19 @@ export const dict: Record