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
47 changes: 47 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 * 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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 안에서
Expand Down
15 changes: 15 additions & 0 deletions ide/src/ai/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 60 additions & 0 deletions ide/src/editor/symbolIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,63 @@ export async function findSymbols(query: string, max = 100): Promise<SymbolAnswe
const ordered = [...hits.filter(h => !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<RefAnswer> {
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 };
}
4 changes: 3 additions & 1 deletion ide/src/ext/extHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface HostDeps {
saveFile: (rel: string) => Promise<boolean>;
readFile: (rel: string) => Promise<string | null>;
revealInView: (viewId: string, element: any, expand: boolean) => Promise<void>;
/** 확장이 중단점을 더하거나 뺀다. */
debugBreakpoints?: (op: "add" | "remove", bps: any[]) => void;
/** WorkspaceEdit 의 파일 만들기·지우기·이름 바꾸기 — 셰임이 그대로 받아 쓴다. */
fileOps?: {
exists: (rel: string) => boolean;
Expand Down Expand Up @@ -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 = {
Expand Down
40 changes: 40 additions & 0 deletions ide/src/ext/vscodeShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface ShimDeps {
readFile: (rel: string) => Promise<string | null>;
/** 트리 뷰에서 한 줄을 펼쳐 보여 준다. */
revealInView: (viewId: string, element: any, expand: boolean) => Promise<void>;
/** 확장이 중단점을 더하거나 뺀다. 앱이 실제 목록을 들고 있다. */
debugBreakpoints?: (op: "add" | "remove", bps: any[]) => void;
/** WorkspaceEdit 의 파일 만들기·지우기·이름 바꾸기. 앱이 모델 정리와 트리 갱신까지
* 맡는다 — 셰임이 디스크만 건드리면 열린 버퍼가 실제 파일과 어긋난다. */
fileOps?: {
Expand Down Expand Up @@ -227,6 +229,30 @@ function langIdsFromSelector(sel: any): string[] {
/** vscode 모듈 셰임 인스턴스 생성 — 확장별로 만든다(구독/컨텍스트 격리). */
/** 셰임이 쏘는 편집기 사건들. 앱이 fireShimEvent 로 밀어 준다 — 예전엔 전부 아무도
* 안 쏘는 빈 EventEmitter 라, 저장·열기를 구독한 확장은 영원히 안 불렸다. */
/** 디버그 사건. 앱이 세션을 열고 닫을 때, 브레이크포인트가 움직일 때 쏜다.
* 확장이 "지금 디버그 중인가" 를 알 통로가 아예 없었다. */
export const debugEvents = {
sessionStarted: new EventEmitter<any>(),
sessionEnded: new EventEmitter<any>(),
activeChanged: new EventEmitter<any>(),
breakpointsChanged: new EventEmitter<any>(),
};

/** 지금 디버그 상태 — 앱이 갱신하고 셰임이 읽는다. */
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<any>(),
selectionChanged: new EventEmitter<any>(),
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions ide/src/i18n/dict/sc2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const dict: Record<string, { ko: string; en: string; de: string; ja: stri
"sc2.verbRead": { ko: "읽기", en: "Read", de: "Lesen", ja: "読み取り" },
"sc2.verbSearch": { ko: "검색", en: "Search", de: "Suche", ja: "検索" },
"sc2.verbSymbol": { ko: "심볼 찾기", en: "Find symbol", de: "Symbol suchen", ja: "シンボル検索" },
"sc2.verbRefs": { ko: "참조 찾기", en: "Find references", de: "Verweise suchen", ja: "参照検索" },
"sc2.verbPlan": { ko: "계획", en: "Plan", de: "Plan", ja: "計画" },
"sc2.noteSteps": { ko: "{n}단계", en: "{n} steps", de: "{n} Schritte", ja: "{n}ステップ" },
"sc2.noteHits": { ko: "{n}곳", en: "{n} hits", de: "{n} Treffer", ja: "{n}件" },
Expand Down
Loading