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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div key={"slot" + si} style={{ display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0, background: "var(--bg-editor)" }}
onMouseDown={() => { this._focusSlot = si; }}
Expand All @@ -8240,6 +8247,12 @@ ${(r.output || "").slice(0, 2000)}`;
<ImagePane key={activeRel + ":" + (s.paneVer[activeRel] ?? 0)} root={s.workspace!.root} rel={activeRel} />
) : isMdPrev ? (
<MarkdownPane key={activeRel + ":md:" + (s.paneVer[activeRel] ?? 0)} root={s.workspace!.root} rel={activeRel} />
) : custom ? (
<CustomEditorPane
key={activeRel + ":ce:" + (s.paneVer[activeRel] ?? 0)}
viewType={custom.viewType}
rel={activeRel}
/>
) : isReal ? (
<MonacoPane
key={activeRel + ":" + (s.paneVer[activeRel] ?? 0)}
Expand Down
64 changes: 64 additions & 0 deletions ide/src/editor/CustomEditorPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState } from "react";
import * as extHost from "../ext/extHost";
import { webviewDoc } from "../ext/views";
import { t } from "../i18n";

/** 확장이 만든 편집기 한 장.
*
* vscode 의 CustomTextEditorProvider 는 웹뷰 하나에 문서 하나를 묶어 준다. 문서는
* 평범한 TextDocument 라, 확장이 고칠 때는 WorkspaceEdit 을 쓴다 — 그러면 모델을
* 거치므로 Ctrl+Z 도 되고 저장 기준선도 어긋나지 않는다.
*
* iframe 은 사이드바 웹뷰와 같은 규칙으로 가둔다(sandbox, 같은 출처 아님). */
export function CustomEditorPane({ viewType, rel }: { viewType: string; rel: string }) {
const [html, setHtml] = useState<string | null>(null);
const [err, setErr] = useState("");
const frameRef = useRef<HTMLIFrameElement | null>(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 <div style={{ padding: 16, fontSize: 12.5, color: "#CE9A9A" }}>⚠️ {err}</div>;
}
if (html == null) {
return <div style={{ padding: 16, fontSize: 12, color: "var(--fg-dim2)" }}>{t("exth.customEditorLoading")}</div>;
}
return (
<iframe
ref={frameRef}
title={rel}
sandbox="allow-scripts"
// 사이드바 웹뷰와 같은 다리를 넣는다. 안 넣으면 확장이 쓴 스크립트의
// acquireVsCodeApi() 가 없어서 첫 줄에서 죽고, 웹뷰가 아무 말도 못 한다.
srcDoc={webviewDoc(html, "editor:" + rel)}
style={{ flex: 1, minHeight: 0, border: "none", background: "var(--bg-editor)" }}
/>
);
}
107 changes: 107 additions & 0 deletions ide/src/ext/customEditors.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
91 changes: 91 additions & 0 deletions ide/src/ext/customEditors.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
): 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;
}
32 changes: 31 additions & 1 deletion ide/src/ext/extHost.ts
Original file line number Diff line number Diff line change
@@ -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; }
Expand Down Expand Up @@ -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<any> {
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<string, unknown>): void {
emitHook(ev, payload, (source, err) => {
Expand Down Expand Up @@ -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<void>[] = []; // activate 를 병렬 수집 — 한 확장의 느린/멈춘 activate 가 나머지를 막지 않게
for (const ext of list) {
Expand Down
Loading
Loading