diff --git a/ide/electron/lsp.cjs b/ide/electron/lsp.cjs index 48a0752..3dfe34a 100644 --- a/ide/electron/lsp.cjs +++ b/ide/electron/lsp.cjs @@ -1,6 +1,6 @@ // LSP 서버 호스트 — 언어 서버를 spawn하고 stdio JSON-RPC(Content-Length 프레임)를 렌더러 IPC로 브리지. const crypto = require("crypto"); -const { registry } = require("./lspRegistry.cjs"); +const { registry, catalog } = require("./lspRegistry.cjs"); const servers = new Map(); // serverId → { child, buffer, senderId } @@ -31,11 +31,19 @@ function frame(message) { } function init(ipcMain) { + // 레지스트리를 미리 지어 둔다. 짓는 데 `where` 를 언어 수만큼 **동기로** 돌리므로 + // 처음 물어보는 쪽(파일 열기)이 그 값을 다 치른다 — 실측으로 몇 초씩 늦었다. + // 앱이 뜬 직후 조용히 해 두면 그 자리가 사라진다. + setTimeout(() => { try { registry(); } catch { /* 없으면 없는 대로 */ } }, 0); + ipcMain.handle("schutz:lspLanguages", () => { const reg = registry(); return Object.keys(reg).filter(k => reg[k].available); }); + // 설치된 것만이 아니라 **아는 것 전부**를 준다 — 없는 것을 말해 주려면 그것도 알아야 한다. + ipcMain.handle("schutz:lspCatalog", () => catalog()); + ipcMain.handle("schutz:lspStart", (e, { languageId, root }) => { const reg = registry(); const desc = reg[languageId]; diff --git a/ide/electron/lspRegistry.cjs b/ide/electron/lspRegistry.cjs index e7c7f47..8848bdc 100644 --- a/ide/electron/lspRegistry.cjs +++ b/ide/electron/lspRegistry.cjs @@ -26,6 +26,21 @@ function onPath(cmd) { } } +/** 이 앱이 아는 언어 서버 전부. 설치돼 있든 아니든 여기 다 적는다. + * + * 예전엔 PATH 에 있는 것만 등록했다. 그래서 gopls 가 없으면 Go 파일은 하이라이트만 + * 되고 정의·진단·심볼이 전부 조용히 없었다 — 사용자는 앱이 원래 그런 줄 안다. + * 무엇이 있고 무엇이 없는지 알려면 "없는 것" 도 알고 있어야 한다. */ +const KNOWN = [ + { languageId: "rust", command: "rust-analyzer", args: [], install: "rustup component add rust-analyzer" }, + { languageId: "go", command: "gopls", args: [], install: "go install golang.org/x/tools/gopls@latest" }, + { languageId: "c", command: "clangd", args: [], install: "LLVM 설치(clangd 포함)" }, + { languageId: "cpp", command: "clangd", args: [], install: "LLVM 설치(clangd 포함)" }, + { languageId: "shell", command: "bash-language-server", args: ["start"], install: "npm i -g bash-language-server" }, + { languageId: "lua", command: "lua-language-server", args: [], install: "lua-language-server 설치" }, + { languageId: "java", command: "jdtls", args: [], install: "Eclipse JDT Language Server 설치" }, +]; + /** 언어 id → 서버 기동 스펙. available=false면 렌더러는 하이라이트만. */ function buildRegistry() { const reg = {}; @@ -43,25 +58,31 @@ function buildRegistry() { }; } - // PATH에 바이너리가 있으면 자동 활성 (없으면 등록 안 함 → 렌더러는 하이라이트만). - // 언어 추가 = 아래 한 줄. key는 monaco 언어 id와 일치해야 함(c/cpp/shell/lua/java 등). - const pathServer = (langId, cmd, args = []) => { - if (!onPath(cmd)) return; - reg[langId] = { languageId: langId, run: (root) => cp.spawn(cmd, args, { cwd: root, env: process.env, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" }), available: true }; - }; - - pathServer("rust", "rust-analyzer"); - pathServer("go", "gopls"); - pathServer("c", "clangd"); - pathServer("cpp", "clangd"); - pathServer("shell", "bash-language-server", ["start"]); - pathServer("lua", "lua-language-server"); - pathServer("java", "jdtls"); + // KNOWN 을 훑어 PATH 에 있는 것만 켠다. 없는 것도 목록에는 남겨 둔다(catalog). + // 언어 추가 = KNOWN 에 한 줄. key 는 monaco 언어 id 와 같아야 한다. + for (const spec of KNOWN) { + if (!onPath(spec.command)) continue; + reg[spec.languageId] = { + languageId: spec.languageId, + run: (root) => cp.spawn(spec.command, spec.args, { cwd: root, env: process.env, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" }), + available: true, + }; + } return reg; } +/** 아는 서버 전부와 그 설치 여부. 렌더러가 "이 언어는 서버가 없다" 를 말할 때 쓴다. */ +function catalog() { + const reg = registry(); + const rows = [{ languageId: "python", command: "pyright", install: "번들됨", available: !!reg.python }]; + for (const spec of KNOWN) { + rows.push({ languageId: spec.languageId, command: spec.command, install: spec.install, available: !!reg[spec.languageId] }); + } + return rows; +} + let _reg = null; function registry() { if (!_reg) _reg = buildRegistry(); return _reg; } -module.exports = { registry, resolvePyright }; +module.exports = { registry, catalog, resolvePyright }; diff --git a/ide/electron/preload.cjs b/ide/electron/preload.cjs index 5a1de21..c6bd34f 100644 --- a/ide/electron/preload.cjs +++ b/ide/electron/preload.cjs @@ -209,6 +209,7 @@ contextBridge.exposeInMainWorld("schutz", { /** LSP 브리지 */ lspLanguages: () => ipcRenderer.invoke("schutz:lspLanguages"), + lspCatalog: () => ipcRenderer.invoke("schutz:lspCatalog"), lspStart: (languageId, root) => ipcRenderer.invoke("schutz:lspStart", { languageId, root }), lspSend: (serverId, message) => ipcRenderer.send("schutz:lspSend", serverId, message), lspStop: (serverId) => ipcRenderer.send("schutz:lspStop", serverId), diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 6848467..bf6f745 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -71,6 +71,7 @@ import monaco, { languageOf, applyTsPaths, revalidateTs } from "./editor/monacoS import * as projectModels from "./editor/projectModels"; import * as proposalDeco from "./editor/proposalDeco"; import * as symbolIndex from "./editor/symbolIndex"; +import { missingFor as missingLspFor, shouldTell as shouldTellLsp, type ServerRow } from "./engine/lspHint"; import { typeEdit, reducedMotion } from "./editor/editAnimator"; import * as lspClient from "./editor/lspClient"; import * as lspConv from "./editor/lspConverters"; @@ -2502,6 +2503,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { openFile(path: string) { extHost.notifyExtensions("file.open", { rel: path }); + void this.maybeTellMissingServer(path); this.navRecord(path); this._touchMru(path); this._cancelPendingClose(path); // 닫힘 애니 중 재오픈 시 뒤늦은 제거 취소 @@ -5002,6 +5004,27 @@ ${(r.output || "").slice(0, 2000)}`; ); } + /** 아는 언어 서버 목록(없는 것 포함). 처음 필요할 때 한 번 읽는다. */ + private _lspCatalog: ServerRow[] | null = null; + private _lspTold = new Set(); + + /** 이 언어에 서버가 없으면 한 번 알려 준다. + * + * 없으면 하이라이트만 되고 정의·진단·심볼이 조용히 없다 — 사용자는 앱이 원래 + * 그런 줄 안다. 언어당 한 번만, 그리고 그 언어 파일을 실제로 열었을 때만 말한다. */ + private async maybeTellMissingServer(rel: string) { + if (!window.schutz?.lspCatalog) return; + const lang = languageOf(rel); + if (!shouldTellLsp(this._lspTold, lang)) return; + if (!this._lspCatalog) { + try { this._lspCatalog = await window.schutz.lspCatalog(); } catch { this._lspCatalog = []; } + } + const row = missingLspFor(this._lspCatalog, lang); + if (!row) return; + this._lspTold.add(lang); + this.toast("info", t("sc3.lspMissing", { lang: row.languageId, cmd: row.command, install: row.install })); + } + /** 프로바이더별 도구 사용 관찰. 세션 동안만 들고 있으면 된다 — 모델을 바꾸면 * 새로 보는 것이 맞다. */ private _toolSupport = new Map(); diff --git a/ide/src/engine/lspHint.test.ts b/ide/src/engine/lspHint.test.ts new file mode 100644 index 0000000..51d38a2 --- /dev/null +++ b/ide/src/engine/lspHint.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { missingFor, shouldTell, hintText, missingList, type ServerRow } from "./lspHint"; + +const CAT: ServerRow[] = [ + { languageId: "python", command: "pyright", install: "번들됨", available: true }, + { languageId: "go", command: "gopls", install: "go install golang.org/x/tools/gopls@latest", available: false }, + { languageId: "rust", command: "rust-analyzer", install: "rustup component add rust-analyzer", available: false }, + { languageId: "c", command: "clangd", install: "LLVM 설치(clangd 포함)", available: true }, +]; + +describe("missingFor", () => { + it("서버가 없는 언어를 짚는다", () => { + expect(missingFor(CAT, "go")?.command).toBe("gopls"); + }); + it("있는 언어는 알릴 것이 없다", () => { + expect(missingFor(CAT, "python")).toBeNull(); + expect(missingFor(CAT, "c")).toBeNull(); + }); + // TypeScript 는 Monaco 워커가 맡으므로 이 목록에 없다 — 없다고 말하면 거짓말이다. + it("모르는 언어는 알리지 않는다", () => { + expect(missingFor(CAT, "typescript")).toBeNull(); + expect(missingFor(CAT, "plaintext")).toBeNull(); + expect(missingFor(CAT, "")).toBeNull(); + }); + it("빈 카탈로그에도 터지지 않는다", () => { + expect(missingFor([], "go")).toBeNull(); + }); +}); + +describe("shouldTell", () => { + it("처음이면 말한다", () => { + expect(shouldTell(new Set(), "go")).toBe(true); + }); + it("이미 말한 언어는 다시 말하지 않는다", () => { + expect(shouldTell(new Set(["go"]), "go")).toBe(false); + }); + it("언어별로 따로 센다", () => { + expect(shouldTell(new Set(["go"]), "rust")).toBe(true); + }); +}); + +describe("hintText", () => { + it("무엇이 없고 무엇을 깔면 되는지 둘 다 말한다", () => { + const s = hintText(CAT[1]!); + expect(s).toContain("gopls"); + expect(s).toContain("go install"); + }); +}); + +describe("missingList", () => { + it("없는 것만 이름순으로 낸다", () => { + expect(missingList(CAT).map(r => r.languageId)).toEqual(["go", "rust"]); + }); + it("원본을 건드리지 않는다", () => { + const before = CAT.map(r => r.languageId); + missingList(CAT); + expect(CAT.map(r => r.languageId)).toEqual(before); + }); + it("다 깔려 있으면 빈 목록", () => { + expect(missingList(CAT.map(r => ({ ...r, available: true })))).toEqual([]); + }); +}); diff --git a/ide/src/engine/lspHint.ts b/ide/src/engine/lspHint.ts new file mode 100644 index 0000000..3aae071 --- /dev/null +++ b/ide/src/engine/lspHint.ts @@ -0,0 +1,40 @@ +// 이 언어에 언어 서버가 있는가, 없으면 무엇을 깔아야 하는가. +// +// 지금까지는 설치된 서버만 목록에 있었다. 그래서 gopls 가 없으면 Go 파일은 하이라이트만 +// 되고 정의·진단·심볼이 전부 조용히 없었다 — 사용자는 **앱이 원래 그런 줄 안다.** +// 없는 것도 알고 있어야 없다고 말할 수 있다. +// +// 언제 말할지가 그다음 문제다. 파일을 열 때마다 말하면 잔소리가 된다. 언어당 한 번, +// 그리고 정말 그 언어의 파일을 열었을 때만. + +export interface ServerRow { + languageId: string; + command: string; + install: string; + available: boolean; +} + +/** 서버가 없어서 알려 줄 만한 언어인가. 아는 언어가 아니면 알릴 것도 없다. */ +export function missingFor(catalog: readonly ServerRow[], languageId: string): ServerRow | null { + const row = catalog.find(r => r.languageId === languageId); + if (!row || row.available) return null; + return row; +} + +/** 이미 말한 언어를 기억한다. 세션 동안만 — 깔고 나서 다시 켜면 새로 판단해야 한다. */ +export function shouldTell(told: ReadonlySet, languageId: string): boolean { + return !told.has(languageId); +} + +/** 화면·에이전트에 함께 쓸 한 줄. 무엇이 없고 무엇을 깔면 되는지 둘 다 있어야 한다. */ +export function hintText(row: ServerRow): string { + return `${row.languageId}: ${row.command} 없음 — ${row.install}`; +} + +/** + * 하이라이트만 되는 언어들. 설정 화면에서 "무엇이 켜져 있나" 를 보여줄 때 쓴다. + * 이름순으로 고정해 화면이 흔들리지 않게 한다. + */ +export function missingList(catalog: readonly ServerRow[]): ServerRow[] { + return catalog.filter(r => !r.available).slice().sort((a, b) => a.languageId.localeCompare(b.languageId)); +} diff --git a/ide/src/i18n/dict/sc3.ts b/ide/src/i18n/dict/sc3.ts index 6f0a657..540797d 100644 --- a/ide/src/i18n/dict/sc3.ts +++ b/ide/src/i18n/dict/sc3.ts @@ -1,5 +1,12 @@ // sc3 도메인 번역 사전 (App.tsx CLI 턴 / git / 인라인 편집 / 디버그 / 검색 / 확장) export const dict: Record = { + // 언어 서버가 없으면 하이라이트만 된다 — 조용히 두면 앱이 원래 그런 줄 안다. + "sc3.lspMissing": { + ko: "{lang} 은(는) 언어 서버가 없어 하이라이트만 됩니다. {cmd} 를 설치하면 정의·진단·심볼이 켜집니다 — {install}", + en: "{lang} has no language server, so only highlighting works. Install {cmd} to turn on definitions, diagnostics and symbols — {install}", + de: "Für {lang} gibt es keinen Sprachserver — nur Hervorhebung. Mit {cmd} kommen Definitionen, Diagnosen und Symbole — {install}", + ja: "{lang} は言語サーバーがないためハイライトのみです。{cmd} を入れると定義・診断・シンボルが有効になります — {install}", + }, "sc3.whoCodex": { ko: "Codex · 구독", en: "Codex · Subscription", de: "Codex · Abo", ja: "Codex · サブスク" }, "sc3.whoClaude": { ko: "Claude · 구독", en: "Claude · Subscription", de: "Claude · Abo", ja: "Claude · サブスク" }, "sc3.verbEdit": { ko: "편집", en: "Edit", de: "Bearbeiten", ja: "編集" }, diff --git a/ide/src/schutz.d.ts b/ide/src/schutz.d.ts index 9b58d27..7dcaa67 100644 --- a/ide/src/schutz.d.ts +++ b/ide/src/schutz.d.ts @@ -106,6 +106,8 @@ interface SchutzApi { git(root: string, action: string, payload?: any): Promise; httpGet(url: string, headers?: Record): Promise<{ ok: boolean; status: number; json?: any; error?: string }>; lspLanguages(): Promise; + /** 아는 언어 서버 전부(설치 안 된 것 포함) — 없는 것을 사용자에게 말하기 위해. */ + lspCatalog(): Promise<{ languageId: string; command: string; install: string; available: boolean }[]>; lspStart(languageId: string, root: string): Promise<{ ok: boolean; serverId?: string; reason?: string }>; lspSend(serverId: string, message: any): void; lspStop(serverId: string): void;