From 85d261ce131a4af1630fabbd9921d26dde068d98 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Mon, 10 Aug 2026 10:54:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=B0=BE=EA=B8=B0(find=5F?= =?UTF-8?q?references)=EC=99=80=20=ED=99=95=EC=9E=A5=EC=9A=A9=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS 워커가 getReferencesAtPosition 은 내준다. getNavigateToItems 는 안 내주길래 이것도 없을 줄 알았는데 확인해보니 됨. 이름으로 정의를 잡고 그 자리에서 참조를 묻는다. 이름이 여러 군데 정의돼 있으면 어디를 뜻하는지 되묻고, path 로 좁힐 수 있게 함. 확장 쪽은 vscode.debug 중 관찰과 중단점까지. activeDebugSession, onDidStartDebugSession / onDidTerminate / onDidChangeBreakpoints, breakpoints, addBreakpoints / removeBreakpoints. 앱이 이미 들고 있는 상태를 연결한 것. registerDebugAdapterDescriptorFactory(확장이 디버거를 제공하는 것)와 커스텀 에디터는 아직 없음. 없는 채로 둬서 부르면 TypeError 가 나게 함. 중간에 만든 버그 하나: 확장이 넣은 중단점 키를 uri.path 로 잡아서 절대 경로가 들어갔다. 개수만 늘고 거터에는 아무것도 안 그려짐. uriToRel 로 고침. --- ide/src/App.tsx | 47 +++++++++++++++++++++++++++ ide/src/ai/claude.ts | 15 +++++++++ ide/src/editor/symbolIndex.ts | 60 +++++++++++++++++++++++++++++++++++ ide/src/ext/extHost.ts | 4 ++- ide/src/ext/vscodeShim.ts | 40 +++++++++++++++++++++++ ide/src/i18n/dict/sc2.ts | 1 + 6 files changed, 166 insertions(+), 1 deletion(-) diff --git a/ide/src/App.tsx b/ide/src/App.tsx index bf6f745..7177973 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 * as vscodeShim from "./ext/vscodeShim"; import { missingFor as missingLspFor, shouldTell as shouldTellLsp, type ServerRow } from "./engine/lspHint"; import { typeEdit, reducedMotion } from "./editor/editAnimator"; import * as lspClient from "./editor/lspClient"; @@ -3489,6 +3490,24 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { } return r.hits.map(h => `${h.rel}:${h.line}:${h.column} ${h.container ? h.container + "." : ""}${h.name}`).join(String.fromCharCode(10)); } + if (call.name === "find_references") { + const name = String(call.input?.name ?? "").trim(); + const inFile = String(call.input?.path ?? "").trim() || undefined; + this.addTool(toolId, agentId, t("sc2.verbRefs"), name); + if (!name) { this.setTool(toolId, { st: "done", note: t("sc2.noteError") }); return "오류: name 이 비었습니다."; } + const r = await symbolIndex.findReferences(name, inFile); + this.setTool(toolId, { st: "done", note: t("sc2.noteHits", { n: r.hits.length }) }); + const NL = String.fromCharCode(10); + if (r.noIndex) return "이 워크스페이스에는 심볼 색인이 없어 참조를 찾을 수 없습니다. search_files 로 찾으세요."; + if (r.ambiguous.length) { + return `"${name}" 이(가) 여러 곳에 정의돼 있습니다. path 로 좁혀서 다시 부르세요:` + NL + + r.ambiguous.map(a2 => ` ${a2.rel}:${a2.line}`).join(NL); + } + if (!r.at) return `"${name}" 의 정의를 못 찾았습니다. 이름이 정확한지 보고, 아니면 search_files 를 쓰세요.`; + if (!r.hits.length) return `${r.at.rel}:${r.at.line} 의 ${r.at.name} — 참조 없음(정의만 있음).`; + return `${r.at.rel}:${r.at.line} 의 ${r.at.name} 을(를) 쓰는 곳 ${r.hits.length}개:` + NL + + r.hits.map(h => ` ${h.rel}:${h.line}:${h.column}`).join(NL); + } if (call.name === "search_files") { const query = String(call.input?.query ?? ""); this.addTool(toolId, agentId, t("sc2.verbSearch"), query); @@ -5004,6 +5023,19 @@ ${(r.output || "").slice(0, 2000)}`; ); } + /** 확장이 볼 디버그 상태. vscode 는 session 객체와 SourceBreakpoint 목록을 준다. */ + private syncDebugToExtensions() { + const d = this.state.debug; + const bps: any[] = []; + for (const [rel, lines] of Object.entries(this.state.breakpoints)) { + for (const line of lines) bps.push({ enabled: true, location: { uri: rel, range: { start: { line: line - 1, character: 0 }, end: { line: line - 1, character: 0 } } } }); + } + vscodeShim.setDebugState({ + active: d ? { id: "schutz-debug", type: "python", name: "Schutz", workspaceFolder: this.state.workspace?.root ?? null } : null, + breakpoints: bps, + }); + } + /** 아는 언어 서버 목록(없는 것 포함). 처음 필요할 때 한 번 읽는다. */ private _lspCatalog: ServerRow[] | null = null; private _lspTold = new Set(); @@ -5784,6 +5816,19 @@ ${(r.output || "").slice(0, 2000)}`; try { return await window.schutz.readFile(ws.root, rel); } catch { return null; } }, revealInView: (viewId, element, expand) => this.revealExtViewRow(viewId, element, expand), + // 확장이 중단점을 더하거나 뺀다. 실제 목록은 앱이 들고 있으므로 여기서 옮긴다. + debugBreakpoints: (op, bps) => { + for (const b of bps ?? []) { + // uri.path 를 그대로 쓰면 절대 경로가 키가 된다 — 개수만 늘고 거터에는 + // 아무것도 안 그려진다(실측). 워크스페이스 상대 경로로 바꿔서 넣는다. + const raw = b?.location?.uri; + const rel = this.uriToRel(String(raw?.toString?.() ?? raw ?? "")); + const line = Number(b?.location?.range?.start?.line ?? 0) + 1; + if (!rel || line < 1) continue; + const has = (this.state.breakpoints[rel] ?? []).includes(line); + if ((op === "add") !== has) this.toggleBreakpoint(rel, line); + } + }, // 확장의 WorkspaceEdit 파일 조작. 디스크만 건드리면 열린 버퍼가 실제 파일과 // 어긋나므로 모델 정리·트리 갱신까지 여기서 함께 한다. fileOps: { @@ -5983,6 +6028,8 @@ ${(r.output || "").slice(0, 2000)}`; // 대기 중인 제안을 코드 옆에 그린다(사유 툴팁 + 수락/거절 CodeLens). // 목록이 실제로 바뀐 판에만 — 매 렌더마다 CodeLens 를 다시 요청하면 깜빡인다. if (_ps && _ps.proposals !== this.state.proposals) this.syncProposalMarks(); + // 확장에게 디버그 상태를 알린다 — 세션이 뜨고 지는 것, 중단점이 움직이는 것. + if (_ps && (_ps.debug !== this.state.debug || _ps.breakpoints !== this.state.breakpoints)) this.syncDebugToExtensions(); // 저장 안 한 파일 목록을 메인에 맞춰 둔다 — 종료를 붙잡을지 여기서 정해진다. this.reportDirty(); // 모드가 바뀌면 Monaco 를 다시 재어준다. automaticLayout 은 display:none 안에서 diff --git a/ide/src/ai/claude.ts b/ide/src/ai/claude.ts index 94f994b..179c70a 100644 --- a/ide/src/ai/claude.ts +++ b/ide/src/ai/claude.ts @@ -319,6 +319,21 @@ export const WORKSPACE_TOOLS: ToolDef[] = [ required: ["query"], }, }, + { + name: "find_references", + description: + "이 심볼을 **누가 쓰는지** 찾는다. 이름으로 정의를 잡은 뒤 그 자리에서 참조를 묻는다. " + + "고칠 때 무엇이 깨지는지 보려면 이걸 써라 — search_files 는 같은 이름의 다른 것과 주석까지 준다. " + + "이름이 여러 군데 정의돼 있으면 어디를 뜻하는지 되묻는다(path 로 좁혀라).", + input_schema: { + type: "object", + properties: { + name: { type: "string", description: "심볼 이름(정확히)" }, + path: { type: "string", description: "같은 이름이 여럿일 때 정의가 있는 파일" }, + }, + required: ["name"], + }, + }, { name: "read_file", description: diff --git a/ide/src/editor/symbolIndex.ts b/ide/src/editor/symbolIndex.ts index 9ebc0c3..46bd761 100644 --- a/ide/src/editor/symbolIndex.ts +++ b/ide/src/editor/symbolIndex.ts @@ -135,3 +135,63 @@ export async function findSymbols(query: string, max = 100): Promise !isTestPath(h.rel)), ...hits.filter(h => isTestPath(h.rel))]; return { hits: ordered.slice(0, max), sources, capped: ts.capped }; } + +// ── 참조 찾기 ─────────────────────────────────────────────────────────────── +// +// "이거 누가 쓰지" 는 심볼 찾기의 반대 방향이고, grep 으로는 답이 안 나온다 — 같은 +// 이름의 다른 것과 주석까지 다 걸리기 때문이다. TS 워커는 getNavigateToItems 와 달리 +// getReferencesAtPosition 을 내준다(실측 확인). + +export interface RefHit { rel: string; line: number; column: number } + +export interface RefAnswer { + /** 어느 정의를 기준으로 찾았나. 못 정하면 null. */ + at: { rel: string; line: number; name: string } | null; + hits: RefHit[]; + /** 이름이 여러 군데 정의돼 있어 고를 수 없었다 — 어디를 뜻하는지 되물어야 한다. */ + ambiguous: { rel: string; line: number }[]; + /** 심볼 색인 자체가 없다. */ + noIndex: boolean; +} + +/** 이름으로 정의를 찾고, 그 자리에서 참조를 묻는다. */ +export async function findReferences(name: string, inFile?: string): Promise { + const q = String(name ?? "").trim(); + if (!q) return { at: null, hits: [], ambiguous: [], noIndex: false }; + + const found = await findSymbols(q, 50); + if (!found.sources.length) return { at: null, hits: [], ambiguous: [], noIndex: true }; + + // 이름이 정확히 같은 것만 남긴다. 부분 일치까지 세면 엉뚱한 것을 기준으로 잡는다. + let exact = found.hits.filter(h => h.name === q); + if (inFile) exact = exact.filter(h => h.rel === inFile || h.rel.endsWith("/" + inFile)); + if (!exact.length) return { at: null, hits: [], ambiguous: [], noIndex: false }; + if (exact.length > 1) { + return { at: null, hits: [], ambiguous: exact.map(h => ({ rel: h.rel, line: h.line })), noIndex: false }; + } + + const def = exact[0]!; + const model = projectModels.getByRel(def.rel); + if (!model || model.isDisposed()) return { at: null, hits: [], ambiguous: [], noIndex: false }; + + const ts: any = (monaco.languages as any).typescript; + if (!ts?.getTypeScriptWorker) return { at: { rel: def.rel, line: def.line, name: def.name }, hits: [], ambiguous: [], noIndex: false }; + + const offset = model.getOffsetAt({ lineNumber: def.line, column: def.column }); + const hits: RefHit[] = []; + try { + const getWorker = await ts.getTypeScriptWorker(); + const client = await getWorker(model.uri); + const refs: any[] = await client.getReferencesAtPosition(model.uri.toString(), offset); + for (const r of refs ?? []) { + const rel = projectModels.relFor(String(r?.fileName ?? "")); + if (!rel) continue; + const m = projectModels.getByRel(rel); + if (!m || m.isDisposed()) continue; + const pos = m.getPositionAt(Number(r?.textSpan?.start ?? 0)); + hits.push({ rel, line: pos.lineNumber, column: pos.column }); + } + } catch { /* 워커가 아직 모르는 파일 */ } + + return { at: { rel: def.rel, line: def.line, name: def.name }, hits, ambiguous: [], noIndex: false }; +} diff --git a/ide/src/ext/extHost.ts b/ide/src/ext/extHost.ts index d0d1ba2..964b1f0 100644 --- a/ide/src/ext/extHost.ts +++ b/ide/src/ext/extHost.ts @@ -29,6 +29,8 @@ export interface HostDeps { saveFile: (rel: string) => Promise; readFile: (rel: string) => Promise; revealInView: (viewId: string, element: any, expand: boolean) => Promise; + /** 확장이 중단점을 더하거나 뺀다. */ + debugBreakpoints?: (op: "add" | "remove", bps: any[]) => void; /** WorkspaceEdit 의 파일 만들기·지우기·이름 바꾸기 — 셰임이 그대로 받아 쓴다. */ fileOps?: { exists: (rel: string) => boolean; @@ -254,7 +256,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, prompt: d.prompt, statusSet: d.statusSet, statusRemove: d.statusRemove, postToView: d.postToView, saveFile: d.saveFile, readFile: d.readFile, revealInView: d.revealInView, fileOps: d.fileOps, registerCommand: addCommand }, ext); + const vscode = makeVscodeApi({ toast: d.toast, showPanel: d.showPanel, getActiveFile: d.getActiveFile, workspaceRoot: d.workspaceRoot, openFiles: d.openFiles, prompt: d.prompt, statusSet: d.statusSet, statusRemove: d.statusRemove, postToView: d.postToView, saveFile: d.saveFile, readFile: d.readFile, revealInView: d.revealInView, fileOps: d.fileOps, debugBreakpoints: d.debugBreakpoints, registerCommand: addCommand }, ext); const moduleObj = { exports: {} as any }; const require = makeHostRequire(vscode); const ctx = { diff --git a/ide/src/ext/vscodeShim.ts b/ide/src/ext/vscodeShim.ts index f14c928..c815133 100644 --- a/ide/src/ext/vscodeShim.ts +++ b/ide/src/ext/vscodeShim.ts @@ -83,6 +83,8 @@ export interface ShimDeps { readFile: (rel: string) => Promise; /** 트리 뷰에서 한 줄을 펼쳐 보여 준다. */ revealInView: (viewId: string, element: any, expand: boolean) => Promise; + /** 확장이 중단점을 더하거나 뺀다. 앱이 실제 목록을 들고 있다. */ + debugBreakpoints?: (op: "add" | "remove", bps: any[]) => void; /** WorkspaceEdit 의 파일 만들기·지우기·이름 바꾸기. 앱이 모델 정리와 트리 갱신까지 * 맡는다 — 셰임이 디스크만 건드리면 열린 버퍼가 실제 파일과 어긋난다. */ fileOps?: { @@ -227,6 +229,30 @@ function langIdsFromSelector(sel: any): string[] { /** vscode 모듈 셰임 인스턴스 생성 — 확장별로 만든다(구독/컨텍스트 격리). */ /** 셰임이 쏘는 편집기 사건들. 앱이 fireShimEvent 로 밀어 준다 — 예전엔 전부 아무도 * 안 쏘는 빈 EventEmitter 라, 저장·열기를 구독한 확장은 영원히 안 불렸다. */ +/** 디버그 사건. 앱이 세션을 열고 닫을 때, 브레이크포인트가 움직일 때 쏜다. + * 확장이 "지금 디버그 중인가" 를 알 통로가 아예 없었다. */ +export const debugEvents = { + sessionStarted: new EventEmitter(), + sessionEnded: new EventEmitter(), + activeChanged: new EventEmitter(), + breakpointsChanged: new EventEmitter(), +}; + +/** 지금 디버그 상태 — 앱이 갱신하고 셰임이 읽는다. */ +let debugState: { active: any | null; breakpoints: any[] } = { active: null, breakpoints: [] }; + +/** 앱이 디버그 상태를 알린다. 세션이 뜨고 지는 것과 중단점 목록. */ +export function setDebugState(next: { active: any | null; breakpoints: any[] }): void { + const wasActive = debugState.active; + const prevBps = debugState.breakpoints; + debugState = next; + if (!wasActive && next.active) { debugEvents.sessionStarted.fire(next.active); debugEvents.activeChanged.fire(next.active); } + else if (wasActive && !next.active) { debugEvents.sessionEnded.fire(wasActive); debugEvents.activeChanged.fire(undefined); } + if (prevBps.length !== next.breakpoints.length) { + debugEvents.breakpointsChanged.fire({ added: next.breakpoints, removed: [], changed: [] }); + } +} + export const editorEvents = { activeChanged: new EventEmitter(), selectionChanged: new EventEmitter(), @@ -906,6 +932,20 @@ export function makeVscodeApi(deps: ShimDeps, ext: { id: string; name: string; c readText: () => navigator.clipboard.readText(), }, }, + // 디버그 — **관찰과 중단점까지**. 디버거를 확장이 제공하는 것 + // (registerDebugAdapterDescriptorFactory)은 아직 없다. 없는 것은 없는 대로 + // 두어 부르면 TypeError 가 나게 한다 — 조용히 되는 척하지 않는다. + debug: { + get activeDebugSession() { return debugState.active ?? undefined; }, + get breakpoints() { return debugState.breakpoints; }, + onDidStartDebugSession: debugEvents.sessionStarted.event, + onDidTerminateDebugSession: debugEvents.sessionEnded.event, + onDidChangeActiveDebugSession: debugEvents.activeChanged.event, + onDidChangeBreakpoints: debugEvents.breakpointsChanged.event, + addBreakpoints: (bps: any[]) => { deps.debugBreakpoints?.("add", bps ?? []); }, + removeBreakpoints: (bps: any[]) => { deps.debugBreakpoints?.("remove", bps ?? []); }, + }, + SourceBreakpoint: class { constructor(public location: any, public enabled = true) {} }, Uri: UriShim, Position, Range, Selection, Location, Disposable, EventEmitter, MarkdownString, CompletionItem, CompletionItemKind, Hover, ThemeIcon, ThemeColor, WorkspaceEdit, CodeAction, CodeActionKind, diff --git a/ide/src/i18n/dict/sc2.ts b/ide/src/i18n/dict/sc2.ts index bb23f97..7990138 100644 --- a/ide/src/i18n/dict/sc2.ts +++ b/ide/src/i18n/dict/sc2.ts @@ -43,6 +43,7 @@ export const dict: Record