Release 0.3.0 - #75
Merged
Merged
Conversation
바뀐 파일이 무엇인지 알려면 소스 컨트롤 패널까지 가야 했다. 트리에는 아무 표시가 없어서, 파일을 열기 전에는 그게 새로 만든 것인지 고친 것인지 알 방법이 없었다. 충돌 > 새 파일 > 수정 순으로 우선한다 — 충돌이 가장 급하고, 새 파일은 수정을 겸한다. 색은 지난 판에서 만든 시맨틱 토큰을 그대로 쓴다(밝은 테마에서도 대비가 보장된다). git 상태가 없거나 저장소가 아니면 표를 아예 만들지 않는다 — 트리 렌더가 매번 빈 Map 을 들고 다닐 이유가 없다.
정의로 점프하고 나면 원래 보던 곳으로 돌아올 길이 없었다 — 파일 이름과 줄 번호를 외우고 있거나, 탭을 뒤져 찾은 뒤 다시 스크롤해야 했다. - 파일이 아니라 **자리**를 기록한다. 같은 파일 안에서 크게 뛴 것도 별개의 자리다. - 한 줄씩 걸어 내려간 것은 기록하지 않는다. 기준이 매번 최신으로 갱신돼 차이가 쌓이지 않는다 — 방향키로 200줄을 내려간 건 "다른 곳으로 갔다" 가 아니고, 그걸 기록하면 뒤로 가기가 커서 이동 취소가 되어 쓸모없어진다. - 뒤로 간 뒤 새로운 곳으로 가면 앞쪽 기록은 버린다(브라우저와 같다). - 지워진 파일은 기록에서 걷어낸다. 안 그러면 되돌아갔을 때 빈 탭이 열린다. - diff·프리뷰 탭은 되돌아갈 자리가 아니라 아예 넣지 않는다. 커서 이동마다 Monaco 리스너를 다는 대신 **떠나기 직전**에 한 번 읽는다. 페인마다 리스너를 붙이지 않아도 결과가 같다. 규칙은 engine/navHistory.ts 에 순수 함수로 두고 테스트 16개를 붙였다.
확장 API 는 명령 등록·패널 표시·토스트뿐이었다. 확장이 먼저 말을 걸 수는 있어도 사용자가 파일을 열거나 저장하는 것, 에이전트가 편집을 수락하는 것은 알 방법이 없었다 — "저장할 때마다 무언가 하는" 종류의 확장은 아예 쓸 수 없었다. schutz.on(event, fn) 을 열고 일곱 가지를 알린다: workspace.open, file.open, file.save, proposal.accept, proposal.reject, run.start, run.end. 목록은 schutz.events 로도 읽을 수 있다. 관찰 전용이다. 훅은 무엇도 막거나 바꾸지 못한다. 계약을 나중에 넓힐 수는 있지만 지금 열어 두면 확장 하나가 저장을 영영 붙잡을 수 있다. - 핸들러 하나가 터져도 나머지는 계속 돈다. 확장이 IDE 의 저장 경로를 멈추게 두면 안 된다. 터진 것은 토스트로 보고한다. - emit 도중 구독/해제가 일어나도 그 판은 흔들리지 않는다(스냅숏을 뜬다). - 확장을 다시 읽으면 훅도 같이 걷어낸다 — 명령과 같은 수명이다. 두 가지가 이 과정에서 드러나 같이 고쳤다. - loadExtensions 가 겹쳐 돌면 앞선 판의 activate 가 뒤늦게 끝나면서 이미 정리된 자리에 다시 등록해, 같은 확장의 핸들러가 두 벌 살아 훅이 두 번씩 왔다. 리로드 버튼을 연타하면 닿는 경로다. 로드 세대를 두어 지난 판의 늦은 등록을 버린다. - workspace.open 이 아무에게도 안 갔다. 프로젝트가 확장 로드보다 먼저 열리면 구독자가 없어 사건이 허공에 사라진다. 확장 로드가 끝난 뒤에도 한 번 알리되, 같은 root 를 두 번 알리지 않는다. file.save 는 dirty=false 와 다르다(저장 말고도 생긴다) — 페인이 디스크에 실제로 쓴 뒤 onSaved 로 따로 알린다.
preload 가 window.schutz 를 조건 없이 노출하므로, `!window.schutz` 뒤에 있던 것은 데스크톱에서 한 번도 실행되지 않았다. 그런데도 그 코드가 값을 치렀다 — 계획 패널과 변경 개요를 채우는 유일한 구현이 거기 있어서, 실제 AI 를 붙이면 두 패널이 늘 비어 있었다. "만들었는데 닿지 않는" 패턴의 원본이다. 지운 것: startRun(대본 재생 97줄), renderDemoBody, freshDocs·hunkDefs 와 그 문서 조작 헬퍼들(updDoc·insertAfter·patchLine·addFile), resolveHunk·resolveFile· resolveAll, diffRows, 하드코딩된 데모 파일 트리, 그리고 renderReview 의 도달 불가 꼬리 80여 줄(첫 줄에서 늘 renderProposals 로 갈라졌다). 살아 있던 세 곳은 지운 게 아니라 다시 썼다. - 초기 탭이 데모 파일(TM)을 열어 둔 채 시작했다 → 빈 탭. - stopRun 이 "검토할 게 남았나" 를 데모 문서의 줄 상태로 판정했다 → 제안 목록으로. - 에디터 대체 화면이 renderDemoBody 였다 → 빈 편집 창 안내. 덤으로 둘. 브라우저에서 4분할로 시작하던 것은 데모 시각화용이었고, 이제 어디서나 단일 그룹이다. renderTree 는 워크스페이스가 없을 때 데스크톱만 빈 상태를 그리고 브라우저에서는 아무것도 안 돌려줬다 — 두 경우가 이제 같다. data.ts 는 절반이 데모였다(224 → 80줄). AGDEF·ENGINE_CREDIT·MENUS·PROJECTS 는 데스크톱이 쓰므로 남는다.
프로젝트 단위 실행(package.json 스크립트)과 파이썬 디버그는 있는데, 파일 하나를 그냥 돌려보는 길이 없었다. .c 하나를 확인하려고 터미널을 열어 gcc 를 직접 쳐야 했다. **출력 패널이 아니라 진짜 터미널에서 돈다.** Code Runner 류의 가장 흔한 불만이 출력 채널에는 표준입력이 없어 input() 이 멈추는 것인데, 여기서는 작업 실행이 쓰는 그 PTY 를 그대로 쓴다 — 입력·색·Ctrl+C 가 공짜로 따라온다. - 없는 도구는 셸 오류를 그대로 토해내지 않는다. 먼저 확인하고 "gcc 을(를) 찾지 못했습니다" 라고 말한다. 없는 걸 눌렀을 때의 경험이 있는 걸 눌렀을 때만큼 중요하다. - 미저장이면 저장하고 실행한다. 안 그러면 방금 고친 게 아니라 옛 파일이 돈다. - 컴파일 산출물은 임시 폴더에 만든다. 프로젝트 폴더에 두면 트리에 뜨고 커밋에 섞인다. - 모르는 확장자는 명령을 지어내지 않고 모른다고 말한다. - 자동 실행은 없다. 남이 준 저장소를 열어보는 일이 흔하고, 키 하나로 임의 코드가 도는 것과 폴더를 여는 것만으로 도는 것은 전혀 다른 이야기다. - F5 는 디버그다. 실행은 VS Code 관례대로 Ctrl+F5. 기본 표는 gcc·python 처럼 가장 흔한 이름을 쓴다. clang 을 쓰거나 python3 여야 하는 환경에서 기능을 통째로 못 쓰게 되는 대신 설정에서 언어별로 명령을 갈아끼운다. 명령 조립은 engine/runFile.ts 에 순수 함수로 뒀다 — 공백 있는 경로, 산출물 이름, 셸별 따옴표가 조용히 틀리는 자리라서 테스트가 붙을 곳에 둔다.
`electron .` 은 dev 서버가 떠 있으면 dist 가 아니라 그쪽을 문다. 조용히 일어나는 일이라, 빌드본을 보고 있다고 착각한 채 개발 전용 동작을 배포본 문제로 오진하기 딱 좋다 — 실제로 그렇게 오진했다. StrictMode 의 마운트 이중 호출(개발 전용)을 배포 빌드의 결함으로 잘못 짚었고, 원인은 남아 있던 vite 서버였다. 이제 무엇을 실었는지 한 줄 남긴다. 그리고 그 로그 덕에 하나 더 보였다 — did-fail-load 와 loadURL 의 catch 가 둘 다 불려서 dev 서버가 없을 때 dist 를 두 번 싣고 있었다. 한 번만 싣는다.
찾기 위젯이 Feldgrau 에서 #252526, Paper 에서 #F3F3F3 로 떴다. 앱 팝업은 #1A1F1C / #FFFFFF 다. 테마를 셋이나 만들어 놓고 에디터 안쪽만 남의 옷을 입고 있었다 — 자동완성·우클릭 메뉴·호버·Peek·줄 이동도 전부 같았다. 색 키를 앱 토큰에서 만든다(editor/monacoColors.ts). CSS 로 덮어쓰지 않은 이유는 Monaco 내부 클래스 이름이 버전마다 바뀌는 반면 색 키는 공개 계약이기 때문이다. 모서리 둥글기·그림자처럼 색이 아닌 것만 CSS 가 맡는다. **정작 고쳐야 할 곳은 다른 데였다.** 내장 테마(monacoSetup)에 색을 아무리 채워도 화면은 그대로였다 — 문법 확장이 하나라도 있으면 에디터는 TextMate 테마로 갈아타는데, 그쪽은 색을 넷(본문·줄번호)만 정하고 나머지를 Monaco 기본값에 맡기고 있었다. 실제로 적용되는 테마가 거기라서, 두 곳 모두에 같은 위젯 색을 넣는다. 앱 테마를 바꾸면 TextMate 테마도 다시 정의해 위젯 색이 따라가게 했다. 덤으로 Graphite 가 Feldgrau 의 monaco 테마를 빌려 쓰던 것도 떼어 냈다 — 팝업 바탕이 한 톤 어긋나 있었다. 물결 밑줄(오류·경고)은 0.1.0 에서 만든 시맨틱 토큰을 그대로 쓴다. 색을 또 만들지 않는다.
에디터 ↔ 에이전트를 오가면 채팅이 엉뚱한 자리에 떨어졌다. 두 모드는 대화를 아주
다르게 그린다 — 여백 16px↔28px, 간격 10↔24, 폭도 사이드바↔52rem 중앙 정렬이라
scrollHeight 가 통째로 달라진다. 그런데 스크롤 판정 서명에 uiMode 가 없어서
"바뀐 게 없다" 로 읽혔고, 아무 보정도 없이 옛 픽셀 위치가 남았다.
픽셀은 두 모드 사이에서 뜻이 없다. 화면 맨 위에 걸친 메시지를 앵커로 잡아 전환 뒤
같은 높이에 다시 놓는다. 하단에 붙어 있었으면 하단으로. switchChatTab 이 탭 전환에
쓰던 규칙("픽셀이 아니라 하단을 기억한다")을 모드 전환에도 세운 것이다.
전환은 View Transition 안에서 일어나 그 시점 레이아웃이 아직 앉지 않았다 —
지금 한 번, 다음 프레임에 한 번 더 맞춘다. 상대 이동이라 두 번 불러도 수렴한다.
그리고 에이전트 모드는 탭을 숨기지만 필터는 그대로 걸려 있다(renderAgentRows 도
visibleMessages 를 쓴다). 표시가 없으면 대화 절반이 사라진 걸 모른 채 "왜 답이
없지" 가 된다 — 걸려 있을 때만 한 줄로 알리고 해제 버튼을 단다.
window.confirm 이 일곱 곳에 남아 있었다 — 파일 삭제, 변경 버리기, 스태시 버리기, 전체 치환, amend 경고, 외부 변경 덮어쓰기(저장모두·페인 각각). 이 저장소는 이미 같은 결론을 한 번 내렸다. askRunApproval 에 이렇게 적혀 있다: "window.confirm 은 렌더러를 통째로 얼려서 인앱 모달로 바꿨다." 실행 승인만 바꾸고 나머지는 남았고, 하필 **되돌릴 수 없는 것들**(삭제·버리기)이 그대로였다. OS 대화상자는 세 가지가 나쁘다. 렌더러를 얼려 애니메이션·타이머가 멈추고, 앱 테마를 따르지 않으며, 문구를 한 덩어리로만 실어 "무엇을" 지우는지 강조할 자리가 없다. askConfirm 은 askRunApproval 과 같은 약속(Promise) 모양이다. 되돌릴 수 없는 것은 danger 로 표시해 확인 버튼이 오류 색을 쓴다. 오버레이 표에 z 233 으로 올려 Esc 가 취소로 닫고, 그동안 전역 단축키는 눌린다 — 확인창 위에서 Ctrl+W 가 뒤의 탭을 닫으면 안 되니까. 확인창이 겹쳐 뜨면 앞선 물음은 취소로 닫는다. MonacoPane 은 App 의 메서드를 부를 수 없어 onConfirm 을 prop 으로 받는다. 창구가 없으면 덮어쓰지 않는다 — 조용히 남의 편집을 지우느니 저장을 미루는 쪽이 맞다.
워크플로가 릴리스 하나뿐이었고 v* 태그에만 걸렸다. push 에도 PR 에도 아무 검사가 없었다. 그래서 테스트 685개와 `npm run typecheck` 를 갖춰 놓고도 아무도 돌리지 않았고, 엄격 설정으로 따로 세운 src/engine 검사는 **25줄 실패한 채 남아 있었다.** 아무도 안 보는 검사는 있으나 마나다. CI 를 붙이고(typecheck·test·build), 빨간불이던 것을 고친다. 실제 코드 셋은 전부 **런타임 가드는 이미 있는데 타입이 그걸 못 읽던** 자리였다 — 잠복 버그는 없었다. 가드를 타입으로 표현하도록 고쳤다. jsonc.ts j < n 으로 막아 둔 인덱스 — ?? "" 로 (빈 문자열은 \s 에 안 걸린다) mcpb.ts 정규식 그룹이 있을 때만 담는다 subagents.ts 빈 목록은 위에서 걸렀다. 반환 타입도 그걸 알게 ?? null 테스트 쪽은 표를 인덱스로 집는 자리다. `!` 를 붙였다 — "그 자리에 있어야 한다" 는 주장이고, 없으면 그 줄에서 죽는 게 맞다. checkpoints 의 only() 는 헬퍼 하나만 고쳐 쓰는 곳 전부가 따라온다. README 의 기여 안내도 고쳤다. "릴리스 태그마다 CI 가 build 한다" 고 적혀 있었는데, 정작 PR 에는 아무것도 안 돌고 있었다.
v0.1.0 이후 들어간 것들이 문서에 없었다. 단축키 표는 다섯 줄에서 멈춰 있었고 (Ctrl+F5·Alt+←/→·Ctrl+W·Ctrl+B·Ctrl+Shift+M 이 빠져 있었다), 기능 목록에는 파일 실행·위치 이동·트리 git 색·확장 이벤트 훅이 없었다. 로드맵 Phase 3 은 "생태계" 를 아직 예정으로 적어 두고 있었는데, 정작 기능 목록에는 VS Code 확장·스킬·MCP·.mcpb 가 이미 들어 있다. 같은 문서 안에서 앞뒤가 어긋난다 — 끝난 것은 끝났다고 적고, 대신 **아직 얇은 곳**을 이름으로 적는다: 셰임이 덮는 VS Code API 범위. 활성 에디터를 읽는 확장은 지금 아무것도 못 받는다. 문서가 앱보다 잘해 보이면 안 되지만, 못해 보여도 안 된다.
demo.gif 를 어떻게 뽑았는지는 docs/record-demo.md 에 산문으로 남아 있는데, 정작 도구(compose.cjs·녹화 스크립트)는 저장소 밖에 있어서 사라졌다. 산문만으로는 다시 못 만든다 — 이번엔 같이 둔다. 이미지 라이브러리가 하나도 없다(sharp·pngjs·canvas 전부 없음). 그런데 우리는 이미 Chromium 을 CDP 로 몰고 있으므로 PNG 디코드·축소·둥근 모서리·그림자는 canvas 가 해 준다. 그래서 프레임을 Node 로 꺼내지 않고 렌더러 안에서 합성·양자화·차분·LZW 까지 끝낸 뒤 완성된 GIF 하나만 돌려받는다 — 프레임을 왕복시키면 클립 하나에 수십 MB 가 오간다. 의존성은 0 이다. 인코딩에서 값을 하는 건 하나다: 전역 팔레트 + 투명 인덱스 차분(dispose 1). IDE 화면은 프레임 사이가 99% 같아서 안 바뀐 픽셀을 투명으로 두면 LZW 가 긴 반복을 통째로 먹는다. 실측 56~333KB — 히어로(2.8MB)와 달리 기능 클립은 README 예산 안에 들어온다. 클립 자체는 아직 안 넣는다. 찍어 보니 지금 시나리오들이 약했다 — 대화가 비어 있는 모드 전환은 빈 화면 둘을 오갈 뿐이고, 터미널에 글자가 찍히는 건 이 앱의 특징이 아니다. 정작 보여 줄 값이 있는 것(실행 되돌리기·줄 단위 수락·편집 애니메이션)은 실제 에이전트 턴이 있어야 화면에 나온다.
되돌리기 클립을 두 번 찍어 보고 둘 다 버렸다. 원인이 도구에 있었다. 체크포인트는 프로젝트 폴더가 아니라 **경로 해시로 userData 에** 남는다. 그래서 녹화 때마다 폴더를 새로 만들어도 지난 녹화의 "Undoable runs" 카드가 그대로 떠 있었고, 그걸 "준비 끝" 신호로 읽는 바람에 턴이 아직 도는 중에 촬영이 시작됐다 — 클립이 "Planning…" 화면으로 끝났다. 찍기 전에 그 경로의 체크포인트를 비운다. 준비 완료 판정도 바꿨다. 카드가 뜨는 것과 턴이 끝나는 것은 다른 사건이다. 이제 Stop 버튼이 사라질 때까지 기다린다. 그리고 무대가 제대로 서지 않으면 **찍지 않고 멈춘다.** 첫 시도가 딱 그 경우였다 — "실행 뒤에 손댄 파일" 을 만들려던 편집이 안 들어갔는데 그대로 찍혀서, 이 앱만 하는 말이 하나도 없는 클립이 나왔다. 무대가 틀리면 결과물은 조용히 무의미해진다. SCHUTZ_GIF_REUSE=1 을 두었다 — 에이전트 턴이 필요한 장면은 연기만 고쳐 다시 찍을 때 턴을 또 돌리지 않는다. 실제 API 호출이라 값이 나가는 일이다.
initialize 응답을 통째로 버리고 있었다. `await request(s, "initialize", …)` 의 반환값을 아무 데도 안 썼다. 그래서 세 가지를 몰랐다. - **협상된 개정판.** 2025-06-18 부터 HTTP 전송은 이후 요청마다 `MCP-Protocol-Version` 헤더를 요구하는데, 실을 값이 없어 아예 안 보냈다. 보내는 쪽도 "2024-11-05" 로 굳어 있었다 — 최초 개정판이다. - **서버 capabilities.** 무엇을 할 수 있는지 모르는 채 tools/list 를 무턱대고 불렀다. - **serverInfo.** 무엇에 붙었는지 화면에 쓸 수가 없었다. 이제 2025-06-18 을 먼저 부르고 **서버가 돌려준 것을 따른다** — 스펙이 그렇게 하라고 되어 있고, 그래야 옛 서버와 새 서버가 같이 붙는다. 그리고 MCP 를 도구로만 쓰고 있었다. 실제로 부르는 메서드가 tools/list·tools/call· notifications/initialized 셋뿐이었다 — 세 기둥 중 둘이 통째로 비어 있어서, 리소스를 내주는 서버(문서·DB 스키마 같은 것)를 붙여도 화면에 아무것도 안 나왔다. resources/list·resources/read·prompts/list·prompts/get 을 붙이고 IPC 로 연다. capabilities 를 광고하는 서버는 그 말을 믿고, 아예 안 주는 옛 서버는 예전처럼 그냥 물어본다 — 없다고 단정해 기능을 잠그는 것보다 낫다. MCP 패널도 "도구 N개" 대신 셋을 다 세고 협상된 개정판을 함께 적는다. 리소스만 내주는 서버가 "도구 0개" 로 보이던 자리다.
확장이 로드되고 "성공" 으로 보고된 뒤 **조용히 아무것도 하지 않았다.**
get activeTextEditor() { return undefined; } // 늘
visibleTextEditors: [] // 늘
workspaceFolders: undefined // 늘
openTextDocument: () => Promise.reject(...)
현재 파일을 읽는 확장은 전부 무동작이었다. 정작 앱은 그 정보를 다 갖고 있었다 —
모델도(projectModels), 열린 페인도(paneRegistry). 셰임만 안 연결돼 있었다.
이제 문서·편집기를 실제 Monaco 모델 위에 만든다. 값은 스냅숏이 아니라 게터로 그때
그때 읽는다 — 스냅숏을 들고 있으면 확장이 옛 내용을 보고 판단한다.
document getText·lineAt·offsetAt·positionAt·getWordRangeAtPosition·lineCount·
languageId·version·isDirty
editor selection(읽기·쓰기)·visibleRanges·revealRange·edit()
workspace textDocuments·workspaceFolders·getWorkspaceFolder·openTextDocument
edit() 는 모델을 거친다. 디스크에 직접 쓰면 되돌릴 수도, 저장 기준선을 맞출 수도 없다.
모델로 가면 Ctrl+Z 가 그대로 듣는다.
이벤트도 아무도 안 쏘는 빈 EventEmitter 였다 — onDidSaveTextDocument 를 구독한
확장은 영원히 안 불렸다. 지난 판에서 만든 훅(file.open·file.save)을 그대로 흘려보낸다.
한 사건에 두 이름이 붙는 셈이라 새 배관이 생기지 않는다.
아직 못 하는 것: 열려 있지 않은 파일의 openTextDocument(모델이 없다), document.save(),
showQuickPick·showInputBox. 앞의 둘은 reject·false 로 분명히 답한다 — 조용히 빈 값을
주는 것보다 낫다.
로드맵에 "확장이 활성 에디터를 읽으면 아무것도 못 받는다" 고 적어 두었는데 방금 고쳤다. 문서가 앱보다 잘해 보이면 안 되지만, 못해 보여도 안 된다. 대신 **아직 얇은 곳을 이름으로** 적는다 — 셰임에 quick-pick·input box 가 없고, 탭에 없는 파일은 못 열고, document.save() 가 없다. 셋 다 조용히 빈 값을 주는 대신 오류로 답하므로, 그게 필요한 확장은 무동작이 아니라 소리 내며 실패한다. MCP 리소스·프롬프트는 읽고 패널에 보이지만 아직 에이전트 도구로는 안 준다. 기능 목록의 MCP·Extensions 두 줄도 실제와 맞춘다.
scan 은 VS Code 확장의 id 를 package.json 의 publisher.name 으로 만들고 실제 폴더 경로(dir)도 같이 돌려준다. 그런데 파일을 읽는 쪽(safeExtPath)은 그 dir 을 버리고 extensions/<id>/ 를 다시 만들어 썼다. 둘이 어긋나면 엔트리·테마·아이콘· 문법 파일을 **전부** 못 읽는다. .vsix 로 설치하면 폴더 이름이 id 라 맞아떨어져서 안 보였다. 폴더를 직접 넣으면 어긋난다 — "확장 폴더 열기" 버튼이 그러라고 있는 자리다. 목록에는 멀쩡히 뜨고 활성화만 "엔트리 파일을 찾을 수 없음" 으로 실패하니, 확장이 잘못된 줄 안다. Schutz 네이티브 쪽도 같다(id 는 매니페스트에서 오고 폴더 이름과 무관하다). id 로 폴더를 찾을 때 이름이 같은 흔한 경우는 statSync 한 번으로 끝내고, 어긋난 것만 스캔해서 찾은 뒤 기억해 둔다. 못 찾으면 원래 자리를 그대로 돌려준다 — 조용히 다른 확장의 파일을 읽는 것보다 ENOENT 로 실패하는 편이 낫다. 경로 이탈 가드는 그대로 두되, 스캔이 준 폴더도 확장 디렉터리 안인지 다시 본다.
셰임에서 묻는 함수는 셋인데 셋 다 묻지 않고 곧장 답했다.
showQuickPick: () => Promise.resolve(undefined),
showInputBox: () => Promise.resolve(undefined),
showInformationMessage: (msg, ..._items) => { toast(...); return undefined; },
vscode 규약에서 undefined 는 "사용자가 취소했다" 다. 그래서 확장은 물음을 띄운
적도 없이 취소당한 줄 알고 흐름을 접었다. 오류도 토스트도 없다. 특히 세 번째가
고약한데, 토스트는 뜨니까 뭔가 일어난 것처럼 보인다 — 정작
`if (await showInformationMessage(m, "Reload") === "Reload")` 는 영원히 거짓이다.
세 물음을 실제 대화창으로 만든다. 고른 항목은 **확장이 넘긴 값 그대로** 돌려준다
(확장은 대개 자기가 붙인 필드를 보고 다음을 정한다). canPickMany, Promise 로 넘긴
항목 목록, matchOnDescription/Detail, password, validateInput 을 지원한다.
검사기는 열 때도 한 번 돌린다. 확장이 준 초기값이 이미 규칙에 어긋날 수 있는데,
그때 아무 표시가 없으면 멀쩡해 보이는 값에 확인을 눌러야 왜 안 되는지 알게 된다.
버튼 없이 부른 알림은 그대로 토스트다 — 알림은 흐름을 막을 일이 아니다.
물음은 늘 누가 묻는지 머리에 밝힌다. 확장이 띄운 창을 앱이 띄운 것으로 오해하면
확장을 지운 뒤에도 앱을 의심하게 된다.
고르는 로직(정규화·필터·커서·검증)은 순수 모듈로 빼서 26개 테스트로 덮는다.
곁들여, 오버레이 zIndex 를 표에서 읽어 가게 했다. "렌더 리터럴과 같아야 한다" 고
주석으로만 적어 뒀더니 실제로 어긋나 있었다 — 확인창은 표에서 233 인데 231 로
그려져 번들 설치(232) 아래였고, 투어는 245 인데 240 이라 가져오기와 같은 층이었다.
Esc 는 표 순서대로 위엣것부터 닫는데 눈에 보이는 순서는 반대가 되는 자리다.
지난 판에 "확인할 수 있게 이름으로 적자" 고 해 놓고 적었는데, 그중 둘이 이번에 채워졌다. 이름으로 적었으니 낡았는지도 바로 보인다. 기능 목록에도 확장이 사용자에게 물을 수 있다는 것을 한 줄 넣는다.
셰임의 languages 네임스페이스는 자동완성·호버만 진짜였고 바로 아래 넉 줄이
등록만 받고 결과를 버렸다.
registerDefinitionProvider() { return noopDisposable; },
registerCodeActionsProvider() { return noopDisposable; },
registerDocumentFormattingEditProvider() { return noopDisposable; },
createDiagnosticCollection(name) { return { set() {}, delete() {}, clear() {}, ... }; },
disposable 을 돌려주니 확장은 등록에 성공한 줄 안다. 그중 진단이 제일 크다 —
린터 확장은 파일을 다 읽고 문제를 찾아 set() 에 넘기고, 그 값이 그대로 사라진다.
밑줄도 없고 문제 패널도 비어 있다.
정의·서식·진단을 Monaco 로 연결한다. 진단은 확장마다 owner 를 나눈다(한 이름을
나눠 쓰면 나중에 set 하는 확장이 앞 확장 것을 지운다). 마커를 올려 둔 모델을
들고 있다가 clear/dispose 에서 되짚어 지운다 — 안 들고 있으면 "지웠다" 고만 하고
화면에는 옛 문제가 남는다. get/has/forEach 도 채운다.
심각도 표는 새로 만든다. vscode 는 **Error 가 0** 이고(Error 0·Warning 1·
Information 2·Hint 3) LSP 는 Error 가 1 이다. lspConverters 의 표를 그대로 쓰면
한 칸씩 밀려 오류가 경고로 내려앉는다.
그리고 프로바이더가 받는 첫 인자를 고친다. vscode 는 TextDocument 를 준다고
약속하는데 Monaco 모델을 그대로 넘기고 있었다. 모델에는 getText·lineAt·fileName 이
없으니, 그걸 부르는 프로바이더는 첫 줄에서 던지고 바깥 try/catch 가 예외를
삼켰다 — 확장 쪽에서는 "결과가 없다" 와 구별되지 않는다. 진작 붙어 있던
자동완성·호버가 등록만 되고 아무것도 안 나오던 이유이기도 하다.
코드 액션은 그대로 둔다. WorkspaceEdit 를 셰임이 아직 안 내주므로, 편집을 담은
액션은 확장이 만들 수조차 없다 — 반쪽을 붙이면 되는 것과 안 되는 것을 구별하기만
더 어려워진다.
getConfiguration 이 이랬다.
get: (_key, def) => def, // 늘 인자로 준 기본값
has: () => false, // 늘 "없다"
update: () => Promise.resolve(), // 아무 데도 안 쓴다
inspect: () => undefined,
get(key, def) 만 보면 그럴듯한데, 확장은 **기본값을 인자로 안 준다.** 자기
package.json 의 contributes.configuration 에 이미 적어 뒀기 때문이다. 그래서
cfg.get("material-icon-theme.folders.theme") 가 vscode 에서는 "specific" 인데
여기서는 undefined 였다. 확장은 설정이 꺼져 있다고 읽고 기능을 접거나, undefined
를 그대로 쓰다 뒤에서 터진다. has() 가 늘 거짓인 것도 같은 종류의 거짓말이다.
선언된 기본값을 읽고, 사용자 값을 localStorage 에 확장별로 저장한다. 값은
사용자 → 선언 → 호출자 인자 순으로 고른다. update(k, undefined) 는 저장을 지워
선언 기본값으로 되돌린다(vscode 규약) — 그대로 저장하면 사용자가 undefined 를
골랐다는 뜻이 되어 기본값이 영영 안 돌아온다.
contributes.configuration 은 객체 하나일 수도 배열일 수도 있다. 한쪽만 처리하면
절반의 확장이 조용히 기본값을 잃는다. default 를 안 적은 항목은 타입별 빈 값으로
채운다 — 없는 키로 두면 "설정 안 됨" 과 구별되지 않는다.
설정 객체는 값을 속성으로도 노출한다(cfg.enable). 그렇게 읽는 확장이 흔하다.
onDidChangeConfiguration 도 실제로 쏜다. affectsConfiguration 은 접두사로 본다 —
a.b 가 바뀌면 a 도 영향받은 것이다.
워크스페이스별 설정은 아직 없다. inspect().workspaceValue 는 undefined 로 둔다 —
"이 층엔 값이 없다" 는 정확한 답이고, 거짓으로 채우는 것보다 낫다.
createStatusBarItem: () => ({ text: "", tooltip: "", command: "", show() {}, hide() {}, dispose() {} }),
setStatusBarMessage: (_msg) => noopDisposable,
text 는 그냥 객체의 속성이라 대입이 성공한다. show() 도 성공한다. 그런데 그 객체를
읽는 곳이 어디에도 없었다. "빌드 중…", "3 problems", 로그인 상태 같은 알림이
통째로 사라졌고, 실패한 흔적조차 없다.
앱 상태바에 확장 항목 자리를 만든다. 우선순위·정렬은 vscode 규칙 그대로다 —
왼쪽 그룹은 큰 값이 더 왼쪽, 오른쪽 그룹은 큰 값이 더 오른쪽. 우리 상태바는
왼쪽부터 그리므로 오른쪽 그룹은 뒤집어야 눈에 보이는 순서가 같아진다.
항목은 Proxy 로 감싼다. 확장은 show() 뒤에도 text 를 계속 갈아 끼운다(진행 표시가
그렇게 쓰인다). 만들 때 한 번만 읽으면 첫 글자에서 멈춘 채로 남는다.
codicon 표기($(sync~spin))는 걷어낸다. 그 폰트를 안 실으므로 그대로 두면 사용자에게
"$(sync~spin)" 이 글자로 보인다. 걷어낸 뒤 아무것도 안 남는 항목(아이콘만 올리는
경우)에는 점 하나를 남긴다 — 빈 글자는 폭이 0 이라 누를 수 있는 것이 화면에서
사라진다.
툴팁에 확장 이름을 함께 싣는다. 상태바는 앱이 말하는 자리로 읽히므로, 누가 올린
글자인지 밝히지 않으면 확장을 지운 뒤에도 앱을 의심하게 된다.
확장을 다시 읽을 때 상태바를 먼저 비운다. 안 그러면 방금 끈 확장의 "빌드 중…" 이
아무도 갱신하지 않은 채 영원히 남는다.
createFileSystemWatcher: () => ({
onDidCreate: new EventEmitter().event,
onDidChange: new EventEmitter().event,
onDidDelete: new EventEmitter().event,
dispose() {},
}),
아무도 쏘지 않는 이미터 셋이었다. 파일이 바뀌면 다시 읽는 확장 — 설정 파일 감시,
빌드 산출물 추적, 인덱스 갱신 — 이 한 번도 깨어나지 않았다.
정작 경로는 처음부터 있었다. 메인의 fs.watch(root, {recursive:true}) 는 바뀐 파일
이름을 콜백으로 받아 무시 규칙(node_modules, .schutz-tmp)에 쓰고는 그대로 버렸고,
렌더러에는 인자 없는 "뭔가 바뀌었다" 만 갔다. 그 이름을 디바운스 동안 모아 함께
보낸다(대량 변경에 대비해 2000개 상한).
이름만으로는 무슨 일이 있었는지 알 수 없다 — 만들어졌는지 지워졌는지 고쳐졌는지.
그래서 syncFromDisk 가 어차피 다시 읽는 트리와 앞뒤로 맞춰 판정한다. 워처가
알려 주지 않은 경로도 트리 비교로 잡히므로, 브랜치 전환처럼 개별 알림이 뭉개지는
자리에서도 만들어짐·지워짐은 놓치지 않는다.
트리가 상한에 걸려 잘렸을 때는 **워처가 이름을 준 경로만** 본다. 그때 "목록에
없다" 는 "지워졌다" 가 아니라 "안 읽었다" 일 수 있고, 그걸 지워짐으로 쏘면 그걸
받은 확장은 자기 인덱스에서 멀쩡한 파일을 실제로 지운다.
글롭은 vscode 문법 그대로다. ** 는 경로 구분자를 넘고 * 는 안 넘는다 — 둘을 같게
다루면 *.json 이 a/b/c.json 까지 잡아 확장이 엉뚱한 파일에 반응한다. `**/` 는
0단계도 뜻하므로 `**/*.ts` 가 최상위 a.ts 도 잡는다.
한 감시자가 던져도 나머지는 받는다. 남의 확장이 우리 확장을 막으면 안 된다.
registerTreeDataProvider: () => noopDisposable, registerWebviewViewProvider: () => noopDisposable, 등록은 성공하는데 그 뷰가 놓일 자리가 앱에 아예 없었다. 확장은 매니페스트에 contributes.views 로 "여기 뷰를 하나 놓겠다" 고 선언하고 프로바이더까지 붙이는데, 화면 어디에도 나타나지 않는다. 레일에 "확장 뷰" 자리를 만들고 등록된 뷰를 그린다. 이름은 매니페스트에서 읽는다 — 프로바이더를 등록하는 쪽은 뷰 id 만 아는데, 사람에게 보여 줄 이름은 거기에만 있다. 안 읽으면 사이드바에 원시 id(myExt.viewA)가 그대로 뜬다. 어느 확장이 붙였는지도 함께 밝힌다. 안 밝히면 앱의 기능으로 읽힌다. 트리는 펼쳐 둔 줄만 자식을 물어본다. 전부 미리 물어보면 파일 트리를 내주는 확장에서 프로젝트 전체를 한 번에 읽게 된다. 확장이 던지면 그 뷰만 실패로 두고 나머지는 그대로 그린다. 웹뷰는 샌드박스 iframe 안에 둔다. 확장의 HTML 을 우리 문서에 그대로 붙이면 앱 DOM 을 자유롭게 만질 수 있다. 프레임 안에서는 스크립트가 돌되(웹뷰는 스크립트가 있어야 산다) 바깥과는 postMessage 로만 오간다. acquireVsCodeApi() 도 넣어 준다 — 거의 모든 웹뷰가 그 한 줄로 시작하고, 없으면 첫 줄에서 죽는다. HTML 만 그려 둔 "떠 있는데 아무 반응 없는 창" 은 아무것도 안 뜨는 것보다 나쁘다. 만들면서 걸린 것 둘: 트리 줄의 명령이 안 돌았다. 확장 명령은 `확장id:명령id` 로 저장되는데 확장이 항목에 적어 두는 것은 자기 이름(probe.pick)뿐이라, 누가 부르는지 같이 넘겨야 찾는다. 안 넘기면 줄을 눌러도 조용히 아무 일이 없다 — 이 판에서 고치려던 바로 그 모양이다. onDidChangeTreeData 구독을 뷰 id 로 기억했더니, 확장을 다시 읽어 프로바이더가 새로 만들어졌을 때 옛 이미터에 걸린 구독이 남았다. 확장은 계속 알리는데 아무도 안 듣고 트리는 처음 그린 모습에서 굳는다. 등록이 바뀔 때마다 전부 떼고 다시 건다. 봉투도 샌다. 앱이 웹뷰로 보내는 메시지는 __schutzToView 를 단 객체로 싸는데, 그게 확장의 리스너에까지 그대로 갔다. 캡처 단계에서 가로채 전파를 끊는다. TreeView.reveal 은 아직 못 한다. 조용히 성공한 척하지 않고 거부한다.
로드맵의 "얇은 곳" 목록에서 이번에 채운 것들을 빼고, 남은 것을 이름으로 적는다 — 코드 액션(WorkspaceEdit 를 안 내주므로 확장이 만들 수조차 없다), TreeView.reveal, document.save(), 탭에 없는 파일 열기. 넷 다 조용히 빈 값을 주는 대신 오류로 답한다. Extensions 줄에는 진단·정의·서식·설정·파일 감시·상태바·사이드바 뷰를 한 줄로 적는다.
셰임에 WorkspaceEdit 가 아예 없었다. `new vscode.WorkspaceEdit()` 는 첫 줄에서
죽었고, 그래서 편집을 담은 코드 액션은 확장이 **만들 수조차** 없었다. 지난 판에
registerCodeActionsProvider 를 빈 disposable 로 남겨 둔 이유가 그것이다 — 반쪽을
붙이면 되는 것과 안 되는 것을 가리기가 더 어려워진다.
이제 WorkspaceEdit·CodeAction·CodeActionKind 를 주고, workspace.applyEdit 와
코드 액션 프로바이더를 함께 붙인다. 빠른 수정 메뉴(Ctrl+.)에 확장의 액션이 뜨고,
고르면 편집이 모델을 거쳐 들어가 한 번의 Ctrl+Z 로 통째로 되돌아간다.
한 파일 안의 편집은 **뒤에서부터** 적용한다. 확장이 주는 범위는 전부 원본 기준이라
앞에서부터 하면 먼저 넣은 글자만큼 뒤 편집이 밀린다. 같은 자리에 여럿이면 넣은
순서를 지킨다 — 한 지점에 조각을 이어 붙일 때 차례가 뒤집히면 안 된다.
겹치는 편집은 통째로 거절한다. 어느 쪽을 살릴지 우리가 정할 수 없고, 반만 적용된
파일은 확장도 사용자도 되돌릴 수 없는 상태가 된다.
같이 채운 것 셋:
- document.save() 가 앱의 저장 경로를 탄다. 확장이 디스크에 직접 쓰면 외부 변경
확인도 기준선 갱신도 저장 사건도 건너뛴다. 그 사이 디스크가 바뀌었으면 조용히
덮지 않고 "못 했다" 로 답한다 — 사람이 없는 자리에서 남의 편집을 지우는 것보다
낫다.
- openTextDocument 가 탭에 없는 파일도 읽는다. **탭은 열지 않는다** — vscode 도
그렇고, 확장이 파일을 훑을 때마다 탭이 생기면 못 쓴다.
- TreeView.reveal 이 프로바이더를 타고 내려가 항목을 찾아 조상을 편다. 그려 둔
줄만 뒤지면 안 된다 — reveal 은 대개 아직 안 보이는 것을 보이게 하려고 부른다.
만들면서 걸린 것 셋:
윈도우에서 드라이브 글자의 대소문자가 어긋났다. 앱은 워크스페이스를 `C:/…` 로
들고 있는데 Uri.fsPath 는 `c:\…` 를 준다. 접두사가 안 맞아 **절대 경로가 그대로
상대 경로 자리에** 들어갔고, 뒤이은 파일 조회가 전부 빗나갔다.
uri 로 모델을 찾을 때 경로를 떼는 방식은 애초에 틀렸다. `uri.toString()` 은
퍼센트 인코딩된 `file:///c%3A/…` 라 루트 문자열과 절대 안 맞는다. 모델 색인이
uri 문자열을 그대로 들고 있으므로 그쪽에 먼저 묻는다.
Range 가 `{ line, character }` 리터럴을 안 받았다. instanceof 만 보니 그 객체가
줄 번호 자리로 들어가 범위가 통째로 엉켰다 — 편집이 엉뚱한 곳에 적히거나 아무
데도 안 적힌다. 확장은 리터럴을 그대로 넘기는 일이 흔하다.
파일 만들기·지우기·이름 바꾸기는 아직 안 한다. 조용히 넘기면 확장은 파일이 생긴
줄 알고 다음 단계로 가므로, 던져서 알린다.
지난 판에 세 기둥을 다 읽게 만들었는데, 읽어서 패널에 세어 두기만 했다. 모델은 그것들이 있다는 사실조차 몰랐다 — 문서 한 벌을 리소스로 내주는 서버를 붙여 놓고 물어보면 "그런 건 없다" 고 답한다. 도구 셋을 붙인다. mcp_list_resources · mcp_read_resource · mcp_get_prompt. 서버마다 만들지 않고 셋으로 고정한다 — 서버가 늘 때마다 도구가 늘면 매 턴 목록이 불어난다. **줄 것이 있을 때만** 만든다. 리소스를 내주는 서버가 하나도 없는데 읽기 도구를 목록에 넣으면, 모델은 매 턴 그 설명을 읽고 가끔 헛되이 부른다. 도구 목록은 공짜가 아니다. 프롬프트는 대개 몇 개뿐이라 이름을 도구 설명에 그대로 적는다. 목록 조회 한 번을 아낀다. 인자는 서버에 보내기 전에 확인한다. 틀린 서버·uri·프롬프트 이름에는 **있는 것을 같이 알려 준다** — 조용한 빈 결과는 모델에게 "그런 건 없다" 로 읽혀서, 있는 것을 두고 물러난다. 필수 인자가 빠진 것도 여기서 잡아 왕복 한 번을 아낀다. 바이너리 리소스는 통째로 싣지 않는다. base64 를 그대로 넣으면 대화창을 먹는다 — 무엇인지와 크기만 알린다. 리소스·프롬프트 캐시는 도구 캐시와 같이 갱신한다. 한 서버가 느리거나 죽어도 나머지는 받아야 하므로 각각 따로 감싼다.
지난번에 적어 둔 넷이 이번에 다 채워졌다 — 코드 액션, TreeView.reveal, document.save(), 탭에 없는 파일 열기. MCP 리소스·프롬프트도 이제 에이전트가 쓴다. 이름으로 적어 둔 덕에 무엇이 낡았는지 한눈에 보였다. 남은 것도 같은 방식으로 적는다 — WorkspaceEdit 의 파일 만들기·지우기·이름 바꾸기, TextEditor 데코레이션, 커스텀 에디터, 디버그 어댑터 API. 넷 다 빈 값 대신 던진다.
평범한 사용 흐름(파일 열기·편집·저장·검색·레일 전환·터미널·모드 전환·외부 변경· 탭 닫기)을 훑으며 콘솔을 지켜봤더니 세 가지가 나왔다. **① 처리 안 된 비동기 실패를 아무도 안 보고 있었다.** main.tsx 의 ErrorBoundary 는 렌더 중에 던진 것만 잡는다. 클릭 핸들러 안의 await 가 거부되면 React 는 아무것도 모르고, 앱에도 지켜보는 곳이 없었다 — 오류도 로그도 토스트도 없이 그냥 아무 일도 안 일어난다. 이 저장소에서 가장 자주 고쳐 온 결함 모양이 정확히 그것이다. 전역으로 받아 콘솔에 남기고, 사용자에게도 한 번은 알린다. 같은 실패는 1분에 한 번만 — 루프 안의 실패가 초당 수십 번 오면 화면을 못 쓴다. 그렇다고 첫 번째만 알리고 마는 것도 곤란해서, 창이 지나면 다시 알린다(한참 뒤 또 나면 그건 새 소식이다). Monaco 의 취소(Canceled)와 우리가 끊은 요청(AbortError)은 뺀다. 취소는 실패가 아니고, 같이 시끄럽게 하면 진짜 오류가 그 속에 묻힌다 — 신호를 만들려다 소음을 만드는 꼴이다. **② 터미널을 열 때마다 예외가 하나씩 쌓였다.** 하단 도크는 0 에서 210 으로 펼쳐지는데, 그 도중에 term.open() 이 불려 xterm 이 글자 크기를 재지 못했다. 렌더 서비스의 치수가 안 만들어지고, 이후 첫 스크롤 동기화가 dimensions 를 undefined 로 읽어 던진다. 화면은 멀쩡해 보여서 아무도 몰랐다. "폭이 0인가" 로는 못 잡는다 — 펼쳐지는 중에는 0 이 아니라 **너무 작을** 뿐이다. 그래서 여는 것 자체를 크기가 잡힐 때까지 미룬다. 그때까지의 출력은 xterm 코어가 들고 있다가 열리는 순간 그려 준다. fit 도 proposeDimensions 로 같은 판정을 한다. **③ 확장 목록의 기여 배지에 key 가 없었다.** 목록 안에서 위치로만 짝이 맞아, 확장을 켜고 끄면 엉뚱한 배지가 남을 수 있었다.
메인 프로세스에 전역 오류 처리가 **아예 없었다.** Node 는 처리 안 된 거부를 기본으로 던지므로, 어딘가의 async 하나가 실패하면 메인이 죽고 앱이 통째로 사라진다 — 창도, 저장 안 한 버퍼도, 아무 설명도 없이. 렌더러에는 공들인 크래시 화면(신고 링크까지)이 있는데 정작 더 치명적인 쪽이 비어 있었다. 죽지 않기로 한다. 메인을 살려 두면 렌더러가 들고 있는 편집본을 저장할 기회가 남는다 — 관계없는 비동기 오류 하나로 그걸 잃는 것보다 낫다. 대신 반드시 남기고, 한 번은 알린다. 모달로 막지는 않는다. 하던 일을 끊을 이유가 없고, 필요한 건 "조용히 넘어가지 않았다" 는 사실과 로그로 가는 길뿐이다. 세션당 한 번만 알린다. 한 번 어긋나면 같은 오류가 초당 여러 번 오는 일이 흔한데, 그때마다 모달을 띄우면 앱을 아예 못 쓴다. 렌더러가 사라졌을 때도 아무 처리가 없었다. 빈 창만 남고, 사용자가 할 수 있는 일은 창을 닫는 것뿐이다. 이제 다시 싣되, 짧은 사이에 되풀이되면 멈추고 사용자에게 넘긴다 — 실을 때마다 같은 이유로 또 죽으면 무한 반복이 된다. 로그는 userData/logs/main.log 에 쌓고 상한을 둔다. 넘치면 **뒤쪽을 남긴다** — 크래시 직전이 궁금하지 처음 켠 날이 궁금한 게 아니다. 잘랐다는 것도 밝힌다. 조용히 지우면 앞이 원래 없었는지 지워졌는지 알 수 없다. 판단 부분(자르기·알림 게이트·재시도 게이트)은 crashPolicy.cjs 로 빼서 13개 테스트로 덮었다. 메인 프로세스 코드도 순수한 부분은 덮을 수 있다.
메인 프로세스 코드는 .cjs 지만 순수한 판단 부분은 렌더러 쪽 테스트에서 그대로 불러 쓴다. 선언이 없으면 엄격 검사(src/engine)가 implicit any 로 막는다.
에이전트 트랙은 이미 여덟 단계 있었다. 문제는 두 트랙이 when 으로 갈려 있어서 **한 모드에 서 있으면 다른 쪽은 영영 안 보인다**는 것이다. 에디터 모드로 첫 실행을 마친 사람은 에이전트 트랙을 한 번도 못 본다 — 첫 화면에서 고르게 해놓고, 고르지 않은 쪽을 못 배우게 두는 셈이다. 모드 단계가 원래 두 트랙을 잇는 다리로 놓여 있었는데, 정작 거기서 건너갈 수가 없었다. "다른 모양도 있다" 는 말만 듣고 끝난다. 그 단계에 건너가기 버튼을 둔다 — 누르면 모드를 바꾸고 그쪽 트랙 첫 단계로 이어진다. 한 번만 건너간다. 안 그러면 양쪽 모드 단계가 서로를 가리키며 끝나지 않는다. 모드 전환은 View Transition 을 타서 레이아웃이 곧바로 자리 잡지 않는다. 바로 다음 단계로 가면 앵커가 아직 없어 조용히 건너뛰므로, 한 박자 기다린 뒤 옮긴다.
dropMissing 이 트리에 없는 모델을 **dirty 검사 없이 전부** 폐기했다. 그래서
1. 파일을 열어 고치고 저장하지 않은 채 두고,
2. 밖에서 그 파일이 사라지면(브랜치 전환·git stash·빌드 스크립트·다른 편집기의
"임시 파일에 쓰고 이름 바꾸기" 식 저장),
3. 다음 트리 동기화가 그 모델을 dispose 한다.
저장 안 한 편집이 경고도 되돌리기도 없이 사라진다. 0.1.0 에서 두 번 고친 것과 같은
부류의 세 번째 형태다.
깨끗한 모델까지 남기면 안 된다 — dirtyRels 가 없는 경로를 계속 내주고 모두 저장이
사용자가 지운 파일을 되살린다(dropUnder 주석이 그걸 막으려던 것이다). 그래서 가른다:
고친 것은 남기고, 안 고친 것은 버린다. 되살아나는 일은 사용자가 직접 저장을 눌렀을
때만 일어나고, 그때는 화면에 dirty 표시가 떠 있다.
남겼다는 사실은 알린다. 조용히 지우면 되돌릴 방법이 없고, 조용히 남기면 다음에 앱을
껐을 때 그 편집이 사라진다. 트리 동기화는 자주 도니 같은 파일을 되풀이해 알리지
않고, 파일이 돌아왔다 다시 사라지면 그건 새 소식으로 친다.
isDirty 가 던지면 **고친 것으로 본다** — 잘못 남기면 탭 하나가 더 열려 있을 뿐이고,
잘못 버리면 작업이 사라진다.
확인 도중에 한 번 헛통과했다. 폐기된 페인의 DOM 이 남아 있어서 화면에는 글자가
그대로 보였다. 탭을 떠났다 돌아오면 페인이 다시 그려지므로 그걸로 판정한다.
트레이 '종료' → app.quit() → before-quit 이 트레이를 없애고 실행 중인 프로세스를 죽인다 → 창을 닫으려는데 렌더러의 beforeunload 가 막는다. 결과: 앱은 살아 있고, 트레이 아이콘은 사라졌고, 눌렀던 종료는 아무 일도 안 한 것처럼 보인다. 창을 트레이로 내려둔 상태였다면 **앱에 닿을 방법이 없다.** 브라우저와 달리 Electron 에선 beforeunload 가 대화상자를 띄우지 않고 조용히 종료를 취소한다. 최소 재현으로 확인했다 — 창의 close 는 통과하는데 3초 뒤에도 프로세스가 살아 있다. 그래서 붙잡기 전에 **묻는다.** 저장하고 종료 / 저장하지 않고 종료 / 취소. 렌더러가 저장 안 한 파일 목록을 **미리** 알려 둔다. 종료를 누른 뒤에 물어보면 늦다 — 그때는 이미 트레이가 사라지고 프로세스가 죽은 다음이다. 트레이는 will-quit 에서 놓는다. before-quit 에서 없애면 종료가 붙잡혔을 때 트레이만 사라져, 되물으려던 그 순간에 앱에 닿을 길이 없어진다. "저장하지 않고 종료" 는 렌더러에 알려 beforeunload 가 더는 막지 않게 한 뒤 나간다. "저장하고 종료" 는 렌더러가 다 저장했다고 알릴 때만 나간다 — 8초 안에 답이 없으면 **종료하지 않고** 그렇게 말한다(저장이 안 됐을 수 있다). 모르는 응답은 취소로 본다. 애매할 때 종료해 버리면 작업이 사라진다. 판단 부분은 quitGuard.cjs 로 빼서 10개 테스트로 덮었고, 흐름 세 갈래는 실제 preload.cjs 를 실은 격리 하니스로 확인했다(취소·버리고 종료·저장 후 종료).
openWorkspacePath 가 아무 검사 없이 projectModels.disposeAll() 을 부르고 paneDirty 까지 비웠다. 다른 폴더를 열거나 최근 목록에서 고르면 저장 안 한 편집이 **경고도 되돌리기도 없이** 사라진다. 프로젝트 전환은 아주 흔한 동작이라 더 나쁘다. 버릴지 묻지 않고 **저장할지**만 묻는다. 버리는 쪽을 버튼으로 주면 한 번의 오클릭으로 작업이 사라지는데 되돌릴 방법이 없다. 정말 버리고 싶으면 편집기에서 되돌리거나 탭을 닫으면 된다 — 그건 되돌릴 수 있는 길이다. 저장이 다 되지 않았으면 열지 않는다. 그냥 열면 방금 못 지킨 것이 그대로 사라진다. 저장 직후 판정은 paneDirty(React 상태)로 하면 안 된다. 방금 부른 setState 가 아직 반영되기 전이라, 저장에 **성공하고도** "끝내지 못했다" 며 열지 않는다. 실제로 그렇게 한 번 걸렸다. markSaved 가 동기로 끝내는 모델 쪽이 그 순간의 사실이다.
**git 변경 취소.** 디스크는 HEAD 로 되돌리면서 열린 버퍼는 안 건드렸다. 그래서 패널은 "변경 없음" 인데 편집기엔 그 변경이 그대로 남고, 저장하면 방금 버린 것이 되살아난다. 절반만 버린 셈이다. 버리기가 뜻하는 것은 "이 파일의 변경을 전부 없앤다" 이고 저장 안 한 편집도 그 파일의 변경이다. 그래서 함께 버린다 — 다만 되돌릴 수 없으니 확인창에서 미리 말한다. **삭제 확인창.** "휴지통으로 옮길까요?" 라고 묻는데, 휴지통에 들어가는 것은 디스크에 있던 내용뿐이다. 저장 안 한 편집은 버퍼에만 있어서 그대로 사라지고 되돌릴 방법이 없다. 확인창이 되돌릴 수 있다고 약속하는 이상, 그 약속이 닿지 않는 부분은 말해야 한다. 무엇이 함께 사라지는지 파일 이름으로 적는다.
UTF-8 BOM 은 본문이 아니라 파일의 표식이다. Monaco 도 그렇게 보고 따로 들고 있어서 getValue() 는 BOM 을 빼고 준다. 그런데 디스크에서 읽은 문자열에는 BOM 이 들어 있고, 그걸 그대로 기준선으로 삼았다. 두 값이 영원히 다르니 — · BOM 파일은 **열자마자 "저장 안 함"** 이 되고, · "모두 저장" 이 손도 안 댄 파일을 고쳐 쓰면서 BOM 을 떼어 버린다. 파일 전체가 바뀐 diff 가 되고, BOM 을 요구하는 도구에서는 빌드가 깨진다. 안쪽은 전부 BOM 없이 다룬다. 읽을 때 떼고, 있었다는 사실만 기억했다가, 디스크에 쓸 때만 도로 붙인다. 비교·오프셋 계산이 전부 BOM 없는 문자열로 도니 한 글자씩 밀리는 일도 없다. 이름을 바꿔도 표식은 그 파일을 따라간다. 쓰는 자리는 넷이다 — 페인 저장(Ctrl+S), 모두 저장, 확장의 document.save(), 제안 수락. 제안 수락은 기준 텍스트가 모델에서 올 때(BOM 없음)와 디스크에서 올 때 (BOM 있음)가 섞이므로, 한 번 떼고 그 파일이 원래 갖고 있었는지로만 다시 붙인다. 같이 확인한 것: 줄 끝(CRLF/LF)과 마지막 줄바꿈 없음은 원래 그대로 보존되고 있었다.
readFile 이 무조건 fs.readFile(abs, "utf8") 이었다. UTF-8 이 아닌 파일은 디코딩에서 U+FFFD 로 바뀌고, 그 상태로 저장하면 원본이 사라진다. 되돌릴 방법이 없다. 재현했다. UTF-16 파일은 열고 Ctrl+S 만 눌러도 16바이트가 20바이트가 됐다. CP949 로 저장된 한글 파일은 `// �ȳ� ����` 로 뜨고, 한 글자만 고쳐 저장하면 파일 전체가 사라진다. 윈도우에서 아주 흔한 인코딩이다. electron/encoding.cjs 에 순수 판정을 두고(BOM·NUL·UTF-8 왕복 비교), readFile 이 아니라고 판단하면 열지 않고 이유를 돌려준다. 렌더러는 그 코드를 4개국어 안내 문구로 바꿔 편집기 자리에 띄운다 — 왜 안 열리는지 말해 주지 않으면 그냥 고장 난 편집기로 보인다. 다른 인코딩을 읽어 주는 것은 기능이고, 조용히 부수지 않는 것은 그 전에 지켜야 할 일이다. 실제 앱 검증 7/7 (고치기 전 2/5), 단위 954개 통과.
readFile 은 방금 고쳤는데 replaceInFiles 에 같은 자리가 그대로 남아 있었다. fs.readFile(abs, "utf8") 로 읽어 치환하고 도로 쓴다. 이쪽이 더 위험하다. 사용자가 열어 보지도 않은 파일을 프로젝트 전체로 훑으며 쓰기 때문이다. 실제 앱에서 재현했다 — CP949 로 된 legacy.c 에서 "int" 를 "long" 으로 한 번 바꿨더니 26바이트가 39바이트가 되고 한글 주석이 전부 U+FFFD 로 바뀌었다. 그러고도 "2개 파일 · 3곳 변경" 성공 토스트가 떴다. UTF-16 파일은 NUL 검사에 걸려 살아남았지만, 살아남았다는 말도 없었다. encoding.detect 로 걸러 건드리지 않고, 건너뛴 파일 목록을 skipped 로 돌려준다. 렌더러는 그 목록을 토스트로 띄운다 — 조용히 빼면 "전부 바꿨다" 로 읽히고, 그 파일들만 옛 이름이 남아 나중에 빌드가 깨진 뒤에야 알게 된다. BOM 붙은 UTF-8 파일은 그대로 치환되고 BOM 도 남는 것을 함께 확인했다. 실제 앱 검증 5/5 (고치기 전 3/5), 단위 954개 통과.
askRunApproval 이 resolve 를 필드 하나(_askRunResolve)에 담고 있었다. 실행이 하나뿐일 때는 맞는데, 이 앱은 위임으로 여러 실행이 동시에 돈다 — 한 라운드에 delegate_task 가 둘이면 하위 실행 둘이 나란히 돌고, 둘 다 셸 명령이나 엔진 작업 승인을 물을 수 있다. 그때 뒤에 온 물음이 앞의 resolve 를 덮었다. 사용자가 답하면 뒤엣것만 풀리고 앞의 실행은 영원히 await 에 매달린다. 상태는 "편집 중" 인 채로 멈추고, 그 실행이 쥔 파일 락은 finally 에 도달하지 못해 풀리지 않으며, 화면에는 아무 말도 안 나온다. 그 파일을 건드리려는 다른 에이전트는 계속 "작업 중입니다" 만 듣는다. engine/askQueue.ts 로 줄을 세운다. 하나씩 보여 주고, 답한 것만 풀고, 다음을 올린다. 모든 물음은 정확히 한 번 풀린다. 에이전트 중지는 이제 보이는 물음뿐 아니라 줄에서 기다리는 그 에이전트의 물음까지 걷어낸다. 안 그러면 중지한 에이전트가 나중에 모달로 되살아나고 그 실행은 계속 매달려 있다. 전체 중지는 줄 전체를 거절로 푼다. 단위 9개 추가(963개 통과). 실제 앱 회귀 확인 5/5.
둘 다 앞선 인코딩 작업에서 남은 자리다. 하나. 메인은 `SCHUTZ_ENCODING:<종류>` 라는 코드만 던지는데(전자는 언어를 모른다) 그 코드를 번역하는 자리가 편집기 안에만 있었다. 같은 오류가 다른 길로 나오면 원시 코드가 그대로 보였다. 특히 에이전트가 그랬다 — read_file 결과로 `오류: SCHUTZ_ENCODING:not-utf8` 을 받으면 무슨 일인지 알 수 없어 같은 파일을 계속 다시 읽으며 라운드를 태운다. 제안 카드의 실패 사유도 마찬가지였다. encodingNote.ts 한 군데로 모으고 세 곳이 같이 쓴다. 둘. runCommand 만 assertRoot 를 안 탔다. 존재하는 디렉터리이기만 하면 됐으니 렌더러가 넘긴 아무 경로에서나(홈 디렉터리라도) 명령이 돌 수 있었다. 다른 fs 핸들러는 전부 태운다. 함께 주석도 고쳤다 — "cwd 로 워크스페이스에 가둔다" 고 적혀 있었는데 shell:true 라 cwd 는 경계가 아니다(명령이 cd 든 절대경로든 쓸 수 있다). 진짜 경계는 그 위의 승인 게이트다. 단위 9개 추가(972개 통과). 실제 앱 검증: 인코딩 7/7, 치환·실행 7/7 (워크스페이스 안 echo 는 그대로 돌고, 밖은 거절된다).
MAX_DEPTH 가 8 이었다. packages/app/src/features/x/components/y/z.ts 정도면 벌써 8 이고, 그보다 깊은 파일은 트리에도 검색에도 치환에도 나타나지 않았다 — 앱 안에서 찾을 방법이 없었다는 뜻이다. 실제로 깊이 10 에 파일을 묻어 두고 readTree 를 불러 확인했다. 16 으로 올린다. node_modules 류는 IGNORE_DIRS 로 이미 빠지고 트리는 MAX_ENTRIES(4000) 로 따로 묶여 있으므로, 깊이를 올려도 비용은 디렉터리 읽기 몇 번이다. 그리고 상한을 정말 넘겼을 때 조용하지 않게 한다. - 검색은 깊이 때문에 안 걸어 본 곳이 있으면 deepSkipped 로 알린다. 예전엔 그냥 결과 0 이었다 — 검색은 "여기 없다" 의 근거로 쓰이는데, 안 찾아본 것과 없는 것이 같은 답으로 나오고 있었다. 패널에 "일부 미탐색" 을 띄우고, 에이전트에게도 같은 말을 붙인다. - list_files 는 트리가 잘렸다는 사실(ws.truncated)을 모델에게 말하지 않고 있었다. 화면에는 "항목이 많아 일부만 표시합니다" 라고 적어 두면서. 그래서 없는 게 아니라 안 걸어 본 파일을 "없다" 로 읽곤 했다. 실제 앱 검증 5/5 — 깊이 10 파일이 트리와 검색 양쪽에 나오고, 깊이 20 을 묻으면 그때는 미탐색이라고 답한다. 단위 972개 통과.
IGNORE_DIRS 와 별개로 "점으로 시작하는 폴더는 전부 건너뛴다(.github 만 예외)" 라는 규칙이 세 순회(트리·검색·치환)에 각각 박혀 있었다. 그래서 .vscode/settings.json 도 .claude/agents/*.md 도 트리에 안 나오고 검색에도 안 걸렸다 — 열려면 경로를 정확히 알아야 했고, 에이전트는 그 파일들이 아예 없는 줄 알았다. .github 만 예외로 뚫려 있던 것이 그 규칙이 임시방편이었다는 표시다. 규칙을 걷어내고 무엇을 숨길지는 IGNORE_DIRS 한 곳이 정하게 한다. 점 폴더를 일괄로 막던 그물이 사라졌으니 무거운 것들(.cache · .turbo · .gradle · .pytest_cache · .svelte-kit · .yarn · .terraform 등)은 이름으로 적어 둔다. .git · node_modules 는 그대로 빠진다. 실제 앱 검증 8/8 (고치기 전 5/8) — .vscode · .claude 안이 트리와 검색 양쪽에 나오고, .git · node_modules 는 여전히 안 나온다. 단위 972개 통과.
`if (touched.size < MAX_TOUCHED) touched.add(...)` 한 줄이었다. 상한(2000)을 넘으면 경로를 조용히 버렸다. 앱 자신은 멀쩡하다 — 새로고침 때 트리를 통째로 다시 읽으니까. 손해는 확장의 파일 감시자가 본다. 잘려 나간 파일은 확장에게 "아무 일 없었다" 가 되고, 확장은 자기가 전부 안다고 여긴다. touchSet.cjs 로 자루를 옮긴다. 상한을 20000 으로 올리고, 그래도 넘치면 overflow 를 알림에 실어 보낸다. 렌더러는 그 신호를 받으면 이름 목록으로 좁히지 않고 앞뒤 트리 전체를 비교해 판정한다 — 잘려 나간 파일이 조용히 누락되지 않게. 트리까지 잘린 경우에만 정말 모르는 채 넘어가고 그때는 남긴다. 전제가 반쯤 틀렸다는 것도 적어 둔다. 파일 2500개를 한 번에 만들어 확인해 보니 워처 알림에 이름이 180개 남짓만 왔다. 우리 상한에 닿기 한참 전에 fs.watch 자체가 이벤트를 흘린다(윈도우 recursive 워처의 커널 버퍼). 실제 손실의 대부분은 거기서 일어난다. 그래서 이 수정이 보장하는 것은 "우리가 조용히 자르지 않는다" 까지고, "이름이 다 온다" 는 OS 가 정한다. 다행히 앱은 이미 견딘다. syncFromDisk 가 트리를 다시 읽고 classify 가 전체 비교로 만들어짐·지워짐을 잡는다. 놓치는 것은 changed 뿐이고, 그걸 메우려면 내용 해시나 폴링 대체 경로가 필요하다 — 별건으로 남긴다. 단위 9개 추가(981개 통과). 실제 앱 검증 3/3.
develop 에 쌓인 45커밋은 전부 "고침" 이라 좋은 릴리스 노트는 되지만 큰 업데이트로는 안 읽힌다. 헤드라인이 될 새 능력이 없다. 0.3.0 이 무엇을 채울지 세 갈래로 정한다. A. 프로바이더 확장(Gemini · 로컬 모델) — 헤드라인. OpenAICompatProvider 가 이미 설정 구동이라 Gemini 는 설정에 가깝고, 진짜 일은 로컬 쪽이다: 상수로 박힌 URL 을 설정으로 빼기, 키 없는 로컬을 미설정으로 보지 않기, /v1/models 로 목록 읽기, 의미 없는 할당량 조회 끄기, 도구 호출을 못 하는 모델은 그렇다고 말하기. B. 에디터 코어 시각 효과(Phase 2 마무리) — 착수 전에 조사 표부터. 무엇이 있고 무엇이 빠졌는지 모르는 채로 후보를 나열하면 없는 것을 있다고 적게 된다. "끝냈다" 고 말하려면 끝의 정의가 먼저다. C. 확장 셰임의 남은 구멍 — WorkspaceEdit 의 파일 생성·삭제·이름변경이 우선. 그리고 첫 커밋은 기능이 아니라 README 정정이어야 한다: 남은 구멍들이 "던진다" 고 적혀 있는데 데코레이션은 조용한 no-op 이다 (vscodeShim.ts:557 · shimDoc.ts:125). 0.2.0 이 없애려 했던 모양이 하나 남아 있고 문서는 반대로 말한다. 잘라내는 선도 미리 박아 둔다 — A 와 C 의 README 정정·WorkspaceEdit 은 필수, B 는 조사 표까지가 최소선, 커스텀 에디터·디버그 어댑터는 여력 보고 정한다. 0.4.0 은 코드베이스 인덱싱 프리뷰로 잡는다. 이번 판에 고친 세 건(깊이 상한, 점 폴더, 잘림 침묵)이 전부 "매번 훑어서 답한다" 는 한 구조에서 나왔다. 어려운 것은 인덱싱이 아니라 갱신이다 — 워처는 이름을 다 주지 못하고(OS 가 흘린다) 낡은 인덱스는 지금보다 나쁘다. 그래서 기본 꺼짐의 프리뷰로 낸다.
README 는 셰임에 남은 구멍들이 "빈 값을 돌려주는 대신 던진다" 고 적어 뒀다.
확인해 보니 셋 중 둘만 맞았다. WorkspaceEdit 는 정말 던지고, 커스텀 에디터와
디버그 어댑터는 API 객체에 아예 없어 TypeError 가 난다. 데코레이션은 아니다 —
createTextEditorDecorationType 이 멀쩡해 보이는 핸들을 주고 setDecorations 가
범위를 받아 놓고, 아무것도 그리지 않는다.
문서가 언급조차 안 한 자리가 둘 더 있었다. env 안이다.
openExternal: () => Promise.resolve(true), // 아무것도 안 열고 true
clipboard: { writeText: () => Promise.resolve(), // 어디에도 안 쓰고
readText: () => Promise.resolve("") } // 늘 빈 문자열
openExternal 은 schutz:openExternal IPC 가 진작 있었는데도 부르지 않고 성공만
답했다. 클립보드는 앱 자신이 navigator.clipboard 를 여섯 군데서 쓰고 있다.
확장 입장에서는 "썼는데 비어 있다" 라 자기 버그로 보인다.
둘을 실제 경로에 연결한다. 메인이 http/https/mailto 만 열므로 거절도 그대로
전한다. 데코레이션은 구현이 따로 필요하므로 지금은 README 에 이름을 박아
둔다 — 이 저장소가 한 판을 통째로 들여 없앤 그 모양이 하나 남아 있다는 사실을,
아니라고 적어 두는 것보다는 낫다.
브라우저를 실제로 열고 클립보드를 덮어쓰는 일이라 실제 앱 프로브는 돌리지
않았다. 배선은 코드로 확인했다. typecheck·build 통과, 단위 981개 통과.
createTextEditorDecorationType 은 멀쩡해 보이는 핸들을 돌려주고 setDecorations 는 범위를 받아 놓고, 아무것도 그리지 않았다. 0.2.0 이 한 판을 통째로 들여 없앤 "조용히 성공하는 답" 이 마지막으로 남아 있던 자리다. 두 API 의 모양이 다르다. VS Code 는 CSS 를 그대로 받고 Monaco 는 클래스 이름만 받는다. 그래서 타입 하나당 규칙을 만들어 스타일시트에 넣고 Monaco 에는 그 이름을 준다. 변환은 decorations.ts 에 순수 함수로 두어 그대로 시험한다 — 확장이 준 contentText 의 따옴표·역슬래시를 막는 것도 여기다(안 막으면 규칙 밖으로 새어 스타일시트 전체가 깨진다). 스타일시트는 한 장을 공유한다. 타입마다 <style> 을 만들면 줄마다 색이 다른 흔한 확장에서 head 가 수천 개로 불어난다. 빈 배열은 "그 타입으로 그린 것을 전부 지운다" 로 동작하고, 확장이 내려가면 그려 둔 것까지 걷는다. 빈 옵션으로 타입을 만들면 경고한다 — 조용히 성공을 답하는 것이 여기서 없애려던 그 모양이다. 그리고 고치다 더 큰 것을 찾았다. onDidChangeActiveTextEditor 가 잘못된 편집기를 주고 있었다. openFile 이 setState 전에 사건을 쏘는데 그 시점엔 페인도 모델도 없어서 shimDocFor 가 빈손으로 돌아오고, 거기서 사건이 함께 끊겼다. 결과적으로 새 파일을 열면 확장은 사건을 아예 못 받고, 받은 한 번은 이전 파일의 편집기였다. 데코레이션을 쓰는 확장은 거의 다 이 사건에 매달려 그리므로 실사용에서는 아무것도 안 그려졌을 것이다. 이제 페인이 실제로 뜰 때까지 최대 1초 기다렸다 쏘고, 같은 파일로 두 번 쏘지 않는다. 임시 확장을 실제로 깔아 CDP 로 몰아 7/7. 계산된 배경색이 rgba(143,168,147,0.35) 이고 ::after 가 실제로 그려지며, 탭을 바꾸면 다시 그려지는 것까지(사건 2회) 확인했다. 단위 28개 추가(1009개 통과).
WorkspaceEdit 의 createFile·deleteFile·renameFile 이 던지고 있었다. 리팩터 확장의 기본기라(파일 옮기기, 심볼 추출해 새 파일 만들기) 그게 없으면 그런 확장은 첫 줄에서 죽는다. 텍스트 편집과 다른 점이 있다. 편집은 모델을 거치므로 Ctrl+Z 로 되돌아오지만 파일을 지우는 것은 되돌릴 수 없다. 그래서 판단을 fileOps.ts 에 순수 함수로 떼어 두고, 조작을 전부 모은 뒤 한꺼번에 정한다. 하나라도 못 하겠으면 아무것도 하지 않는다 — 반만 적용된 리팩터는 확장도 사용자도 수습할 수 없다. 가장 조심한 것은 미저장 편집이다. 저장하지 않은 편집이 있는 파일은 지우지도 덮어쓰지도 않는다. 사람이 안 보는 자리에서 도는 코드라 물어볼 수도 없으니 확장에게 "못 했다" 로 답하고, 왜 안 했는지는 토스트로 말한다. 워크스페이스 밖으로 나가는 경로도 거절한다. 이름 바꾸기는 rekeyUnder 로 미저장 버퍼와 dirty 를 새 경로로 옮긴다. 만든 파일은 탭으로 연다. 확장은 보통 파일을 만든 뒤 편집으로 채우는데 그 편집은 vscode 와 마찬가지로 버퍼에만 들어간다(저장은 따로다). 탭이 없으면 저장 안 한 내용이 어디에도 안 보이는 채로 남는다. 고치다 relOfWith 의 버그를 하나 찾았다. 루트와 안 맞는 경로를 받으면 null 이 아니라 받은 문자열을 그대로 돌려준다. 그래서 `file:///c%3A/...` 같은 uri 키가 상대 경로 행세를 했고, "파일을 만든 뒤 거기에 편집을 넣는" 가장 흔한 리팩터가 "모델을 못 찾았다" 로 통째로 접혔다. 키를 먼저 디코드하고 결과를 badPath 로 검증하게 했다. 트리 삭제·이름변경이 하던 탭·접힘·dirty 정리는 forgetPath·remapPath 로 뽑아 확장 경로와 같은 코드를 쓰게 했다. 임시 확장을 깔아 실제 앱에서 11/11. 단위 28개 추가(1037개 통과).
0.3.0 의 헤드라인. 로드맵 Phase 3 의 절반이다. Gemini 는 Google 이 OpenAI 호환 엔드포인트를 내주므로 전용 어댑터 없이 OpenAICompatProvider 설정 한 줄로 붙는다. 도구 호출도 chat/completions 의 tool_calls 형식이라 기존 스트림 처리 경로를 그대로 탄다. 로컬 모델(Ollama · LM Studio · llama.cpp)은 프로토콜이 같고 다른 것이 둘이다 — 주소와 "키가 없다" 는 사실. - 주소가 프로바이더에 상수로 박혀 있었다. 벤더 넷은 그래도 됐지만 로컬은 주소가 곧 설정이다(포트가 사람마다 다르고 원격에 띄우기도 한다). 사내 프록시도 같은 문제라 프로바이더마다 덮어쓸 수 있게 했다. 사람이 적는 것은 보통 "http://localhost:11434" 까지라 나머지는 chatUrlFrom 이 채운다. - 키 없음을 미설정으로 보지 않는다. isConfigured 도, 스트림 진입부의 가드도 키만 보고 있었다 — 주소를 제대로 적어 두고도 "API 키가 없습니다" 로 끝났다. 넣을 키가 없는 서버인데. 빈 Bearer 를 보내면 거부하는 구현이 있어 헤더도 아예 안 붙인다. - 어떤 모델을 받아 뒀는지는 서버만 안다. /v1/models 를 물어 고르게 하고, 못 물으면 조용히 비우지 않고 이유를 그 자리에 적는다. - 주소가 없을 때의 문구도 따로 뒀다. "API 키" 라고 말하면 엉뚱한 데를 찾게 된다. 잔여량 조회는 손대지 않았다 — claude·gpt 의 OAuth 경로만 돌므로 애초에 로컬·Gemini 와 무관하다. 검증: OpenAI 호환 stub 서버를 띄워 실제 앱에서 로컬 경로를 끝까지 태웠다 9/9 — 키 없이 연결, 주소 완성, 모델 목록, 스트리밍 텍스트, 조각으로 오는 도구 호출 합치기, 토큰 사용량, authorization 헤더 없음, 도구 정의 전달까지 확인했다. Gemini 자체는 Google 키가 없어 끝까지 못 태웠다 — 주소와 키만 다른 같은 코드 경로다. 단위 8개 추가(1045개 통과).
무엇이 있고 무엇이 빠졌는지 모르는 채로는 "끝냈다" 를 말할 수 없다. 네 기둥 (DESIGN.md 2.1~2.4)을 기준으로 코드를 훑어 표로 정리한다. 먼저 나온 것은 표가 아니라 이것이다 — 끝의 정의가 두 문서에서 다르고 둘 다 현실과 다르다. DESIGN.md 의 Phase 2 는 "Code-OSS 포크로 이전" 이고 README 는 "에디터 코어 렌더러 레벨 시각 효과 심화" 인데, 이 앱은 포크가 아니라 Electron + React + Monaco 로 처음부터 자기 렌더러를 갖고 있다. 포크가 필요했던 이유가 애초에 없다. 원래 목표는 다른 방법으로 이미 달성됐고, 남은 것은 포크 여부가 아니라 네 기둥이 화면에 있느냐다. 판정: - 2.3 작업 상태·계획 패널은 끝났다. 현재 행동·계획 뷰·툴 로그·토큰 미터 네 항목 모두 실제 경로로 채워진다. - 2.1 은 글로우와 잔광, undo 한 덩어리가 있고 커서 고스트가 없다. 스트리밍 삽입은 완성된 텍스트의 재생이라 모델이 쓰는 속도와 무관하다. - 2.2 는 거터 색이 git 기준이고, 사유는 카드에만 있으며, CodeLens 는 registerCodeLensProvider 호출이 0건이다. - 2.4 는 배지·진행 표시·점프가 있고 그리드가 아니라 막대 목록이다. 그래서 완료 조건을 셋으로 확정한다 — 커서 고스트, 변경 사유 툴팁, CodeLens 수락/거절. 설계가 이름으로 적었는데 코드에 없는 것들이고, 셋 다 데코레이션 계층 위에 얹힌다. 포크 항목은 아키텍처가 달라져 해당 없음으로 닫는다. 나머지 △ 는 설계와 다른 방식으로 이미 답이 있는 것들이라(전체 diff 는 별도 탭, 파일 개요는 목록) 문구에 맞춰 바꾸는 것은 Phase 2 완료가 아니라 별개의 UI 결정으로 남긴다.
조사에서 확정한 Phase 2 의 남은 셋이다. 지금까지 제안은 오른쪽 카드에만
있었다. 무엇이 왜 바뀌는지 보려면 코드에서 눈을 떼야 했고, 바꿀지 말지도
거기서만 눌렀다. 설계(DESIGN.md 2.1~2.2)가 적어 둔 자리는 바뀌는 그 줄이다.
유령 커서 — editAnimator 가 타이핑하는 정확한 글자 자리에 길이 0 데코레이션의
가짜 요소로 캐럿을 그린다. 폭이 0 이라 글자를 밀지 않는다. 줄 하이라이트만으로는
긴 줄에서 눈이 어디를 따라가야 할지 알 수 없다. 모션을 줄이겠다고 한 사람에게는
깜빡이지 않는다.
사유 툴팁과 CodeLens — 둘은 같은 질문을 공유한다: 이 제안이 지금 이 텍스트의
어디에 붙는가. 그 답만 review/proposalMarks.ts 에 순수 함수로 두고 화면에
얹는 일은 editor/proposalDeco.ts 가 한다.
판정 순서를 applyProposal 과 같게 맞춘 것이 요점이다. 다르면 카드에 뜬 자리와
편집기에 그린 자리가 어긋나고, 그건 사용자가 다른 곳을 보며 수락을 누르게
만든다. 못 찾거나 조각이 두 군데면 null 로 두고 아예 안 그린다 — 틀린 자리에
그리느니 안 그리는 편이 낫다.
문서도 사실에 맞춘다. Phase 2 는 원래 "Code-OSS 포크로 이전" 이었는데, 그
포크는 하지 않았고 하지 않는 것이 맞다 — 확장 API 로 렌더러에 손댈 수 없다는
것이 포크의 이유였고, 이 앱은 처음부터 자기 렌더러를 갖고 있어 그 이유가 없다.
포크 항목을 해당 없음으로 닫고 네 기둥 기준으로 완료 표시한다. 프로바이더
목록도 Gemini·로컬이 빠져 있어 함께 고친다.
검증: propose_edit 하나를 내는 stub 모델을 로컬 프로바이더로 붙여 실제
에이전트 루프를 태웠다 7/7 — 제안 생성, 줄에 표시, 호버로 사유("local — b 가
1부터 세는 곳에서 쓰여 22 로 올린다"), 렌즈 표시, 수락 중 유령 커서, 편집
적용, 수락 뒤 표시 사라짐. 단위 18개 추가(1063개 통과).
두 갈래가 이 판을 관통했다. 지키겠다고 한 작업을 앱이 잃고 있었고, 한 번도 제대로 보지 않은 파일에 대해 자신 있게 답하고 있었다. 실패하는 방식이 같았다 — 조용히, 성공한 것처럼 생긴 답으로. 미저장 편집이 사라지던 자리를 전부 막았다. 밖에서 지워지거나 이름이 바뀐 파일, 종료(Electron 에서 beforeunload 는 대화상자 없이 app.quit() 을 취소한다), 프로젝트 전환, git 버리기, 되돌리기가 손대지도 않은 파일까지 디스크로 덮던 것. 전부 실제 앱에서 재현한 뒤에 고쳤다. 못 보던 파일도 같은 계열이다. 깊이 8 을 넘는 파일은 트리·검색·치환 어디에도 없었고, 점으로 시작하는 폴더는 통째로 숨겨져 있었으며, 상한에 걸려 그만둔 것을 아무도 말하지 않아 "안 찾아봤다" 와 "없다" 가 같은 답으로 나왔다. 파괴하던 자리도 셋이다. UTF-8 이 아닌 파일을 열면 원본이 깨졌고, 찾아 바꾸기는 열어 보지도 않은 파일에 같은 일을 하면서 성공을 보고했고, BOM 은 열기만 해도 떨어져 나갔다. 새로 되는 것: Gemini 와 OpenAI 호환 로컬 서버(주소만 있으면 된다), 확장의 데코레이션·파일 조작·브라우저·클립보드, 그리고 제안이 카드가 아니라 코드 그 줄에 뜬다 — 사유는 툴팁으로, 수락·거절은 CodeLens 로, 쓰는 동안에는 유령 커서로. 이로써 편집 시각화 네 기둥이 찼다. 그 단계의 원래 계획은 Code-OSS 포크였는데 하지 않았고, 하지 않는 것이 맞다 — 이 앱은 처음부터 자기 렌더러를 갖고 있다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cuts 0.3.0. Version bump and CHANGELOG only — everything else already landed on
developacross 26 commits since 0.2.0.What is in it
Two strands ran through the 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.
WorkspaceEditcan create/delete/rename files,env.openExternalandenv.clipboardwork, andonDidChangeActiveTextEditorstops handing over the previous file's editor.Checks
npm run typecheck, 1063 unit tests, andnpm run buildall clean on this commit.Merging this tags
v0.3.0, which is what the release workflow builds the five installers from.