diff --git a/README.md b/README.md index e2e28f7..1ad7c69 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ same table the app dispatches from, so it cannot drift from what actually happen ## Design principles -- **Provider-agnostic** — Claude, OpenAI, Grok, and GLM today, behind a swappable adapter +- **Provider-agnostic** — Claude, OpenAI, Grok, GLM and Gemini today, plus any OpenAI-compatible + server you point it at, all behind a swappable adapter - **Observable by default** — every AI action surfaces in the UI - **Human-in-the-loop** — every change can be accepted, rejected, or reverted - **Progressive fidelity** — validate the experience first, then deepen it at the editor core @@ -121,8 +122,19 @@ npm run dist:linux # Linux build ## Roadmap - **Phase 1** — validate the core experience *(done)* -- **Phase 2** — deepen renderer-level visual effects at the editor core *(in progress)* -- **Phase 3** — more providers (Gemini, local/OpenAI-compatible endpoints) and codebase indexing +- **Phase 2** — the four editing-visualization pillars *(done)* +- **Phase 3** — more providers *(done)* and codebase indexing *(next, as a 0.4 preview)* + +Phase 2 was originally written as "fork Code-OSS", on the assumption that the visual effects +were impossible through the extension API. That fork never happened and should not: the app +has its own renderer already (Electron + React + Monaco), so the reason for forking never +applied. [docs/PHASE2-SURVEY.md](docs/PHASE2-SURVEY.md) audits the four pillars against the +code and closes the fork item as not-applicable. The three things the design named and the +code lacked — a ghost cursor, a rationale tooltip on the changed line, and accept/reject +CodeLens — now exist. + +Providers: Claude, GPT, Grok, GLM, Gemini, and any OpenAI-compatible server you point it at — +Ollama, LM Studio, llama.cpp, or a corporate proxy. A local server needs an address, not a key. The ecosystem work that used to sit in Phase 3 has shipped: VS Code extensions, Claude Code skills and subagents, MCP servers over stdio and HTTP, and `.mcpb` bundles are all in the app diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c974b64..213a196 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -142,12 +142,22 @@ AI의 편집은 즉시 파일에 쓰이지 않고 **트랜잭션**으로 관리 - 멀티파일 오버뷰 Webview (2.4) - **검증 질문**: 확장 API로 어디까지 "멋지게" 되는가? 무엇이 답답한가? -### Phase 2 — 포크(Fork)로 이전 -> 목표: 확장으로 불가능했던 시각 효과를 위해 Code-OSS 포크. 진짜 차별화 완성. -- Code-OSS / VSCodium 기반 브랜딩·빌드 파이프라인 -- 에디터 렌더러 레벨의 부드러운 타이핑 애니메이션 (2.1 완성) -- 확장 코드를 코어 기능으로 통합 -- 배포(자동 업데이트, 서명 등) +### Phase 2 — 자체 렌더러 위의 시각화 *(완료, 2026-08-05)* +> 원래 계획은 "Code-OSS 포크" 였다. **하지 않았고, 하지 않는 것이 맞다.** +> 확장 API 로 렌더러에 손댈 수 없다는 것이 포크의 이유였는데, 이 앱은 +> Electron + React + Monaco 로 처음부터 자기 렌더러를 갖고 있어 그 이유가 없다. +> 판정과 근거는 [PHASE2-SURVEY.md](PHASE2-SURVEY.md). + +- ~~Code-OSS / VSCodium 기반 브랜딩·빌드 파이프라인~~ — 해당 없음(포크 안 함) +- 타이핑 애니메이션 + 글로우·잔광 + **유령 커서** (2.1) +- **변경 사유 툴팁**과 **수락/거절 CodeLens** — 바뀌는 그 줄에서 (2.2) +- 작업 상태·계획 패널 (2.3) +- 멀티파일 변경 개요 (2.4) +- 배포: 설치본 5종(서명은 아직, 자동 업데이트는 의도적으로 안 씀) + +남은 것 하나 — 2.1 의 "스트리밍 삽입" 은 지금 **완성된 텍스트의 재생**이라 모델이 +실제로 쓰는 속도와 무관하다. 진짜 토큰 스트림을 흘리려면 제안·승인 흐름 자체를 +바꿔야 해서, 시각 효과가 아니라 별건으로 남긴다. ### Phase 3 — 확장·생태계 - 멀티 프로바이더 완성 @@ -162,7 +172,7 @@ AI의 편집은 즉시 파일에 쓰이지 않고 **트랜잭션**으로 관리 |---|---|---| | 언어 | TypeScript | VSCode 생태계 표준 | | Phase 1 베이스 | VSCode Extension API | 빠른 검증 | -| Phase 2 베이스 | Code-OSS (VSCodium 방식) | 포크 | +| Phase 2 베이스 | ~~Code-OSS (VSCodium 방식)~~ → Electron + React + Monaco | 포크하지 않음 — 렌더러가 이미 우리 것 | | UI (패널·오버뷰) | Webview + (React 등) | 커스텀 시각화 자유도 | | AI 통신 | 스트리밍 HTTP/SSE | 실시간성 필수 | | 상태 관리 | (미정) | 오케스트레이터 이벤트 기반 | @@ -172,7 +182,7 @@ AI의 편집은 즉시 파일에 쓰이지 않고 **트랜잭션**으로 관리 ## 6. 리스크 & 열린 질문 **리스크** -- **R1. 확장 API의 시각 표현 한계** — 2.1의 매끄러운 애니메이션이 확장에서 기대만큼 안 나올 수 있음. → Phase 1에서 조기 검증, 안 되면 Phase 2 앞당김. +- **R1. 확장 API의 시각 표현 한계** — 2.1의 매끄러운 애니메이션이 확장에서 기대만큼 안 나올 수 있음. → Phase 1에서 조기 검증, 안 되면 Phase 2 앞당김. **(해소: 확장이 아니라 자체 렌더러로 갔다. 애니메이션·유령 커서·CodeLens 모두 우리 코드다.)** - **R2. 포크 유지보수 부담** — VSCode 업스트림 추종 비용. VSCodium 사례 참고로 최소화. - **R3. 실시간 스트리밍 편집의 충돌** — 사용자가 편집 중인데 AI도 편집하면? → 트랜잭션 모델 + 락/머지 정책 필요. diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 471ec40..c170238 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -68,6 +68,7 @@ import { XtermView } from "./editor/XtermView"; import { ImagePane, MarkdownPane, isImage, mdToHtml } from "./editor/MediaPane"; import monaco, { languageOf, applyTsPaths, revalidateTs } from "./editor/monacoSetup"; import * as projectModels from "./editor/projectModels"; +import * as proposalDeco from "./editor/proposalDeco"; import { typeEdit, reducedMotion } from "./editor/editAnimator"; import * as lspClient from "./editor/lspClient"; import * as lspConv from "./editor/lspConverters"; @@ -4963,6 +4964,19 @@ ${(r.output || "").slice(0, 2000)}`; return parseFindings(out); } + /** 대기 중인 제안을 편집기 위에 반영한다 — 사유 툴팁과 수락/거절 렌즈. */ + private syncProposalMarks() { + proposalDeco.ensureRegistered({ + accept: id => void this.acceptProposal(id), + reject: id => this.rejectProposal(id), + }); + proposalDeco.refresh( + this.state.proposals + .filter(p => p.status === "pending" && !this.parseDiffKey(p.rel)) + .map(p => ({ id: p.id, rel: p.rel, find: p.find, rationale: p.rationale, agent: p.agent, range: p.range ?? null })), + ); + } + componentDidMount() { window.addEventListener("resize", this._clampChatOnResize); applyTheme(getThemeId()); @@ -5899,6 +5913,9 @@ ${(r.output || "").slice(0, 2000)}`; this.setState({ openingPhase: "intro" }); } if (_ps) this._restoreClosedFocus(_ps); + // 대기 중인 제안을 코드 옆에 그린다(사유 툴팁 + 수락/거절 CodeLens). + // 목록이 실제로 바뀐 판에만 — 매 렌더마다 CodeLens 를 다시 요청하면 깜빡인다. + if (_ps && _ps.proposals !== this.state.proposals) this.syncProposalMarks(); // 저장 안 한 파일 목록을 메인에 맞춰 둔다 — 종료를 붙잡을지 여기서 정해진다. this.reportDirty(); // 모드가 바뀌면 Monaco 를 다시 재어준다. automaticLayout 은 display:none 안에서 diff --git a/ide/src/editor/editAnimator.ts b/ide/src/editor/editAnimator.ts index 7206fc2..3f8c1b5 100644 --- a/ide/src/editor/editAnimator.ts +++ b/ide/src/editor/editAnimator.ts @@ -102,10 +102,19 @@ export async function typeEdit( const head = posAt(model, startOffset + written); if (editor && !editor.getModel()?.isDisposed()) { // 쓰고 있는 줄을 강조 — git 거터와 별개 채널 - decoIds = editor.deltaDecorations(decoIds, [{ - range: new monaco.Range(head.lineNumber, 1, head.lineNumber, 1), - options: { isWholeLine: true, className: "sz-ai-typing", overviewRuler: undefined }, - }]); + decoIds = editor.deltaDecorations(decoIds, [ + { + range: new monaco.Range(head.lineNumber, 1, head.lineNumber, 1), + options: { isWholeLine: true, className: "sz-ai-typing", overviewRuler: undefined }, + }, + // 유령 커서 — AI 가 **지금 어느 글자에** 쓰고 있는지. 줄 하이라이트만으로는 + // 긴 줄에서 눈이 어디를 따라가야 할지 알 수 없다. 길이 0 범위에 붙이는 + // 가짜 요소라 텍스트를 밀지 않는다. + { + range: new monaco.Range(head.lineNumber, head.column, head.lineNumber, head.column), + options: { beforeContentClassName: "sz-ai-caret" }, + }, + ]); if (opts.reveal !== false) editor.revealLineInCenterIfOutsideViewport(head.lineNumber, 0 /* Smooth */); } await delay(tickMs); diff --git a/ide/src/editor/proposalDeco.ts b/ide/src/editor/proposalDeco.ts new file mode 100644 index 0000000..add5ee1 --- /dev/null +++ b/ide/src/editor/proposalDeco.ts @@ -0,0 +1,121 @@ +// 아직 수락하지 않은 제안을 **코드 옆에** 보여 준다. +// +// 지금까지 제안은 오른쪽 카드에만 있었다. 무엇이 왜 바뀌는지 보려면 코드에서 눈을 +// 떼야 했고, 바꿀지 말지도 거기서만 눌렀다. 설계(DESIGN.md 2.2)가 적어 둔 자리는 +// 바뀌는 그 줄이다 — 사유는 툴팁으로, 수락·거절은 CodeLens 로. +// +// 자리를 찾는 일은 review/proposalMarks.ts 가 한다. 여기는 화면에 얹기만 한다. + +import monaco from "./monacoSetup"; +import * as projectModels from "./projectModels"; +import { locate, markTooltip } from "../review/proposalMarks"; +import { t } from "../i18n"; + +export interface PendingProposal { + id: string; + rel: string; + find: string; + rationale: string; + agent: string; + range?: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number } | null; +} + +export interface ProposalHandlers { + accept: (id: string) => void; + reject: (id: string) => void; +} + +let pending: PendingProposal[] = []; +let handlers: ProposalHandlers | null = null; +let registered = false; +// Monaco 의 CodeLensProvider.onDidChange 는 프로바이더 자신을 실어 보낸다. +const changed = new monaco.Emitter(); +/** 모델 uri → 지금 그려 둔 툴팁 데코레이션 */ +const marks = new Map(); + +/** 이 모델에 걸린 제안들과 그 자리. 못 찾은 것은 뺀다 — 틀린 자리에 그리느니 안 그린다. */ +function marksFor(model: monaco.editor.ITextModel): { p: PendingProposal; at: ReturnType }[] { + const rel = projectModels.relFor(model.uri.toString()); + if (!rel) return []; + const text = model.getValue(); + const out: { p: PendingProposal; at: ReturnType }[] = []; + for (const p of pending) { + if (p.rel !== rel) continue; + const at = locate({ text, find: p.find, range: p.range ?? null }); + if (at) out.push({ p, at }); + } + return out; +} + +/** 한 번만 등록한다. 앱이 살아 있는 동안 목록만 갈아 끼운다. */ +export function ensureRegistered(h: ProposalHandlers): void { + handlers = h; + if (registered) return; + registered = true; + + // CodeLens 의 command 는 id 로 부른다 — 렌즈마다 클로저를 넘길 수 없다. + monaco.editor.registerCommand("schutz.acceptProposal", (_a, id: string) => { handlers?.accept(String(id)); }); + monaco.editor.registerCommand("schutz.rejectProposal", (_a, id: string) => { handlers?.reject(String(id)); }); + + const provider: monaco.languages.CodeLensProvider = { + onDidChange: changed.event, + provideCodeLenses: (model) => { + const lenses: monaco.languages.CodeLens[] = marksFor(model).flatMap(({ p, at }) => ([ + { + range: { startLineNumber: at!.startLineNumber, startColumn: 1, endLineNumber: at!.startLineNumber, endColumn: 1 }, + id: "acc-" + p.id, + command: { id: "schutz.acceptProposal", title: "✓ " + t("misc.accept"), arguments: [p.id] }, + }, + { + range: { startLineNumber: at!.startLineNumber, startColumn: 1, endLineNumber: at!.startLineNumber, endColumn: 1 }, + id: "rej-" + p.id, + command: { id: "schutz.rejectProposal", title: "✕ " + t("misc.reject"), arguments: [p.id] }, + }, + ])); + return { lenses, dispose: () => { /* 목록만 넘긴다 */ } }; + }, + }; + fireChange = () => changed.fire(provider); + monaco.languages.registerCodeLensProvider({ scheme: "file" }, provider); +} + +/** 등록 전에 refresh 가 오면 쏠 곳이 없다 — 등록될 때 채워진다. */ +let fireChange: () => void = () => { /* 아직 등록 전 */ }; + +/** 제안 목록이 바뀔 때마다 부른다 — 렌즈를 새로 요청하게 하고 툴팁을 다시 그린다. */ +export function refresh(list: PendingProposal[]): void { + pending = list; + fireChange(); + for (const model of monaco.editor.getModels()) { + if (model.isDisposed()) continue; + const key = model.uri.toString(); + const decos = marksFor(model).flatMap(({ p, at }) => { + const hover = markTooltip(p.rationale, p.agent); + if (!hover) return []; + return [{ + range: new monaco.Range(at!.startLineNumber, at!.startColumn, at!.endLineNumber, at!.endColumn), + options: { + className: "sz-prop-pending", + hoverMessage: { value: hover }, + // 개요 눈금에도 표시 — 긴 파일에서 제안이 어디 있는지 스크롤 없이 보인다. + overviewRuler: { color: "rgba(196,168,130,.7)", position: monaco.editor.OverviewRulerLane.Right }, + }, + }]; + }); + const prev = marks.get(key) ?? []; + if (!prev.length && !decos.length) continue; + marks.set(key, model.deltaDecorations(prev, decos)); + } +} + +/** 워크스페이스를 닫을 때 등 — 그려 둔 것을 걷는다. */ +export function clearAll(): void { + pending = []; + for (const model of monaco.editor.getModels()) { + const key = model.uri.toString(); + const prev = marks.get(key); + if (prev?.length && !model.isDisposed()) { try { model.deltaDecorations(prev, []); } catch { /* */ } } + } + marks.clear(); + fireChange(); +} diff --git a/ide/src/global.css b/ide/src/global.css index b00e7d0..30a2ec3 100644 --- a/ide/src/global.css +++ b/ide/src/global.css @@ -100,6 +100,30 @@ button:disabled { opacity: .55; cursor: default !important; } .sz-ai-typing 은 지금 쓰고 있는 줄, .sz-ai-changed 는 다 쓴 뒤 잠깐 남는 잔광. */ .sz-ai-typing { background: color-mix(in srgb, var(--accent) 15%, transparent); } .sz-ai-changed { background: color-mix(in srgb, var(--accent) 9%, transparent); animation: szAiFade 1.4s var(--ease) both; } +/* 유령 커서 — AI 가 쓰고 있는 바로 그 자리. 길이 0 데코레이션의 ::before 라 + 글자를 밀지 않도록 폭을 0 으로 두고 테두리로만 그린다. */ +.sz-ai-caret::before { + content: ""; + display: inline-block; + width: 0; + height: 1.05em; + vertical-align: text-bottom; + border-left: 2px solid var(--accent); + animation: szCaretBlink 1s steps(2, start) infinite; +} +@keyframes szCaretBlink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: .25; } } +/* 모션을 줄이겠다고 한 사람에게 깜빡이는 것을 들이밀지 않는다. */ +@media (prefers-reduced-motion: reduce) { + .sz-ai-caret::before { animation: none; } +} + +/* 아직 수락하지 않은 제안이 걸린 자리. 마우스를 올리면 왜 바꾸는지가 뜬다. + git 거터(초록/노랑/빨강)와 색이 겹치지 않게 황토색 밑줄로만 표시한다 — + 이미 적용된 변경과 아직 아닌 제안은 눈에서 갈려야 한다. */ +.sz-prop-pending { + border-bottom: 1px dashed rgba(196,168,130,.85); + background: color-mix(in srgb, #C4A882 8%, transparent); +} @keyframes szAiFade { from { background-color: color-mix(in srgb, var(--accent) 20%, transparent); } to { background-color: transparent; } } /* 확장 상세 README */ diff --git a/ide/src/review/proposalMarks.test.ts b/ide/src/review/proposalMarks.test.ts new file mode 100644 index 0000000..c8ff1e2 --- /dev/null +++ b/ide/src/review/proposalMarks.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { posAt, offsetOf, locate, markTooltip } from "./proposalMarks"; + +const TEXT = "const a = 1;\nconst b = 2;\nconst c = 3;\n"; + +describe("posAt", () => { + it("첫 글자는 1줄 1칸", () => { + expect(posAt(TEXT, 0)).toEqual({ line: 1, column: 1 }); + }); + it("줄바꿈 다음은 다음 줄 첫 칸", () => { + expect(posAt(TEXT, 13)).toEqual({ line: 2, column: 1 }); + }); + it("줄 가운데도 센다", () => { + expect(posAt(TEXT, 19)).toEqual({ line: 2, column: 7 }); + }); + it("범위를 넘으면 끝으로 붙인다", () => { + expect(posAt(TEXT, 9999).line).toBe(4); + expect(posAt(TEXT, -5)).toEqual({ line: 1, column: 1 }); + }); +}); + +describe("offsetOf", () => { + it("posAt 의 역이다", () => { + for (const off of [0, 5, 13, 19, 30]) { + const p = posAt(TEXT, off); + expect(offsetOf(TEXT, p.line, p.column)).toBe(off); + } + }); + it("없는 줄은 -1", () => { + expect(offsetOf(TEXT, 99, 1)).toBe(-1); + expect(offsetOf(TEXT, 0, 1)).toBe(-1); + expect(offsetOf(TEXT, 1, 0)).toBe(-1); + }); +}); + +describe("locate", () => { + it("유일한 조각을 찾는다", () => { + expect(locate({ text: TEXT, find: "const b = 2;" })).toEqual({ + startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 13, + }); + }); + + it("여러 줄에 걸친 조각도 찾는다", () => { + const m = locate({ text: TEXT, find: "const b = 2;\nconst c = 3;" }); + expect(m).toEqual({ startLineNumber: 2, startColumn: 1, endLineNumber: 3, endColumn: 13 }); + }); + + it("새 파일 생성은 첫 줄에 건다", () => { + expect(locate({ text: "", find: "" })).toEqual({ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }); + }); + + it("없으면 null — 없는 자리에 그리지 않는다", () => { + expect(locate({ text: TEXT, find: "nope" })).toBeNull(); + }); + + // 찍어서 그리면 엉뚱한 줄에 "여기가 바뀝니다" 가 붙는다. + it("두 군데면 null", () => { + expect(locate({ text: "x;\nx;\n", find: "x;" })).toBeNull(); + }); + + it("범위를 주면 그쪽을 먼저 믿는다 — 같은 조각이 하나뿐이어도 범위가 이긴다", () => { + const m = locate({ + text: TEXT, find: "const c = 3;", + range: { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 13 }, + }); + expect(m).toEqual({ startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 13 }); + }); + + it("범위가 낡았으면 무시하고 텍스트로 찾는다", () => { + // 파일이 그 사이 바뀌어 3번 줄이 다른 코드가 된 상황 + const m = locate({ + text: TEXT, find: "const b = 2;", + range: { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 13 }, + }); + expect(m).toEqual({ startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 13 }); + }); + + it("범위가 낡았고 조각이 여러 군데면 null", () => { + const m = locate({ + text: "x;\nx;\n", find: "x;", + range: { startLineNumber: 9, startColumn: 1, endLineNumber: 9, endColumn: 3 }, + }); + expect(m).toBeNull(); + }); + + it("범위가 파일 밖을 가리켜도 터지지 않는다", () => { + expect(locate({ text: TEXT, find: "없는것", range: { startLineNumber: 999, startColumn: 999, endLineNumber: 999, endColumn: 999 } })).toBeNull(); + }); +}); + +describe("markTooltip", () => { + it("사유와 에이전트를 함께 낸다", () => { + expect(markTooltip("null 체크가 없어 추가함", "claude")).toBe("**claude** — null 체크가 없어 추가함"); + }); + it("에이전트가 없으면 사유만", () => { + expect(markTooltip("이유", "")).toBe("이유"); + }); + it("사유가 비면 툴팁을 달지 않는다", () => { + expect(markTooltip("", "claude")).toBeNull(); + expect(markTooltip(" ", "claude")).toBeNull(); + expect(markTooltip(undefined as any, "claude")).toBeNull(); + }); +}); diff --git a/ide/src/review/proposalMarks.ts b/ide/src/review/proposalMarks.ts new file mode 100644 index 0000000..97a94e7 --- /dev/null +++ b/ide/src/review/proposalMarks.ts @@ -0,0 +1,85 @@ +// 아직 수락하지 않은 제안이 **파일 안 어디에 걸려 있는가**. +// +// 지금까지 제안은 오른쪽 카드에만 있었다. 그래서 "이 줄이 왜 바뀌는가" 를 보려면 +// 코드에서 눈을 떼고 카드로 가야 했고, 바꿀지 말지도 거기서만 눌렀다. 설계(2.2)가 +// 적어 둔 자리는 **바뀌는 줄 그 자리**다 — 사유는 툴팁으로, 수락·거절은 CodeLens 로. +// +// 두 기능이 같은 질문을 공유한다: 이 제안이 지금 이 텍스트의 몇 번째 글자에 붙는가. +// 그 답만 여기서 낸다. 화면에 무엇을 그릴지는 부르는 쪽이 정한다. + +export interface MarkInput { + /** 지금 편집기에 있는 전체 텍스트 */ + text: string; + /** 제안이 바꾸려는 원본 조각. 빈 문자열이면 새 파일 생성이다. */ + find: string; + /** 제안이 만들어질 때 잡아 둔 범위(있으면 먼저 믿는다). 1-based. */ + range?: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number } | null; +} + +export interface Mark { + /** 1-based 줄·칸 — Monaco 가 그대로 받는 모양. */ + startLineNumber: number; startColumn: number; + endLineNumber: number; endColumn: number; +} + +/** 오프셋 → 1-based 줄·칸. */ +export function posAt(text: string, offset: number): { line: number; column: number } { + const o = Math.max(0, Math.min(offset, text.length)); + let line = 1, last = 0; + for (let i = 0; i < o; i++) { + if (text.charCodeAt(i) === 10) { line++; last = i + 1; } + } + return { line, column: o - last + 1 }; +} + +/** 1-based 줄·칸 → 오프셋. 범위를 벗어나면 -1. */ +export function offsetOf(text: string, line: number, column: number): number { + if (line < 1 || column < 1) return -1; + let at = 0; + for (let l = 1; l < line; l++) { + const nl = text.indexOf("\n", at); + if (nl < 0) return -1; + at = nl + 1; + } + const off = at + (column - 1); + return off <= text.length ? off : -1; +} + +/** + * 제안이 붙을 자리. 못 찾거나 애매하면 null — **틀린 자리에 그리느니 안 그린다.** + * + * 판정 순서는 applyProposal 과 같다. 다르면 카드에 뜬 자리와 편집기에 그린 자리가 + * 어긋나고, 그건 사용자가 다른 곳을 보며 수락을 누르게 만든다. + */ +export function locate(inp: MarkInput): Mark | null { + const { text, find } = inp; + // 새 파일 생성은 파일 전체가 곧 그 제안이다. 첫 줄에 건다. + if (find === "") return { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }; + + let start = -1; + if (inp.range) { + const s0 = offsetOf(text, inp.range.startLineNumber, inp.range.startColumn); + const e0 = offsetOf(text, inp.range.endLineNumber, inp.range.endColumn); + // 범위가 가리키는 **현재 내용**이 그 조각일 때만 믿는다. 파일이 그 사이 바뀌었으면 + // 같은 줄 번호가 전혀 다른 코드다. + if (s0 >= 0 && e0 >= s0 && e0 <= text.length && text.slice(s0, e0) === find) start = s0; + } + if (start < 0) { + const idx = text.indexOf(find); + if (idx < 0) return null; + // 두 군데면 어느 쪽인지 모른다. 찍어서 그리면 엉뚱한 줄에 "여기가 바뀝니다" 가 붙는다. + if (text.indexOf(find, idx + 1) >= 0) return null; + start = idx; + } + const a = posAt(text, start); + const b = posAt(text, start + find.length); + return { startLineNumber: a.line, startColumn: a.column, endLineNumber: b.line, endColumn: b.column }; +} + +/** 툴팁에 넣을 문장. 사유가 비어 있으면 툴팁을 달 이유가 없다. */ +export function markTooltip(rationale: string, agent: string): string | null { + const r = String(rationale ?? "").trim(); + if (!r) return null; + const who = String(agent ?? "").trim(); + return who ? `**${who}** — ${r}` : r; +}