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
226 changes: 214 additions & 12 deletions ide/src/App.tsx

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion ide/src/ext/extHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface HostDeps {
* activeTextEditor 가 영원히 undefined 였다. */
workspaceRoot: () => string | null;
openFiles: () => string[];
/** 확장이 사용자에게 묻는 통로. 없으면 셰임이 곧장 취소로 답한다. */
prompt: (req: any) => Promise<any>;
}

let commands: ExtCommand[] = [];
Expand Down Expand Up @@ -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 = {
Expand Down
126 changes: 126 additions & 0 deletions ide/src/ext/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
113 changes: 113 additions & 0 deletions ide/src/ext/prompt.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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[] };
53 changes: 48 additions & 5 deletions ide/src/ext/vscodeShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -15,6 +16,10 @@ export interface ShimDeps {
* 없이 두었더니 activeTextEditor 가 영원히 undefined 였다. */
workspaceRoot: () => string | null;
openFiles: () => string[];
/** 사용자에게 묻는다. 취소면 undefined 로 풀린다 — 그게 vscode 규약이다.
* 이 통로가 없던 동안 showQuickPick·showInputBox 는 **묻지도 않고** undefined 를
* 돌려줬고, 확장은 사용자가 취소한 줄 알고 흐름을 접었다. */
prompt: (req: PromptReq) => Promise<any>;
}

const disposables: monaco.IDisposable[] = [];
Expand Down Expand Up @@ -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 = "";
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions ide/src/i18n/dict/extask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// extask 도메인 — 확장이 사용자에게 묻는 물음(빠른 선택·입력·버튼)
export const dict: Record<string, { ko: string; en: string; de: string; ja: string }> = {
// 누가 묻는지 늘 밝힌다. 확장이 띄운 창을 앱이 띄운 것으로 오해하면 안 된다.
"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} 件選択" },
};
3 changes: 2 additions & 1 deletion ide/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -42,7 +43,7 @@ export type Msg = { ko: string; en: string; de: string; ja: string };

export const MESSAGES: Record<string, Msg> = {
...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: "次へ" },
Expand Down
Loading
Loading