change-explainer 해설 창 디자인 확인용 샘플 (고정 데이터, 모델 호출 없음)

A Claude Code mod that explains what Claude changed in your code, and why, so you understand the change before you move on.
When Claude edits code, the result stays but the reasoning disappears into the conversation. Changes you never really understood pile up as knowledge debt. change-explainer records every change Claude makes, per turn, and explains it in a pane inside the terminal when you ask.
sed -i, cat >, git commit), including in other repositories and worktrees. Subagent changes go to the turn that started them./explain opens a pane for the last changed turn. Sections are generated only when you open them:| Key | Section | | :- | :- | | 1 | Summary: what changed, why, how it works, what to check | | 2 | Background: the problem before the change and its cause | | 3 | Code walkthrough: real code lines (read from the snapshots, never written by the model), what each does, why, alternatives | | 4 | Flow chart (bordered cards) | | 5 | Sequence diagram | | 6 | Before / after behavior | | 7 | Impact: callers, new functions, tests to check | | 8 | Terms: new concepts, mark the ones you already know | | 9 | Quiz: answer all correctly to mark the turn as understood |
d): side by side on wide panes, unified on narrow ones, changed words highlighted, only the code area scrolls.h/l). Esc returns the keys to the pane. Terminals do not pass horizontal wheel or trackpad movement to mods, so dragging stands in for it./explain-list): Claude Code keeps every conversation of a project. The list shows every past turn that changed something, newest first, one line each, titled by its commit message (or the files it changed). Open one to explain it. Sessions are read in the background and indexed, so the list opens at once the next time. Edit/Write changes come from the conversation record (exact), Bash changes from the commits you made in that turn's time window. Nothing to configure: no repository paths, no base branches.claude --version.claude -p and the VS Code chat panel, /explain answers with text instead of a pane.git is optional. Without it, Edit/Write changes are still recorded and explained; only changes made through Bash are missed. You do not need to manage or configure anything in git: the mod only reads it.sh, grep and awk (macOS and Linux). On Windows the past-session list stays empty; live recording works.From a shell:
claude plugin marketplace add SigLee2247/change-explainer
claude plugin install change-explainer@change-explainer
Or inside Claude Code: /plugin marketplace add SigLee2247/change-explainer, then /plugin install change-explainer@change-explainer.
To try it from a clone without installing:
git clone https://github.com/SigLee2247/change-explainer change-explainer-repo
claude --plugin-dir ./change-explainer-repo/change-explainer
After installing, run /plugin and check that change-explainer is listed as active. The install may say that 3 options are not set yet; every option has a default, so it works without setting them.
/explain.1–9 to open sections, d for the diff, t for the work list, Esc to close./explain always explains the current session. /explain-list opens the work list: this session's turns and your past work. Inside the pane, t opens the list too.
Live recording starts when the mod is loaded. Work from before that is in the list under past work, as long as Claude Code still keeps the conversation (30 days by default).
Set them with /plugin configure change-explainer@change-explainer, or pass --config KEY=VALUE to claude plugin install.
| Option | Default | Meaning | | :- | :- | :- | | language | ko | Language the explanations are written in: ko or en. The pane's own labels are Korean for now. | | theme | dark | light for light terminal themes (diff colors and text). | | model | session | Model for explaining past sessions (haiku, sonnet, opus or a model id). session uses the session's model. Turns of the current session always fork the current conversation. |
~/.claude/explanations/: per turn, the before/after snapshots of changed files, the generated sections, your questions and answers, and quiz results. Delete the folder to remove it all.~/.claude/projects/, runs git in the repositories you work in, and calls the model with your plan or API key. It sends nothing anywhere else. Review what it does with claude plugin validate ./change-explainer.r. Current-session explanations fork the conversation, so most of the prompt comes from the prompt cache.usage.json. As a reference, a summary of a past turn with 11 changed files used about 13k input and 4k output tokens on the session model; set model to sonnet or haiku to make past-session explanations cheaper.language setting.claude --plugin-dir ./change-explainer # load with hot reload
cd change-explainer && claude plugin test # 52 tests, no session or network needed
claude plugin validate ./change-explainer --strict
claude plugin validate . # the marketplace file
CHANGE_EXPLAINER_DEV=1 adds /explain-check <section> [session-id-prefix/turn], which generates one section with the real model and prints the JSON and the tokens used.SPEC.md (Korean) records the design and the decisions behind it.samples/explain-preview is a fixed-data mod for checking the pane's design; samples/try-change-explainer.sh builds a demo repository and starts Claude Code with the mod.MIT. See LICENSE.
Claude Code가 턴마다 바꾼 코드를 기록하고, 무엇을 왜 어떻게 바꿨는지 터미널 창에서 해설하는 mod입니다. 이해하지 못한 채 쌓이는 코드(지식 부채)를 줄이는 것이 목적입니다.
설치
claude plugin marketplace add SigLee2247/change-explainer
claude plugin install change-explainer@change-explainer
설치 없이 써 보려면 저장소를 받은 뒤 claude --plugin-dir ./change-explainer/change-explainer로 시작하세요. Claude Code 2.1.287 이상이 필요합니다.
사용
/explain: 그 턴의 해설 창이 열립니다.1~9: 섹션 펼치기 (펼칠 때 생성)d: difft: 작업 목록Esc: 닫기/explain은 항상 지금 세션을 해설합니다./explain-list: 이 세션의 턴과 지난 작업 목록 (해설 창에서는 t). 지난 대화에서 실제로 무언가 바꾼 턴이 최근 것부터 한 줄씩(커밋 메시지나 바꾼 파일 이름으로) 나오고, 고르면 해설 창이 열립니다. 저장소 경로나 기준 브랜치 같은 설정은 필요 없습니다.Wait, what?: 누를 때마다 다른 방식으로 다시 설명하고, 그 부분만 따로 질문할 수 있습니다.설정 (/plugin configure change-explainer@change-explainer)
language: 해설 언어 (ko/en)theme: 터미널 테마 (dark/light)model: 지난 세션 해설 모델 (비용을 줄이려면 sonnet이나 haiku)자세한 내용은 위 영어 표를 참고하세요.
데이터와 비용
~/.claude/explanations/에만 저장됩니다.hooks/register.js 465 lines1// explain-preview: change-explainer 해설 창의 디자인을 터미널에서 확인하기 위한 샘플.
2// 고정된 예시 데이터(로그인 재시도 추가)로 그리며, 모델을 호출하지 않는다.
3// "만드는 중"과 질문 답변은 잠깐 기다리는 것만 흉내 낸다.
4
5import { C, EASY, EASY_AGAIN, EASY_AGAIN_DEFAULT, EASY_ASK_AFTER, FILES, HUNKS, QUIZ, SAMPLE_ANSWER, SECTION_ANSWER, SECTIONS, TURN } from './data.js'
6import {
7 backgroundView, baView, btn, buttonRow, link, diffCodeRows, diffModel, diffView, flowView, impactView, quizView, rich, rule,
8 seqMaxLeft, seqView, summaryView, termsView, walkView,
9} from './views.js'
10
11const PANE = 'explain-preview'
12const WANT_COLUMNS = 100
13const FAKE_DELAY_MS = 700
14const STEP_ROWS = 5
15const STEP_COLS = 8
16
17// 화면: 'explain'(해설) 또는 'diff'
18let mode = 'explain'
19// diff: 보고 있는 변경 블록(HUNKS 위치), 코드 영역의 위쪽 줄과 가로 밀림
20let pos = 0
21let diffTop = 0
22let diffLeft = 0
23// 마지막으로 그린 diff 코드 영역의 크기: 휠 스크롤의 한계를 정할 때 쓴다
24let diffBounds = { maxTop: 0 }
25// 시퀀스 다이어그램의 가로 밀림
26let seqLeft = 0
27// 섹션별 펼침 여부(다음에도 기억)와 생성 상태(none → loading → done)
28let open = { summary: true }
29let gen = Object.fromEntries(SECTIONS.map((s) => [s.id, s.id === 'summary' ? 'done' : 'none']))
30// 섹션별 Wait, what? 대화: { [섹션 id]: [{ type: 'easy', text } | { type: 'q', q, a }] }
31// text나 a가 null이면 만드는 중
32let easy = {}
33// 이미 아는 용어 id 목록 (다음에도 기억): 다음 해설에서는 짧게 넘어간다
34let known = []
35// 확인 퀴즈: 문제별 보기 순서(섞은 결과)와 고른 보기. 보기 원래 위치 0이 정답
36let quiz = newQuiz()
37// 마지막으로 펼친 섹션: r(다시 만들기)의 대상
38let current = 'summary'
39// 퀴즈를 다 맞혀서 이 턴을 이해했다고 표시했는지 (다음에도 기억)
40let understood = false
41// 질문과 답변: [{ q, a }], a가 null이면 답을 만드는 중
42let qa = []
43
44
45// 보기 순서를 섞는다. 모델은 정답을 첫 번째에 두는 버릇이 있어서 순서는 코드가 정한다
46function shuffled(n) {
47 const a = Array.from({ length: n }, (_, i) => i)
48 for (let i = n - 1; i > 0; i--) {
49 const j = Math.floor(Math.random() * (i + 1))
50 ;[a[i], a[j]] = [a[j], a[i]]
51 }
52 return a
53}
54
55function newQuiz() {
56 return { order: QUIZ.map((q) => shuffled(q.options.length)), picked: QUIZ.map(() => null) }
57}
58const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n))
59
60// 섹션 생성을 흉내 낸다. 실제 mod에서는 여기서 $.model.fork로 구조를 받아 온다
61function generate($, id) {
62 gen = { ...gen, [id]: 'loading' }
63 $.ui.invalidate('ui.render')
64 $.clock.after(FAKE_DELAY_MS, () => {
65 gen = { ...gen, [id]: 'done' }
66 if (id === 'quiz') quiz = newQuiz()
67 $.ui.invalidate('ui.render')
68 })
69}
70
71// 섹션 대화의 i번째 항목을 채운다
72function fill(id, i, patch) {
73 easy = { ...easy, [id]: easy[id].map((x, j) => (j === i ? { ...x, ...patch } : x)) }
74}
75
76// Wait, what?: 같은 내용을 더 쉬운 말로, 빠진 전제를 채워 다시 설명한다 (짧게 줄이는 것이 아님).
77// 여러 번 누를 수 있고, 두 번째부터는 다른 비유로 설명한다
78function explainEasier($, id) {
79 const thread = easy[id] || []
80 const round = thread.filter((x) => x.type === 'easy').length
81 const i = thread.length
82 easy = { ...easy, [id]: [...thread, { type: 'easy', text: null }] }
83 $.ui.invalidate('ui.render')
84 $.clock.after(FAKE_DELAY_MS, () => {
85 const again = EASY_AGAIN[id] || []
86 fill(id, i, { text: round === 0 ? EASY[id] : again[round - 1] || EASY_AGAIN_DEFAULT })
87 $.ui.invalidate('ui.render')
88 })
89}
90
91// 섹션 안에서 추가 질문: 답은 그 섹션 대화에 이어 붙는다
92function askInSection($, id, q) {
93 const thread = easy[id] || []
94 const i = thread.length
95 easy = { ...easy, [id]: [...thread, { type: 'q', q, a: null }] }
96 $.ui.invalidate('ui.render')
97 $.clock.after(FAKE_DELAY_MS, () => {
98 fill(id, i, { a: SECTION_ANSWER })
99 $.ui.invalidate('ui.render')
100 })
101}
102
103function statusOf(id) {
104 if (gen[id] === 'done') return ['● 생성됨', C.green]
105 if (gen[id] === 'loading') return ['◌ 만드는 중', C.accent]
106 return ['○ 펼치면 생성', C.faint]
107}
108
109export function register(on) {
110 on('session.start', async ($, e, next) => {
111 // 지난번에 펼쳐 둔 섹션과 이해함 표시를 불러온다
112 const savedOpen = await $.store.get('open')
113 if (savedOpen && typeof savedOpen === 'object') open = { ...savedOpen, summary: savedOpen.summary !== false }
114 understood = (await $.store.get('understood')) === true
115 const savedKnown = await $.store.get('known')
116 if (Array.isArray(savedKnown)) known = savedKnown
117 try {
118 await $.command.register({
119 name: 'explain-sample',
120 description: '변경 해설 창 디자인 샘플 열기',
121 immediate: true,
122 })
123 } catch (err) {
124 $.ui.log('/explain-sample 등록 실패: ' + err)
125 }
126 return next(e)
127 })
128
129 on('command.run', { command: 'explain-sample' }, async ($) => {
130 mode = 'explain'
131 // 기억해 둔 섹션은 창을 열 때 바로 만든다 (사용자가 늘 보는 섹션이라서)
132 SECTIONS.forEach((s) => { if (open[s.id] && gen[s.id] === 'none') generate($, s.id) })
133 await $.ui.open({ id: PANE, title: '변경 해설', focus: true, closeOnEscape: true, columns: WANT_COLUMNS })
134 return {}
135 })
136
137 // diff 화면에서는 휠과 스크롤 키로 창 전체가 아니라 코드 영역만 움직인다
138 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
139 if (mode !== 'diff') return next(e)
140 diffTop = clamp(diffTop + e.by, 0, diffBounds.maxTop)
141 $.ui.invalidate('ui.render')
142 return {}
143 })
144
145 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
146 if (e.requestId !== PANE) return next(e)
147 const el = $.ui.resolve(e)
148 const cols = e.props.bodyColumns || 80
149 const bodyRows = (e.props.scroll && e.props.scroll.bodyRows) || 30
150 const redraw = () => $.ui.invalidate('ui.render')
151 const saveOpen = () => { $.store.set('open', open).catch(() => {}) }
152 // 스크롤할 수 없는 곳(테스트 등)에서는 그냥 둔다
153 const reveal = (key) => { $.ui.scroll({ in: PANE, to: { key }, block: 'start' }).catch(() => {}) }
154
155 const toggle = (id, show) => {
156 open = { ...open, [id]: !open[id] }
157 if (open[id]) {
158 current = id
159 if (gen[id] === 'none') generate($, id)
160 }
161 saveOpen()
162 redraw()
163 if (show && open[id]) reveal('sec-' + id)
164 }
165
166 // 변경 블록으로 이동: 그 블록이 코드 영역 위쪽에 오도록 맞춘다
167 const goTo = (n) => {
168 pos = clamp(n, 0, HUNKS.length - 1)
169 diffTop = Math.max(0, diffModel(cols, pos).hunkStart - 2)
170 diffLeft = 0
171 redraw()
172 }
173 const showDiff = (fileIdx) => {
174 mode = 'diff'
175 goTo(fileIdx === undefined ? pos : HUNKS.findIndex((h) => h.f === fileIdx))
176 $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
177 }
178
179 // ── diff 화면 ──
180 if (mode === 'diff') {
181 const m = diffModel(cols, pos)
182 diffBounds = { maxTop: Math.max(0, m.lines.length - diffCodeRows(bodyRows)) }
183 diffTop = clamp(diffTop, 0, diffBounds.maxTop)
184 diffLeft = clamp(diffLeft, 0, m.maxLeft)
185 return diffView(el, cols, bodyRows, { pos, top: diffTop, left: diffLeft }, {
186 prev: () => goTo(pos - 1),
187 next: () => goTo(pos + 1),
188 nextFile: () => showDiff((HUNKS[pos].f + 1) % FILES.length),
189 pickFile: (i) => showDiff(i),
190 back: () => { mode = 'explain'; redraw() },
191 up: () => { diffTop = clamp(diffTop - STEP_ROWS, 0, diffBounds.maxTop); redraw() },
192 down: () => { diffTop = clamp(diffTop + STEP_ROWS, 0, diffBounds.maxTop); redraw() },
193 leftward: () => { diffLeft = clamp(diffLeft - STEP_COLS, 0, m.maxLeft); redraw() },
194 rightward: () => { diffLeft = clamp(diffLeft + STEP_COLS, 0, m.maxLeft); redraw() },
195 })
196 }
197
198 // ── 해설 화면 ──
199 const allOpen = SECTIONS.every((s) => open[s.id])
200
201 const header = el.Box({
202 flexDirection: 'column',
203 children: [
204 rich(el, [['#' + TURN.n + ' ', C.accent, true], [TURN.title, C.title, true]]),
205 rich(el, [
206 ['파일 ' + FILES.length + '개 ', C.dim],
207 ['+' + FILES.reduce((a, f) => a + f.add, 0), C.green], ' ',
208 ['−' + FILES.reduce((a, f) => a + f.del, 0), C.red],
209 [' · 서브에이전트 1 · ' + TURN.when + ' · ', C.dim],
210 understood ? ['✓ 이해함 (퀴즈 통과)', C.green, true] : ['○ 아직 확인 안 함', C.faint],
211 ], { wrap: 'truncate-end' }),
212 el.Text({ children: [' '] }),
213 rich(el, [['요청 ', C.faint], [TURN.request, C.dim]]),
214 el.Box({
215 marginTop: 1,
216 borderStyle: 'round',
217 borderColor: C.accent,
218 paddingX: 1,
219 flexDirection: 'column',
220 children: [
221 el.Text({ bold: true, color: C.accent, children: ['한 줄 요약'] }),
222 el.Text({ color: C.title, children: [TURN.tldr] }),
223 ],
224 }),
225 el.Box({ marginTop: 1, children: [buttonRow(el, [
226 btn(el, { key: 'diff', hotkey: 'd', label: 'diff', primary: true, onPress: () => showDiff() }),
227 btn(el, { key: 'all', hotkey: 'a', label: allOpen ? '모두 접기' : '모두 펼치기', onPress: () => {
228 const target = !allOpen
229 SECTIONS.forEach((s) => { if (!!open[s.id] !== target) toggle(s.id, false) })
230 } }),
231 btn(el, { key: 'regen', hotkey: 'r', label: '다시 만들기', dim: gen[current] === 'loading', onPress: () => {
232 if (gen[current] === 'loading') return
233 if (!open[current]) toggle(current, true)
234 else generate($, current)
235 } }),
236 btn(el, { key: 'go-quiz', hotkey: 'q', label: '퀴즈', onPress: () => {
237 if (!open.quiz) toggle('quiz', true)
238 else reveal('sec-quiz')
239 } }),
240 ])] }),
241 // 목차: 대괄호 없는 글자 링크. 펼친 섹션은 밝게, 접힌 섹션은 흐리게. 누르면 펼치고 그 위치로 이동
242 el.Box({
243 marginTop: 1,
244 flexDirection: 'row',
245 flexWrap: 'wrap',
246 children: SECTIONS.flatMap((sec, i) => [
247 ...(i ? [el.Text({ color: C.faint, children: [' · '] })] : []),
248 link(el, {
249 key: 'toc-' + sec.id,
250 label: sec.key + ' ' + sec.title,
251 dim: !open[sec.id],
252 onPress: () => {
253 if (!open[sec.id]) toggle(sec.id, true)
254 else reveal('sec-' + sec.id)
255 },
256 }),
257 ]),
258 }),
259 ],
260 })
261
262 const body = []
263 SECTIONS.forEach((s) => {
264 const [status, statusColor] = statusOf(s.id)
265 body.push(rule(el, cols))
266 body.push(
267 el.Box({
268 key: 'sec-' + s.id,
269 flexDirection: 'row',
270 justifyContent: 'space-between',
271 children: [
272 btn(el, { key: 'btn-' + s.id, hotkey: s.key, label: s.title + (open[s.id] ? ' ▾' : ' ▸'), onPress: () => toggle(s.id, true) }),
273 el.Box({
274 flexDirection: 'row',
275 columnGap: 2,
276 children: [
277 ...(open[s.id] && gen[s.id] === 'done' && !easy[s.id]
278 ? [btn(el, { key: 'easy-' + s.id, label: 'Wait, what?', primary: true, onPress: () => explainEasier($, s.id) })]
279 : []),
280 el.Text({ color: statusColor, children: [status] }),
281 ],
282 }),
283 ],
284 }),
285 )
286 // Wait, what? 대화 상자: 쉬운 설명들과 추가 질문이 이어진다
287 if (open[s.id] && easy[s.id]) {
288 const thread = easy[s.id]
289 const busy = thread.some((x) => (x.type === 'easy' ? x.text === null : x.a === null))
290 // 여러 번 설명해도 모르겠다면, 더 설명하기보다 어디가 막히는지 듣는 편이 낫다
291 const rounds = thread.filter((x) => x.type === 'easy').length
292 const askFirst = rounds >= EASY_ASK_AFTER
293 let round = 0
294 body.push(el.Box({
295 key: 'easy-box-' + s.id,
296 marginLeft: 2,
297 marginTop: 1,
298 borderStyle: 'round',
299 borderColor: C.blue,
300 paddingX: 1,
301 flexDirection: 'column',
302 rowGap: 1,
303 children: [
304 el.Text({ bold: true, color: C.blue, children: ['Wait, what? 쉬운 말로 다시 설명'] }),
305 ...thread.map((x) => {
306 if (x.type === 'easy') {
307 round += 1
308 return el.Box({
309 flexDirection: 'column',
310 children: [
311 el.Text({ bold: true, color: C.blue, children: ['설명 ' + round] }),
312 x.text === null
313 ? el.Text({ color: C.dim, children: ['쉬운 말로 다시 설명하는 중…'] })
314 : el.Text({ children: [x.text] }),
315 ],
316 })
317 }
318 return el.Box({
319 flexDirection: 'column',
320 children: [
321 rich(el, [['Q ', C.accent, true], [x.q, C.title]]),
322 x.a === null
323 ? el.Text({ color: C.dim, children: [' 답을 만드는 중…'] })
324 : rich(el, [['A ', C.green, true], [x.a, C.fg]]),
325 ],
326 })
327 }),
328 askFirst
329 ? rich(el, [['세 번 설명했는데도 막힌다면 ', C.dim], ['어느 문장, 어느 단어', C.accent, true], ['에서 막히는지 아래에 적어 주세요. 그 지점부터 다시 설명합니다.', C.dim]])
330 : el.Text({ children: [''] }),
331 buttonRow(el, [
332 btn(el, {
333 key: 'easy-again-' + s.id,
334 label: askFirst ? 'Wait, what? 그래도 한 번 더' : 'Wait, what? 아직 모르겠어요',
335 primary: !askFirst,
336 dim: busy || askFirst,
337 onPress: () => { if (!busy) explainEasier($, s.id) },
338 }),
339 ]),
340 el.Input(Object.assign({
341 key: 'easy-ask-' + s.id,
342 label: '이 부분 질문',
343 placeholder: '어디가 막히는지 적고 Enter',
344 value: '',
345 submitLabel: '묻기',
346 onSubmit: (value) => {
347 const q = value.trim()
348 if (q) askInSection($, s.id, q)
349 },
350 }, askFirst ? { autoFocus: true } : {})),
351 ],
352 }))
353 }
354 if (open[s.id] && gen[s.id] === 'loading') {
355 body.push(el.Box({ paddingLeft: 2, paddingY: 1, children: [el.Text({ color: C.dim, children: ['현재 대화를 바탕으로 만드는 중…'] })] }))
356 }
357 if (open[s.id] && gen[s.id] === 'done') {
358 const inner = cols - 2
359 let view
360 if (s.id === 'summary') view = summaryView(el, (i) => showDiff(i))
361 else if (s.id === 'background') view = backgroundView(el)
362 else if (s.id === 'walk') view = walkView(el)
363 else if (s.id === 'terms') {
364 view = termsView(el, known, (id) => {
365 known = known.includes(id) ? known.filter((x) => x !== id) : [...known, id]
366 redraw()
367 $.store.set('known', known).catch(() => {})
368 })
369 } else if (s.id === 'quiz') {
370 view = quizView(el, quiz, {
371 pick: (i, orig) => {
372 quiz = { ...quiz, picked: quiz.picked.map((x, j) => (j === i ? orig : x)) }
373 const allRight = quiz.picked.every((x) => x === 0)
374 if (allRight && !understood) {
375 understood = true
376 $.store.set('understood', true).catch(() => {})
377 $.ui.toast('#' + TURN.n + ' 턴을 이해함으로 표시했습니다')
378 }
379 redraw()
380 },
381 retry: () => { quiz = newQuiz(); redraw() },
382 })
383 } else if (s.id === 'flow') view = flowView(el, inner)
384 else if (s.id === 'ba') view = baView(el)
385 else if (s.id === 'impact') view = impactView(el)
386 else {
387 // 시퀀스: 창보다 넓으면 좌우로 밀어 볼 수 있다
388 const maxLeft = seqMaxLeft(inner)
389 seqLeft = clamp(seqLeft, 0, maxLeft)
390 const diagram = seqView(el, inner, seqLeft)
391 view = maxLeft === 0 ? diagram : el.Box({
392 flexDirection: 'column',
393 rowGap: 1,
394 children: [
395 buttonRow(el, [
396 btn(el, { key: 'seq-left', hotkey: 'h', label: '왼쪽', dim: seqLeft === 0, onPress: () => { seqLeft = clamp(seqLeft - STEP_COLS, 0, maxLeft); redraw() } }),
397 btn(el, { key: 'seq-right', hotkey: 'l', label: '오른쪽', dim: seqLeft >= maxLeft, onPress: () => { seqLeft = clamp(seqLeft + STEP_COLS, 0, maxLeft); redraw() } }),
398 el.Text({ color: C.faint, children: [' 창보다 넓은 다이어그램' + (seqLeft ? ' · 가로 +' + seqLeft : '')] }),
399 ]),
400 diagram,
401 ],
402 })
403 }
404 body.push(el.Box({ paddingLeft: 2, paddingY: 1, children: [view] }))
405 }
406 })
407 body.push(rule(el, cols))
408
409 // ── 질문하기 ──
410 const ask = el.Box({
411 flexDirection: 'column',
412 rowGap: 1,
413 children: [
414 el.Text({ bold: true, color: C.accent, children: ['질문하기'] }),
415 ...qa.map((x, i) =>
416 el.Box({
417 key: 'qa-' + i,
418 flexDirection: 'column',
419 paddingLeft: 2,
420 children: [
421 rich(el, [['Q ', C.accent, true], [x.q, C.title]]),
422 x.a === null
423 ? el.Text({ color: C.dim, children: [' 답을 만드는 중…'] })
424 : rich(el, [['A ', C.green, true], [x.a, C.fg]]),
425 ],
426 }),
427 ),
428 el.Input({
429 key: 'ask',
430 label: '질문',
431 placeholder: '이 변경에 대해 궁금한 점을 입력하고 Enter',
432 value: '',
433 submitLabel: '묻기',
434 onSubmit: (value) => {
435 const q = value.trim()
436 if (!q) return
437 const i = qa.length
438 qa = [...qa, { q, a: null }]
439 redraw()
440 $.ui.scroll({ in: PANE, to: { key: 'qa-' + i }, block: 'nearest' }).catch(() => {})
441 $.clock.after(FAKE_DELAY_MS, () => {
442 qa = qa.map((x, j) => (j === i ? { ...x, a: SAMPLE_ANSWER } : x))
443 $.ui.invalidate('ui.render')
444 })
445 },
446 }),
447 el.Text({ color: C.faint, children: ['본 대화에는 남지 않습니다.'] }),
448 ],
449 })
450
451 return el.Box({
452 flexDirection: 'column',
453 children: [
454 header,
455 el.Text({ children: [' '] }),
456 ...body,
457 ask,
458 rule(el, cols),
459 el.Text({ color: C.faint, children: ['휠·↑↓·PgUp/PgDn 위아래 목차로 섹션 이동 1–9 펼치기·접기 Esc 닫기'] }),
460 el.Text({ color: C.faint, children: ['대화 기록을 바탕으로 재구성한 해설입니다. 실제 내부 추론과 다를 수 있습니다.'] }),
461 ],
462 })
463 })
464}
465hooks/data.js 307 lines1// 샘플 데이터: "로그인 실패 시 재시도 추가" 턴 하나.
2// 실제 mod에서는 이 모양의 데이터를 모델(구조)과 diff 계산(코드)이 채운다.
3
4export const C = {
5 fg: '#d6d6d6',
6 title: '#f0f0f0',
7 dim: '#858b94',
8 faint: '#5c6370',
9 rule: '#2f3238',
10 accent: '#e3a857',
11 blue: '#79b8ff',
12 green: '#7fc79a',
13 red: '#f08c7c',
14 purple: '#c3a6ff',
15}
16
17// diff 배경색 (인텔리제이 다크 테마 계열)
18export const D = {
19 add: '#1f3a28', addHi: '#2f6e41',
20 mod: '#1c2c43', modHi: '#2f5a8f', modFill: '#151d29',
21 del: '#2a2c31', fill: '#16171a',
22 rem: '#3a2326', remHi: '#6e2f36',
23}
24
25export const SECTIONS = [
26 { id: 'summary', key: '1', title: '요약' },
27 { id: 'background', key: '2', title: '배경' },
28 { id: 'walk', key: '3', title: '코드 따라가기' },
29 { id: 'flow', key: '4', title: '흐름도' },
30 { id: 'seq', key: '5', title: '시퀀스' },
31 { id: 'ba', key: '6', title: '전후 비교' },
32 { id: 'impact', key: '7', title: '영향 범위' },
33 { id: 'terms', key: '8', title: '용어' },
34 { id: 'quiz', key: '9', title: '확인 퀴즈' },
35]
36
37export const TURN = {
38 n: 5,
39 title: '로그인 실패 시 재시도 추가',
40 request: '로그인 API가 가끔 타임아웃 나는데 재시도 좀 넣어줘',
41 when: '오늘 14:02',
42 tldr: '네트워크 오류와 5xx 응답일 때만 로그인 요청을 최대 3번 재시도하도록 바꿨습니다.',
43}
44
45export const FLOW = [
46 { n: 1, title: 'login(u, p)', desc: 'LoginPage에서 호출', kind: 'step' },
47 { n: 2, title: 'api(u, p) 호출', desc: '5번에서 재시도하면 여기로 돌아옴', kind: 'step' },
48 {
49 n: 3, title: 'res.ok ?', desc: '응답이 성공인지', kind: 'cond', next: '아니오',
50 branch: { label: '예', title: '응답 반환', desc: '정상 종료', kind: 'ok' },
51 },
52 {
53 n: 4, title: '재시도 대상인가?', desc: '네트워크 오류 또는 5xx', tag: '이번에 추가 · isRetryable()', kind: 'changed', next: '예',
54 branch: { label: '아니오', title: '에러 그대로 던짐', desc: '401 등 인증 실패', kind: 'err' },
55 },
56 {
57 n: 5, title: '시도 횟수 < 3 ?', desc: '최대 3번까지', tag: '이번에 추가 · retry()', kind: 'changed', next: '아니오',
58 branch: { label: '예', title: '200ms × 2ⁿ 대기', desc: '그다음 2번으로 돌아감', kind: 'changed' },
59 },
60 { n: 6, title: '마지막 에러를 던짐', desc: '호출부의 기존 에러 처리로 전달', kind: 'err' },
61]
62
63export const KIND_COLOR = { step: C.faint, cond: C.blue, changed: C.accent, ok: C.green, err: C.red }
64
65export const LANES = [
66 { name: 'LoginPage', color: C.dim },
67 { name: 'login()', color: C.fg },
68 { name: 'retry()', color: C.accent },
69 { name: 'Auth API', color: C.blue },
70]
71
72// [from, to, label, color, note]
73export const MSGS = [
74 [0, 1, 'submit(u, p)', C.fg, ''],
75 [1, 2, 'retry(fn, 3)', C.accent, '이번에 추가된 재시도 래퍼'],
76 [2, 3, '1차 호출', C.fg, ''],
77 [3, 2, '503', C.red, '서버 과부하로 실패'],
78 [2, 2, '200ms 대기', C.accent, '5xx라서 재시도 대상'],
79 [2, 3, '2차 호출', C.fg, ''],
80 [3, 2, '200 OK', C.green, '성공'],
81 [2, 1, 'res', C.fg, ''],
82 [1, 0, 'ok', C.fg, '사용자는 재시도를 모름'],
83]
84
85export const BEFORE_AFTER = [
86 { when: '네트워크 끊김', before: '즉시 에러', after: '최대 3회 재시도 후 에러', color: C.green },
87 { when: '서버 503', before: '즉시 에러', after: '재시도, 보통 2차에서 성공', color: C.green },
88 { when: '비밀번호 틀림 (401)', before: '즉시 에러', after: '즉시 에러 (변화 없음)', color: C.dim },
89 { when: '최악 응답 시간', before: '약 0.3초', after: '약 1.7초', color: C.red },
90]
91
92const added = (lines) => lines.map((t, i) => ['add', null, '', i + 1, t, 1])
93
94// diff 행: ['eq'|'add'|'del'|'mod', 왼쪽 줄번호, 왼쪽 글, 오른쪽 줄번호, 오른쪽 글, 변경 블록 번호, 왼쪽 강조, 오른쪽 강조]
95// ['fold', 설명]: 변경 없는 줄 접힘
96export const FILES = [
97 {
98 name: 'login.ts', path: 'src/auth/login.ts', tag: '수정', tagColor: C.blue, add: 6, del: 2,
99 desc: 'api 호출을 retry()로 감싸고 로그 한 줄 제거',
100 rows: [
101 ['fold', '변경 없는 9줄'],
102 ['eq', 10, "import { api } from '../lib/api'", 10, "import { api } from '../lib/api'"],
103 ['add', null, '', 11, "import { retry, isRetryable } from '../lib/retry'", 1],
104 ['eq', 11, '', 12, ''],
105 ['eq', 12, 'export async function login(u: string, p: string) {', 13, 'export async function login(u: string, p: string) {'],
106 ['mod', 13, " const res = await api.post('/login', { u, p })", 14, " const res = await retry(() => api.post('/login', { u, p }), {", 2, [], ['retry(() => ', '), {']],
107 ['mod', null, '', 15, ' times: 3,', 2, [], [' times: 3,']],
108 ['mod', null, '', 16, ' when: isRetryable,', 2, [], [' when: isRetryable,']],
109 ['mod', null, '', 17, ' })', 2, [], [' })']],
110 ['eq', 14, ' if (!res.ok) throw new LoginError(res.status)', 18, ' if (!res.ok) throw new LoginError(res.status)'],
111 ['del', 15, " log.info('login', res.status)", null, '', 3],
112 ['eq', 16, ' return res.json()', 19, ' return res.json()'],
113 ['eq', 17, '}', 20, '}'],
114 ['fold', '변경 없는 4줄'],
115 ],
116 },
117 {
118 name: 'retry.ts', path: 'src/lib/retry.ts', tag: '새 파일', tagColor: C.green, add: 18, del: 0,
119 desc: 'retry(), isRetryable() 헬퍼',
120 rows: added([
121 'export function isRetryable(err: unknown) {',
122 ' if (err instanceof NetworkError) return true',
123 ' return err instanceof HttpError && err.status >= 500',
124 '}',
125 '',
126 'export async function retry<T>(',
127 ' fn: () => Promise<T>,',
128 ' { times, when }: RetryOptions,',
129 '): Promise<T> {',
130 ' for (let n = 0; ; n++) {',
131 ' try {',
132 ' return await fn()',
133 ' } catch (err) {',
134 ' if (n + 1 >= times || !when(err)) throw err',
135 ' await sleep(200 * 2 ** n)',
136 ' }',
137 ' }',
138 '}',
139 ]),
140 },
141 {
142 name: 'login.test.ts', path: 'src/auth/login.test.ts', tag: '새 파일', tagColor: C.green, add: 18, del: 0,
143 desc: '재시도 테스트 3개', agent: '서브에이전트 · general-purpose',
144 rows: added([
145 "describe('login retry', () => {",
146 " it('503 다음 200이면 성공', async () => {",
147 ' api.post.mockRejectedOnce(http(503)).mockResolvedOnce(ok())',
148 " await expect(login('a', 'b')).resolves.toBeDefined()",
149 ' })',
150 " it('401은 재시도하지 않음', async () => {",
151 ' api.post.mockRejectedOnce(http(401))',
152 " await expect(login('a', 'b')).rejects.toThrow(LoginError)",
153 ' expect(api.post).toHaveBeenCalledTimes(1)',
154 ' })',
155 " it('3회 모두 실패하면 에러', async () => { /* … */ })",
156 '})',
157 ]),
158 },
159]
160
161// 변경 블록 목록 (파일 순서대로). note는 해설 요약에서 함께 만들어지는 한 줄 설명
162export const HUNKS = [
163 { f: 0, h: 1, note: 'retry 모듈 import 추가' },
164 { f: 0, h: 2, note: 'api 호출을 retry()로 감쌈. 최대 3회, isRetryable(err)일 때만 재시도' },
165 { f: 0, h: 3, note: '로그 한 줄 삭제. 재시도 중 같은 로그가 반복 출력되지 않도록' },
166 { f: 1, h: 1, note: '재시도 헬퍼 신규 작성. 200ms × 2ⁿ 지수 백오프, 마지막 에러는 그대로 던짐' },
167 { f: 2, h: 1, note: '서브에이전트가 작성한 테스트 3개. 503 후 성공, 401 즉시 실패, 3회 모두 실패' },
168]
169
170// 질문 입력창 샘플 답변 (실제 mod에서는 현재 대화를 fork해서 답한다)
171export const SAMPLE_ANSWER =
172 '(샘플 답변) 401을 재시도하지 않은 이유는, 잘못된 비밀번호로 여러 번 요청하면 계정 잠금 정책에 걸리기 때문입니다. 실제 mod에서는 현재 대화를 바탕으로 답합니다.'
173
174// ── 배경: 원래 어떤 문제가 있었나 ──
175export const BACKGROUND = {
176 problem: '로그인 API가 가끔 타임아웃이나 503으로 실패하면, 사용자는 곧바로 "로그인 실패" 화면을 봤습니다.',
177 cause: '서버가 잠깐 바쁠 때 생기는 일시적인 실패인데, 기존 login()은 한 번 실패하면 바로 에러를 던졌습니다.',
178 causeAt: 'src/auth/login.ts:13-14 (변경 전)',
179 goal: '일시적인 실패는 사용자가 모르게 다시 시도하고, 비밀번호가 틀린 것 같은 진짜 실패는 지금처럼 바로 알려 줍니다.',
180 outOfScope: ['요청 타임아웃 값 자체', '서버가 바빠지는 원인'],
181}
182
183// ── 코드 따라가기: 단계마다 코드 조각, 하는 일, 이유, 검토한 다른 방법 ──
184export const WALK = [
185 {
186 title: '재시도 래퍼 만들기',
187 at: 'src/lib/retry.ts:6-18',
188 code: [
189 'export async function retry<T>(fn, { times, when }) {',
190 ' for (let n = 0; ; n++) {',
191 ' try {',
192 ' return await fn()',
193 ' } catch (err) {',
194 ' if (n + 1 >= times || !when(err)) throw err',
195 ' await sleep(200 * 2 ** n)',
196 ' }',
197 ' }',
198 '}',
199 ],
200 does: 'fn을 호출하고, 실패하면 기다렸다가 다시 호출합니다. 최대 times번까지 시도하고, when(err)가 false면 바로 멈춥니다.',
201 why: '재시도 규칙을 login() 안에 직접 쓰지 않고 함수로 분리했습니다. 나중에 다른 API 호출에도 그대로 쓸 수 있습니다.',
202 alt: 'HTTP 클라이언트 전체(인터셉터)에 재시도를 거는 방법도 있습니다. 하지만 그러면 결제처럼 멱등하지 않은 요청까지 중복으로 보낼 수 있어서 쓰지 않았습니다.',
203 },
204 {
205 title: '재시도 대상 판별',
206 at: 'src/lib/retry.ts:1-4',
207 code: [
208 'export function isRetryable(err: unknown) {',
209 ' if (err instanceof NetworkError) return true',
210 ' return err instanceof HttpError && err.status >= 500',
211 '}',
212 ],
213 does: '네트워크 오류이거나 서버 오류(5xx)일 때만 true를 돌려줍니다.',
214 why: '401(인증 실패)은 다시 보내도 결과가 같습니다. 잘못된 비밀번호를 반복해서 보내면 계정 잠금 정책에 걸릴 수 있습니다.',
215 alt: '모든 에러를 재시도하는 방법은 401까지 세 번 보내게 되어 쓰지 않았습니다.',
216 },
217 {
218 title: '기다리는 시간을 두 배씩 늘리기',
219 at: 'src/lib/retry.ts:15',
220 code: [' await sleep(200 * 2 ** n)'],
221 does: '첫 실패 뒤에는 200ms, 두 번째 실패 뒤에는 400ms를 기다립니다. 이것을 지수 백오프라고 합니다.',
222 why: '바쁜 서버에 곧바로 다시 요청하면 부하가 더 커집니다. 기다리는 시간을 늘리면 서버가 회복할 시간이 생깁니다.',
223 alt: '항상 200ms를 기다리는 고정 대기도 있습니다. 단순하지만 서버가 회복하기 전에 다시 실패할 가능성이 큽니다.',
224 },
225 {
226 title: 'login()에 적용',
227 at: 'src/auth/login.ts:14-17',
228 code: [
229 " const res = await retry(() => api.post('/login', { u, p }), {",
230 ' times: 3,',
231 ' when: isRetryable,',
232 ' })',
233 ],
234 does: 'api.post 호출을 retry로 감쌌습니다. 최대 3번, 재시도 대상일 때만 다시 시도합니다.',
235 why: '호출하는 쪽(LoginPage)은 바꾸지 않고 login() 안에서만 해결했습니다. 화면 코드는 재시도가 있는지 몰라도 됩니다.',
236 alt: 'LoginPage에서 재시도하는 방법은 화면 코드에 네트워크 규칙이 섞여서 쓰지 않았습니다.',
237 },
238 {
239 title: '로그 한 줄 삭제',
240 at: 'src/auth/login.ts:15 (삭제)',
241 code: [" log.info('login', res.status)"],
242 does: '로그인 응답마다 남기던 로그를 지웠습니다.',
243 why: '재시도가 생기면서 같은 로그가 한 번 로그인에 여러 줄 찍힐 수 있었습니다.',
244 alt: '로그를 retry 안으로 옮기는 방법도 있습니다. 필요하면 다음 작업에서 시도 횟수와 함께 남기는 것을 검토하세요.',
245 },
246]
247
248// ── 용어: 이번 변경에 처음 나온 개념 ──
249export const TERMS = [
250 { id: 'transient', term: '일시적인 실패', en: 'transient failure', def: '잠시 뒤 다시 시도하면 성공할 수 있는 실패. 네트워크 끊김, 서버 과부하(503) 등.' },
251 { id: 'backoff', term: '지수 백오프', en: 'exponential backoff', def: '재시도할 때마다 기다리는 시간을 두 배로 늘리는 방식. 여기서는 200ms 다음 400ms.' },
252 { id: 'idempotent', term: '멱등성', en: 'idempotency', def: '같은 요청을 여러 번 보내도 한 번 보낸 것과 결과가 같은 성질. 재시도해도 안전한지 판단하는 기준.' },
253 { id: '5xx', term: '5xx', en: 'server error', def: '서버 쪽 문제를 뜻하는 HTTP 상태 코드(500~599). 요청을 보낸 쪽의 잘못이 아닐 수 있음.' },
254]
255
256// ── 확인 퀴즈: 보기는 단어 수를 맞추고, 순서는 mod 코드가 섞는다. 첫 번째 보기가 정답 ──
257export const QUIZ = [
258 {
259 q: '서버가 401(인증 실패)을 돌려주면 login()은 어떻게 동작하나요?',
260 options: ['재시도 없이 바로 실패한다', '3회 재시도 후 실패한다', '한 번만 다시 시도한다'],
261 explain: 'isRetryable()이 401에 false를 돌려주므로 retry()가 바로 에러를 던집니다.',
262 },
263 {
264 q: '세 번째 호출 전에는 얼마나 기다리나요?',
265 options: ['400ms를 기다린다', '200ms를 기다린다', '800ms를 기다린다'],
266 explain: '첫 실패 뒤 200ms, 두 번째 실패 뒤 400ms를 기다립니다. 세 번째 호출은 두 번째 실패 뒤입니다.',
267 },
268 {
269 q: '재시도를 HTTP 클라이언트 전체에 걸지 않은 이유는?',
270 options: ['중복되면 안 되는 요청이 있어서', '인터셉터가 비동기 요청을 지원하지 않아서', '로그인 요청은 클라이언트를 거치지 않아서'],
271 explain: '결제처럼 멱등하지 않은 요청이 재시도로 두 번 실행될 수 있기 때문입니다.',
272 },
273]
274
275// ── Wait, what?: 섹션을 더 쉬운 말로, 빠진 전제를 채워 다시 설명 (wait-what 스킬) ──
276export const EASY = {
277 summary: '로그인할 때 서버가 잠깐 바빠서 실패하면, 프로그램이 알아서 조금 기다렸다가 다시 시도하게 만들었습니다. 비밀번호가 틀린 경우는 다시 시도해도 소용없으니 바로 알려 줍니다.',
278 background: '식당에 전화했는데 통화 중이면 바로 포기하지 않고 잠시 뒤 다시 거는 것과 같습니다. 원래 코드는 통화 중이면 바로 포기했습니다.',
279 walk: '새 함수 retry()가 "실패하면 기다렸다가 다시"를 맡고, isRetryable()이 "다시 해 볼 만한 실패인지"를 판단합니다. login()은 이 둘을 조합해서 쓰기만 합니다.',
280 flow: '위에서 아래로 읽으면 됩니다. 성공하면 끝, 다시 해 볼 만한 실패면 기다렸다가 2번으로 돌아가고, 아니면 에러로 끝납니다.',
281 seq: '왼쪽에서 오른쪽으로 요청이 가고, 오른쪽에서 왼쪽으로 응답이 옵니다. retry()가 중간에서 실패를 받아 한 번 더 보내는 것이 이번 변경입니다.',
282 ba: '바뀐 것은 "서버가 잠깐 바쁠 때"뿐입니다. 비밀번호가 틀린 경우는 전과 똑같습니다.',
283 impact: 'login()을 부르는 곳은 코드를 고치지 않아도 되지만, 응답이 늦어질 수 있으니 로딩 표시와 토큰 갱신 쪽을 한 번 확인하라는 뜻입니다.',
284 terms: '모르는 용어가 있으면 [Known]을 누르지 말고 두세요. 다음 해설에서도 계속 풀어서 설명합니다.',
285 quiz: '정답을 고르면 이유가 바로 나옵니다. 틀려도 괜찮습니다. 이유를 읽고 다시 풀면 됩니다.',
286}
287
288// 같은 섹션에서 Wait, what?을 또 누르면: 회차마다 다른 비유로, 막힌 전제를 하나씩 짚어 다시 설명.
289// 실제 mod에서는 앞의 설명들을 모델에 함께 넘겨 "이것들과 다르게" 새로 만든다
290export const EASY_AGAIN = {
291 background: [
292 '이번에는 전제부터 짚어 보겠습니다. ① 서버도 바쁠 때가 있습니다. ② 바쁠 때 온 요청은 실패로 돌아옵니다. ③ 그런데 몇백 ms 뒤에는 대개 한가해집니다. 그래서 "조금 기다렸다 다시"가 효과가 있습니다.',
293 '숫자로 보면: 서버가 1초에 100개를 처리할 수 있는데 순간 150개가 몰리면 50개는 실패합니다. 0.2초 뒤에는 몰렸던 요청이 빠져서 다시 보내면 대부분 처리됩니다.',
294 ],
295 walk: [
296 '역할로 나눠 보면 쉽습니다. retry()는 "몇 번, 얼마나 기다렸다 다시 할지"만 압니다. isRetryable()은 "이 실패가 다시 해 볼 만한지"만 압니다. login()은 둘에게 일을 맡기는 관리자입니다.',
297 '실행 순서로 따라가 보면: login()이 retry()를 부름 → retry()가 api.post를 부름 → 503 실패 → isRetryable()에게 물어봄(예) → 200ms 쉼 → 다시 api.post → 성공 → login()에게 결과 전달.',
298 ],
299}
300// 준비된 설명이 떨어졌을 때 (샘플 전용). 같은 글을 반복하지 않는다
301export const EASY_AGAIN_DEFAULT = '(샘플) 준비된 설명은 여기까지입니다. 실제 mod에서는 앞의 설명들과 겹치지 않게 새로 만듭니다.'
302// 이 회차부터는 더 설명하기보다 어디가 막히는지 묻는다
303export const EASY_ASK_AFTER = 3
304
305// 섹션 안에서 추가 질문을 했을 때의 샘플 답변
306export const SECTION_ANSWER = '(샘플 답변) 실제 mod에서는 이 섹션의 내용과 앞의 쉬운 설명을 바탕으로, 현재 대화를 fork해서 답합니다.'
307hooks/views.js 659 lines1// 해설 창의 각 화면을 그리는 함수들. `el`은 $.ui.resolve(e)가 준 요소 생성자 묶음.
2//
3// 그리기 규칙
4// - 한 줄에 여러 색이 필요하면 Text 하나 안에 Text를 넣는다. 조각을 Box 가로줄로
5// 늘어놓으면 좁은 창에서 조각마다 따로 줄바꿈되어 글자가 뒤섞인다.
6// - 다이어그램과 코드 줄은 줄바꿈 대신 잘라낸다(wrap: 'truncate-end').
7// - 한글은 2칸을 차지하므로 다이어그램 줄에서 한글은 줄 끝에만 둔다.
8
9import { BACKGROUND, BEFORE_AFTER, C, D, FILES, FLOW, HUNKS, KIND_COLOR, LANES, MSGS, QUIZ, TERMS, WALK } from './data.js'
10
11// ── 공통 ─────────────────────────────────────────────────────
12
13// 한 줄(또는 한 문단) 안의 색 조각들: parts = ['plain' | [text, color, bold?], ...]
14export function rich(el, parts, opts) {
15 return el.Text({
16 color: C.fg,
17 ...(opts || {}),
18 children: parts.map((p) => (typeof p === 'string' ? p : el.Text({ color: p[1], bold: !!p[2], children: [p[0]] }))),
19 })
20}
21
22// 버튼: Claude Code의 기본 버튼. 터미널에서는 [ d diff 보기 ], Desktop 앱에서는 네이티브 버튼으로 그려진다.
23// 대괄호 버튼은 단축키를 따로 표시하지 않으므로 라벨 앞에 키를 넣는다.
24// primary는 화면마다 하나만 두는 주요 버튼(터미널에서 강조색)
25export function btn(el, { key, hotkey, label, onPress, primary, dim }) {
26 const props = { key, label: hotkey ? hotkey + ' ' + label : label, onPress }
27 if (hotkey) props.hotkey = hotkey
28 if (primary) props.variant = 'primary'
29 if (dim) props.dimColor = true
30 return el.Button(props)
31}
32
33// 대괄호 없는 글자 링크: 목차처럼 여러 개를 늘어놓을 때. 누를 수 있고, 흐리게 하면 보조 항목
34export function link(el, { key, label, onPress, dim }) {
35 const props = { key, label, plain: true, onPress }
36 if (dim) props.dimColor = true
37 return el.Button(props)
38}
39
40// 버튼들을 한 줄에 늘어놓고, 넘치면 다음 줄로
41export function buttonRow(el, children) {
42 return el.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 0, children })
43}
44
45// 터미널 표시 폭: 한글·한자·전각 문자는 2칸
46export function widthOf(s) {
47 let w = 0
48 for (const ch of s) {
49 const c = ch.codePointAt(0)
50 w += (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
51 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60) ? 2 : 1
52 }
53 return w
54}
55
56// 색 조각들의 앞에서 n글자를 버린다 (가로 스크롤)
57function shift(parts, n) {
58 const out = []
59 let rest = n
60 for (const p of parts) {
61 const t = p[0]
62 if (rest >= t.length) { rest -= t.length; continue }
63 out.push(rest ? [t.slice(rest), p[1], p[2], p[3]] : p)
64 rest = 0
65 }
66 return out
67}
68
69export function rule(el, cols) {
70 return el.Text({ color: C.rule, wrap: 'truncate-end', children: ['─'.repeat(Math.max(10, cols))] })
71}
72
73function heading(el, text) {
74 return el.Text({ bold: true, color: C.accent, children: [text] })
75}
76
77function block(el, title, children) {
78 return el.Box({
79 flexDirection: 'column',
80 children: [heading(el, title), el.Box({ flexDirection: 'column', paddingLeft: 2, children })],
81 })
82}
83
84// ── 요약 ─────────────────────────────────────────────────────
85
86// onFile(i): 파일을 누르면 그 파일의 diff를 연다
87export function summaryView(el, onFile) {
88 return el.Box({
89 flexDirection: 'column',
90 rowGap: 1,
91 children: [
92 block(el, '무엇을 바꿨나', [
93 ...FILES.map((f, i) =>
94 el.Box({
95 flexDirection: 'column',
96 children: [
97 btn(el, { key: 'file-' + i, label: f.path + ' → diff', onPress: () => onFile(i) }),
98 rich(el, [
99 ' ',
100 [f.tag, f.tagColor],
101 ' ',
102 ['+' + f.add, C.green],
103 f.del ? ' ' : '',
104 f.del ? ['−' + f.del, C.red] : '',
105 f.agent ? ' ' : '',
106 f.agent ? ['서브에이전트', C.purple] : '',
107 ' ',
108 [f.desc, C.dim],
109 ]),
110 ],
111 }),
112 ),
113 el.Text({ color: C.faint, children: ['파일을 누르면 그 파일의 diff로 이동합니다.'] }),
114 ]),
115 block(el, '왜 이렇게 했나', [
116 el.Text({ children: ['일시적인 실패(네트워크 오류, 5xx)만 재시도하고, 401 같은 인증 실패는 즉시 실패시켰습니다.'] }),
117 el.Text({ color: C.dim, children: ['잘못된 비밀번호를 세 번 보내면 계정 잠금 정책에 걸릴 수 있기 때문입니다.'] }),
118 ]),
119 block(el, '어떻게 동작하나', [
120 rich(el, [['1 ', C.accent], ['retry(fn, { times: 3, when })', C.blue]]),
121 el.Text({ color: C.dim, children: [' 실패하면 200ms, 400ms 기다렸다가 다시 호출합니다.'] }),
122 rich(el, [['2 ', C.accent], ['isRetryable(err)', C.blue]]),
123 el.Text({ color: C.dim, children: [' 네트워크 오류이거나 status가 500 이상이면 재시도 대상입니다.'] }),
124 rich(el, [['3 ', C.accent], ['마지막까지 실패하면', C.fg]]),
125 el.Text({ color: C.dim, children: [' 원래 에러를 그대로 던집니다. 호출부의 에러 처리는 바뀌지 않습니다.'] }),
126 ]),
127 block(el, '검토할 점', [
128 rich(el, [['! ', C.red, true], '최악의 경우 로그인 응답이 약 1.4초 늦어집니다. 로딩 표시를 확인하세요.']),
129 rich(el, [['? ', C.blue, true], '타임아웃 값 자체는 그대로입니다. 원인이 긴 타임아웃이면 별도 조치가 필요합니다.']),
130 ]),
131 ],
132 })
133}
134
135// ── 배경 ─────────────────────────────────────────────────────
136
137export function backgroundView(el) {
138 return el.Box({
139 flexDirection: 'column',
140 rowGap: 1,
141 children: [
142 block(el, '무슨 문제가 있었나', [el.Text({ children: [BACKGROUND.problem] })]),
143 block(el, '왜 그랬나', [
144 el.Text({ children: [BACKGROUND.cause] }),
145 el.Text({ color: C.blue, children: [BACKGROUND.causeAt] }),
146 ]),
147 block(el, '이번 변경의 목표', [el.Text({ children: [BACKGROUND.goal] })]),
148 block(el, '이번에 다루지 않은 것', BACKGROUND.outOfScope.map((x) => rich(el, [['- ', C.faint], [x, C.dim]]))),
149 ],
150 })
151}
152
153// ── 코드 따라가기 ────────────────────────────────────────────
154// 단계마다: 제목과 위치 → 코드 조각 → 하는 일 → 이유 → 검토한 다른 방법
155
156function labeled(el, label, color, text) {
157 return el.Box({
158 flexDirection: 'column',
159 children: [
160 el.Text({ bold: true, color, children: [label] }),
161 el.Box({ paddingLeft: 2, children: [el.Text({ children: [text] })] }),
162 ],
163 })
164}
165
166export function walkView(el) {
167 return el.Box({
168 flexDirection: 'column',
169 rowGap: 1,
170 children: [
171 el.Text({ color: C.dim, children: ['실제 코드를 순서대로 따라가며 각 줄이 하는 일과 그렇게 한 이유를 설명합니다.'] }),
172 ...WALK.map((st, i) =>
173 el.Box({
174 flexDirection: 'column',
175 rowGap: 1,
176 children: [
177 el.Box({
178 flexDirection: 'column',
179 children: [
180 rich(el, [[(i + 1) + '단계 ', C.accent, true], [st.title, C.title, true]]),
181 el.Text({ color: C.blue, wrap: 'truncate-start', children: [st.at] }),
182 ],
183 }),
184 el.Box({
185 flexDirection: 'column',
186 paddingLeft: 1,
187 children: st.code.map((line) =>
188 rich(el, [['┃ ', C.faint], [line, C.fg]], { wrap: 'truncate-end' }),
189 ),
190 }),
191 labeled(el, '하는 일', C.green, st.does),
192 labeled(el, '이유', C.accent, st.why),
193 labeled(el, '검토한 다른 방법', C.purple, st.alt),
194 ],
195 }),
196 ),
197 ],
198 })
199}
200
201// ── 용어 ─────────────────────────────────────────────────────
202// known: 이미 아는 용어 id 목록. 아는 용어는 한 줄로 접고, 다음 해설에서도 길게 풀지 않는다
203
204export function termsView(el, known, onToggle) {
205 const rows = TERMS.map((t) => {
206 const isKnown = known.includes(t.id)
207 const button = btn(el, { key: 'term-' + t.id, label: isKnown ? 'Undo' : 'Known', onPress: () => onToggle(t.id) })
208 if (isKnown) {
209 return el.Box({
210 flexDirection: 'row',
211 flexWrap: 'wrap',
212 columnGap: 2,
213 children: [rich(el, [['✓ ', C.green], [t.term, C.dim]]), button],
214 })
215 }
216 return el.Box({
217 flexDirection: 'column',
218 children: [
219 el.Box({
220 flexDirection: 'row',
221 flexWrap: 'wrap',
222 columnGap: 2,
223 children: [rich(el, [[t.term, C.title, true], [' ' + t.en, C.faint]]), button],
224 }),
225 el.Box({ paddingLeft: 2, children: [el.Text({ children: [t.def] })] }),
226 ],
227 })
228 })
229 return el.Box({
230 flexDirection: 'column',
231 rowGap: 1,
232 children: [
233 el.Text({ color: C.dim, children: ['이번 변경에 처음 나온 개념입니다. 이미 아는 것은 [Known]을 누르면 다음 해설부터 짧게 넘어갑니다.'] }),
234 ...rows,
235 ],
236 })
237}
238
239// ── 확인 퀴즈 ────────────────────────────────────────────────
240// st = { order: [[보기 위치...] 문제별], picked: [고른 보기 원래 위치 | null] }. 원래 위치 0이 정답
241
242export function quizView(el, st, on) {
243 const answered = st.picked.filter((x) => x !== null).length
244 const correct = st.picked.filter((x) => x === 0).length
245 const done = answered === QUIZ.length
246 return el.Box({
247 flexDirection: 'column',
248 rowGap: 1,
249 children: [
250 el.Text({ color: C.dim, children: ['기억에서 떠올리며 풀어 보세요. 다 맞히면 이 턴이 이해함으로 표시됩니다.'] }),
251 ...QUIZ.map((qz, i) => {
252 const picked = st.picked[i]
253 const options = st.order[i].map((orig, j) => {
254 const label = String.fromCharCode(65 + j) + ' ' + qz.options[orig]
255 if (picked === null) {
256 return btn(el, { key: 'q' + i + '-o' + orig, label, onPress: () => on.pick(i, orig) })
257 }
258 const mark = orig === 0 ? '✓ ' : orig === picked ? '✗ ' : ' '
259 const color = orig === 0 ? C.green : orig === picked ? C.red : C.faint
260 return el.Text({ color, children: [mark + label] })
261 })
262 return el.Box({
263 flexDirection: 'column',
264 children: [
265 rich(el, [['Q' + (i + 1) + ' ', C.accent, true], [qz.q, C.title, true]]),
266 el.Box({ flexDirection: 'column', paddingLeft: 2, children: options }),
267 picked === null ? el.Text({ children: [' '] }) : el.Box({
268 paddingLeft: 2,
269 children: [rich(el, [[picked === 0 ? '맞았습니다. ' : '틀렸습니다. ', picked === 0 ? C.green : C.red, true], [qz.explain, C.fg]])],
270 }),
271 ],
272 })
273 }),
274 done
275 ? el.Box({
276 flexDirection: 'row',
277 flexWrap: 'wrap',
278 columnGap: 2,
279 children: [
280 rich(el, [[correct + '/' + QUIZ.length + ' 정답', correct === QUIZ.length ? C.green : C.accent, true],
281 [correct === QUIZ.length ? ' 이 턴을 이해함으로 표시했습니다.' : ' 틀린 문제의 이유를 읽고 다시 풀어 보세요.', C.dim]]),
282 correct === QUIZ.length ? el.Text({ children: [''] }) : btn(el, { key: 'quiz-retry', label: '다시 풀기', onPress: on.retry }),
283 ],
284 })
285 : el.Text({ color: C.faint, children: [answered + '/' + QUIZ.length + ' 답함'] }),
286 ],
287 })
288}
289
290// ── 흐름도 (카드) ────────────────────────────────────────────
291
292function card(el, item, width) {
293 const color = KIND_COLOR[item.kind]
294 const titleColor = item.kind === 'err' ? C.red : item.kind === 'ok' ? C.green : C.title
295 const rows = [
296 rich(el, [item.n ? [item.n + ' ', C.accent, true] : '', [item.title, titleColor, true]]),
297 el.Text({ color: C.dim, children: [item.desc] }),
298 ]
299 if (item.tag) rows.push(el.Text({ color: C.accent, children: [item.tag] }))
300 const props = { flexDirection: 'column', borderStyle: 'round', borderColor: color, paddingX: 1, children: rows }
301 if (width) props.width = width
302 return el.Box(props)
303}
304
305function connector(el, width, label) {
306 return el.Box({
307 paddingLeft: Math.floor(width / 2),
308 children: [rich(el, [['│', C.faint], label ? [' ' + label, C.dim] : ''])],
309 })
310}
311
312export function flowView(el, cols) {
313 const cardW = Math.min(34, cols - 2)
314 // 옆 가지 카드를 나란히 둘 공간이 없으면 카드 아래로 내린다
315 const wide = cols >= cardW + 34
316 const out = [
317 rich(el, [
318 ['■ ', C.accent], ['이번에 추가 ', C.dim],
319 ['■ ', C.blue], ['조건 ', C.dim],
320 ['■ ', C.green], ['정상 종료 ', C.dim],
321 ['■ ', C.red], ['에러', C.dim],
322 ]),
323 el.Text({ children: [' '] }),
324 ]
325 FLOW.forEach((item, i) => {
326 const main = card(el, item, cardW)
327 if (item.branch) {
328 const arrow = el.Text({ color: KIND_COLOR[item.branch.kind], children: [' ─ ' + item.branch.label + ' ─> '] })
329 const side = card(el, item.branch)
330 out.push(
331 wide
332 ? el.Box({ flexDirection: 'row', alignItems: 'center', children: [main, arrow, side] })
333 : el.Box({
334 flexDirection: 'column',
335 children: [
336 main,
337 el.Box({ flexDirection: 'row', alignItems: 'center', paddingLeft: 3, children: [el.Text({ color: C.faint, children: ['└'] }), arrow, side] }),
338 ],
339 }),
340 )
341 } else {
342 out.push(main)
343 }
344 if (i < FLOW.length - 1) out.push(connector(el, cardW, item.next))
345 })
346 return el.Box({ flexDirection: 'column', children: out })
347}
348
349// ── 시퀀스 ───────────────────────────────────────────────────
350// 각 호출은 두 줄: 위 줄에 번호와 라벨, 아래 줄에 화살표. 설명은 그 아래 흐린 줄.
351// 왼쪽 번호 칸은 고정이고, 나머지는 left만큼 가로로 밀어서 그린다.
352
353const SEQ_GUTTER = 3
354
355function seqGeometry(cols) {
356 const laneW = Math.max(12, Math.min(20, Math.floor((cols - SEQ_GUTTER) / LANES.length)))
357 const center = (i) => i * laneW + Math.floor(laneW / 2)
358 const width = laneW * LANES.length
359 // 가장 긴 줄: 라벨 또는 설명이 생명선 오른쪽으로 뻗는 끝
360 let longest = width
361 MSGS.forEach(([from, to, label, , note]) => {
362 const start = Math.min(center(from), center(to)) + 2
363 longest = Math.max(longest, start + widthOf(label), start + widthOf(note || ''))
364 })
365 return { laneW, center, width, longest }
366}
367
368// 가로로 밀 수 있는 최대 칸 수
369export function seqMaxLeft(cols) {
370 const g = seqGeometry(cols)
371 return Math.max(0, g.longest - (cols - SEQ_GUTTER))
372}
373
374export function seqView(el, cols, left) {
375 const { laneW, center, width } = seqGeometry(cols)
376 const lifeline = () => {
377 const a = new Array(width).fill(' ')
378 LANES.forEach((_, i) => { a[center(i)] = '│' })
379 return a
380 }
381 // 번호 칸(고정) + 가로로 민 나머지
382 const row = (gutter, parts) =>
383 rich(el, [[gutter, C.accent, true], ...shift(parts, left)], { wrap: 'truncate-end' })
384 const paint = (cells, start, end, color) => [
385 [cells.slice(0, start).join(''), C.faint],
386 [cells.slice(start, end).join(''), color],
387 [cells.slice(end).join(''), C.faint],
388 ]
389 // col까지 생명선 + 글. 글 뒤로는 생명선을 그리지 않는다(한글 폭 때문에)
390 const textAt = (col, parts) => [[lifeline().slice(0, col).join(''), C.faint], ...parts]
391
392 // 참여자 머리 상자 세 줄을 글자로 그린다 (가로 스크롤이 되도록)
393 const boxLine = (fn) => LANES.map((l) => [fn(l).padEnd(laneW, ' '), l.color, true])
394 const inner = laneW - 3
395 const fit = (name) => (name.length > inner ? name.slice(0, inner - 1) + '…' : name)
396 const centerText = (name) => {
397 const t = fit(name)
398 const pad = inner - t.length
399 return ' '.repeat(Math.floor(pad / 2)) + t + ' '.repeat(pad - Math.floor(pad / 2))
400 }
401 const rows = [
402 row(' ', boxLine(() => '╭' + '─'.repeat(inner) + '╮')),
403 row(' ', boxLine((l) => '│' + centerText(l.name) + '│')),
404 // 아래 테두리의 ┬가 생명선과 같은 칸(레인 안 laneW/2)에 오도록
405 row(' ', boxLine(() => '╰' + '─'.repeat(Math.floor(laneW / 2) - 1) + '┬' + '─'.repeat(inner - Math.floor(laneW / 2)) + '╯')),
406 ]
407 MSGS.forEach(([from, to, label, color, note], i) => {
408 const l = Math.min(center(from), center(to))
409 const r = Math.max(center(from), center(to))
410 rows.push(row(String(i + 1).padStart(2, ' ') + ' ', textAt(l + 2, [[label, color, true]])))
411 const a = lifeline()
412 if (from === to) {
413 // 자기 자신을 부르는 호출: 오른쪽으로 나갔다가 돌아오는 고리
414 const c = center(from)
415 const b = lifeline()
416 a[c] = '├'; a[c + 1] = '─'; a[c + 2] = '╮'
417 b[c] = '│'; b[c + 1] = '<'; b[c + 2] = '╯'
418 rows.push(row(' ', paint(a, c, c + 3, color)))
419 rows.push(row(' ', paint(b, c + 1, c + 3, color)))
420 } else {
421 for (let k = l + 1; k < r; k++) a[k] = '─'
422 if (from < to) a[r - 1] = '>'
423 else a[l + 1] = '<'
424 rows.push(row(' ', paint(a, l + 1, r, color)))
425 }
426 if (note) rows.push(row(' ', textAt(l + 2, [[note, C.dim]])))
427 })
428 rows.push(row(' ', [[lifeline().join(''), C.faint]]))
429 return el.Box({ flexDirection: 'column', children: rows })
430}
431
432// ── 전후 비교 ────────────────────────────────────────────────
433
434export function baView(el) {
435 return el.Box({
436 flexDirection: 'column',
437 rowGap: 1,
438 children: BEFORE_AFTER.map((r) =>
439 el.Box({
440 flexDirection: 'column',
441 children: [
442 el.Text({ bold: true, color: C.title, children: [r.when] }),
443 rich(el, [' ', [r.before, C.dim], [' → ', C.faint], [r.after, r.color, true]]),
444 ],
445 }),
446 ),
447 })
448}
449
450// ── 영향 범위 ────────────────────────────────────────────────
451
452export function impactView(el) {
453 const item = (path, desc, flag) =>
454 el.Box({
455 flexDirection: 'column',
456 children: [
457 el.Text({ color: C.blue, wrap: 'truncate-start', children: [path] }),
458 rich(el, [' ', [desc, C.fg], flag ? [' ' + flag, C.red, true] : '']),
459 ],
460 })
461 return el.Box({
462 flexDirection: 'column',
463 rowGap: 1,
464 children: [
465 block(el, 'login()을 부르는 곳 2', [
466 item('src/pages/LoginPage.tsx:41', '로딩 표시 시간이 길어질 수 있습니다.'),
467 item('src/auth/session.ts:88', '토큰 갱신에도 재시도가 적용됩니다.', '의도했는지 확인 필요'),
468 ]),
469 block(el, '새로 생긴 함수', [item('retry(), isRetryable()', '아직 다른 사용처는 없습니다.')]),
470 block(el, '확인할 테스트', [
471 item('src/auth/login.test.ts', '이번에 추가된 테스트'),
472 item('src/auth/session.test.ts', '기존 테스트, 재시도 영향을 받음'),
473 ]),
474 ],
475 })
476}
477
478// ── diff (인텔리제이식) ──────────────────────────────────────
479// 위쪽 안내·버튼과 아래 설명은 고정하고, 가운데 코드 영역만 위아래(top)·좌우(left)로 민다.
480
481// 강조할 부분 문자열들로 한 줄을 조각낸다: [[글, 글자색, 굵게, 배경색], ...]
482function segments(text, highlights, hi, fg) {
483 if (!text) return [[' ', fg]]
484 const out = []
485 let rest = text
486 ;(highlights || []).forEach((h) => {
487 const i = rest.indexOf(h)
488 if (i < 0) return
489 if (i > 0) out.push([rest.slice(0, i), fg])
490 out.push([h, fg, false, hi])
491 rest = rest.slice(i + h.length)
492 })
493 if (rest) out.push([rest, fg])
494 return out
495}
496
497// 줄 표시 칸(고정) + 가로로 민 코드
498function codeLine(el, mark, num, parts, left) {
499 return el.Text({
500 wrap: 'truncate-end',
501 children: [
502 el.Text({ color: C.accent, bold: true, children: [mark] }),
503 el.Text({ color: C.faint, children: [num.padStart(4, ' ') + ' '] }),
504 ...shift(parts, left).map(([t, fg, bold, bg]) => el.Text(bg ? { color: fg, bold: !!bold, backgroundColor: bg, children: [t] } : { color: fg, bold: !!bold, children: [t] })),
505 ],
506 })
507}
508
509function filled(el, width, bg, child) {
510 const props = { width, flexShrink: 0, children: [child] }
511 if (bg) props.backgroundColor = bg
512 return el.Box(props)
513}
514
515// 보고 있는 변경 블록의 파일을 줄 목록으로 만든다 (그리기 전 데이터)
516export function diffModel(cols, pos) {
517 const cur = HUNKS[pos]
518 const file = FILES[cur.f]
519 const side = cols >= 96
520 const mark = (r) => (r[0] !== 'eq' && r[5] === cur.h ? '>' : ' ')
521 const lines = []
522 let hunkStart = -1
523 let longest = 0
524 const note = (r) => { if (hunkStart < 0 && r[5] === cur.h && r[0] !== 'eq') hunkStart = lines.length }
525 file.rows.forEach((r) => {
526 if (r[0] === 'fold') { lines.push({ fold: r[1] }); return }
527 const k = r[0]
528 longest = Math.max(longest, (r[2] || '').length + 2, (r[4] || '').length + 2)
529 if (side) {
530 note(r)
531 lines.push({
532 mark: mark(r),
533 l: {
534 num: r[1] == null ? '' : String(r[1]),
535 parts: segments(r[2], r[6], k === 'mod' ? D.modHi : null, k === 'del' ? C.dim : C.fg),
536 bg: k === 'eq' ? null : k === 'add' ? D.fill : k === 'del' ? D.del : r[2] ? D.mod : D.modFill,
537 },
538 r: {
539 num: r[3] == null ? '' : String(r[3]),
540 parts: segments(r[4], r[7], k === 'mod' ? D.modHi : k === 'add' ? D.addHi : null, C.fg),
541 bg: k === 'eq' ? null : k === 'add' ? D.add : k === 'del' ? D.fill : D.mod,
542 },
543 })
544 return
545 }
546 // 통합 보기: 지운 줄(−) 다음에 추가한 줄(+)
547 if (k === 'eq') {
548 lines.push({ mark: ' ', num: String(r[3]), parts: [[' ' + r[4], C.dim]], bg: null })
549 return
550 }
551 note(r)
552 if (r[2] || k === 'del') lines.push({ mark: mark(r), num: String(r[1]), parts: [['− ', C.red], ...segments(r[2], r[6], D.remHi, C.fg)], bg: D.rem })
553 if (k !== 'del') lines.push({ mark: mark(r), num: String(r[3]), parts: [['+ ', C.green], ...segments(r[4], r[7], D.addHi, C.fg)], bg: D.add })
554 })
555 const codeCols = side ? Math.floor((cols - 1) / 2) - 6 : cols - 6
556 return { cur, file, side, lines, hunkStart: Math.max(0, hunkStart), maxLeft: Math.max(0, longest - codeCols) }
557}
558
559// 코드 영역에 보일 줄 수: 창 높이에서 고정된 위아래 영역을 뺀 만큼
560export function diffCodeRows(bodyRows) {
561 return Math.max(6, bodyRows - 15)
562}
563
564// st = { pos, top, left }. on = { prev, next, nextFile, pickFile, back, up, down, leftward, rightward }
565export function diffView(el, cols, bodyRows, st, on) {
566 const m = diffModel(cols, st.pos)
567 const codeRows = diffCodeRows(bodyRows)
568 const top = Math.min(st.top, Math.max(0, m.lines.length - codeRows))
569 const left = Math.min(st.left, m.maxLeft)
570 const half = Math.floor((cols - 1) / 2)
571
572 const tabs = buttonRow(el, FILES.map((f, i) =>
573 btn(el, { key: 'tab-' + i, label: f.name, primary: i === m.cur.f, dim: i !== m.cur.f, onPress: () => on.pickFile(i) }),
574 ))
575
576 const nav = buttonRow(el, [
577 btn(el, { key: 'prev', hotkey: 'p', label: '이전 변경', dim: st.pos === 0, onPress: on.prev }),
578 btn(el, { key: 'next', hotkey: 'n', label: '다음 변경', primary: true, dim: st.pos === HUNKS.length - 1, onPress: on.next }),
579 btn(el, { key: 'next-file', hotkey: 'f', label: '다음 파일', onPress: on.nextFile }),
580 btn(el, { key: 'back', hotkey: 'b', label: '해설로', onPress: on.back }),
581 el.Text({ color: C.accent, children: [' 변경 ' + (st.pos + 1) + '/' + HUNKS.length] }),
582 ])
583
584 const scrollBar = buttonRow(el, [
585 btn(el, { key: 'up', hotkey: 'k', label: '위', dim: top === 0, onPress: on.up }),
586 btn(el, { key: 'down', hotkey: 'j', label: '아래', dim: top + codeRows >= m.lines.length, onPress: on.down }),
587 btn(el, { key: 'left', hotkey: 'h', label: '왼쪽', dim: left === 0, onPress: on.leftward }),
588 btn(el, { key: 'right', hotkey: 'l', label: '오른쪽', dim: left >= m.maxLeft, onPress: on.rightward }),
589 el.Text({
590 color: C.faint,
591 children: [' 줄 ' + (m.lines.length ? top + 1 : 0) + '–' + Math.min(top + codeRows, m.lines.length) + ' / ' + m.lines.length + (left ? ' · 가로 +' + left : '') + ' · 휠로도 스크롤'],
592 }),
593 ])
594
595 const meta = rich(el, [
596 [m.file.path, C.blue],
597 ' ',
598 [m.file.tag, m.file.tagColor],
599 ' ',
600 ['+' + m.file.add, C.green],
601 m.file.del ? ' ' : '',
602 m.file.del ? ['−' + m.file.del, C.red] : '',
603 m.file.agent ? [' ' + m.file.agent, C.purple] : '',
604 [' · ' + (m.side ? '좌우 보기' : '통합 보기 (창이 좁음)'), C.faint],
605 ], { wrap: 'truncate-end' })
606
607 const code = []
608 if (m.side) {
609 code.push(el.Box({
610 flexDirection: 'row',
611 children: [
612 filled(el, half, null, el.Text({ color: C.faint, children: [' 변경 전 · 턴 시작 시점'] })),
613 el.Text({ color: C.rule, children: ['│'] }),
614 filled(el, half, null, el.Text({ color: C.faint, children: [' 변경 후 · 턴 종료 시점'] })),
615 ],
616 }))
617 }
618 m.lines.slice(top, top + codeRows).forEach((ln) => {
619 if (ln.fold) {
620 code.push(el.Box({ justifyContent: 'center', children: [el.Text({ color: C.faint, children: ['··· ' + ln.fold + ' ···'] })] }))
621 } else if (m.side) {
622 code.push(el.Box({
623 flexDirection: 'row',
624 children: [
625 filled(el, half, ln.l.bg, codeLine(el, ln.mark, ln.l.num, ln.l.parts, left)),
626 el.Text({ color: C.rule, children: ['│'] }),
627 filled(el, half, ln.r.bg, codeLine(el, ln.mark, ln.r.num, ln.r.parts, left)),
628 ],
629 }))
630 } else {
631 code.push(filled(el, cols, ln.bg, codeLine(el, ln.mark, ln.num, ln.parts, left)))
632 }
633 })
634
635 return el.Box({
636 flexDirection: 'column',
637 children: [
638 rich(el, [['diff ', C.accent, true], ['파일을 고르거나 n/p로 변경 사이를 이동합니다.', C.dim]]),
639 tabs,
640 nav,
641 scrollBar,
642 rule(el, cols),
643 meta,
644 ...code,
645 rule(el, cols),
646 el.Box({
647 borderStyle: 'round',
648 borderColor: C.accent,
649 paddingX: 1,
650 flexDirection: 'column',
651 children: [
652 el.Text({ bold: true, color: C.accent, children: ['변경 ' + (st.pos + 1) + ' 설명'] }),
653 el.Text({ color: C.title, children: [m.cur.note] }),
654 ],
655 }),
656 ],
657 })
658}
659