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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,17 @@ files, and the shim has no custom editors and no debug adapter API. Those three
`WorkspaceEdit` throws, and the other two are absent from the API object, so calling one is a
`TypeError` rather than a quiet nothing.

One gap does not behave that way, and saying otherwise here was worse than the gap itself:
**`createTextEditorDecorationType` returns a working-looking handle and `setDecorations`
accepts ranges, and nothing is ever drawn.** An extension doing inline blame or coverage
highlighting gets a successful answer and no pixels. It is the one place left with the shape
this project spent a release removing; it is named here until it is built rather than
described as something it is not.
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
a real rule, `setDecorations` puts it on the Monaco editor, and passing an empty array clears
what that type drew. Inline blame and coverage highlighting work, `before`/`after` content
included.

Fixing it surfaced the reason such an extension would have appeared broken anyway:
`onDidChangeActiveTextEditor` fired before the editor existed, and the notification was
dropped on the way out, so opening a file delivered either nothing or the *previous* file's
editor. Extensions that draw on editor changes — which is most of them — were being handed
the wrong editor. The event now waits for the pane and fires once per file.

## Contributing

Expand Down
15 changes: 14 additions & 1 deletion ide/src/editor/MonacoPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,19 @@ export interface PaneApi {
editor: monaco.editor.IStandaloneCodeEditor;
save: () => Promise<void>;
}
export const paneRegistry: { panes: Map<string, PaneApi>; focused: PaneApi | null } = {
/** 페인 하나가 실제로 뜬 뒤에 부른다.
*
* 확장에게 "이 편집기가 활성이다" 를 알리는 시점이 여기여야 한다. 예전엔 openFile 이
* setState 전에 알려서, 그 사건을 받은 확장이 곧장 setDecorations 를 불러도 붙일
* 편집기가 아직 없었다 — 조용히 아무 일도 안 일어났다. 데코레이션을 쓰는 확장은
* 거의 다 이 사건에 매달려 그린다.
*
* 훅 필드로 두는 이유는 순환 임포트를 피하기 위해서다(extHost 가 여기를 읽는다). */
export const paneRegistry: {
panes: Map<string, PaneApi>;
focused: PaneApi | null;
onReady?: (rel: string) => void;
} = {
panes: new Map(),
focused: null,
};
Expand Down Expand Up @@ -193,6 +205,7 @@ function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onConfirm, onStatus

const api: PaneApi = { rel, editor, save };
paneRegistry.panes.set(rel, api);
try { paneRegistry.onReady?.(rel); } catch { /* 확장이 던져도 페인은 살아 있어야 한다 */ }
editor.onDidFocusEditorWidget(() => {
paneRegistry.focused = api;
const p = editor!.getPosition();
Expand Down
91 changes: 91 additions & 0 deletions ide/src/ext/decoStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 데코레이션 타입을 실제로 화면에 붙이는 쪽. 변환 규칙은 decorations.ts 에 있고,
// 여기는 스타일시트 한 장과 편집기별 적용분을 들고 있는 살림꾼이다.
//
// 스타일시트가 한 장인 이유: 타입마다 <style> 을 만들면 확장이 데코레이션 타입을
// 자주 만들고 버리는 흔한 꼴(줄마다 다른 색)에서 head 가 수천 개로 불어난다.

import type monacoNS from "monaco-editor";
import { styleSheetFor, monacoOptions, normalizeDecos, drawsNothing } from "./decorations";

let sheet: HTMLStyleElement | null = null;
const rules = new Map<string, string>(); // 타입 id → 규칙 전문
let seq = 0;

function flush(): void {
if (typeof document === "undefined") return;
if (!sheet) {
sheet = document.createElement("style");
sheet.setAttribute("data-schutz", "ext-decorations");
document.head.appendChild(sheet);
}
sheet.textContent = [...rules.values()].filter(Boolean).join("\n");
}

type Collection = monacoNS.editor.IEditorDecorationsCollection;

export interface DecoTypeHandle {
/** Monaco 에 넘길 옵션 — setDecorations 가 읽는다. */
readonly _szDeco: ReturnType<typeof monacoOptions>;
readonly key: string;
/** 이 타입이 아무것도 안 그리는가(확장이 빈 옵션을 준 경우). */
readonly _szEmpty: boolean;
/** 편집기별로 지금 그려 둔 것. 같은 타입으로 다시 설정하면 갈아끼워야 한다(누적이 아니다). */
readonly _szByEditor: WeakMap<object, Collection>;
/** 폐기할 때 걷어야 하므로 따로 모아 둔다 — WeakMap 은 훑을 수 없다. */
readonly _szCols: Set<Collection>;
dispose(): void;
}

/** vscode.window.createTextEditorDecorationType */
export function createDecoType(opts: any): DecoTypeHandle {
const id = String(++seq);
const css = styleSheetFor(id, opts);
if (css) { rules.set(id, css); flush(); }
const options = monacoOptions(id, opts);
const empty = drawsNothing(opts);
const cols = new Set<Collection>();
return {
_szDeco: options,
_szEmpty: empty,
_szByEditor: new WeakMap<object, Collection>(),
_szCols: cols,
key: "szdeco-" + id,
dispose() {
rules.delete(id);
flush();
// 이 타입으로 그려 둔 것도 함께 걷는다 — 규칙만 지우면 클래스 없는
// 데코레이션이 남아 다음 편집에서 엉뚱한 자리를 잡는다.
for (const c of cols) { try { c.clear(); } catch { /* 이미 폐기된 편집기 */ } }
cols.clear();
},
};
}

/** vscode.TextEditor.setDecorations */
export function applyDecos(editor: any, type: any, list: any): void {
const h = type as DecoTypeHandle | undefined;
if (!editor || !h?._szDeco || !h._szByEditor) return;

const decos = normalizeDecos(list).map(d => ({
range: d.range,
options: d.hover ? { ...h._szDeco, hoverMessage: { value: d.hover } } : h._szDeco,
}));

const existing = h._szByEditor.get(editor);
if (existing) {
// set([]) 이 곧 "이 타입으로 그린 것을 전부 지운다" 이다. 확장이 그렇게 지운다.
try { existing.set(decos as any); } catch { /* 폐기된 편집기 */ }
return;
}
if (!decos.length) return; // 지울 것도 그릴 것도 없다
try {
const c = editor.createDecorationsCollection(decos) as Collection;
h._szByEditor.set(editor, c);
h._szCols.add(c);
} catch { /* 폐기된 편집기 */ }
}

/** 확장 하나가 내려갈 때 그 확장이 만든 타입을 전부 정리한다. */
export function disposeAllDecos(handles: Iterable<DecoTypeHandle>): void {
for (const h of handles) { try { h.dispose(); } catch { /* */ } }
}
151 changes: 151 additions & 0 deletions ide/src/ext/decorations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, it, expect } from "vitest";
import {
cssColor, cssDecls, pseudoDecls, classNames, styleSheetFor,
monacoOptions, drawsNothing, normalizeDecos, hoverText,
} from "./decorations";

describe("cssColor", () => {
it("문자열 색은 그대로 쓴다", () => {
expect(cssColor("#8FA893")).toBe("#8FA893");
expect(cssColor(" rgba(0,0,0,.2) ")).toBe("rgba(0,0,0,.2)");
});
it("ThemeColor 는 우리 토큰과 대응이 없어 버린다", () => {
expect(cssColor({ id: "editor.background" })).toBeNull();
});
it("빈 값은 색이 아니다", () => {
expect(cssColor("")).toBeNull();
expect(cssColor(undefined)).toBeNull();
expect(cssColor(null)).toBeNull();
});
});

describe("cssDecls", () => {
it("알아듣는 속성을 CSS 선언으로 옮긴다", () => {
const d = cssDecls({ backgroundColor: "#222", color: "red", fontWeight: "bold" });
expect(d).toContain("background:#222");
expect(d).toContain("color:red");
expect(d).toContain("font-weight:bold");
});
it("모르는 속성은 조용히 넘긴다", () => {
expect(cssDecls({ someFutureThing: "x" })).toEqual([]);
});
it("ThemeColor 로 온 색은 그 속성만 빠지고 나머지는 남는다", () => {
const d = cssDecls({ backgroundColor: { id: "editor.bg" }, color: "red" });
expect(d).toEqual(["color:red"]);
});
it("빈 옵션에도 터지지 않는다", () => {
expect(cssDecls(undefined)).toEqual([]);
expect(cssDecls(null)).toEqual([]);
});
});

describe("pseudoDecls", () => {
it("contentText 가 있어야 만든다", () => {
expect(pseudoDecls({ color: "red" })).toBeNull();
expect(pseudoDecls({ contentText: "" })).toBeNull();
expect(pseudoDecls(undefined)).toBeNull();
});
it("글자와 스타일을 함께 낸다", () => {
const d = pseudoDecls({ contentText: " 3 refs", color: "#888", margin: "0 0 0 1em" });
expect(d).toContain('content:" 3 refs"');
expect(d).toContain("color:#888");
expect(d).toContain("margin:0 0 0 1em");
});
// 확장이 준 문자열이 규칙 밖으로 새어 나가면 스타일시트 전체가 깨진다.
it("따옴표와 역슬래시를 막는다", () => {
const d = pseudoDecls({ contentText: 'a"b\\c' });
expect(d![0]).toBe('content:"a\\"b\\\\c"');
});
it("줄바꿈은 한 줄로 눕힌다", () => {
expect(pseudoDecls({ contentText: "a\nb" })![0]).toBe('content:"a b"');
});
});

describe("styleSheetFor", () => {
it("본문과 앞뒤를 각각 규칙으로 낸다", () => {
const css = styleSheetFor("7", { backgroundColor: "#111", after: { contentText: "x" } });
expect(css).toContain(".szdeco-7{background:#111}");
expect(css).toContain('.szdeco-7-a::after{content:"x"}');
});
it("그릴 것이 없으면 빈 문자열", () => {
expect(styleSheetFor("7", {})).toBe("");
expect(styleSheetFor("7", { isWholeLine: true })).toBe("");
});
it("클래스 이름은 id 로 갈린다", () => {
expect(classNames("1").base).not.toBe(classNames("2").base);
});
});

describe("monacoOptions", () => {
it("글자 범위는 inlineClassName 으로 간다", () => {
const o = monacoOptions("1", { backgroundColor: "#111" });
expect(o.inlineClassName).toBe("szdeco-1");
expect(o.className).toBeUndefined();
});
it("줄 전체는 className 과 isWholeLine 으로 간다", () => {
const o = monacoOptions("1", { backgroundColor: "#111", isWholeLine: true });
expect(o.className).toBe("szdeco-1");
expect(o.inlineClassName).toBeUndefined();
expect(o.isWholeLine).toBe(true);
});
it("스타일이 없어도 앞뒤 글자만으로 그린다", () => {
const o = monacoOptions("1", { after: { contentText: "→" } });
expect(o.afterContentClassName).toBe("szdeco-1-a");
expect(o.inlineClassName).toBeUndefined();
});
it("개요 눈금 색을 옮긴다", () => {
expect(monacoOptions("1", { overviewRulerColor: "#C97B7B" }).overviewRuler).toEqual({ color: "#C97B7B", position: 1 });
});
it("ThemeColor 눈금은 대응이 없어 빠진다", () => {
expect(monacoOptions("1", { overviewRulerColor: { id: "x" } }).overviewRuler).toBeUndefined();
});
});

describe("drawsNothing", () => {
it("빈 옵션은 아무것도 안 그린다", () => {
expect(drawsNothing({})).toBe(true);
expect(drawsNothing({ isWholeLine: true })).toBe(true);
});
it("무엇이든 하나 있으면 그린다", () => {
expect(drawsNothing({ backgroundColor: "#111" })).toBe(false);
expect(drawsNothing({ before: { contentText: "•" } })).toBe(false);
expect(drawsNothing({ overviewRulerColor: "red" })).toBe(false);
});
});

describe("normalizeDecos", () => {
const R = (l: number, c: number, l2: number, c2: number) => ({ start: { line: l, character: c }, end: { line: l2, character: c2 } });

it("Range 배열을 받는다", () => {
expect(normalizeDecos([R(0, 0, 0, 4)])).toEqual([{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 5 } }]);
});
it("DecorationOptions 배열도 받는다", () => {
const out = normalizeDecos([{ range: R(2, 1, 2, 3), hoverMessage: "왜" }]);
expect(out).toEqual([{ range: { startLineNumber: 3, startColumn: 2, endLineNumber: 3, endColumn: 4 }, hover: "왜" }]);
});
it("배열이 아니면 빈 목록", () => {
expect(normalizeDecos(undefined)).toEqual([]);
expect(normalizeDecos(null)).toEqual([]);
expect(normalizeDecos("nope")).toEqual([]);
});
it("빈 칸은 건너뛴다", () => {
expect(normalizeDecos([null, undefined, {}])).toEqual([]);
});
it("빈 배열은 '전부 지우기' 다 — 빈 목록으로 그대로 전한다", () => {
expect(normalizeDecos([])).toEqual([]);
});
});

describe("hoverText", () => {
it("문자열·MarkdownString·배열을 모두 받는다", () => {
expect(hoverText("a")).toBe("a");
expect(hoverText({ value: "**b**" })).toBe("**b**");
expect(hoverText(["a", { value: "b" }])).toBe("a\n\nb");
});
it("비어 있으면 없는 것으로 친다", () => {
expect(hoverText("")).toBeUndefined();
expect(hoverText({ value: "" })).toBeUndefined();
expect(hoverText([])).toBeUndefined();
expect(hoverText(undefined)).toBeUndefined();
});
});
Loading
Loading