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
10 changes: 9 additions & 1 deletion ide/electron/lsp.cjs
Original file line number Diff line number Diff line change
@@ -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 }

Expand Down Expand Up @@ -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];
Expand Down
51 changes: 36 additions & 15 deletions ide/electron/lspRegistry.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -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 };
1 change: 1 addition & 0 deletions ide/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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); // 닫힘 애니 중 재오픈 시 뒤늦은 제거 취소
Expand Down Expand Up @@ -5002,6 +5004,27 @@ ${(r.output || "").slice(0, 2000)}`;
);
}

/** 아는 언어 서버 목록(없는 것 포함). 처음 필요할 때 한 번 읽는다. */
private _lspCatalog: ServerRow[] | null = null;
private _lspTold = new Set<string>();

/** 이 언어에 서버가 없으면 한 번 알려 준다.
*
* 없으면 하이라이트만 되고 정의·진단·심볼이 조용히 없다 — 사용자는 앱이 원래
* 그런 줄 안다. 언어당 한 번만, 그리고 그 언어 파일을 실제로 열었을 때만 말한다. */
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<string, ToolSupportState>();
Expand Down
62 changes: 62 additions & 0 deletions ide/src/engine/lspHint.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
40 changes: 40 additions & 0 deletions ide/src/engine/lspHint.ts
Original file line number Diff line number Diff line change
@@ -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<string>, 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));
}
7 changes: 7 additions & 0 deletions ide/src/i18n/dict/sc3.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// sc3 도메인 번역 사전 (App.tsx CLI 턴 / git / 인라인 편집 / 디버그 / 검색 / 확장)
export const dict: Record<string, { ko: string; en: string; de: string; ja: string }> = {
// 언어 서버가 없으면 하이라이트만 된다 — 조용히 두면 앱이 원래 그런 줄 안다.
"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: "編集" },
Expand Down
2 changes: 2 additions & 0 deletions ide/src/schutz.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ interface SchutzApi {
git(root: string, action: string, payload?: any): Promise<any>;
httpGet(url: string, headers?: Record<string, string>): Promise<{ ok: boolean; status: number; json?: any; error?: string }>;
lspLanguages(): Promise<string[]>;
/** 아는 언어 서버 전부(설치 안 된 것 포함) — 없는 것을 사용자에게 말하기 위해. */
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;
Expand Down
Loading