diff --git a/CHANGELOG.md b/CHANGELOG.md index d26744b..56f4f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,60 @@ ## [Unreleased] +## [0.3.0] — What it could not see, and what it could lose + +Two things ran through this release. The app was losing work it had promised to keep, and it was answering questions about files it had never actually looked at. Both failed the same way: quietly, with a successful-looking answer. + +On top of that, three new things: it talks to more models, extensions can do considerably more, and a proposal now appears in the code rather than only on a card. + +### Your unsaved work stops disappearing + +Every one of these was reproduced in the running app before it was fixed, and each is a place where edits vanished with no warning and no undo. + +- **Files deleted or renamed outside the app** took your unsaved buffer with them. They are kept now, and you are told they are orphaned so you can decide. +- **Quitting** with unsaved files did nothing visible: `beforeunload` cancels `app.quit()` in Electron with no dialog at all. There is now a real prompt with save / discard / cancel — and "save" that does not finish does not quit. +- **Switching projects** dropped unsaved edits without asking. +- **Discarding in git** only half-discarded, and the delete confirmation promised something it did not do. +- **Undo of an agent run** reloaded every open model from disk, including files the run never touched — while the screen said unsaved edits would be left alone. + +### Files it could not see + +- **Anything nested deeper than 8 levels was invisible** to the tree, to search, and to replace — all three walked with the same cap. `packages/app/src/features/x/components/y/z.ts` is already 8. The limit is 16 now. +- **Dot-directories were hidden wholesale.** `.vscode/settings.json`, `.claude/agents/*.md` and friends appeared in no listing and matched no search; `.github` had been carved out as a special case, which was the tell. What is hidden is now decided in one place. +- **Neither the tree nor search said when it had stopped early**, so "no results" and "never looked" arrived as the same answer. Both now say which it was — including to the agent, which had been told nothing at all. + +### Files it could destroy + +- **Opening a non-UTF-8 file corrupted it.** Everything was decoded as UTF-8, so a UTF-16 file grew from 16 to 20 bytes on open-and-save alone, and a CP949 Korean file turned to replacement characters the moment you edited one line. Such files are now refused, with an explanation. +- **Replace-across-files did the same thing**, on files you never opened — and reported success. Non-UTF-8 files are skipped and named. +- **A UTF-8 BOM was stripped by opening the file.** Monaco keeps the BOM outside the model, so the baseline never matched and "save all" rewrote untouched files. + +### More models + +Gemini, and any OpenAI-compatible server you point it at — Ollama, LM Studio, llama.cpp, or a corporate proxy. The address is a setting rather than a constant, a local server is not treated as unconfigured just because it has no API key, and its model list is read from the server, since only the server knows what you have pulled. + +### Extensions + +- **Decorations draw.** `createTextEditorDecorationType` used to return a working-looking handle that drew nothing — the last place in the shim that answered successfully and did nothing. +- **`onDidChangeActiveTextEditor` handed over the wrong editor.** It fired before the editor existed and the notification was dropped on the way out, so opening a file delivered either nothing or the *previous* file's editor. Extensions that draw on editor changes were drawing onto whatever was open before. +- **`WorkspaceEdit` can create, delete and rename files.** All-or-nothing: if one operation cannot be done, none are. A file with unsaved edits is neither deleted nor overwritten. +- **`env.openExternal` and `env.clipboard` do their jobs** instead of reporting success and doing nothing. + +### Proposals in the code + +A pending proposal is marked on the line it changes; hovering shows why. Accept and reject are CodeLens on that line, not only buttons on the card. While an edit types itself in, a ghost caret shows the exact character being written. + +This completes the four editing-visualization pillars. The original plan for that phase was to fork Code-OSS; the fork never happened and should not, since this app has had its own renderer from the start. [docs/PHASE2-SURVEY.md](docs/PHASE2-SURVEY.md) audits the pillars against the code. + +### Also + +- The app survives a main-process crash instead of vanishing, and writes a crash log. +- Async failures that used to disappear are surfaced. +- The tour teaches both modes rather than only the one you are in. +- Shell commands run only in the open workspace. +- MCP resources and prompts are offered to the agent, not just listed. + + ## [0.2.0] — Everything the extension host said it could do The previous release fixed behaviour the app claimed to have. This one does the same thing one layer out: an extension could load, report success, and then do nothing at all, because most of what it asked for was answered by a function that returned an empty value. diff --git a/README.md b/README.md index 0408bf8..1ad7c69 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,14 @@ same table the app dispatches from, so it cannot drift from what actually happen **Debugging** — Breakpoints, call stack, variables, and stepping via DAP (Python/debugpy today). -**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. The `vscode` shim gives them the active editor for real: reading the document, moving the selection, and editing through the model so `Ctrl+Z` still works. Extensions can ask you things too — quick picks, input boxes with validation, and messages with buttons, each labelled with the extension doing the asking. Diagnostics land in the editor and the problems panel; definitions and formatters work; settings, file watchers and status bar items all do what they say. Trees and webviews contributed by an extension get their own place in the sidebar, webviews sandboxed. Schutz-native extensions can register commands, show panels, and subscribe to what happens in the IDE — files opened and saved, proposals accepted or rejected, agent turns starting and ending. +**Extensions** — Install VS Code extensions from Open VSX, with TextMate grammars and icon themes. The `vscode` shim gives them the active editor for real: reading the document, moving the selection, and editing through the model so `Ctrl+Z` still works. Extensions can ask you things too — quick picks, input boxes with validation, and messages with buttons, each labelled with the extension doing the asking. Diagnostics land in the editor and the problems panel; definitions and formatters work; settings, file watchers and status bar items all do what they say. Trees and webviews contributed by an extension get their own place in the sidebar, webviews sandboxed. Quick fixes appear in the editor's own menu and apply through the model, so one `Ctrl+Z` takes them back. Schutz-native extensions can register commands, show panels, and subscribe to what happens in the IDE — files opened and saved, proposals accepted or rejected, agent turns starting and ending. **Localization** — The full UI ships in Korean, English, German, and Japanese. ## 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 @@ -130,12 +142,24 @@ today. A VS Code extension can read and edit the active document, publish diagno you a question, read its own settings, watch files, show status, and contribute a tree or webview to the sidebar. MCP speaks all three of its pillars rather than tools alone. -Where it is still thin, specifically: code actions are not wired (the shim does not expose -`WorkspaceEdit`, so an extension cannot build one), `TreeView.reveal` and `document.save()` -are unimplemented, and a file that is not already in a tab cannot be opened. Each of those -returns an error rather than an empty value, so an extension that needs one fails loudly -instead of doing nothing. MCP resources and prompts are readable and shown in the panel, but -are not yet offered to the agent as tools. +MCP resources and prompts are offered to the agent, not just listed in a panel. + +Where it is still thin, specifically: a `WorkspaceEdit` cannot create, delete or rename +files, and the shim has no custom editors and no debug adapter API. Those three fail loudly — +`WorkspaceEdit` throws, and the other two are absent from the API object, so calling one is a +`TypeError` rather than a quiet nothing. + +Decorations used to sit in that list as the one gap that answered successfully and drew +nothing. They now draw: `createTextEditorDecorationType` compiles the requested styling into +a real rule, `setDecorations` puts it on the Monaco editor, and passing an empty array clears +what that type drew. Inline blame and coverage highlighting work, `before`/`after` content +included. + +Fixing it surfaced the reason such an extension would have appeared broken anyway: +`onDidChangeActiveTextEditor` fired before the editor existed, and the notification was +dropped on the way out, so opening a file delivered either nothing or the *previous* file's +editor. Extensions that draw on editor changes — which is most of them — were being handed +the wrong editor. The event now waits for the pane and fires once per file. ## Contributing 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/docs/PHASE2-SURVEY.md b/docs/PHASE2-SURVEY.md new file mode 100644 index 0000000..4bd6f9e --- /dev/null +++ b/docs/PHASE2-SURVEY.md @@ -0,0 +1,75 @@ +# Phase 2 조사 — 무엇이 있고 무엇이 빠졌나 + +작성일: 2026-08-05 · 기준 커밋: `develop` (main 대비 50커밋) · 목적: "Phase 2 를 끝냈다" 고 말하려면 끝의 정의가 먼저 필요하다 + +--- + +## 0. 먼저: 끝의 정의가 두 문서에서 다르고, 둘 다 현실과 다르다 + +- [DESIGN.md](DESIGN.md) 의 **Phase 2 는 "Code-OSS 포크로 이전"** 이다. 확장 API 로 불가능한 시각 효과를 위해 VSCodium 방식으로 포크한다는 계획이었다. +- [README.md](../README.md) 의 **Phase 2 는 "에디터 코어의 렌더러 레벨 시각 효과 심화"** 로 적혀 있고 *(in progress)* 다. +- **실제로는 둘 다 아니다.** 이 앱은 Code-OSS 포크가 아니라 Electron 43 + React 18 + Monaco 로 **처음부터 자기 렌더러를 갖고** 있다. 포크가 필요했던 이유(렌더러에 손댈 수 없다)가 애초에 없다. + +즉 Phase 2 의 원래 목표는 **다른 방법으로 이미 달성됐고**, 남은 것은 포크 여부가 아니라 **네 기둥(DESIGN.md 2.1~2.4)이 실제로 화면에 있는가** 다. 아래 표의 기준은 그 네 기둥이다. + +판정은 코드로 확인한 것만 적는다. △ 는 "비슷한 것이 있으나 설계가 말한 자리에 없다" 는 뜻이다. + +--- + +## 1. 2.1 편집 과정 애니메이션 + +| 항목 | 상태 | 확인한 것 | +|---|---|---| +| 스트리밍 삽입 | △ | [editAnimator.ts](../ide/src/editor/editAnimator.ts) 가 16ms마다 3글자씩 모델에 밀어 넣는다(최대 2200ms). 다만 흘리는 것은 **이미 완성된 최종 텍스트의 재생**이지 모델의 토큰 스트림이 아니다. 설계는 "토큰 스트림을 그대로 흘려서" 라고 적었다. | +| 글로우/하이라이트 | ✓ | `.sz-ai-typing`(쓰고 있는 줄) + `.sz-ai-changed`(1.4초 잔광, `szAiFade`). `reducedMotion()` 을 직접 물어 모션 최소화도 지킨다. | +| 커서 고스트 | ✗ | 없다. 코드베이스 어디에도 유령 커서가 없다. | +| undo 한 덩어리 | ✓ | 타이핑 시작에 `pushStackElement` 한 번 — Ctrl+Z 로 AI 편집 전체가 한 번에 돌아온다. | + +## 2. 2.2 변경 diff 시각화 + +| 항목 | 상태 | 확인한 것 | +|---|---|---| +| 인라인 diff 색 | △ | 거터 막대는 있다(`.sz-gd-add/mod/del`, [MonacoPane.tsx](../ide/src/editor/MonacoPane.tsx) 245~254). 그런데 그건 **git 기준 변경**이다. 아직 수락하지 않은 제안은 편집기 안에 안 보이고, 전체 diff 는 별도 탭([DiffPane.tsx](../ide/src/editor/DiffPane.tsx), `createDiffEditor`)에서 본다. | +| 변경 사유 툴팁 | △ | `rationale` 은 있고 제안 카드에 보인다(App.tsx:8387). 설계가 말한 자리 — **바뀐 줄 위의 툴팁** — 에는 없다. | +| 라인 단위 수락/거절 | △ | 헝크 단위 선택이 있다([review/hunks.ts](../ide/src/review/hunks.ts), `hunkSel`). 다만 카드 안에서 고르는 것이고, 설계가 적은 **CodeLens** 는 없다(`registerCodeLensProvider` 호출이 0건). | +| 변경 그룹핑 | △ | 한 제안 안에서 헝크로 묶인다. 파일을 가로지르는 "논리적 변경 하나" 묶음은 없다. | + +## 3. 2.3 AI 작업 상태·계획 패널 + +| 항목 | 상태 | 확인한 것 | +|---|---|---| +| 현재 행동 | ✓ | `statusKey` 가 도구 실행·편집·검토를 따라간다. | +| 계획 뷰 | ✓ | `set_plan` 도구 → `plan` 상태 → 패널. 체크·진행·예정이 구분된다. | +| 툴 호출 로그 | ✓ | `addTool`/`setTool` 로 타임라인에 남고 펼쳐서 출력까지 본다. | +| 토큰/비용 미터 | ✓ | `bumpAgent(tin, tout)` + 잔여량 조회. | + +**이 기둥은 끝났다.** 네 항목 모두 실제 경로로 채워진다. + +## 4. 2.4 멀티파일 동시 편집 뷰 + +| 항목 | 상태 | 확인한 것 | +|---|---|---| +| 미니 파일 그리드 | △ | `renderChangeOverview`(App.tsx:8532)가 있다. 다만 **카드 그리드가 아니라 막대가 붙은 목록**이고, 파일이 2개 이상일 때만 뜬다. | +| 변경량 배지 | ✓ | 파일마다 `+N / −N`, 가장 큰 파일 기준 상대 막대. | +| 동시 진행 표시 | ✓ | 파일마다 점 색으로 pending·failed·담당 에이전트를 구분한다. | +| 카드 클릭 → 점프 | ✓ | 누르면 그 파일을 연다. | + +--- + +## 5. 그래서 Phase 2 의 "끝" 을 무엇으로 둘 것인가 + +포크는 판단에서 뺀다 — 이 구조에서는 할 이유가 없고, 하지 않은 것이 맞다. 남은 것은 네 기둥의 빈칸이고, 그중 **설계가 이름으로 적었는데 코드에 없는 것**은 셋이다. + +1. **커서 고스트** (2.1) — 없다. AI 가 지금 어디를 고치는지 눈으로 따라가게 하는 장치인데, 지금은 줄 하이라이트만 있다. 작고, 데코레이션으로 되고, 효과가 바로 보인다. +2. **변경 사유 툴팁** (2.2) — `rationale` 은 이미 있다. 바뀐 줄에 붙이기만 하면 된다. 이것도 데코레이션 hover 로 된다. +3. **CodeLens 수락/거절** (2.2) — 헝크 선택 로직이 이미 있으므로 편집기 안에 그 버튼을 다는 일이다. 셋 중 가장 크다. + +나머지 △ 는 **설계와 다른 방식으로 이미 답이 있는 것**들이다(전체 diff 는 별도 탭, 파일 개요는 목록). 굳이 설계 문구에 맞춰 바꿀 값어치는 없어 보이고, 바꾼다면 그건 Phase 2 완료가 아니라 별개의 UI 결정이다. + +"스트리밍 삽입" 만 애매하다. 지금은 완성된 텍스트를 재생하는 것이라 **모델이 실제로 쓰는 속도와 무관**하다. 진짜 토큰 스트림을 흘리려면 제안 경로(도구 호출 → 카드 → 수락)를 건드려야 하는데, 그건 시각 효과가 아니라 승인 모델의 변경이다. 별건으로 남긴다. + +### 제안하는 완료 조건 + +> Phase 2 는 **커서 고스트 · 변경 사유 툴팁 · CodeLens 수락/거절** 셋이 실제 앱에서 동작하면 끝난 것으로 한다. 포크 항목은 아키텍처가 달라져 해당 없음으로 닫고, DESIGN.md 와 README 의 Phase 2 문구를 그 사실에 맞게 고친다. + +셋 다 데코레이션·CodeLens 계층이라 [#70](https://github.com/SchutzScript/Schutz/pull/70) 에서 만든 것을 그대로 쓴다. diff --git a/docs/PLAN-0.3.md b/docs/PLAN-0.3.md new file mode 100644 index 0000000..d033986 --- /dev/null +++ b/docs/PLAN-0.3.md @@ -0,0 +1,110 @@ +# 0.3.0 계획서 — 그리고 0.4.0 프리뷰 + +작성일: 2026-08-05 · 상태: 초안 · 기준 브랜치: `develop` (`main` 대비 45커밋) + +--- + +## 0. 지금 어디에 있나 + +`develop` 에 쌓인 45커밋은 **전부 "고침"** 이다. 미저장 편집 유실(외부 삭제·종료·프로젝트 전환·되돌리기), BOM·UTF-8 아닌 파일 파괴, 승인 물음이 실행을 매달아 두던 것, 깊은 폴더·점 폴더가 앱 어디에도 안 보이던 것, 워처가 목록을 말없이 자르던 것. + +좋은 릴리스 노트가 되지만 **큰 업데이트로는 안 읽힌다.** 헤드라인이 될 새 능력이 없기 때문이다. 0.3.0 은 그것을 채운다. + +0.2.0 이 "확장이 할 수 있다고 말한 것을 실제로 하게 만든" 판이었다면, 0.3.0 은 **고를 수 있게 만드는** 판이다 — 어떤 모델로 돌릴지, 무엇을 볼지, 확장이 어디까지 손댈 수 있는지. + +--- + +## 1. 0.3.0 범위 — 세 갈래 + +### 트랙 A — 프로바이더 확장 (Gemini · 로컬 모델) + +로드맵 Phase 3 의 절반. 지금 붙어 있는 것은 Claude · GPT · Grok · GLM 넷이고, 앞의 셋은 `OpenAICompatProvider` 하나를 설정만 바꿔 재사용한다(`src/ai/openaiCompat.ts:303~`). 그 구조 덕에 이 트랙은 대부분 **설정과 UI 문제**이지 프로토콜 문제가 아니다. + +**A-1. Gemini.** Google 이 OpenAI 호환 엔드포인트를 내주므로 `OpenAICompatProvider` 설정 한 줄로 시작한다. 확인해야 할 것은 호환 계층이 **도구 호출 스트리밍을 어떤 모양으로 흘리는가** 다 — `openaiCompat.ts` 의 두 경로(Responses 계열 `response.output_item.*`, Chat Completions 계열 `tool_calls` 델타) 중 어디에 붙는지, 인자 JSON 이 조각으로 오는지. 붙지 않으면 그때 전용 어댑터를 판다. + +**A-2. 로컬 모델 (Ollama · LM Studio · llama.cpp).** 셋 다 OpenAI 호환 서버라 프로토콜은 같다. 새로 필요한 것은 이쪽이다: + +- **주소를 사용자가 정한다.** 지금 URL 은 프로바이더에 상수로 박혀 있다. `baseUrl` 을 설정으로 빼고, 로컬은 API 키를 요구하지 않아야 한다(`isConfigured()` 가 키 없음을 미설정으로 보면 로컬은 영원히 못 쓴다). +- **모델 목록을 서버에서 읽는다.** 로컬은 사용자가 무엇을 받아 뒀는지 우리가 알 수 없다. `/v1/models` 를 물어 고르게 한다. 못 물으면 직접 입력. +- **연결 확인.** 서버가 안 떠 있으면 "네트워크 오류" 가 아니라 "그 주소에 서버가 없습니다" 로 말한다. +- **할당량 조회는 끈다.** `quotaProbe` 는 벤더 헤더를 읽는 것이라 로컬에 의미가 없다. 조용히 0 을 보여주면 고장으로 읽힌다. +- **도구 호출을 못 하는 모델이 있다.** 작은 로컬 모델은 tool use 를 지원하지 않거나 흉내만 낸다. 처음 실행에서 도구 호출이 한 번도 안 나오면 **그 사실을 말한다** — 조용히 대화만 하는 에이전트는 고장으로 보인다. + +**왜 이걸 먼저 두나:** 위험이 가장 낮고 사용자 가치가 가장 분명하다("내 모델로 돌린다"). 그리고 트랙 C 와 달리 이미 있는 추상(`AgentProvider`)이 실제로 그 목적으로 설계돼 있다. + +### 트랙 B — 에디터 코어 시각 효과 (Phase 2 마무리) + +README 가 **진행 중**이라고 적어 둔 항목이고, 이 제품의 한 줄 정의("AI가 코드를 고치는 과정을 실시간으로 멋지게 보여주는")가 정확히 여기 걸려 있다. 지금 있는 것은 `src/editor/editAnimator.ts` 의 타이핑 반영 하나다. + +**착수 전에 할 일이 하나 있다.** 무엇이 이미 있고 무엇이 빠졌는지 코드로 훑어 표로 만든다 — 지금 이 문서에 후보를 나열하면 없는 것을 있다고 적게 된다. Phase 2 를 "끝냈다" 고 말하려면 **끝의 정의**부터 필요하다. 그 표가 이 트랙의 첫 산출물이다. + +### 트랙 C — 확장 셰임의 남은 구멍 + +README 가 이름으로 적어 둔 것들이다. 그런데 **README 의 설명이 한 군데 틀렸다.** 이렇게 적혀 있다: + +> Each of those throws rather than returning an empty value, so an extension that needs one fails loudly instead of quietly doing nothing. + +`WorkspaceEdit` 는 정말 던진다(`vscodeShim.ts:146~148`). 그런데 데코레이션은 아니다: + +```ts +createTextEditorDecorationType: () => ({ dispose() {}, key: "sz-deco" }), // vscodeShim.ts:557 +setDecorations() { /* 데코레이션 타입이 no-op 이라 여기도 no-op */ }, // shimDoc.ts:125 +``` + +**조용히 성공하는 답이다** — 0.2.0 전체가 없애려 했던 바로 그 모양이 하나 남아 있고, 문서는 그렇지 않다고 말한다. 이 트랙의 첫 커밋은 기능이 아니라 **README 를 사실과 맞추는 것**이어야 한다. + +우선순위: + +1. **`WorkspaceEdit` 의 파일 생성·삭제·이름변경.** 리팩터 확장의 기본기다. 세 IPC(`writeFile` · `deleteEntry` · `renameEntry`)가 이미 있고 각자 안전장치도 있으므로(휴지통 경유, 덮어쓰기 금지) 셰임에서 이어 주는 일에 가깝다. **미저장 버퍼와 되돌리기**를 어떻게 다룰지가 진짜 설계 지점이다 — 이번 판에서 반복해 확인한 자리다. +2. **데코레이션.** Monaco 의 데코레이션 API 로 실제로 그린다. 인라인 blame, 커버리지 표시, 오류 하이라이트가 전부 여기 걸려 있다. +3. **커스텀 에디터 · 디버그 어댑터 API.** 크다. 0.3 에 넣을지는 A·B 가 끝난 뒤 남은 여력으로 정한다. 넣지 않으면 **던지게** 두고 README 에 그대로 적는다. + +--- + +## 2. 잘라내는 선 + +세 트랙을 다 넣으면 0.3 이 안 끝난다. 순서와 기준을 미리 정해 둔다. + +- **A 는 반드시 들어간다.** 헤드라인이다. +- **C-1(WorkspaceEdit 파일 조작)과 C 의 README 정정도 반드시 들어간다.** 후자는 하루짜리이고, 문서가 거짓말하는 상태로 릴리스하지 않는다. +- **B 는 조사 표까지가 최소선.** 표를 보고 나서, 한 판에 들어갈 만한 항목만 고른다. +- **C-3 은 선택.** 마감에 밀리면 다음으로 넘긴다 — 던지고 있으므로 조용히 고장 나지 않는다. + +마일스톤은 기존 `v0.3 (2026-09-30)`. + +--- + +## 3. 0.4.0 프리뷰 — 코드베이스 인덱싱 + +0.3 에는 넣지 않는다. 다만 **왜 이것이 다음인지**는 지금 적어 둔다. 근거가 이번 판에서 나왔기 때문이다. + +### 문제 + +에이전트가 저장소를 보는 눈은 `list_files` 와 `search_files` 둘뿐이고, 둘 다 **매번 디스크를 훑어서** 답한다. 그 구조에서 나온 결함을 이번 판에만 세 건 고쳤다: + +| 무엇 | 증상 | +|---|---| +| 깊이 상한 8 | 8단계보다 깊은 파일이 트리·검색·치환 **어디에도** 없었다 | +| 점 폴더 일괄 제외 | `.vscode/`·`.claude/` 안이 통째로 안 보였다 | +| 잘렸다고 말 안 함 | 상한에 걸린 목록을 "전부" 로 내밀어, 있는 파일을 "없다" 로 답했다 | + +셋 다 막았지만 **근본은 그대로다.** 매번 훑는 구조에서는 상한이 필요하고, 상한이 있으면 "안 찾아본 것" 과 "없는 것" 이 계속 같은 답으로 나온다. 그리고 이름으로만 찾으므로 "인증을 어디서 하지" 같은 질문에는 애초에 답할 수 없다. + +### 방향 (0.4 에서 결정할 것) + +- **심볼 인덱스가 먼저다.** 파일·심볼·참조 관계를 미리 만들어 두면 "정의가 어디" · "누가 부르나" 를 훑지 않고 답한다. TypeScript 는 이미 언어 서비스가 떠 있어(`src/editor/lspClient.ts`) 그 산출물을 재료로 쓸 수 있는지부터 본다. +- **의미 검색은 그다음.** 임베딩은 모델·저장소·비용·프라이버시(로컬 임베딩이냐)가 전부 딸려 온다. 심볼 인덱스만으로 어디까지 되는지 본 뒤에 판단한다. +- **갱신이 진짜 문제다.** 인덱스는 낡는 순간 거짓말을 시작하고, 그건 지금 상태보다 나쁘다. 이번 판에서 확인했듯 **워처는 이름을 다 주지 못한다**(OS 가 흘린다). 그래서 "워처 알림으로 증분 갱신" 은 그 자체로 못 미덥고, 트리 비교·해시·주기 재확인 중 무엇을 섞을지가 설계의 중심이 된다. +- **낡았을 때 그렇다고 말해야 한다.** 이 판 내내 지킨 규칙이 인덱스에도 그대로 적용된다 — 모르는 것을 아는 척하지 않는다. + +### 프리뷰의 뜻 + +0.4.0 은 **프리뷰**로 낸다. 인덱싱은 위의 갱신 문제 때문에 한 번에 옳게 만들기 어렵고, 틀린 인덱스는 조용히 잘못된 답을 만든다. 기본은 꺼 두고, 켠 사람에게는 인덱스가 언제 만들어졌고 무엇을 못 봤는지 보이게 한다. + +--- + +## 4. 다음 한 걸음 + +1. 트랙 C 의 README 정정 — 데코레이션이 던지지 않고 조용히 no-op 이라는 사실을 문서에 반영한다. +2. 트랙 B 의 조사 표 — Phase 2 에 무엇이 있고 무엇이 빠졌는지. +3. 트랙 A-1 착수 — Gemini 를 `OpenAICompatProvider` 에 붙여 보고, 도구 호출 스트림이 어느 경로에 맞는지 확인한다. diff --git a/ide/electron/crashPolicy.cjs b/ide/electron/crashPolicy.cjs new file mode 100644 index 0000000..73afee1 --- /dev/null +++ b/ide/electron/crashPolicy.cjs @@ -0,0 +1,49 @@ +// 메인 프로세스가 죽거나 렌더러가 사라졌을 때 무엇을 할지 정하는 **순수한 부분**. +// +// 메인에는 전역 오류 처리가 아예 없었다. Node 는 처리 안 된 거부를 기본으로 던지므로, +// 어딘가의 async IPC 하나가 실패하면 메인이 죽고 **앱이 통째로 사라진다** — 창도, +// 저장 안 한 버퍼도, 아무 설명도 없이. 렌더러에는 공들인 크래시 화면이 있는데 +// 정작 더 치명적인 쪽이 비어 있었다. +// +// 여기 있는 것은 판단만 한다. 파일·다이얼로그는 main.cjs 가 한다. + +/** 로그가 무한히 자라지 않게 자른다. 뒤쪽(최근)을 남긴다 — 크래시 직전이 궁금하지 + * 처음 켠 날이 궁금한 게 아니다. */ +function trimLog(text, maxBytes = 512 * 1024) { + const s = String(text ?? ""); + if (Buffer.byteLength(s, "utf8") <= maxBytes) return s; + // 대충 자른 뒤 첫 줄바꿈까지 버린다 — 반 토막 난 줄로 시작하지 않게. + const cut = s.slice(-Math.floor(maxBytes * 0.9)); + const nl = cut.indexOf("\n"); + return "…(앞부분 잘림)\n" + (nl >= 0 ? cut.slice(nl + 1) : cut); +} + +/** 사용자에게 알릴 것인가. + * + * 한 번 어긋나면 같은 오류가 초당 여러 번 오는 일이 흔하다. 그때마다 모달을 띄우면 + * 앱을 아예 못 쓴다. 세션당 한 번만 알리고, 나머지는 로그로만 남긴다. */ +function makeNotifyGate() { + let told = false; + return () => { if (told) return false; told = true; return true; }; +} + +/** 렌더러가 죽었을 때 다시 실을 것인가. + * + * 빈 창을 그대로 두면 아무것도 못 한다. 그래서 다시 싣되, 짧은 사이에 되풀이되면 + * 멈춘다 — 새로 실을 때마다 같은 이유로 또 죽는 상황이면 무한 반복이 된다. + * 그때는 사용자에게 넘긴다. */ +function makeReloadGate(limit = 2, windowMs = 60_000) { + const times = []; + return (now) => { + while (times.length && now - times[0] > windowMs) times.shift(); + times.push(now); + return times.length <= limit; + }; +} + +/** 한 줄 로그. 시간과 종류를 앞에 붙인다 — 여러 종류가 섞이면 순서가 유일한 단서다. */ +function logLine(when, kind, detail) { + return "[" + new Date(when).toISOString() + "] " + kind + ": " + String(detail ?? "").replace(/\r?\n/g, "\n ") + "\n"; +} + +module.exports = { trimLog, makeNotifyGate, makeReloadGate, logLine }; diff --git a/ide/electron/crashPolicy.d.cts b/ide/electron/crashPolicy.d.cts new file mode 100644 index 0000000..588b8c3 --- /dev/null +++ b/ide/electron/crashPolicy.d.cts @@ -0,0 +1,6 @@ +// crashPolicy.cjs 의 타입. 메인 프로세스 코드는 .cjs 지만 순수한 판단 부분은 +// 렌더러 쪽 테스트에서 그대로 불러 쓴다 — 그러려면 선언이 필요하다. +export declare function trimLog(text: string | null | undefined, maxBytes?: number): string; +export declare function makeNotifyGate(): () => boolean; +export declare function makeReloadGate(limit?: number, windowMs?: number): (now: number) => boolean; +export declare function logLine(when: number, kind: string, detail: unknown): string; diff --git a/ide/electron/encoding.cjs b/ide/electron/encoding.cjs new file mode 100644 index 0000000..2dcab3a --- /dev/null +++ b/ide/electron/encoding.cjs @@ -0,0 +1,42 @@ +// 이 바이트들을 텍스트 편집기에 실어도 되는가 — **순수한 판정**. +// +// readFile 이 무조건 fs.readFile(abs, "utf8") 이었다. UTF-8 이 아닌 파일은 디코딩에서 +// 깨진 글자(U+FFFD)로 바뀌고, 그 상태로 저장하면 **원본이 파괴된다.** 되돌릴 방법이 +// 없다. +// +// 실제로 재현했다. UTF-16 파일은 열고 Ctrl+S 만 눌러도 16바이트가 20바이트가 됐다. +// CP949 로 저장된 한글 파일은 화면에 `// �ȳ� ����` 로 뜨고, 거기서 한 글자만 고쳐 +// 저장하면 파일 전체가 사라진다. 윈도우에서 아주 흔한 인코딩이다. +// +// 다른 인코딩을 **읽어 주는 것**은 기능이고, 조용히 부수지 않는 것은 그 전에 지켜야 +// 할 일이다. 그래서 지금은 판정만 하고, 아니면 열지 않고 이유를 말한다. + +/** + * @returns null 이면 UTF-8 텍스트로 안전하다. 아니면 왜 아닌지. + * "utf16le" | "utf16be" — 바이트 순서 표식이 붙은 UTF-16 + * "binary" — NUL 바이트가 있다(텍스트가 아니다) + * "not-utf8" — UTF-8 로 해석되지 않는 바이트가 있다(CP949·Shift-JIS·Latin-1 …) + */ +function detect(buf) { + if (!buf || typeof buf.length !== "number") return null; + if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) return "utf16le"; + if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) return "utf16be"; + // NUL 은 텍스트 파일에 있을 이유가 없다. UTF-16 은 위에서 걸렀으므로 여기 오는 + // NUL 은 진짜 바이너리다(확장자만 텍스트인 파일이 흔하다). + if (buf.indexOf(0) >= 0) return "binary"; + // 왕복시켜 본다 — 디코딩이 손실 없이 됐다면 다시 인코딩한 바이트가 같아야 한다. + // 깨진 바이트는 U+FFFD 로 바뀌므로 길이부터 달라진다. + const round = Buffer.from(buf.toString("utf8"), "utf8"); + return round.equals(buf) ? null : "not-utf8"; +} + +/** IPC 로 넘길 오류 메시지. 렌더러가 앞부분을 보고 안내 문구를 고른다. */ +const PREFIX = "SCHUTZ_ENCODING:"; +function errorFor(kind) { return PREFIX + kind; } +function kindOf(message) { + const s = String(message || ""); + const i = s.indexOf(PREFIX); + return i < 0 ? null : s.slice(i + PREFIX.length).trim().split(/\s/)[0]; +} + +module.exports = { detect, errorFor, kindOf, PREFIX }; diff --git a/ide/electron/encoding.d.cts b/ide/electron/encoding.d.cts new file mode 100644 index 0000000..ecdb8b6 --- /dev/null +++ b/ide/electron/encoding.d.cts @@ -0,0 +1,5 @@ +// encoding.cjs 의 타입. 메인 프로세스 코드지만 판정 부분은 테스트에서 그대로 부른다. +export declare function detect(buf: Buffer | null | undefined): null | "utf16le" | "utf16be" | "binary" | "not-utf8"; +export declare function errorFor(kind: string): string; +export declare function kindOf(message: unknown): string | null; +export declare const PREFIX: string; diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index 2e9a43a..9a6c662 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -16,15 +16,74 @@ const DEV_URL = process.env.SCHUTZ_DEV_URL || "http://localhost:4322"; const isDev = !app.isPackaged; /** 트리 스캔에서 제외할 디렉터리 */ +// 순회에서 뺄 폴더. **여기 적힌 것만** 뺀다. +// +// 예전엔 이 목록과 별개로 "점으로 시작하는 폴더는 전부 건너뛴다(.github 만 예외)" 라는 +// 규칙이 세 순회에 각각 박혀 있었다. 그래서 .vscode/settings.json 도 .claude/agents/*.md +// 도 트리에 안 나오고 검색에도 안 걸렸다 — 열려면 경로를 정확히 알아야 했고, 에이전트는 +// 그 파일들이 없는 줄 알았다. .github 만 예외였던 것이 그 규칙이 임시방편이었다는 표시다. +// 무엇을 숨길지는 이 한 곳이 정한다. const IGNORE_DIRS = new Set([ "node_modules", ".git", ".hg", ".svn", "dist", "out", "release", ".next", ".astro", "__pycache__", ".venv", "venv", "target", ".idea", ".vscode-test", + // 점 폴더를 일괄로 막던 규칙을 걷어냈으니, 무거운 것들은 이름으로 적어 둔다. + ".cache", ".turbo", ".parcel-cache", ".gradle", ".pytest_cache", ".mypy_cache", ".ruff_cache", + ".tox", ".nuxt", ".output", ".svelte-kit", ".angular", ".yarn", ".pnpm-store", ".terraform", ".vs", ]); const MAX_ENTRIES = 4000; -const MAX_DEPTH = 8; +// 8 은 너무 얕았다. packages/app/src/features/x/components/y/z.ts 정도면 벌써 8이고, +// 그보다 깊은 파일은 트리·검색·치환 **어디에도 안 나타났다** — 앱 안에서 찾을 방법이 +// 없었다는 뜻이다. node_modules 류는 IGNORE_DIRS 로 이미 빠지고 트리는 MAX_ENTRIES +// 로 따로 묶여 있으므로, 깊이를 올려도 비용은 디렉터리 읽기 몇 번이다. +const MAX_DEPTH = 16; const MAX_FILE_BYTES = 2 * 1024 * 1024; // 2MB — 에디터로 열 상한 let winCounter = 0; + +/* ── 메인이 죽을 때 ──────────────────────────────────────────────────────── + 여기에는 전역 오류 처리가 아예 없었다. Node 는 처리 안 된 거부를 기본으로 + 던지므로, 어딘가의 async IPC 하나가 실패하면 메인이 죽고 **앱이 통째로 + 사라진다** — 창도, 저장 안 한 버퍼도, 아무 설명도 없이. 렌더러에는 공들인 + 크래시 화면이 있는데 정작 더 치명적인 쪽이 비어 있었다. + + 그래서 죽지 않는다. 메인을 살려 두면 렌더러가 들고 있는 편집본을 저장할 + 기회가 남는다 — 관계없는 비동기 오류 하나로 그걸 잃는 것보다 낫다. 대신 + 반드시 남기고, 한 번은 알린다. */ +const crashPolicy = require("./crashPolicy.cjs"); +const notifyGate = crashPolicy.makeNotifyGate(); +let logPath = null; +function logFile() { + if (logPath) return logPath; + try { logPath = path.join(app.getPath("userData"), "logs", "main.log"); } catch { logPath = null; } + return logPath; +} +function recordCrash(kind, err) { + const detail = err && err.stack ? err.stack : String(err); + try { console.error("[schutz] " + kind + ":", detail); } catch { /* */ } + const file = logFile(); + if (file) { + try { + require("fs").mkdirSync(path.dirname(file), { recursive: true }); + let prev = ""; + try { prev = require("fs").readFileSync(file, "utf8"); } catch { /* 첫 기록 */ } + require("fs").writeFileSync(file, crashPolicy.trimLog(prev + crashPolicy.logLine(Date.now(), kind, detail)), "utf8"); + } catch { /* 로그를 못 써도 앱은 계속 돈다 */ } + } + if (!notifyGate() || !app.isReady()) return; + // 모달로 막지 않는다 — 사용자가 하던 일을 끊을 이유가 없다. 알리고, 로그로 가는 길만 준다. + try { + dialog.showMessageBox({ + type: "warning", + message: "백그라운드에서 오류가 발생했습니다", + detail: "앱은 계속 사용할 수 있습니다. 저장하지 않은 내용이 있으면 먼저 저장해 주세요.\n\n" + String(detail).slice(0, 400), + buttons: file ? ["확인", "로그 폴더 열기"] : ["확인"], + defaultId: 0, noLink: true, + }).then(r => { if (r.response === 1 && file) shell.showItemInFolder(file); }).catch(() => { /* */ }); + } catch { /* */ } +} +process.on("uncaughtException", (e) => recordCrash("uncaughtException", e)); +process.on("unhandledRejection", (e) => recordCrash("unhandledRejection", e)); + // 진짜 종료 중인가. 평소 창 닫기는 트레이로 최소화하지만(아래 win.on("close")), // 트레이 '종료' → app.quit() → before-quit 에서 이 값이 서면 창이 정상적으로 닫힌다. let isQuitting = false; @@ -79,6 +138,24 @@ function createWindow(layout) { void loadDist(); } + /* 렌더러가 사라지면 빈 창만 남는다. 아무 처리가 없어서 그 창은 영원히 흰 화면이었다 — + 사용자가 할 수 있는 일이 창을 닫는 것뿐이다. 다시 싣되, 짧은 사이에 되풀이되면 + 멈추고 사용자에게 넘긴다(실을 때마다 또 죽는 상황이면 무한 반복이 된다). */ + const reloadGate = crashPolicy.makeReloadGate(); + win.webContents.on("render-process-gone", (_e, details) => { + recordCrash("render-process-gone", (details && details.reason) || "unknown"); + if (win.isDestroyed()) return; + if (reloadGate(Date.now())) { try { win.reload(); } catch { /* */ } return; } + try { + dialog.showMessageBox(win, { + type: "error", + message: "편집기 화면이 반복해서 종료되고 있습니다", + detail: "다시 여는 것을 멈췄습니다. 창을 닫았다가 앱을 다시 시작해 주세요.", + buttons: ["확인"], noLink: true, + }).catch(() => { /* */ }); + } catch { /* */ } + }); + // 외부 링크는 기본 브라우저로 — http/https/mailto 만 허용 (file: 등 위험 스킴 차단) win.webContents.setWindowOpenHandler(({ url }) => { try { @@ -199,7 +276,7 @@ ipcMain.handle("schutz:readTree", async (_e, root) => { }); for (const it of items) { if (entries.length >= MAX_ENTRIES) return; - if (it.name.startsWith(".") && it.isDirectory() && it.name !== ".github") continue; + // 점으로 시작하는 폴더를 전부 숨기지 않는다 — 무엇을 숨길지는 IGNORE_DIRS 하나가 정한다. if (it.isDirectory() && IGNORE_DIRS.has(it.name)) continue; if (it.name.endsWith(".schutz-tmp")) continue; // 원자적 쓰기 중의 임시 파일 — 트리에 잠깐 나타나면 안 된다 const rel = relBase ? relBase + "/" + it.name : it.name; @@ -222,13 +299,20 @@ ipcMain.handle("schutz:readTree", async (_e, root) => { return { root, name: path.basename(root), entries, branch, truncated: entries.length >= MAX_ENTRIES || depthCapped }; }); +const encoding = require("./encoding.cjs"); ipcMain.handle("schutz:readFile", async (_e, root, rel) => { const abs = safeJoin(root, rel); const st = await fs.stat(abs); if (st.size > MAX_FILE_BYTES) { throw new Error("파일이 너무 큽니다 (" + Math.round(st.size / 1024) + " KB)"); } - return await fs.readFile(abs, "utf8"); + // 바이트로 읽어 UTF-8 인지 먼저 본다. 예전엔 무조건 "utf8" 로 디코딩해서, UTF-8 이 + // 아닌 파일은 깨진 글자로 열리고 **저장하는 순간 원본이 파괴됐다.** 되돌릴 수 없다. + // (UTF-16 파일은 열고 Ctrl+S 만 눌러도 바이트가 바뀌는 것을 확인했다.) + const buf = await fs.readFile(abs); + const kind = encoding.detect(buf); + if (kind) throw new Error(encoding.errorFor(kind)); + return buf.toString("utf8"); }); /** 원자적 쓰기 — 같은 디렉터리에 임시 파일로 쓴 뒤 rename. 도중에 죽어도 반쪽짜리 파일이 남지 않는다. @@ -320,13 +404,16 @@ ipcMain.handle("schutz:searchFiles", async (_e, root, query, opts) => { // 1) 후보 파일 수집 (디렉터리 순회 — 저비용) const files = []; + // 깊이 때문에 안 걸어 본 곳이 있으면 알린다. 결과가 없다는 것과 안 찾아봤다는 것은 + // 다르고, 검색은 "여기 없다" 의 근거로 쓰이기 때문에 그 차이가 중요하다. + let deepSkipped = false; async function walk(dirAbs, relBase, depth) { - if (depth > MAX_DEPTH) return; + if (depth > MAX_DEPTH) { deepSkipped = true; return; } let items; try { items = await fs.readdir(dirAbs, { withFileTypes: true }); } catch { return; } for (const it of items) { if (it.isSymbolicLink()) continue; // 심볼릭 링크는 따라가지 않음(워크스페이스 밖 접근/쓰기 방지) - if (it.name.startsWith(".") && it.isDirectory() && it.name !== ".github") continue; + // 점으로 시작하는 폴더를 전부 숨기지 않는다 — 무엇을 숨길지는 IGNORE_DIRS 하나가 정한다. if (it.isDirectory() && IGNORE_DIRS.has(it.name)) continue; const rel = relBase ? relBase + "/" + it.name : it.name; if (it.isDirectory()) { await walk(path.join(dirAbs, it.name), rel, depth + 1); continue; } @@ -359,7 +446,7 @@ ipcMain.handle("schutz:searchFiles", async (_e, root, query, opts) => { let idx = 0; const worker = async () => { while (idx < files.length && !truncated) { await processOne(files[idx++]); } }; await Promise.all(Array.from({ length: Math.min(12, files.length) }, () => worker())); - return { hits, truncated }; + return { hits, truncated, deepSkipped }; }); // ── Git 통합 ──────────────────────────────────────────────────────────────── @@ -682,7 +769,9 @@ function syncTray(img) { } catch { /* 트레이가 없는 환경도 있다 — 없어도 앱은 돈다 */ } } // 앱이 끝날 때 놓아준다. 안 그러면 종료 후에도 아이콘이 남아 클릭하면 아무 일도 안 난다. -app.on("before-quit", () => { try { tray?.destroy(); } catch { /* */ } tray = null; }); +// 트레이는 **정말 나갈 때** 놓는다. before-quit 에서 없애면, 종료가 붙잡혔을 때 +// (저장 안 한 것이 있어 되물을 때) 트레이만 사라져 앱에 닿을 길이 없어진다. +app.on("will-quit", () => { try { tray?.destroy(); } catch { /* */ } tray = null; }); // 파일/폴더 이름 변경 · 삭제 ipcMain.handle("schutz:renameEntry", async (_e, root, relFrom, relTo) => { @@ -761,13 +850,14 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt const inc = globToMatcher(opts?.include); const exc = globToMatcher(opts?.exclude); let changed = 0, files = 0; + const skipped = []; // UTF-8 이 아니라 건드리지 않은 파일 async function walk(dirAbs, relBase, depth) { if (depth > MAX_DEPTH) return; let items; try { items = await fs.readdir(dirAbs, { withFileTypes: true }); } catch { return; } for (const it of items) { if (it.isSymbolicLink()) continue; // 심볼릭 링크는 따라가지 않음(워크스페이스 밖 접근/쓰기 방지) - if (it.name.startsWith(".") && it.isDirectory() && it.name !== ".github") continue; + // 점으로 시작하는 폴더를 전부 숨기지 않는다 — 무엇을 숨길지는 IGNORE_DIRS 하나가 정한다. if (it.isDirectory() && IGNORE_DIRS.has(it.name)) continue; const rel = relBase ? relBase + "/" + it.name : it.name; if (it.isDirectory()) { await walk(path.join(dirAbs, it.name), rel, depth + 1); continue; } @@ -778,8 +868,14 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt const abs = path.join(dirAbs, it.name); let st; try { st = await fs.stat(abs); } catch { continue; } if (st.size > MAX_FILE_BYTES) continue; - let text; try { text = await fs.readFile(abs, "utf8"); } catch { continue; } - if (text.indexOf(String.fromCharCode(0)) >= 0) continue; + let buf; try { buf = await fs.readFile(abs); } catch { continue; } + // UTF-8 이 아니면 손대지 않는다. 여기가 readFile 보다 위험하다 — 사용자가 열어 + // 보지도 않은 파일을 훑으며 쓰기 때문에, CP949 로 된 파일 하나가 치환 한 번에 + // 조용히 파괴됐다(26바이트 → 39바이트, 한글이 전부 U+FFFD 로). 성공 토스트까지 + // 떴다. 무엇을 건너뛰었는지는 아래에서 돌려준다 — 조용히 빼면 "다 됐다" 로 읽힌다. + const kind = encoding.detect(buf); + if (kind) { skipped.push(rel); continue; } + const text = buf.toString("utf8"); re.lastIndex = 0; const matches = text.match(re); if (!matches || !matches.length) continue; @@ -792,9 +888,9 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt } // walk 가 중간에 죽으면 부분 결과가 남는다 — 성공인 척하지 말고 어디까지 됐는지 알린다 try { await walk(root, "", 0); } catch (e) { - return { changed, files, partial: true, error: e && e.message ? e.message : String(e) }; + return { changed, files, skipped, partial: true, error: e && e.message ? e.message : String(e) }; } - return { changed, files }; + return { changed, files, skipped }; }); // ── 에이전트 명령 실행 ───────────────────────────────────────────────────── @@ -827,8 +923,12 @@ ipcMain.handle("schutz:runCommand", async (e, opts) => { const root = String(opts?.cwd || ""); const command = String(opts?.command || "").trim(); if (!command) return { ok: false, error: "빈 명령" }; + // 열려 있는 워크스페이스에서만 돈다. 예전엔 존재하는 디렉터리이기만 하면 됐다 — + // 렌더러가 넘긴 아무 경로에서나(홈 디렉터리라도) 명령이 돌 수 있었다는 뜻이다. + // 다른 fs 핸들러는 전부 assertRoot 를 태우는데 여기만 빠져 있었다. + try { assertRoot(root); } catch { return { ok: false, error: "열려 있지 않은 워크스페이스" }; } let rootOk = false; - try { rootOk = !!root && require("fs").existsSync(root); } catch { rootOk = false; } + try { rootOk = require("fs").existsSync(root); } catch { rootOk = false; } if (!rootOk) return { ok: false, error: "워크스페이스가 없습니다" }; const id = String(opts.id || ("run" + Date.now())); @@ -857,7 +957,9 @@ ipcMain.handle("schutz:runCommand", async (e, opts) => { let child; try { - // shell:true — 사용자가 터미널에 치는 것과 같은 해석. cwd 로 워크스페이스에 가둔다. + // shell:true — 사용자가 터미널에 치는 것과 같은 해석. cwd 는 워크스페이스에서 + // **시작한다**는 뜻일 뿐, 경계가 아니다(명령이 cd 든 절대경로든 쓸 수 있다). + // 진짜 경계는 그 위의 승인 게이트다 — 여기 주석이 "가둔다" 고 잘못 적혀 있었다. child = spawn(command, { cwd: root, shell: true, env: process.env, windowsHide: true }); } catch (err) { return finish({ ok: false, error: err && err.message ? err.message : String(err) }); @@ -1345,6 +1447,7 @@ ipcMain.on("schutz:termReconcile", (e, ids) => { }); // ── 파일 워처 (외부 변경 감지) ────────────────────────────────────────────── +const touchSet = require("./touchSet.cjs"); const watchers = new Map(); // webContents.id → { watcher, timer } ipcMain.on("schutz:watchStart", (e, root) => { @@ -1359,15 +1462,15 @@ ipcMain.on("schutz:watchStart", (e, root) => { // 어떤 파일이 움직였는지 모아 둔다. fs.watch 는 처음부터 이름을 주는데, 예전엔 // 무시 규칙에만 쓰고 버려서 렌더러에는 "뭔가 바뀌었다" 는 빈 신호만 갔다. // 그래서 확장의 파일 감시자를 만들 수가 없었다. - let touched = new Set(); - const MAX_TOUCHED = 2000; // 대량 변경(브랜치 전환)에서 무한히 쌓이지 않게 + // 상한은 있되 넘치면 넘쳤다고 말한다 — touchSet.cjs 에 사연이 있다. + const touched = touchSet.makeTouchSet(); try { const watcher = fs.watch ? require("fs").watch(root, { recursive: true }, (_type, filename) => { if (filename) { const parts = String(filename).replace(/\\/g, "/").split("/"); if (parts.some(seg => IGNORE_DIRS.has(seg))) return; // node_modules/.git 등 무시 if (parts[parts.length - 1].endsWith(".schutz-tmp")) return; // 우리가 방금 만든 임시 파일 — 새로고침을 유발할 이유가 없다 - if (touched.size < MAX_TOUCHED) touched.add(String(filename).replace(/\\/g, "/")); + touched.add(filename); } dirty = true; const cur = watchers.get(wid); @@ -1376,9 +1479,10 @@ ipcMain.on("schutz:watchStart", (e, root) => { cur.timer = setTimeout(() => { if (dirty && !e.sender.isDestroyed()) { dirty = false; - const rels = [...touched]; - touched = new Set(); - e.sender.send("schutz:fsChange", rels); + const d = touched.drain(); + // overflow 를 함께 보낸다. 받는 쪽이 "이 목록이 전부" 라고 믿으면 안 되는 + // 경우를 알아야, 개별 이름 대신 트리 비교로 판정할 수 있다. + e.sender.send("schutz:fsChange", d.rels, d.overflow); } }, 350); } @@ -1439,7 +1543,61 @@ app.whenReady().then(() => { // 백그라운드 실행(dev 서버)은 앱보다 오래 산다 — 종료 때 정리하지 않으면 // 포트를 계속 물고 있어 다음 실행이 EADDRINUSE 로 죽는다. -app.on("before-quit", () => { +/* ── 저장 안 한 채로 나가려 할 때 ────────────────────────────────────────── + 렌더러에는 beforeunload 가드가 있는데, Electron 에서 그건 **대화상자 없이 종료를 + 조용히 취소한다.** 그러면 종료를 눌렀는데 아무 일도 안 일어난 것처럼 보이고, 그 + 시점엔 이미 트레이가 사라지고 실행 중인 프로세스가 죽은 뒤다 — 창을 트레이로 + 내려둔 상태였다면 앱에 닿을 방법이 없다. (최소 재현으로 확인했다.) + + 그래서 붙잡기 전에 **묻는다.** 렌더러가 미리 알려 둔 목록을 쓰므로 종료 순간에 + 왕복할 필요가 없다. */ +const quitGuard = require("./quitGuard.cjs"); +/** 창(webContents id) → 저장 안 한 파일 목록. */ +const dirtyByWin = new Map(); +let quitDecided = false; +ipcMain.on("schutz:dirty", (e, files) => { + const list = Array.isArray(files) ? files.filter(x => typeof x === "string") : []; + if (list.length) dirtyByWin.set(e.sender.id, list); else dirtyByWin.delete(e.sender.id); +}); +const allDirty = () => [...new Set([].concat(...dirtyByWin.values()))]; + +app.on("before-quit", (e) => { + const files = allDirty(); + if (quitGuard.shouldAsk(files.length, quitDecided)) { + e.preventDefault(); + const win = BrowserWindow.getAllWindows().find(w => !w.isDestroyed()) || null; + const opts = { + type: "warning", + message: "저장하지 않은 파일이 " + files.length + "개 있습니다", + detail: quitGuard.describe(files) + "\n\n저장하지 않고 종료하면 이 내용은 사라집니다.", + buttons: quitGuard.BUTTONS, defaultId: 0, cancelId: 2, noLink: true, + }; + const handle = (r) => { + const what = quitGuard.decide(r && r.response); + if (what === "cancel") return; // 트레이도 창도 그대로 — 누른 적 없는 것과 같다 + quitDecided = true; + if (what === "discard") { + // 렌더러의 beforeunload 가 더는 막지 않게 알린 뒤 나간다. + for (const w of BrowserWindow.getAllWindows()) { try { w.webContents.send("schutz:quitForce"); } catch { /* */ } } + setTimeout(() => app.quit(), 120); + return; + } + // 저장하고 종료 — 렌더러가 끝났다고 알리면 그때 나간다. 답이 없으면 8초 뒤 포기하고 + // **다시 묻지 않고** 나가지 않는다(저장이 안 됐을 수 있다). + let done = false; + const off = () => { ipcMain.removeListener("schutz:quitReady", onReady); }; + const onReady = () => { if (done) return; done = true; off(); app.quit(); }; + ipcMain.on("schutz:quitReady", onReady); + for (const w of BrowserWindow.getAllWindows()) { try { w.webContents.send("schutz:quitSave"); } catch { /* */ } } + setTimeout(() => { + if (done) return; + done = true; off(); quitDecided = false; + try { dialog.showMessageBox(win, { type: "error", message: "저장을 끝내지 못해 종료하지 않았습니다", detail: "직접 저장한 뒤 다시 시도해 주세요.", buttons: ["확인"], noLink: true }).catch(() => {}); } catch { /* */ } + }, 8000); + }; + try { (win ? dialog.showMessageBox(win, opts) : dialog.showMessageBox(opts)).then(handle).catch(() => {}); } catch { /* */ } + return; + } // 이제부터의 창 닫기는 트레이 최소화가 아니라 진짜 종료다 — win.on("close") 가 이 값을 본다. isQuitting = true; for (const c of runProcs.values()) { try { killProcTree(c); } catch { /* 이미 죽음 */ } } diff --git a/ide/electron/preload.cjs b/ide/electron/preload.cjs index d5d441b..5a1de21 100644 --- a/ide/electron/preload.cjs +++ b/ide/electron/preload.cjs @@ -52,7 +52,9 @@ contextBridge.exposeInMainWorld("schutz", { onFsChange: (cb) => { // 바뀐 파일들의 상대 경로를 함께 넘긴다. 예전엔 인자 없이 불러서, 무엇이 // 움직였는지 아는 쪽(메인)의 정보가 렌더러에 닿지 않았다. - const h = (_e, rels) => cb(Array.isArray(rels) ? rels : []); + // overflow=true 면 이 목록이 전부가 아니다(상한에 걸려 이름이 잘렸다). + // 받는 쪽은 개별 이름 대신 트리 비교로 판정해야 한다. + const h = (_e, rels, overflow) => cb(Array.isArray(rels) ? rels : [], !!overflow); ipcRenderer.on("schutz:fsChange", h); return () => ipcRenderer.removeListener("schutz:fsChange", h); }, @@ -185,6 +187,23 @@ contextBridge.exposeInMainWorld("schutz", { return () => ipcRenderer.removeListener("schutz:quota", h); }, + /** 저장 안 한 파일 목록을 메인에 알려 둔다. + * 종료를 누른 **뒤에** 물어보면 늦다 — 그때는 이미 트레이가 사라지고 실행 중인 + * 프로세스가 죽은 다음이다. 미리 알고 있어야 종료 자체를 붙잡을 수 있다. */ + reportDirty: (files) => ipcRenderer.send("schutz:dirty", files), + /** 메인이 "저장하고 종료" 를 골랐을 때. 저장이 끝나면 done() 을 부른다. */ + onQuitSave: (cb) => { + const h = () => cb(() => ipcRenderer.send("schutz:quitReady")); + ipcRenderer.on("schutz:quitSave", h); + return () => ipcRenderer.removeListener("schutz:quitSave", h); + }, + /** 메인이 "저장하지 않고 종료" 를 골랐을 때 — beforeunload 가 더는 막으면 안 된다. */ + onQuitForce: (cb) => { + const h = () => cb(); + ipcRenderer.on("schutz:quitForce", h); + return () => ipcRenderer.removeListener("schutz:quitForce", h); + }, + /** 범용 GET (CORS 우회) — 모델 목록 등 */ httpGet: (url, headers) => ipcRenderer.invoke("schutz:httpGet", url, headers), diff --git a/ide/electron/quitGuard.cjs b/ide/electron/quitGuard.cjs new file mode 100644 index 0000000..030b9da --- /dev/null +++ b/ide/electron/quitGuard.cjs @@ -0,0 +1,38 @@ +// 저장 안 한 것이 있는데 종료를 누르면 무엇을 할지 — **순수한 부분**. +// +// 지금까지는 이랬다. 트레이 '종료' → app.quit() → before-quit 이 트레이를 없애고 +// 실행 중인 프로세스를 죽인다 → 창을 닫으려는데 렌더러의 beforeunload 가 막는다. +// 결과: 앱은 살아 있고, 트레이 아이콘은 사라졌고, 눌렀던 종료는 아무 일도 안 한 것처럼 +// 보인다. 창을 트레이로 내려둔 상태였다면 **앱에 닿을 방법이 없다.** +// +// (Electron 에서 beforeunload 가 app.quit() 을 막는다는 것은 최소 재현으로 확인했다. +// 브라우저처럼 대화상자를 띄우지 않고 조용히 취소된다.) +// +// 그래서 막기 전에 **묻는다.** 여기 있는 것은 판단만 한다 — 대화상자는 main.cjs 가 띄운다. + +/** 물어볼 것인가. 저장 안 한 것이 없으면 그냥 나간다. */ +function shouldAsk(dirtyCount, alreadyDecided) { + return !alreadyDecided && typeof dirtyCount === "number" && dirtyCount > 0; +} + +/** 대화상자 버튼. 순서가 곧 응답 번호라 한곳에서 정한다. */ +const BUTTONS = ["저장하고 종료", "저장하지 않고 종료", "취소"]; + +/** 응답 번호 → 무엇을 할지. 모르는 번호(창을 그냥 닫는 등)는 **취소**로 본다 — + * 판단이 애매할 때 종료해 버리면 작업이 사라진다. */ +function decide(response) { + if (response === 0) return "save"; + if (response === 1) return "discard"; + return "cancel"; +} + +/** 사람에게 보여 줄 문구. 파일 이름을 몇 개 보여 주고 나머지는 수로 줄인다 — + * 스무 개를 늘어놓으면 무엇을 잃는지가 오히려 안 읽힌다. */ +function describe(files, max = 5) { + const list = Array.isArray(files) ? files.filter(Boolean) : []; + if (!list.length) return ""; + const head = list.slice(0, max).join("\n"); + return list.length > max ? head + "\n… 외 " + (list.length - max) + "개" : head; +} + +module.exports = { shouldAsk, decide, describe, BUTTONS }; diff --git a/ide/electron/quitGuard.d.cts b/ide/electron/quitGuard.d.cts new file mode 100644 index 0000000..3688ea2 --- /dev/null +++ b/ide/electron/quitGuard.d.cts @@ -0,0 +1,5 @@ +// quitGuard.cjs 의 타입. 메인 프로세스 코드지만 판단 부분은 테스트에서 그대로 부른다. +export declare function shouldAsk(dirtyCount: unknown, alreadyDecided: boolean): boolean; +export declare function decide(response: unknown): "save" | "discard" | "cancel"; +export declare function describe(files: unknown, max?: number): string; +export declare const BUTTONS: string[]; diff --git a/ide/electron/touchSet.cjs b/ide/electron/touchSet.cjs new file mode 100644 index 0000000..ff00894 --- /dev/null +++ b/ide/electron/touchSet.cjs @@ -0,0 +1,39 @@ +// 워처가 알려 준 경로를 한 번의 알림으로 모으는 자루. +// +// 예전엔 `if (touched.size < MAX_TOUCHED) touched.add(...)` 한 줄이었다. 상한을 넘으면 +// **말없이 버렸다.** 앱 자신은 멀쩡하다(새로고침 때 트리를 통째로 다시 읽으니까). +// 손해는 확장의 파일 감시자가 본다 — 브랜치를 갈아타 파일 3000개가 바뀌면 확장은 +// 앞의 것만 통보받고 나머지는 영영 모른 채, 자기가 다 안다고 여긴다. +// +// 두 가지를 고친다. 상한을 실제 저장소 크기에 맞게 올리고(2000 → 20000), 그래도 +// 넘치면 넘쳤다고 말한다. 조용히 자르는 것만은 안 한다. + +const DEFAULT_MAX = 20000; + +function makeTouchSet(max = DEFAULT_MAX) { + let set = new Set(); + let dropped = 0; + return { + add(rel) { + const r = String(rel || "").replace(/\\/g, "/"); + if (!r) return; + if (set.has(r)) return; + if (set.size >= max) { dropped++; return; } + set.add(r); + }, + get size() { return set.size; }, + /** 상한에 걸려 버린 경로가 있었나 — 있으면 이 알림은 전부가 아니다. */ + get overflowed() { return dropped > 0; }, + get dropped() { return dropped; }, + /** 모은 것을 꺼내고 자루를 비운다. */ + drain() { + const rels = [...set]; + const info = { rels, overflow: dropped > 0, dropped }; + set = new Set(); + dropped = 0; + return info; + }, + }; +} + +module.exports = { makeTouchSet, DEFAULT_MAX }; diff --git a/ide/electron/touchSet.d.cts b/ide/electron/touchSet.d.cts new file mode 100644 index 0000000..d791c78 --- /dev/null +++ b/ide/electron/touchSet.d.cts @@ -0,0 +1,16 @@ +// touchSet.cjs 의 타입. 메인 프로세스 코드지만 판단 부분은 테스트에서 그대로 부른다. +export interface TouchDrain { + rels: string[]; + /** 상한에 걸려 버린 경로가 있었다 — 이 목록은 전부가 아니다. */ + overflow: boolean; + dropped: number; +} +export interface TouchSet { + add(rel: unknown): void; + readonly size: number; + readonly overflowed: boolean; + readonly dropped: number; + drain(): TouchDrain; +} +export declare function makeTouchSet(max?: number): TouchSet; +export declare const DEFAULT_MAX: number; diff --git a/ide/package.json b/ide/package.json index 051330f..c1627a1 100644 --- a/ide/package.json +++ b/ide/package.json @@ -7,7 +7,7 @@ "type": "git", "url": "https://github.com/SchutzScript/Schutz.git" }, - "version": "0.2.0", + "version": "0.3.0", "private": true, "license": "FSL-1.1-Apache-2.0", "type": "module", diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 51762c5..c170238 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -13,8 +13,9 @@ import { WORKSPACE_TOOLS, DELEGATE_TOOL, } from "./ai/claude"; import { PROVIDERS_MAP, testProvider, getManagerId, setManagerId } from "./ai/registry"; -import { CLAUDE_MODELS, CODEX_MODELS, OPENAI_MODELS, GROK_MODELS, GLM_MODELS, ModelOpt } from "./ai/models"; -import { Message, ToolCall, ToolDef, NeutralMsg, AgentProvider, getStoredKey, setStoredKey, getOAuth, setOAuth, freshOAuth, getModelOverride, setModelOverride } from "./ai/provider"; +import { LOCAL_PROVIDER } from "./ai/openaiCompat"; +import { CLAUDE_MODELS, CODEX_MODELS, OPENAI_MODELS, GROK_MODELS, GLM_MODELS, GEMINI_MODELS, ModelOpt } from "./ai/models"; +import { Message, ToolCall, ToolDef, NeutralMsg, AgentProvider, getStoredKey, setStoredKey, getOAuth, setOAuth, freshOAuth, getModelOverride, setModelOverride, getEndpoint, setEndpoint } from "./ai/provider"; import { MonacoPane, paneRegistry } from "./editor/MonacoPane"; import { DiffPane } from "./editor/DiffPane"; import { PreviewPane } from "./editor/PreviewPane"; @@ -31,6 +32,8 @@ import { import { resolveRenameTarget, isMove } from "./engine/movePath"; import { applyProposal } from "./engine/editApply"; import { planRun, langFor, LANGS as RUN_LANGS } from "./engine/runFile"; +import { AskQueue } from "./engine/askQueue"; +import { encodingMessage } from "./encodingNote"; import { getRunOverride, getRunOverrides, setRunOverride } from "./settings"; import { emptyNav, push as navPush, back as navBack, forward as navForward, current as navCurrent, dropMissing as navDropMissing, type NavState } from "./engine/navHistory"; import { shouldProbeQuota } from "./engine/quotaPoll"; @@ -38,6 +41,8 @@ import { OVERLAYS, OVERLAY_KEY, topOverlay, suppressesAction, overlayZ } from ". import { filterPicks, stepIndex, validateInput, type PromptReq, type NormPick } from "./ext/prompt"; import { upsert as sbUpsert, remove as sbRemove, ordered as sbOrdered, ALIGN_LEFT, ALIGN_RIGHT, type StatusItem } from "./ext/statusBar"; import { classify as fsClassify } from "./ext/fsWatch"; +import { ASYNC_ERROR_EVENT, makeThrottle } from "./asyncErrors"; +import { newlyGone, stillGone } from "./engine/staleModels"; import { rowKey, webviewDoc, type TreeRow } from "./ext/views"; import { targetIdOf, isSubagentTarget, findSubagent, providerFor, filterTools, @@ -63,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"; @@ -72,6 +78,7 @@ import * as vscodeExt from "./ext/vscodeExt"; import * as iconTheme from "./ext/iconTheme"; import * as textmate from "./ext/textmate"; import * as mcp from "./mcp/mcpClient"; +import * as mcpExtras from "./mcp/mcpExtras"; import * as mcpGen from "./mcp/generator"; import * as engines from "./gameEngine/adapters"; import { buildReviewSystemPrompt, buildReviewUserPrompt, parseFindings, severityRank, Finding, type ReviewLang } from "./review/reviewer"; @@ -159,7 +166,7 @@ import { BINDINGS, buildMap, chordOf, chordFor, conflictsOf, displayChord, getOverrides, isModifierOnly, setOverride, resetOverrides, type ActionId, } from "./keymap"; -import { TOUR_STEPS, anchorRect, cardPos, visibleSteps, visiblePos } from "./tour"; +import { TOUR_STEPS, anchorRect, cardPos, visibleSteps, visiblePos, trackStartIndex, otherMode } from "./tour"; import { TourFigure, type FigureRegion } from "./tourFigure"; import { Opening } from "./opening/Opening"; import { @@ -253,6 +260,9 @@ function accel(s: string): string { return [hasCtrl && "Ctrl", hasAlt && "Alt", hasShift && "Shift", key].filter(Boolean).join("+"); } +/** 승인 물음 하나. okLabel/cancelLabel 은 자리에 맞는 문구가 있을 때만 채운다(없으면 기본 허용/거부). */ +interface AskRunItem { command: string; rationale: string; agent: string; okLabel?: string; cancelLabel?: string } + interface S { statusKey: "idle" | "thinking" | "tool" | "review" | "stopped"; running: boolean; @@ -363,9 +373,9 @@ interface S { extAskPicked: number[]; /** validateInput 이 돌려준 문구. 있으면 확인을 막는다. */ extAskErr: string | null; - /** 실행 승인 대기 중인 명령 (수동 정책일 때) */ - /** 승인 대기. okLabel/cancelLabel 은 자리에 맞는 문구가 있을 때만 채운다(없으면 기본 허용/거부). */ - askRun: { command: string; rationale: string; agent: string; okLabel?: string; cancelLabel?: string } | null; + /** 실행 승인 대기 중인 명령 (수동 정책일 때). + * 줄 맨 앞의 하나만 화면에 있다 — 뒤엣것은 AskQueue 가 들고 기다린다. */ + askRun: AskRunItem | null; /** 제안 카드에서 diff 를 펼친 것 (id → true) */ openDiffs: Record; /** 트랜스크립트에서 펼친 도구 줄 */ @@ -474,6 +484,8 @@ interface S { mcpGen: null | { mode: "cli" | "project" | "openapi" | "generic"; input: string; status: string; }; /** 사용법 스포트라이트 투어 */ tourOpen: boolean; + /** 투어가 이미 다른 모드 트랙으로 건너갔는가. 한 번만 건너간다. */ + tourCrossed: boolean; /** 오프닝 오버레이 국면. off=안 뜸, intro=마크·선언·세팅, outro=마무리 */ openingPhase: "off" | "intro" | "outro"; /** 데모 진행 중 하단 자막 키. null 이면 자막 없음 */ @@ -510,7 +522,11 @@ interface S { searchResults: SearchHit[]; searchSel: number; searchBusy: boolean; + /** 로컬 서버가 들고 있는 모델 id 들. 우리가 알 수 없어 서버에 물어본다. */ + localModels: string[]; searchTruncated: boolean; + /** 깊이 상한 때문에 안 걸어 본 폴더가 있었다 — "없음" 을 단정하지 못한다 */ + searchDeep: boolean; /** 미저장 탭 닫기 확인 */ askClose: { rel: string; slot: number } | null; /** 진단(문제 패널) */ @@ -703,7 +719,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { termReal: "", termInput: "", settingsOpen: false, aboutOpen: false, usageOpen: false, keysOpen: false, commandsOpen: false, agentCommands: [], mcpOpen: false, mcpServers: [], mcpDiscovered: [], mcpBusy: "", engineStatus: {}, skills: [], subagents: [], pluginOpen: false, plugins: [], pluginQuery: "", pluginCat: "", pluginBusy: "", cloudOpen: false, cloudTasks: [], cloudPrompt: "", cloudBusy: "", - engineOpen: false, engineShot: null, engineTree: "", engineViewBusy: "", engineViewErr: "", mcpJson: "", mcpGen: null, tourOpen: false, tourStep: 0, openingPhase: "off", demoCaption: null, demoRunning: false, closing: [], closingTabs: [], testMsg: {}, + engineOpen: false, engineShot: null, engineTree: "", engineViewBusy: "", engineViewErr: "", mcpJson: "", mcpGen: null, tourOpen: false, tourCrossed: false, tourStep: 0, openingPhase: "off", demoCaption: null, demoRunning: false, closing: [], closingTabs: [], testMsg: {}, layout: (() => { const m = /[?&]layout=(\d)/.exec(window.location.search); if (m) { const v = parseInt(m[1], 10); return v === 2 ? 2 : v === 4 ? 4 : 1; } @@ -715,7 +731,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { slashSel: 0, quickOpen: false, quickQuery: "", quickSel: 0, symOpen: false, symQuery: "", symSel: 0, symLoading: false, symResults: [], - searchOpen: false, searchQuery: "", searchResults: [], searchSel: 0, searchBusy: false, searchTruncated: false, + localModels: [], + searchOpen: false, searchQuery: "", searchResults: [], searchSel: 0, searchBusy: false, searchTruncated: false, searchDeep: false, askClose: null, problems: [], tsLargeProject: false, cmdOpen: false, cmdQuery: "", cmdSel: 0, modelPickFor: null, @@ -808,7 +825,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (silent || !await this.askConfirm({ title: t("confirm.overwriteTitle"), body: t("sc1.externalChangedOverwrite", { rel }), okLabel: t("confirm.overwriteOk"), danger: true })) { failed.push(rel + " (" + t("sc1.externalChangedSkipped") + ")"); continue; } } try { - await window.schutz.writeFile(ws.root, rel, content); + // BOM 은 본문이 아니라 파일의 표식이라 모델 밖에 있다 — 도로 붙여 써야 + // 손대지 않은 표식이 조용히 사라지지 않는다. 기준선은 BOM 없는 값으로 둔다. + await window.schutz.writeFile(ws.root, rel, projectModels.diskText(rel) ?? content); projectModels.markSaved(ws.root, rel, content); this.notifySaved(rel); this.setState(st => ({ paneDirty: { ...st.paneDirty, [rel]: false } })); @@ -1053,20 +1072,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { try { await window.schutz.renameEntry(ws.root, rel, relTo); if (isMove(rel, relTo)) this.toast("ok", t("move.moved", { to: relTo })); - const remap = (p: string) => p === rel ? relTo : p.startsWith(rel + "/") ? relTo + p.slice(rel.length) : p; projectModels.rekeyUnder(ws.root, rel, relTo); // 하위 모델을 새 경로로 재생성(미저장 버퍼·dirty 보존, 옛 경로 잔존 없음) await this.refreshWorkspace(); - this.setState(s => { - const collapsed: Record = {}; - for (const [k, v] of Object.entries(s.collapsed)) collapsed[remap(k)] = v; // 접힘 상태 이동 - const paneDirty: Record = {}; - for (const [k, v] of Object.entries(s.paneDirty)) paneDirty[remap(k)] = v; // dirty 도 새 경로로 이동(버퍼 보존됨) - return { - tabs: s.tabs.map(t => t.map(remap)), - active: s.active.map(remap), - collapsed, paneDirty, - }; - }); + this.remapPath(rel, relTo); } catch (e) { this.toast("error", t("sc1.rename_failed") + (e instanceof Error ? e.message : String(e))); } } @@ -1119,23 +1127,20 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { async deleteAt(rel: string) { const ws = this.state.workspace; if (!ws || !window.schutz) return; - if (!await this.askConfirm({ title: t("confirm.deleteTitle"), body: t("sc1.confirm_delete", { rel }), okLabel: t("confirm.deleteOk"), danger: true })) return; + // 휴지통에 들어가는 것은 **디스크에 있던 내용**뿐이다. 저장 안 한 편집은 버퍼에만 + // 있어서 되돌릴 방법이 없다 — 확인창이 "휴지통" 이라고 약속하는 이상, 그 약속이 + // 닿지 않는 부분은 말해 줘야 한다. + const dirtyGone = this.dirtyUnder(rel); + const body = t("sc1.confirm_delete", { rel }) + + (dirtyGone.length ? "\n\n" + t("sc1.deleteDirtyWarn", { files: dirtyGone.slice(0, 5).join(", ") + (dirtyGone.length > 5 ? ` 외 ${dirtyGone.length - 5}개` : "") }) : ""); + if (!await this.askConfirm({ title: t("confirm.deleteTitle"), body, okLabel: t("confirm.deleteOk"), danger: true })) return; try { const del = await window.schutz.deleteEntry(ws.root, rel); projectModels.dropUnder(ws.root, rel); // 하위 파일 모델까지 dispose(옛 dirty 모델 잔존→Save All 이 삭제 파일 재생성하는 버그 방지) // 휴지통이 안 되는 환경에선 영구 삭제됐다는 사실을 반드시 알린다 — 되돌릴 방법이 없다 if (del && del.trashed === false) this.toast("info", t("sc1.deleted_permanently", { rel })); await this.refreshWorkspace(); - const gone = (p: string) => p !== rel && !p.startsWith(rel + "/"); - this.setState(s => { - const tabs = s.tabs.map(t => t.filter(gone)); - const active = s.active.map((a, i) => (gone(a) ? a : (tabs[i][tabs[i].length - 1] ?? ""))); - const collapsed: Record = {}; - for (const [k, v] of Object.entries(s.collapsed)) if (gone(k)) collapsed[k] = v; // 삭제 경로 접힘키 제거 - const paneDirty: Record = {}; - for (const [k, v] of Object.entries(s.paneDirty)) if (gone(k)) paneDirty[k] = v; - return { tabs, active, collapsed, paneDirty }; - }); + this.forgetPath(rel); } catch (e) { this.toast("error", t("sc1.delete_failed") + (e instanceof Error ? e.message : String(e))); } } @@ -1219,6 +1224,29 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { /** 경로로 워크스페이스 열기 (다이얼로그 없이 — 복원/최근용) */ async openWorkspacePath(root: string) { if (!window.schutz) return; + // 프로젝트를 바꾸면 아래에서 모델을 통째로 버린다(disposeAll) — 저장 안 한 편집이 + // 경고도 되돌리기도 없이 사라진다. 프로젝트 전환은 아주 흔한 동작이라 더 나쁘다. + // + // 버릴지 물어보지 않고 **저장할지**만 묻는다. 버리는 쪽을 버튼으로 주면 한 번의 + // 오클릭으로 작업이 사라지는데, 되돌릴 방법이 없다. 정말 버리고 싶으면 편집기에서 + // 되돌리거나 탭을 닫으면 된다 — 그건 되돌릴 수 있는 길이다. + const dirty = this.dirtyFiles(); + if (dirty.length) { + const ok = await this.askConfirm({ + title: t("sc1.switchDirtyTitle"), + body: t("sc1.switchDirtyBody", { files: dirty.slice(0, 5).join(", ") + (dirty.length > 5 ? ` 외 ${dirty.length - 5}개` : "") }), + okLabel: t("sc1.switchDirtyOk"), + }); + if (!ok) return; + await this.saveAllDirtyModels(true); + // 저장이 다 되지 않았으면(디스크가 그 사이 바뀌었거나 쓰기 실패) 열지 않는다. + // 여기서 그냥 열면 방금 못 지킨 것이 그대로 사라진다. + // + // paneDirty(React 상태)는 방금 부른 setState 가 아직 반영되기 전이라 못 믿는다 — + // 그걸 보면 저장에 성공하고도 "저장을 끝내지 못했다" 며 열지 않는다. 모델 쪽은 + // markSaved 가 동기로 끝내 두므로 그쪽이 지금 이 순간의 사실이다. + if (projectModels.dirtyRels().length) { this.toast("error", t("sc1.switchDirtyFailed")); return; } + } try { const tree = await window.schutz.readTree(root); this.clearTimers(); @@ -1498,6 +1526,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { ((/^(gpt-|o\d|chatgpt)/.test(q)) && connectedId("gpt")) || (q.startsWith("grok") && connectedId("grok")) || (q.startsWith("glm") && connectedId("glm")) || + (q.startsWith("gemini") && connectedId("gemini")) || AGDEF.find(d => this.modelChannel(d.id))?.id; if (guess) { const ch = this.modelChannel(guess)!; @@ -1577,10 +1606,21 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { async testConn(id: string) { this.setState(st => ({ testMsg: { ...st.testMsg, [id]: t("sc1.checking") } })); + // 로컬은 연결 확인이 곧 모델 목록 읽기다 — 무엇을 고를 수 있는지도 그때 정해진다. + if (id === "local") { await this.refreshLocalModels(); } const r = await testProvider(id); this.setState(st => ({ testMsg: { ...st.testMsg, [id]: r.ok ? t("sc1.connected_ok") : "⚠️ " + r.message.slice(0, 120) } })); } + /** 로컬 서버가 들고 있는 모델을 물어본다. 못 물으면 목록을 비우고 넘어간다 — + * 그때는 사용자가 이름을 직접 적을 수 있어야 하므로 조용히 실패하지 않는다. */ + async refreshLocalModels(): Promise { + const p = LOCAL_PROVIDER; + const r = await p.listModels(); + if (r.ok && r.models?.length) this.setState({ localModels: r.models }); + else this.setState(st => ({ localModels: [], testMsg: { ...st.testMsg, local: "⚠️ " + (r.error || t("reg.apiKeyEmpty")).slice(0, 120) } })); + } + private _cliOff: (() => void) | null = null; /** CLI 세션 id (멀티턴 --resume) */ private _cliSession: string | null = null; @@ -1882,7 +1922,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (exists) throw new Error(t("proposal.fileExists")); newContent = eff; await this.captureBefore(ws.root, p); - await window.schutz.writeFile(ws.root, p.rel, newContent); + await window.schutz.writeFile(ws.root, p.rel, this.forDisk(p.rel, newContent)); await this.markAfter(ws.root, p); const tree = await window.schutz.readTree(ws.root); this.setState({ workspace: tree }); @@ -1899,7 +1939,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (res.ok === false) throw new Error(t(res.error === "multiple" ? "sc1.orig_multiple" : "sc1.orig_not_found")); newContent = res.text; await this.captureBefore(ws.root, p); - await window.schutz.writeFile(ws.root, p.rel, newContent); + await window.schutz.writeFile(ws.root, p.rel, this.forDisk(p.rel, newContent)); await this.markAfter(ws.root, p); editStart = res.start; editEnd = res.end; @@ -1915,7 +1955,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // 그건 코드를 한 프레임에 갈아끼우고 에디터를 깜빡이게 하며 스크롤을 날렸다. await this.animateEditIntoModel(ws.root, p.rel, newContent, editStart, editEnd, p.find, p.replace); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + // 인코딩 거절 코드가 카드에 그대로 뜨지 않게 사람 말로 바꾼다. + const msg = encodingMessage(e instanceof Error ? e.message : String(e)); this._proposalsById.set(id, { ...p, status: "failed", error: msg }); this.setState(s => ({ proposals: s.proposals.map(x => x.id === id ? { ...x, status: "failed" as const, error: msg } : x), @@ -2001,7 +2042,10 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { private _runIds = new Set(); /** 백그라운드 서버 — 프리뷰 탭 rel → runId. 에이전트 중지와 수명을 분리한다. */ private _bgRuns = new Map(); - private _askRunResolve: ((ok: boolean) => void) | null = null; + /** 승인 물음은 줄을 세운다 — 위임으로 실행이 동시에 여럿 돌기 때문이다. + * 예전엔 resolve 를 필드 하나에 담아, 두 번째 물음이 첫 번째를 덮고 그 실행을 + * 영원히 await 에 매달아 뒀다(파일 락도 안 풀린 채로). askQueue.ts 에 사연이 있다. */ + private _askRunQueue = new AskQueue(); private _confirmResolve: ((ok: boolean) => void) | null = null; /** 인앱 확인 — window.confirm 을 대신한다. @@ -2111,14 +2155,19 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { * labels 를 주면 버튼 문구를 갈아끼운다: 커밋 게이트처럼 "허용/거부" 가 어색한 자리를 위해. */ private askRunApproval(command: string, rationale: string, agent: string, labels?: { ok: string; cancel: string }): Promise { return new Promise(resolve => { - this._askRunResolve = resolve; - this.setState({ askRun: { command, rationale, agent, okLabel: labels?.ok, cancelLabel: labels?.cancel } }); + const item: AskRunItem = { command, rationale, agent, okLabel: labels?.ok, cancelLabel: labels?.cancel }; + this._askRunQueue.add(item, resolve); + // 이미 무언가 물어보는 중이면 화면은 그대로 두고 줄에서 기다린다. + this.setState({ askRun: this._askRunQueue.current() }); }); } private answerRun(ok: boolean) { - const r = this._askRunResolve; - this._askRunResolve = null; - this.setState({ askRun: null }, () => r?.(ok)); + // 화면을 먼저 비우고, 답을 푼 뒤, 다음 물음을 올린다 — 그래야 모달이 갈아끼워지는 + // 것이 보이고, 답한 것과 다음 것을 헷갈리지 않는다. + this.setState({ askRun: null }, () => { + const next = this._askRunQueue.answer(ok); + if (next) this.setState({ askRun: next }); + }); } /** 켤 때 잔여 할당량 조회 — 헤더는 요청을 보내야 오므로 1토큰짜리 최소 요청을 한 번 던진다. @@ -2232,6 +2281,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { } if (id === "grok") return getStoredKey("grok").trim() ? "grok-4" : null; if (id === "glm") return getStoredKey("glm").trim() ? "glm-4.6" : null; + if (id === "gemini") return getStoredKey("gemini").trim() ? "gemini-3-pro" : null; + // 로컬은 주소가 곧 연결이다. 어떤 모델을 받아 뒀는지는 서버만 안다. + if (id === "local") return getEndpoint("local") ? (getModelOverride("local") || this.state.localModels[0] || "…") : null; return null; } @@ -2252,6 +2304,12 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { } if (id === "grok") return getStoredKey("grok").trim() ? { overrideKey: "grok", options: GROK_MODELS, current: getModelOverride("grok") || "grok-4" } : null; if (id === "glm") return getStoredKey("glm").trim() ? { overrideKey: "glm", options: GLM_MODELS, current: getModelOverride("glm") || "glm-4.6" } : null; + if (id === "gemini") return getStoredKey("gemini").trim() ? { overrideKey: "gemini", options: GEMINI_MODELS, current: getModelOverride("gemini") || "gemini-3-pro" } : null; + if (id === "local") { + if (!getEndpoint("local")) return null; + const opts = this.state.localModels.map(m => ({ id: m, label: m })); + return { overrideKey: "local", options: opts, current: getModelOverride("local") || opts[0]?.id || "" }; + } return null; } @@ -2358,7 +2416,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (!runId) return; // 승인 대기는 abort 로 안 깨진다(answerRun 만 resolve 한다). 중지가 그걸 거절로 풀어주지 // 않으면 그 실행의 finally 가 영영 안 오고, running 이 모달을 답할 때까지 잡힌다. - if (this.state.askRun?.agent === id) this.answerRun(false); + // 보이는 것뿐 아니라 **줄에서 기다리는 것까지** 걷어낸다. 뒤에 서 있던 물음만 + // 남으면, 중지한 에이전트가 나중에 모달로 되살아나고 그 실행은 계속 매달려 있다. + this.setState({ askRun: this._askRunQueue.cancelWhere(a => a.agent === id) }); for (const [rel, holder] of [...this.fileLocks.entries()]) if (holder === runId) this.fileLocks.delete(rel); this.setAgent(id, { status: "stop", file: null }); // 인라인 편집·MCP 생성은 세지 않는다 — 예전엔 abortCtls.size 라 그것들까지 셌고, @@ -2376,7 +2436,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // 전역 중지 — 역할을 가리지 않고 전부(인라인 편집·MCP 생성 포함). // 레코드는 남긴다: 각 루프의 finally 가 finish() 로 자기 정리를 마무리한다. this.engine.runs.cancelAll(); - if (this._askRunResolve) this.answerRun(false); + this._askRunQueue.cancelAll(false); // 줄 전체를 거절로 풀어 준다 — 매달린 실행을 남기지 않는다 + this.setState({ askRun: null }); this.abortCtls.clear(); this.fileLocks.clear(); this.clearTimers(); @@ -3149,11 +3210,39 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { /** 도구 실행 (워크스페이스 모드, 에이전트별) */ /** 실행 중 MCP 서버의 도구를 provider ToolDef 로 변환 (mcp__server__tool) */ private mcpToolDefs(): ToolDef[] { - return mcp.getMcpTools().map(t => ({ + const tools = mcp.getMcpTools().map(t => ({ name: mcp.mcpToolName(t.server, t.name), description: (t.description ? t.description + " " : "") + `[MCP: ${t.server}]`, input_schema: (t.inputSchema && typeof t.inputSchema === "object") ? t.inputSchema : { type: "object", properties: {} }, })); + // MCP 는 도구 말고 리소스·프롬프트도 내준다. 읽어서 패널에 세어 두기만 하던 동안 + // 모델은 그것들이 있다는 사실조차 몰랐다. 줄 것이 있을 때만 도구를 붙인다. + return [...tools, ...mcpExtras.extraToolDefs(mcp.getMcpResources(), mcp.getMcpPrompts())]; + } + + /** 리소스·프롬프트 도구 실행. 인자는 서버에 보내기 전에 확인한다 — 조용한 빈 결과는 + * 모델에게 "그런 건 없다" 로 읽혀서, 있는 것을 두고 물러난다. */ + private async runMcpExtra(name: string, input: any): Promise { + const resources = mcp.getMcpResources(), prompts = mcp.getMcpPrompts(); + const arg = (input && typeof input === "object") ? input : {}; + if (name === mcpExtras.LIST_RESOURCES) { + return mcpExtras.formatResourceList(resources, arg.server ? String(arg.server) : undefined); + } + if (name === mcpExtras.READ_RESOURCE) { + const bad = mcpExtras.checkResourceArgs(resources, arg.server, arg.uri); + if (bad) return "오류: " + bad; + const r = await mcp.readResource(String(arg.server), String(arg.uri)); + return r.ok ? mcpExtras.formatResourceContents(r.result) : "⚠️ MCP 오류: " + (r.error || "알 수 없음"); + } + if (name === mcpExtras.GET_PROMPT) { + const bad = mcpExtras.checkPromptArgs(prompts, arg.server, arg.name); + if (bad) return "오류: " + bad; + const miss = mcpExtras.missingPromptArgs(prompts, String(arg.server), String(arg.name), arg.arguments); + if (miss.length) return "오류: 필수 인자가 빠졌습니다 — " + miss.join(", "); + const r = await mcp.getPrompt(String(arg.server), String(arg.name), arg.arguments); + return r.ok ? mcpExtras.formatPromptResult(r.result) : "⚠️ MCP 오류: " + (r.error || "알 수 없음"); + } + return "오류: 알 수 없는 도구 " + name; } // ── 스킬 (Claude Code 생태계) ─────────────────────────────────────────────── @@ -3284,6 +3373,14 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { } } // MCP 도구 — 워크스페이스와 무관하게 실행 + if (mcpExtras.isExtraTool(call.name)) { + const mid = "rt" + (this._uid++); + const note = call.name === mcpExtras.GET_PROMPT ? String(call.input?.name ?? "") : String(call.input?.uri ?? call.input?.server ?? ""); + this.addTool(mid, agentId, "MCP", note || call.name); + const out = await this.runMcpExtra(call.name, call.input); + this.setTool(mid, { st: "done", note: out.startsWith("오류") || out.startsWith("⚠️") ? t("mcpui.failed") : undefined }); + return out; + } if (mcp.isMcpToolName(call.name)) { const r = mcp.resolveMcpTool(call.name); if (!r) return "오류: 알 수 없는 MCP 도구 " + call.name; @@ -3359,9 +3456,15 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // 모델이 "전부 봤다"고 착각하고 없는 파일이 없다고 단정하는 것을 막는다. const shown = all.slice(0, LIST_MAX); const cut = all.length - shown.length; - this.setTool(toolId, { st: "done", note: t("sc2.noteCount", { n: all.length }) + (cut ? " · " + t("sc2.noteCut") : "") }); - if (!all.length) return glob ? `(${glob} 에 맞는 파일 없음)` : "(빈 워크스페이스)"; - return shown.join("\n") + (cut ? `\n\n… ${cut}개 더 있음(전체 ${all.length}). glob 으로 좁혀서 다시 부르세요.` : ""); + this.setTool(toolId, { st: "done", note: t("sc2.noteCount", { n: all.length }) + (cut || ws.truncated ? " · " + t("sc2.noteCut") : "") }); + // 트리 자체가 잘렸으면(파일 4000개 초과·깊이 8단계 초과) 이 목록은 **전부가 아니다.** + // 화면에는 잘렸다고 적어 두면서 모델에게는 말하지 않고 있었다. 그래서 없는 게 + // 아니라 안 걸어 본 파일을 "없다" 로 읽고, 이미 있는 파일을 새로 만들곤 했다. + const partial = ws.truncated + ? "\n\n주의: 이 워크스페이스는 파일이 너무 많거나 깊어 목록이 잘려 있습니다. 여기 없다고 파일이 없는 것은 아닙니다 — 확인하려면 search_files 를 쓰세요." + : ""; + if (!all.length) return (glob ? `(${glob} 에 맞는 파일 없음)` : "(빈 워크스페이스)") + partial; + return shown.join("\n") + (cut ? `\n\n… ${cut}개 더 있음(전체 ${all.length}). glob 으로 좁혀서 다시 부르세요.` : "") + partial; } if (call.name === "search_files") { const query = String(call.input?.query ?? ""); @@ -3381,9 +3484,12 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { return "검색 오류: " + r.error; } this.setTool(toolId, { st: "done", note: t("sc2.noteHits", { n: r.hits.length }) + (r.truncated ? " · " + t("sc2.noteCut") : "") }); - if (!r.hits.length) return `"${query}" 에 대한 결과 없음.`; + // 깊이 때문에 안 걸어 본 폴더가 있으면 반드시 붙인다 — 검색은 "여기 없다" 의 + // 근거로 쓰이는데, 안 찾아본 것과 없는 것이 같은 답으로 나오면 안 된다. + const deep = r.deepSkipped ? "\n\n주의: 폴더가 너무 깊어 일부는 찾아보지 않았습니다. 결과가 없다고 없는 것은 아닙니다." : ""; + if (!r.hits.length) return `"${query}" 에 대한 결과 없음.` + deep; const body = r.hits.map(h => `${h.rel}:${h.line}: ${h.preview.slice(0, SEARCH_PREVIEW)}`).join("\n"); - return body + (r.truncated ? `\n\n… 결과가 상한(${SEARCH_MAX})에서 잘렸습니다. include 로 좁히세요.` : ""); + return body + (r.truncated ? `\n\n… 결과가 상한(${SEARCH_MAX})에서 잘렸습니다. include 로 좁히세요.` : "") + deep; } if (call.name === "read_file") { const rel = String(call.input?.path ?? ""); @@ -3523,7 +3629,9 @@ ${(r.output || "").slice(0, 2000)}`; return "알 수 없는 도구: " + call.name; } catch (e) { this.setTool(toolId, { st: "done", note: t("sc2.noteError") }); - return "오류: " + (e instanceof Error ? e.message : String(e)); + // 인코딩 거절은 코드(`SCHUTZ_ENCODING:...`)로 온다. 그대로 넘기면 모델이 무슨 + // 일인지 몰라 같은 파일을 계속 다시 읽으며 라운드를 태운다 — 사람 말로 바꾼다. + return "오류: " + encodingMessage(e instanceof Error ? e.message : String(e)); } } @@ -4360,7 +4468,12 @@ ${(r.output || "").slice(0, 2000)}`; /** 워처가 알려 준, 아직 처리하지 않은 경로들. 디바운스 동안 모았다가 트리를 * 다시 읽은 뒤 만들어짐/고쳐짐/지워짐으로 나눈다. */ private _fsTouched = new Set(); - private onFsChange = (rels?: string[]) => { + /** 사라졌는데 미저장 편집이 있어 남겨 둔 파일들 — 이미 알린 것을 되풀이하지 않게. */ + private _goneTold = new Set(); + /** 이번 알림 묶음에서 워처가 이름을 다 못 준 적이 있었나. syncFromDisk 가 지우고 간다. */ + private _fsOverflow = false; + private onFsChange = (rels?: string[], overflow?: boolean) => { + if (overflow) this._fsOverflow = true; for (const r of rels ?? []) this._fsTouched.add(r); if (this._fsTimer) clearTimeout(this._fsTimer); this._fsTimer = setTimeout(() => void this.syncFromDisk(), 250); @@ -4385,16 +4498,35 @@ ${(r.output || "").slice(0, 2000)}`; // 일 수 있다. 그때 트리 전체를 비교하면 멀쩡한 파일에 지워짐을 쏜다 — 그걸 받은 // 확장은 인덱스에서 실제로 지운다. 그래서 **워처가 이름을 준 경로만** 본다. const capped = tree.truncated || ws.truncated; + const overflow = this._fsOverflow; + this._fsOverflow = false; const t = new Set(touched); + // 워처가 이름을 다 못 줬으면(overflow) 이름 목록으로 좁히는 것이 위험하다 — + // 잘려 나간 파일이 "아무 일 없었다" 가 되기 때문이다. 트리가 온전한 한 + // 앞뒤 전체를 비교하는 쪽이 만들어짐·지워짐을 놓치지 않는다. + // 트리까지 잘렸으면(capped) 얘기가 다르다: 그때 전체를 비교하면 멀쩡한 파일에 + // 지워짐을 쏘게 되므로, 덜 아는 쪽(이름 목록)을 그대로 쓴다. const delta = capped ? fsClassify(before.filter(r => t.has(r)), after.filter(r => t.has(r)), touched) : fsClassify(before, after, touched); + if (overflow && capped) { + // 둘 다 잘린 경우에만 정말로 모르는 채 넘어간다. 조용히 넘기지는 않는다. + console.warn("[fs] 변경 이름과 트리가 모두 상한에 걸렸습니다 — 이번 알림은 일부만 반영됩니다."); + } if (delta.created.length || delta.changed.length || delta.deleted.length) extHost.notifyFsDelta(delta); } // 사라진 파일(외부 삭제·브랜치 전환)의 stale 모델·진단·문제패널 항목 정리 — 트리 완전할 때만(truncated 면 실존 파일 오삭제 위험) if (tree && !(tree as any).truncated) { const present = new Set(tree.entries.filter(e => !e.dir).map(e => e.rel)); - projectModels.dropMissing(ws.root, present); + // 사라졌지만 저장 안 한 편집이 있는 파일은 남는다. 남았다는 사실을 알려야 + // 사용자가 저장해 되살릴지 버릴지 정할 수 있다 — 조용히 두면 다음에 앱을 + // 껐을 때 그 편집이 사라진다. + const kept = projectModels.dropMissing(ws.root, present, this.isDirtyRel); + const fresh = newlyGone(kept, this._goneTold); + this._goneTold = stillGone(new Set([...this._goneTold, ...kept]), present); + if (fresh.length) { + this.toast("error", t("sc1.goneButDirty", { files: fresh.slice(0, 3).join(", ") + (fresh.length > 3 ? ` 외 ${fresh.length - 3}개` : "") })); + } this._nav = navDropMissing(this._nav, rel => present.has(rel)); // 지워진 파일로 되돌아가면 빈 탭이 열린다 } // 대량 변경(브랜치 전환/pull/stash pop): 열지 않은 preload 모델도 디스크로 재로드해 stale 진단 방지 @@ -4438,6 +4570,33 @@ ${(r.output || "").slice(0, 2000)}`; } catch { if (!stale()) this.setState({ git: null }); } } + /** 지워진 경로(와 그 하위)를 탭·접힘·dirty 표시에서 걷어낸다. + * 트리 삭제와 확장의 WorkspaceEdit 이 같은 정리를 해야 해서 여기로 모았다. */ + private forgetPath(rel: string) { + const gone = (p: string) => p !== rel && !p.startsWith(rel + "/"); + this.setState(s => { + const tabs = s.tabs.map(t => t.filter(gone)); + const active = s.active.map((a, i) => (gone(a) ? a : (tabs[i][tabs[i].length - 1] ?? ""))); + const collapsed: Record = {}; + for (const [k, v] of Object.entries(s.collapsed)) if (gone(k)) collapsed[k] = v; + const paneDirty: Record = {}; + for (const [k, v] of Object.entries(s.paneDirty)) if (gone(k)) paneDirty[k] = v; + return { tabs, active, collapsed, paneDirty }; + }); + } + + /** 옮겨진 경로(와 그 하위)를 탭·접힘·dirty 표시에서 새 이름으로 따라가게 한다. */ + private remapPath(rel: string, relTo: string) { + const remap = (p: string) => p === rel ? relTo : p.startsWith(rel + "/") ? relTo + p.slice(rel.length) : p; + this.setState(s => { + const collapsed: Record = {}; + for (const [k, v] of Object.entries(s.collapsed)) collapsed[remap(k)] = v; + const paneDirty: Record = {}; + for (const [k, v] of Object.entries(s.paneDirty)) paneDirty[remap(k)] = v; + return { tabs: s.tabs.map(t => t.map(remap)), active: s.active.map(remap), collapsed, paneDirty }; + }); + } + /** 브랜치 전환 */ async gitCheckout(branch: string) { if (this.anyDirty()) { this.toast("error", t("sc3.unsavedChanges")); return; } @@ -4805,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()); @@ -4814,6 +4986,13 @@ ${(r.output || "").slice(0, 2000)}`; if (window.schutz) { this.setState(s => ({ ...this.normSlots([], [], s.layout), leftTab: "tree" } as any)); window.addEventListener("beforeunload", this._onBeforeUnload); + // 메인이 종료를 붙잡고 물었을 때의 두 갈래. + this._quitSaveOff = window.schutz.onQuitSave?.(async (done) => { + try { await this.saveAllDirtyModels(true); } catch { /* 저장 실패해도 알려야 한다 */ } + // 정말 다 저장됐을 때만 알린다 — 남아 있으면 메인이 8초 뒤 "종료하지 않았다" 고 말한다. + if (!this.anyDirty()) done(); + }) ?? null; + this._quitForceOff = window.schutz.onQuitForce?.(() => { this._forceQuit = true; }) ?? null; // MCP 번들을 창 아무 데나 끌어다 놓을 수 있게. capture 로 먼저 잡아야 컴포저 첨부보다 // 앞선다. dragover 를 안 막으면 브라우저가 그 파일로 네비게이트해 앱이 통째로 날아간다. window.addEventListener("dragover", this.onWindowDragOver); @@ -4843,6 +5022,9 @@ ${(r.output || "").slice(0, 2000)}`; // 확장이 뷰를 붙이거나 뗄 때 사이드바를 다시 맞춘다. this._viewsOff = extHost.onExtViewsChanged(this.syncExtViews); window.addEventListener("message", this.onViewMessage); + // 처리 안 된 비동기 실패를 사용자에게도 한 번은 알린다. 콘솔에만 남기면 + // "눌렀는데 아무 일도 안 일어났다" 는 그대로 남는다. + window.addEventListener(ASYNC_ERROR_EVENT, this.onAsyncError); // MCP 서버 시작 (Schutz 호스트) → 도구를 에이전트 루프에 노출 mcp.setMcpChangeHandler(() => this.forceUpdate()); void mcp.startAll(); @@ -4891,7 +5073,16 @@ ${(r.output || "").slice(0, 2000)}`; this.qt(() => { if (this.state.messages.length === 0) this.send(); }, 800); } } + /** 같은 실패는 1분에 한 번만 알린다 — 루프 안의 실패가 화면을 덮으면 안 된다. */ + private _asyncThrottle = makeThrottle(60_000); + private onAsyncError = (e: Event) => { + const title = String((e as CustomEvent).detail?.title ?? ""); + if (!this._asyncThrottle(title, Date.now())) return; + this.toast("error", t("async.failed")); + }; + componentWillUnmount() { + window.removeEventListener(ASYNC_ERROR_EVENT, this.onAsyncError); this._viewsOff?.(); this._viewsOff = null; for (const off of this._viewChangeOff.values()) off(); this._viewChangeOff.clear(); @@ -4921,6 +5112,8 @@ ${(r.output || "").slice(0, 2000)}`; this._oauthOff = null; window.removeEventListener("keydown", this._onGlobalKey); window.removeEventListener("beforeunload", this._onBeforeUnload); + this._quitSaveOff?.(); this._quitSaveOff = null; + this._quitForceOff?.(); this._quitForceOff = null; this._markersOff?.dispose(); this._fsOff?.(); this._langOff?.(); @@ -4934,11 +5127,17 @@ ${(r.output || "").slice(0, 2000)}`; } /** 앱 종료 가드 — 미저장 변경이 있으면 네이티브 확인 */ + /** 사용자가 "저장하지 않고 종료" 를 골랐다 — 더는 막지 않는다. */ + private _forceQuit = false; + private _quitSaveOff: (() => void) | null = null; + private _quitForceOff: (() => void) | null = null; private _onBeforeUnload = (e: BeforeUnloadEvent) => { // 대화는 턴이 끝날 때만 저장돼서, 스트리밍 중에 끄면 그 주고받음이 통째로 사라졌다. // 슬래시 명령 응답처럼 턴 밖에서 생긴 메시지도 마찬가지 — 나가기 직전에 한 번 더 저장한다. try { this.saveSession(); } catch { /* 저장 실패가 종료를 막으면 안 된다 */ } - if (this.anyDirty()) { + // Electron 에선 이 가드가 **대화상자 없이** 종료를 취소한다. 그래서 메인이 먼저 + // 묻고, 사용자가 그냥 나가기로 했으면 여기서 붙잡으면 안 된다. + if (!this._forceQuit && this.anyDirty()) { e.preventDefault(); e.returnValue = ""; } @@ -5114,7 +5313,7 @@ ${(r.output || "").slice(0, 2000)}`; const seq = ++this._searchSeq; if (!ws || !window.schutz || query.trim().length < 2) { this._shownQuery = ""; this._shownOpts = ""; - this.setState({ searchResults: [], searchBusy: false, searchTruncated: false }); + this.setState({ searchResults: [], searchBusy: false, searchTruncated: false, searchDeep: false }); return; } this.setState({ searchBusy: true }); @@ -5123,10 +5322,10 @@ ${(r.output || "").slice(0, 2000)}`; const r = await window.schutz.searchFiles(ws.root, query, { max: 500, ...opts }); if (seq !== this._searchSeq) return; // 최신 쿼리만 반영 this._shownQuery = query; this._shownOpts = JSON.stringify(opts); - this.setState({ searchResults: r.hits ?? [], searchBusy: false, searchTruncated: !!r.truncated, searchSel: 0 }); + this.setState({ searchResults: r.hits ?? [], searchBusy: false, searchTruncated: !!r.truncated, searchDeep: !!r.deepSkipped, searchSel: 0 }); } catch { if (seq !== this._searchSeq) return; - this.setState({ searchResults: [], searchBusy: false, searchTruncated: false }); + this.setState({ searchResults: [], searchBusy: false, searchTruncated: false, searchDeep: false }); } } @@ -5150,6 +5349,11 @@ ${(r.output || "").slice(0, 2000)}`; if (r.error) { this.toast("error", t("sc3.replaceFailed") + r.error); return; } if (r.partial) this.toast("error", t("sc3.replacePartial", { files: r.files, changed: r.changed })); else this.toast("ok", t("sc3.replaceResult", { files: r.files, changed: r.changed })); + // UTF-8 이 아니라 건너뛴 파일은 반드시 말한다. 안 말하면 "전부 바꿨다" 로 읽히고, + // 그 파일들만 옛 이름이 남아 나중에 빌드가 깨진 뒤에야 알게 된다. + if (r.skipped?.length) { + this.toast("error", t("enc.replaceSkipped", { n: r.skipped.length, files: r.skipped.slice(0, 6).join(", ") })); + } // 모든 non-dirty owned 모델 재로드 — 열린 탭뿐 아니라 preload(닫힌) 모델도 디스크 반영(#8: 나중에 열면 stale 방지). dirty 는 위에서 차단됨. void projectModels.reloadAll(ws.root, (r, rel) => window.schutz!.readFile(r, rel), this.isDirtyRel); this.setState(st => { const pv = { ...st.paneVer }; for (const p of this.allOpen(st)) pv[p] = (pv[p] ?? 0) + 1; return { paneVer: pv }; }); @@ -5281,6 +5485,16 @@ ${(r.output || "").slice(0, 2000)}`; private isDirtyRel = (rel: string): boolean => !!this.state.paneDirty[rel] || projectModels.isDirty(rel); /** 이 파일에 편집을 얹을 **기준 텍스트** — 미저장 버퍼가 있으면 그 내용, 없으면 null(호출측이 디스크를 읽는다). * 디스크를 기준으로 쓰면 그 write 가 미저장 편집을 조용히 덮는다. */ + /** 디스크에 쓸 문자열로 맞춘다. + * + * BOM 은 본문이 아니라 파일의 표식이다. 기준 텍스트가 모델에서 왔으면 BOM 이 빠져 + * 있고, 디스크에서 왔으면 붙어 있다 — 그대로 쓰면 전자에서 표식이 조용히 사라진다. + * 한 번 떼고 그 파일이 원래 갖고 있었는지로만 다시 붙인다(두 번 붙지 않게). */ + private forDisk(rel: string, text: string): string { + const BOM = ""; + const body = text.startsWith(BOM) ? text.slice(BOM.length) : text; + return (projectModels.hasBom(rel) || text.startsWith(BOM)) ? BOM + body : body; + } private baseTextFor(root: string, rel: string): string | null { if (!this.isDirtyRel(rel)) return null; const m = projectModels.getByRel(rel); @@ -5288,7 +5502,29 @@ ${(r.output || "").slice(0, 2000)}`; } /** 어디든 미저장이 있는가 (종료·브랜치 전환 가드용) */ private anyDirty(): boolean { - return Object.values(this.state.paneDirty).some(Boolean) || projectModels.dirtyRels().length > 0; + return this.dirtyFiles().length > 0; + } + /** 이 경로(또는 그 아래)에서 저장 안 한 파일들. 되돌릴 수 없는 일을 하기 전에 + * 무엇이 함께 사라지는지 말하려고 쓴다. */ + private dirtyUnder(rel: string): string[] { + const prefix = rel + "/"; + return this.dirtyFiles().filter(r => r === rel || r.startsWith(prefix)); + } + /** 저장 안 한 파일들. 종료를 붙잡을 때 메인이 이 목록을 그대로 보여 준다. */ + private dirtyFiles(): string[] { + const fromPanes = Object.entries(this.state.paneDirty).filter(([, d]) => d).map(([rel]) => rel); + return [...new Set([...fromPanes, ...projectModels.dirtyRels()])].filter(rel => !this.parseDiffKey(rel)); + } + /** 메인에 알려 둔 마지막 목록 — 같은 값을 되풀이해 보내지 않는다. */ + private _dirtyReported = ""; + /** 종료를 누른 **뒤에** 물어보면 늦다. 그때는 이미 트레이가 사라지고 실행 중인 + * 프로세스가 죽은 다음이다. 바뀔 때마다 미리 알려 둔다. */ + private reportDirty() { + const files = this.dirtyFiles(); + const key = files.join("|"); + if (key === this._dirtyReported) return; + this._dirtyReported = key; + try { window.schutz?.reportDirty?.(files); } catch { /* */ } } // MonacoPane 콜백 — 안정 참조(arrow property)로 두어 React.memo 가 불필요한 리렌더를 차단하게 한다 private handleDirtyChange = (rel: string, d: boolean) => this.setState(st => ({ paneDirty: { ...st.paneDirty, [rel]: d } })); @@ -5456,6 +5692,85 @@ ${(r.output || "").slice(0, 2000)}`; prompt: req => this.askExtension(req), statusSet: item => this.setState(st => ({ extStatus: sbUpsert(st.extStatus, { ...item, seq: this._sbSeq++ }) })), statusRemove: id => this.setState(st => ({ extStatus: sbRemove(st.extStatus, id) })), + // document.save() — 앱의 저장 경로를 그대로 탄다(외부 변경 확인·기준선·저장 사건). + saveFile: async rel => { + const ws = this.state.workspace; + const m = projectModels.getByRel(rel); + if (!ws || !window.schutz || !m) return false; + const content = m.getValue(); + const ext = projectModels.externalChangeOf(rel); + // 그 사이 디스크가 바뀌었으면 조용히 덮지 않는다. 확장에는 "못 했다" 로 답한다 — + // 사람이 없는 자리에서 남의 편집을 지우는 것보다 낫다. + if (ext !== null && ext !== content) return false; + try { + await window.schutz.writeFile(ws.root, rel, projectModels.diskText(rel) ?? content); + projectModels.markSaved(ws.root, rel, content); + this.notifySaved(rel); + this.setState(st => ({ paneDirty: { ...st.paneDirty, [rel]: false } })); + return true; + } catch { return false; } + }, + // openTextDocument — 아직 안 연 파일도 읽어 준다. 탭은 열지 않는다. + readFile: async rel => { + const ws = this.state.workspace; + if (!ws || !window.schutz) return null; + try { return await window.schutz.readFile(ws.root, rel); } catch { return null; } + }, + revealInView: (viewId, element, expand) => this.revealExtViewRow(viewId, element, expand), + // 확장의 WorkspaceEdit 파일 조작. 디스크만 건드리면 열린 버퍼가 실제 파일과 + // 어긋나므로 모델 정리·트리 갱신까지 여기서 함께 한다. + fileOps: { + exists: rel => !!this.state.workspace?.entries.some(e => !e.dir && e.rel === rel), + isDirty: rel => this.isDirtyRel(rel), + create: async (rel, content, overwrite) => { + const ws = this.state.workspace; + if (!ws || !window.schutz) return false; + try { + await window.schutz.writeFile(ws.root, rel, content); + // 이미 열려 있던 파일을 덮어썼으면 모델도 새 내용으로 맞춘다. + if (overwrite) projectModels.reload(ws.root, rel, content, false); + await this.refreshWorkspace(); + // 탭으로 연다. 확장은 보통 파일을 만든 뒤 편집으로 채우는데, 그 편집은 + // vscode 와 마찬가지로 **버퍼에만** 들어간다(저장은 따로다). 탭이 없으면 + // 저장 안 한 내용이 어디에도 안 보이는 채로 남는다 — 만든 줄도 모른다. + this.openFile(rel); + return true; + } catch { return false; } + }, + remove: async rel => { + const ws = this.state.workspace; + if (!ws || !window.schutz) return false; + try { + const r = await window.schutz.deleteEntry(ws.root, rel); + if (!r?.ok) return false; + // 미저장 편집은 planFileOps 가 이미 막았으므로 여기서 버려도 잃을 것이 없다. + projectModels.dropUnder(ws.root, rel); + this.forgetPath(rel); + await this.refreshWorkspace(); + return true; + } catch { return false; } + }, + rename: async (from, to, overwrite) => { + const ws = this.state.workspace; + if (!ws || !window.schutz) return false; + try { + // 덮어쓰기면 목적지를 먼저 치운다 — renameEntry 는 조용한 파괴를 막으려고 + // 이미 있는 이름을 거부한다. + if (overwrite && this.state.workspace?.entries.some(e => !e.dir && e.rel === to)) { + const d = await window.schutz.deleteEntry(ws.root, to); + if (!d?.ok) return false; + projectModels.dropUnder(ws.root, to); + this.forgetPath(to); + } + await window.schutz.renameEntry(ws.root, from, to); + // 열린 버퍼와 dirty 를 새 경로로 옮긴다 — drop 하면 미저장 편집이 사라진다. + projectModels.rekeyUnder(ws.root, from, to); + this.remapPath(from, to); + await this.refreshWorkspace(); + return true; + } catch { return false; } + }, + }, postToView: (viewId, msg) => { const f = this._viewFrames.get(viewId); try { f?.contentWindow?.postMessage({ __schutzToView: true, data: msg }, "*"); } catch { /* 사라진 프레임 */ } @@ -5598,6 +5913,11 @@ ${(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 안에서 // 0×0 으로 측정하고, 다시 보일 때 ResizeObserver 가 뒤늦게 따라오면서 한 프레임 // 어긋난 크기가 보인다. 명시적으로 한 번 재면 그 깜빡임이 없어진다. @@ -6473,8 +6793,31 @@ ${(r.output || "").slice(0, 2000)}`; } async gitDiscard(path: string, untracked: boolean) { - if (!await this.askConfirm({ title: t("confirm.discardTitle"), body: t("sc4.discardConfirm", { path }), okLabel: t("confirm.discardOk"), danger: true })) return; - await this.gitDo("discard", { path, untracked }); + // 버리기는 디스크를 HEAD 로 되돌린다. 그런데 열린 버퍼는 안 건드려서, 패널은 + // "변경 없음" 인데 편집기엔 그 변경이 그대로 남아 있었다 — 그러고 저장하면 방금 + // 버린 것이 되살아난다. 절반만 버린 셈이다. + // + // 버리기가 뜻하는 것은 "이 파일의 변경을 전부 없앤다" 이고, 저장 안 한 편집도 + // 그 파일의 변경이다. 그래서 함께 버리되, 되돌릴 수 없으니 미리 말한다. + const dirtyGone = this.dirtyUnder(path); + const body = t("sc4.discardConfirm", { path }) + + (dirtyGone.length ? "\n\n" + t("sc1.discardDirtyWarn") : ""); + if (!await this.askConfirm({ title: t("confirm.discardTitle"), body, okLabel: t("confirm.discardOk"), danger: true })) return; + const ok = await this.gitDo("discard", { path, untracked }); + const ws = this.state.workspace; + if (ok === true && ws && window.schutz) { + // 디스크가 HEAD 로 돌아왔으니 버퍼도 거기에 맞춘다. isDirty=false 로 넘겨야 + // 실제로 갈아 끼워진다(참이면 충돌로 보고 그대로 둔다). + for (const rel of dirtyGone) { + try { projectModels.reload(ws.root, rel, await window.schutz.readFile(ws.root, rel), false); } + catch { projectModels.drop(ws.root, rel); } // 버리기가 파일 자체를 없앤 경우 + } + if (dirtyGone.length) this.setState(st => { + const paneDirty = { ...st.paneDirty }; + for (const rel of dirtyGone) delete paneDirty[rel]; + return { paneDirty }; + }); + } void this.refreshWorkspace(); } @@ -8741,6 +9084,10 @@ ${(r.output || "").slice(0, 2000)}`; {s.searchBusy ? t("palette.searching") : hits.length > 0 ? t("palette.hitCount", { n: hits.length, plus: s.searchTruncated ? "+" : "" }) : ""} + {/* 안 찾아본 곳이 있으면 말한다 — 특히 결과가 0일 때, 침묵이 "없다" 로 읽힌다. */} + {!s.searchBusy && s.searchDeep && ( + {t("palette.deepSkipped")} + )}
this.setState(st => ({ searchOpts: { ...st.searchOpts, include: e.target.value } }), () => this.onSearchInput(this.state.searchQuery))} @@ -9020,6 +9367,43 @@ ${(r.output || "").slice(0, 2000)}`; ); } + /** TreeView.reveal — 확장이 가리킨 줄을 찾아 펼쳐 보여 준다. + * + * 그려 둔 줄만 뒤지면 안 된다. reveal 은 대개 **아직 안 보이는** 항목을 보이게 하려고 + * 부르는 것이라, 접힌 가지 안에 있는 게 정상이다. 그래서 프로바이더에게 직접 물어 + * 가지를 타고 내려가며 찾는다. 못 찾으면 조용히 성공한 척하지 않고 거절한다. */ + private async revealExtViewRow(viewId: string, element: any, expand: boolean): Promise { + const v = extHost.listExtViews().find(x => x.id === viewId); + if (!v || v.kind !== "tree") throw new Error("TreeView.reveal: 트리 뷰가 아닙니다"); + + const path = await (async function find(parent: any, at: number[], depth: number): Promise { + if (depth > 12) return null; // 순환 구조를 주는 확장이 있다 + const kids = await v.children!(parent); + for (let i = 0; i < kids.length; i++) { + const here = [...at, i]; + if (kids[i] === element) return here; + const row = await v.item!(kids[i]); + if (row.collapse === "none") continue; + const deeper = await find(kids[i], here, depth + 1); + if (deeper) return deeper; + } + return null; + })(undefined, [], 0); + + if (!path) throw new Error("TreeView.reveal: 트리에 없는 항목입니다"); + + this.setState(prev => { + const open = { ...prev.extViewOpen }; + // 조상을 전부 편다. 자기 자신은 expand 를 준 경우에만. + for (let i = 1; i < path.length; i++) open[rowKey(viewId, path.slice(0, i))] = true; + if (expand) open[rowKey(viewId, path)] = true; + return { leftTab: "extview" as const, extViewOpen: open }; + }); + // setState 가 반영된 뒤에 다시 그려야 펼친 상태가 반영된다. + await new Promise(r => this.setState({}, r)); + await this.refreshExtView(viewId); + } + /** 트리 줄이 달고 있는 명령을 돌린다. * * 확장 명령은 `확장id:명령id` 로 저장된다. 확장이 트리 항목에 적어 두는 것은 그냥 @@ -9031,7 +9415,9 @@ ${(r.output || "").slice(0, 2000)}`; renderExt() { const s = this.state; - const badge = (text: string, color: string) => {text}; + // key 를 받는다. 목록 안에서 쓰이는데 없으면 React 가 위치로만 짝을 맞춰, + // 확장을 켜고 끄거나 목록이 바뀔 때 엉뚱한 배지가 남는다. + const badge = (text: string, color: string, key?: string) => {text}; return (
{/* Open VSX 마켓플레이스 검색 */} @@ -9105,7 +9491,7 @@ ${(r.output || "").slice(0, 2000)}`; {ext.description &&
{ext.description}
}
v{ext.version} - {ext.contributes.map(c => badge(c, "#8B8F9E"))} + {ext.contributes.map(c => badge(c, "#8B8F9E", c))} {ext.kind === "vscode" && ext.programmatic && !limited && {t("extui.programmatic")}}
{limited && ( @@ -9863,6 +10249,7 @@ ${(r.output || "").slice(0, 2000)}`; const rel = this.allOpen()[0] ?? this.state.proposals[this.state.proposals.length - 1]?.rel; if (rel) this.openSheet(rel); }, + setMode: m => this.toggleUiMode(m), }; } @@ -9891,6 +10278,24 @@ ${(r.output || "").slice(0, 2000)}`; }); } + /** 다른 모드의 트랙으로 건너간다. + * + * 두 트랙은 when 으로 갈려 있어서 한 모드에 서 있으면 다른 쪽은 **영영 안 보인다.** + * 에디터 모드로 첫 실행을 마친 사람은 에이전트 트랙 여덟 단계를 한 번도 못 본다 — + * 고를 수 있게 해놓고 고르지 않은 쪽을 못 배우게 두는 셈이다. + * + * 한 번만 건너간다. 안 그러면 모드 단계에서 서로를 가리키며 끝나지 않는다. */ + private tourCrossTracks() { + const to = otherMode(this.state.uiMode); + const at = trackStartIndex(to); + if (at < 0) return; + this.setState({ tourCrossed: true }); + this.toggleUiMode(to); + // 모드 전환은 View Transition 을 타서 레이아웃이 곧바로 자리 잡지 않는다. + // 바로 다음 단계로 가면 앵커가 아직 없어 조용히 건너뛴다. + this.qt(() => this.tourStepTo(at), 700); + } + /** 완주(또는 사용자가 그만 보겠다고 닫음) — 다시 자동으로 뜨지 않는다. */ endTour() { window.removeEventListener("resize", this._tourResize); @@ -9959,6 +10364,14 @@ ${(r.output || "").slice(0, 2000)}`;
+ {/* 모드 단계가 두 트랙을 잇는 다리다. 여기서 건너갈 수 없으면 "다른 모양도 + 있다" 는 말만 듣고 끝난다 — 정작 그쪽 여덟 단계는 못 본 채로. */} + {step.id === "mode" && !this.state.tourCrossed && ( + + )} {cur > 0 && } @@ -11127,10 +11540,12 @@ ${(r.output || "").slice(0, 2000)}`;
{d.name} setStoredKey(d.id as any, e.target.value.trim())} - placeholder={t("settings.apiKeyPlaceholder")} + // 로컬 서버는 키가 아니라 **주소**가 설정이다. 같은 자리에서 그것을 받는다 — + // 키 칸만 있으면 "키를 안 넣었으니 미설정" 으로 보여 영원히 못 쓴다. + type={d.id === "local" ? "text" : "password"} + defaultValue={d.id === "local" ? getEndpoint("local") : getStoredKey(d.id as any)} + onChange={e => (d.id === "local" ? setEndpoint("local", e.target.value.trim()) : setStoredKey(d.id as any, e.target.value.trim()))} + placeholder={d.id === "local" ? t("settings.endpointPlaceholder") : t("settings.apiKeyPlaceholder")} style={{ flex: 1, minWidth: 0, background: "var(--bg-root)", border: "1px solid var(--w10)", borderRadius: 7, height: 30, padding: "0 11px", color: "var(--fg)", fontSize: 11.5, fontFamily: MONO, outline: "none" }} />