My Dotfiles!

sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin init --source=~/ghq/github.com/0ta2/dots git@github.com:0ta2/dots.git
~/ghq/github.com/0ta2/dots/bootstrap.sh
Homebrew・mise を導入した後 mise run install (ドットファイル反映・不足ツール・skill・plugin の 導入・Codex 設定同期) を実行する。
mise run sync # ドットファイル反映 + Codex設定マージ (副作用なし、何度実行しても収束する)
mise run update # brew/mise/skill/uv tool の更新、最後に sync
直接 chezmoi コマンドを実行する場合は下記の通り:
~/.local/bin/chezmoi --source=~/ghq/github.com/0ta2/dots apply
コンピュータ名 を変更パスワードやパスキーを自動入力 をOFFドラッグにトラックパッドを使用 をONDock のサイズ小さく好みのサイズへ変更Dock の拡大を好みの大きさへ変更Dock を自動的に表示/非表示をOFF外観モードをダークに変更Spotlight検索を表示 のショートカットのチェックを外すCmd + スペース に変更Cmd + shift + スペース に変更CapsLock を Ctrl に変更するiwano.nvim / amanoukihashi.nvim をローカルのリポジトリから読み込むため、下記のシンボリックリンクを作成する:
mkdir -p ~/.local/share/nvim/site/pack/mine/opt
ln -s ~/ghq/github.com/0ta2/iwano.nvim ~/.local/share/nvim/site/pack/mine/opt/iwano.nvim-local
ln -s ~/ghq/github.com/0ta2/amanoukihashi.nvim ~/.local/share/nvim/site/pack/mine/opt/amanoukihashi.nvim-localhooks/register.tsx 387 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Asked, FileChange, RepoSnapshot } from '../types'
5import { dirsInCommand, fitHunks, hunkRange, pickLines, snapshot, type Git } from './git.ts'
6
7const PANE = 'unmerged'
8const CODE_LIMIT = 10000
9const UNTRACKED_LIMIT = 1_000_000
10const CONTEXT_LIMIT = 32_000
11const GIT_ARGS = ['--no-optional-locks', '-c', 'core.quotePath=false', '-c', 'diff.relative=false']
12const repos = atom({ plugin: 'unmerged', key: 'repos' } as const, [])
13const snapshots = atom({ plugin: 'unmerged', key: 'snapshots' } as const, [])
14const selected = atom({ plugin: 'unmerged', key: 'selected' } as const, null)
15const isOpen = atom({ plugin: 'unmerged', key: 'isOpen' } as const, false)
16const asked = atom({ plugin: 'unmerged', key: 'asked' } as const, null)
17const expanded = atom({ plugin: 'unmerged', key: 'expanded' } as const, {})
18
19const gitOf = ($: EngineInterface, okCodes = [0]): Git => async (dir, args) => {
20 const r = await $.process.run(['git', '-C', dir, ...GIT_ARGS, ...args]).catch(() => undefined)
21 return r && okCodes.includes(r.exitCode ?? -1) && !r.isStdoutTruncated ? r.stdout : undefined
22}
23
24async function rootsOf(git: Git, dirs: string[]): Promise<string[]> {
25 const roots = await Promise.all(dirs.map(async d => (await git(d, ['rev-parse', '--show-toplevel']))?.trim()))
26 return roots.filter((r): r is string => !!r)
27}
28
29async function track($: EngineInterface, dirs: string[]) {
30 const roots = await rootsOf(gitOf($), dirs)
31 if (roots.length) await update($, repos, list => [...new Set([...(list ?? []), ...roots])])
32}
33
34export function parseRepos(text: string): { path: string; task?: string }[] {
35 try {
36 const { repos, task } = JSON.parse(text) as { repos?: unknown; task?: unknown }
37 return Array.isArray(repos)
38 ? repos.filter((path): path is string => typeof path === 'string').map(path => ({ path, ...(typeof task === 'string' && { task }) }))
39 : []
40 } catch {
41 return []
42 }
43}
44
45async function delegatedRepos($: EngineInterface): Promise<{ path: string; task?: string }[]> {
46 const [home, workspace] = await Promise.all([$.env.get('HOME'), $.env.get('HERDR_WORKSPACE_ID')])
47 if (!home || !workspace) return []
48 const dir = `${home}/.local/state/herdr-team/${workspace}`
49 const entries = await $.fs.list(dir).catch(() => [])
50 const lists = await Promise.all(
51 entries
52 .filter(f => f.kind === 'file' && f.name.endsWith('.json'))
53 .map(async f => parseRepos(await $.fs.read(`${dir}/${f.name}`).catch(() => ''))),
54 )
55 return lists.flat()
56}
57
58async function diffOf($: EngineInterface, snap: RepoSnapshot, file: FileChange): Promise<string> {
59 const opts = ['--no-textconv', '--no-ext-diff']
60 let diff: string | undefined
61 if (file.isUntracked) {
62 const stat = await $.fs.stat(`${snap.root}/${file.path}`).catch(() => undefined)
63 if (!stat || (!stat.isLink && stat.size > UNTRACKED_LIMIT)) return ''
64 diff = await gitOf($, [0, 1])(snap.root, ['diff', '--no-index', ...opts, '--', '/dev/null', file.path])
65 } else {
66 diff = await gitOf($)(snap.root, ['diff', '--no-renames', ...opts, snap.mergeBase, '--', file.path])
67 }
68 return !diff || diff.includes('\0') ? '' : diff
69}
70
71let selection = 0
72let delegatedTasks = new Map<string, string | undefined>()
73
74async function select($: EngineInterface, snap: RepoSnapshot, file: FileChange) {
75 const mine = ++selection
76 const at = { root: snap.root, path: file.path, isUntracked: file.isUntracked }
77 const isSameFile = (now: typeof at | null | undefined) => !!now && now.root === at.root && now.path === at.path && now.isUntracked === at.isUntracked
78 await update($, selected, now => (isSameFile(now) ? now : { ...at, diff: null }))
79 const diff = await diffOf($, snap, file)
80 if (mine === selection) await update($, selected, now => (isSameFile(now) && now!.diff === diff ? now : { ...at, diff }))
81}
82
83async function unselect($: EngineInterface) {
84 selection++
85 await update($, selected, () => null)
86}
87
88async function refresh($: EngineInterface) {
89 const git = gitOf($)
90 const delegated = await Promise.all(
91 (await delegatedRepos($)).map(async ({ path, task }) => {
92 const root = (await git(path, ['rev-parse', '--show-toplevel']))?.trim()
93 return root ? { root, task } : undefined
94 }),
95 )
96 const delegatedBefore = JSON.stringify([...delegatedTasks])
97 delegatedTasks = new Map(delegated.filter((repo): repo is { root: string; task: string | undefined } => !!repo).map(({ root, task }) => [root, task]))
98 const list = [...new Set([...((await read($, repos)) ?? []), ...delegatedTasks.keys()])]
99 const snaps = (await Promise.all(list.map(root => snapshot(git, root)))).filter(
100 (s): s is RepoSnapshot => !!s,
101 )
102 const isDelegationSame = delegatedBefore === JSON.stringify([...delegatedTasks])
103 await update($, snapshots, now => (isDelegationSame && JSON.stringify(now) === JSON.stringify(snaps) ? now : snaps))
104 const sel = await read($, selected)
105 if (!sel) return
106 const snap = snaps.find(s => s.root === sel.root)
107 const file = snap?.files.find(f => f.path === sel.path && f.isUntracked === sel.isUntracked)
108 if (snap && file) await select($, snap, file)
109 else await unselect($)
110}
111
112const POLL_MS = 5000
113let poll: { cancel: () => void } | undefined
114
115function startPolling($: EngineInterface) {
116 poll?.cancel()
117 let isBusy = false
118 poll = $.clock.every(POLL_MS, () => {
119 if (isBusy) return
120 isBusy = true
121 void refresh($).finally(() => {
122 isBusy = false
123 })
124 })
125}
126
127const CUT_NOTE = '\n(The rest of this diff was cut: it did not fit in the prompt.)'
128let carrying = false
129const isSame = (a: Asked | null, b: Asked | null) => !!a && !!b && a.root === b.root && a.path === b.path
130
131async function askText($: EngineInterface, at: Asked, room: number): Promise<{ name: string; text?: string }> {
132 const name = `${basename(at.root)}/${at.path}`
133 const snap = await snapshot(gitOf($), at.root)
134 const file = snap?.files.findLast(f => f.path === at.path)
135 if (!snap || !file) return { name }
136 const what = at.range ? `${at.range.isOld ? 'removed ' : ''}lines ${spanOf(at.range)} of the diff` : 'the diff'
137 const head = `The user attached ${what} of ${at.path} in ${at.root} (since its merge base with ${snap.base}) from the unmerged pane to this prompt:\n`
138 const diff = at.range?.source ?? (await diffOf($, snap, file))
139 const whole = fitHunks(diff, room - head.length)
140 const fit = whole?.isCut ? fitHunks(diff, room - head.length - CUT_NOTE.length) : whole
141 return { name, text: fit?.source ? head + fit.source + (fit.isCut ? CUT_NOTE : '') : undefined }
142}
143
144const askedName = (at: Asked) =>
145 `${basename(at.root)}/${at.path}${at.range ? `(${at.range.isOld ? '削除した ' : ''}${spanOf(at.range)} 行)` : ''}`
146const spanOf = (r: { from: number; to: number }) => (r.from === r.to ? `${r.from}` : `${r.from}–${r.to}`)
147const STATUS_COLORS: Record<string, string> = { A: 'success', M: 'warning', T: 'warning', D: 'error', U: 'error' }
148type Count = { text: string; color?: string }
149const counts = (f: FileChange): Count[] =>
150 f.isUntracked
151 ? []
152 : f.added === null
153 ? [{ text: 'bin' }]
154 : [
155 ...(f.added ? [{ text: `+${f.added}`, color: 'success' }] : []),
156 ...(f.removed ? [{ text: `-${f.removed}`, color: 'error' }] : []),
157 ]
158const countsWidth = (c: Count[]) => c.reduce((w, { text }) => w + text.length, Math.max(c.length - 1, 0))
159
160const dirname = (p: string) => p.slice(0, p.lastIndexOf('/')) || '/'
161const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1)
162
163const quote = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`)
164
165async function openInEditor($: EngineInterface, editor: string, root: string, path: string) {
166 const herdr = async (...args: string[]) => {
167 const r = await $.process.run(['herdr', ...args], { timeoutMs: 10_000 }).catch(() => undefined)
168 if (r?.exitCode !== 0 || r.isStdoutTruncated) throw new Error('herdr failed')
169 return r.stdout
170 }
171 let tab: string | undefined
172 try {
173 const pane = await $.env.get('HERDR_PANE_ID')
174 const moved = pane ? await herdr('pane', 'get', pane).then(out => JSON.parse(out).result?.pane?.workspace_id, () => undefined) : undefined
175 const workspace = (typeof moved === 'string' && moved) || (await $.env.get('HERDR_WORKSPACE_ID'))
176 if (!workspace) throw new Error('no workspace')
177 const tabs = JSON.parse(await herdr('tab', 'list', '--workspace', workspace)).result?.tabs
178 const number = (label: unknown) => (typeof label === 'string' ? Number(/^E(\d+)$/.exec(label)?.[1] ?? 0) : 0)
179 const label = `E${(Array.isArray(tabs) ? Math.max(0, ...tabs.map(tab => number(tab?.label))) : 0) + 1}`
180 const { result } = JSON.parse(await herdr('tab', 'create', '--workspace', workspace, '--cwd', root, '--label', label, '--no-focus'))
181 tab = result.tab.tab_id
182 await herdr('pane', 'run', result.root_pane.pane_id, `${editor} ${quote(path.startsWith('-') ? `./${path}` : path)}`)
183 await herdr('tab', 'focus', tab!)
184 } catch {
185 if (tab) await herdr('tab', 'close', tab).catch(() => undefined)
186 await $.ui.status('開けませんでした')
187 }
188}
189
190export const register: Register = (on, options) => {
191 const editor = (options as { editor?: string } | undefined)?.editor || 'nvim'
192 on('session.start', async ($, e, next) => {
193 await $.command.register({ name: 'unmerged', description: 'このセッションで触ったリポジトリのマージ前の差分を表示する' })
194 await track($, [await $.session.cwd()])
195 if (await read($, isOpen)) startPolling($)
196 return next(e)
197 })
198
199 on('command.run', { command: 'unmerged' }, async $ => {
200 await refresh($)
201 await update($, isOpen, () => true)
202 startPolling($)
203 await $.ui.open({ id: PANE, title: 'Unmerged' })
204 return { text: 'Unmerged pane opened.' }
205 })
206
207 on('ui.close', { id: PANE }, async ($, e, next) => {
208 await update($, isOpen, () => false)
209 poll?.cancel()
210 poll = undefined
211 return next(e)
212 })
213
214 on('tool.call', async ($, e, next) => {
215 const ran = await next(e)
216 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
217 await track($, [dirname(e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path)])
218 } else if (e.tool === 'Bash') {
219 await track($, dirsInCommand(e.command, await $.session.cwd(), (await $.env.get('HOME')) ?? ''))
220 } else {
221 return ran
222 }
223 if (await read($, isOpen)) await refresh($)
224 return ran
225 })
226
227 on('prompt.submit', async ($, e, next) => {
228 const at = await read($, asked)
229 if (!at || carrying || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
230 carrying = true
231 try {
232 const context = e.context ?? []
233 const ask = await askText($, at, CONTEXT_LIMIT - context.reduce((n, c) => n + c.length, 0))
234 const isSent = ask.text !== undefined
235 const result = isSent ? await next({ ...e, context: [...context, ask.text!] }) : undefined
236 if (!isSent || result?.drop === undefined) {
237 await update($, asked, now => (isSame(now, at) ? null : now))
238 await $.ui.status(isSent ? undefined : `${ask.name} の差分を添付できませんでした`)
239 }
240 return result ?? (await next(e))
241 } finally {
242 carrying = false
243 }
244 })
245
246 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
247 const at = await read($, asked)
248 if (!at || e.props.hasSurvey) return next(e)
249 const { Box, Text, Button } = $.ui.resolve(e)
250 return (
251 <Box flexDirection="row" gap={1}>
252 <Text>{`📎 ${askedName(at)}${at.range ? '' : ' '}を次の送信に添付`}</Text>
253 <Button key="unask" label="外す" onPress={() => update($, asked, () => null)} />
254 </Box>
255 )
256 })
257
258 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
259 const { Box, Text, Button, Code } = $.ui.resolve(e)
260 const snaps = (await read($, snapshots)) ?? []
261 const sel = await read($, selected)
262 const at = await read($, asked)
263 const opened = (await read($, expanded)) ?? {}
264 const isAsked = isSame(at, sel)
265 const canOpen = (await $.env.get('HERDR_ENV')) === '1'
266 const fit = sel?.diff ? fitHunks(sel.diff, CODE_LIMIT) : undefined
267 const isExpanded = (root: string) => opened[root] ?? (snaps.length < 2 || sel?.root === root)
268 const selectedDiff = sel && (
269 <Box flexDirection="column">
270 <Box flexDirection="row" gap={1}>
271 <Button
272 key="ask"
273 label={isAsked ? '添付を外す' : '添付'}
274 onPress={async () => {
275 await $.ui.status(undefined)
276 if (isAsked) return update($, asked, () => null)
277 const picked = await $.ui.selection()
278 const range = sel.diff && picked && !picked.requestId ? pickLines(sel.diff, picked.text) : undefined
279 await update($, asked, () => ({ root: sel.root, path: sel.path, ...(range && { range }) }))
280 }}
281 />
282 <Text bold>
283 {basename(sel.root)}/{sel.path}
284 </Text>
285 </Box>
286 {sel.diff === null ? (
287 <Text dimColor>読み込み中…</Text>
288 ) : fit?.source ? (
289 <Box flexDirection="column">
290 {fit.source.split(/(?=^@@ )/m).map((hunk, index) => {
291 const range = hunkRange(hunk)
292 const isAskedHunk = !!range && isSame(at, sel) && at?.range?.source === range.source
293 return (
294 <Box key={`hunk-box:${index}`} flexDirection="column">
295 <Button
296 key={`hunk:${index}`}
297 label={isAskedHunk ? '添付を外す' : '添付'}
298 onPress={async () => {
299 await $.ui.status(undefined)
300 if (!range) return
301 await update($, asked, now =>
302 isSame(now, sel) && now?.range?.source === range.source
303 ? null
304 : { root: sel.root, path: sel.path, range },
305 )
306 }}
307 />
308 <Code source={hunk} format="diff" path={sel.path} />
309 </Box>
310 )
311 })}
312 {fit.isCut && <Text dimColor>(長いので以降のハンクを省略しました)</Text>}
313 </Box>
314 ) : fit ? (
315 <Text dimColor>差分が大きすぎて表示できません</Text>
316 ) : (
317 <Text dimColor>表示できる差分がありません (バイナリ・大きすぎる差分など)</Text>
318 )}
319 </Box>
320 )
321
322 return (
323 <Box flexDirection="column">
324 {snaps.length === 0 && <Text dimColor>マージ前の変更はありません</Text>}
325 {snaps.map(snap => {
326 const isOpenRepo = isExpanded(snap.root)
327 const width = Math.max(...snap.files.map(f => countsWidth(counts(f))))
328 const marks = isOpenRepo ? '' : `${at?.root === snap.root ? ' 📎' : ''}${sel?.root === snap.root ? ' (表示中)' : ''}`
329 const task = delegatedTasks.get(snap.root)
330 const delegatedMark = delegatedTasks.has(snap.root) ? ` · 委譲${task ? `: ${task}` : ''}` : ''
331 return (
332 <Box key={`group:${snap.root}`} flexDirection="column" marginBottom={isOpenRepo ? 1 : 0}>
333 <Button
334 key={`repo:${snap.root}`}
335 plain
336 label={`${isOpenRepo ? '▾' : '▸'} ${basename(snap.root)} (${snap.files.length})${marks}${delegatedMark}`}
337 onPress={() => update($, expanded, now => ({ ...now, [snap.root]: !isOpenRepo }))}
338 />
339 {isOpenRepo && (
340 <Text dimColor>
341 {snap.branch} · {snap.ahead} commits ahead of {snap.base}
342 </Text>
343 )}
344 {isOpenRepo &&
345 snap.files.map(f => {
346 const id = `${snap.root}:${f.isUntracked ? 'untracked' : 'tracked'}:${f.path}`
347 return (
348 <Box key={`row:${id}`} flexDirection="column">
349 <Box flexDirection="row" gap={1}>
350 <Text color={STATUS_COLORS[f.status]} dimColor={f.isUntracked}>
351 {f.status}
352 </Text>
353 {width > 0 && (
354 <Box width={width} flexShrink={0} flexDirection="row" gap={1}>
355 {counts(f).map(c => (
356 <Text key={c.text} color={c.color} dimColor={!c.color}>
357 {c.text}
358 </Text>
359 ))}
360 </Box>
361 )}
362 <Button
363 key={`file:${id}`}
364 plain
365 label={`${at?.root === snap.root && at.path === f.path ? '📎 ' : ''}${f.path}`}
366 onPress={() =>
367 sel?.root === snap.root && sel.path === f.path && sel.isUntracked === f.isUntracked
368 ? unselect($)
369 : select($, snap, f)
370 }
371 />
372 {canOpen && f.status !== 'D' && (
373 <Button key={`open:${id}`} plain label="[開く]" onPress={() => openInEditor($, editor, snap.root, f.path)} />
374 )}
375 </Box>
376 {sel?.root === snap.root && sel.path === f.path && sel.isUntracked === f.isUntracked && selectedDiff}
377 </Box>
378 )
379 })}
380 </Box>
381 )
382 })}
383 </Box>
384 )
385 })
386}
387hooks/git.ts 166 lines1import type { FileChange, RepoSnapshot } from '../types'
2
3export type Git = (dir: string, args: string[]) => Promise<string | undefined>
4
5const PATH_ARG = `("[^"]+"|'[^']+'|[^\\s;&|)]+)`
6
7export function dirsInCommand(cmd: string, cwd: string, home: string): string[] {
8 const bases = [cwd]
9 const dirs: string[] = []
10 const resolve = (arg: string) => {
11 const p = arg.replace(/^(["'])(.*)\1$/, '$2').replace(/^~(?=\/|$)/, home)
12 return p.startsWith('/') ? [p] : bases.map(b => `${b}/${p}`)
13 }
14 for (const segment of cmd.split(/&&|\|\||[;|\n]/)) {
15 const cd = new RegExp(`^\\s*\\(*\\s*cd\\s+${PATH_ARG}`).exec(segment)?.[1]
16 if (cd) bases.push(...resolve(cd))
17 for (const m of segment.matchAll(new RegExp(`(?:^|\\s)(?:-C\\s+|--cwd[\\s=]+)${PATH_ARG}`, 'g'))) dirs.push(...resolve(m[1]!))
18 }
19 return [...new Set([...bases.slice(1), ...dirs])]
20}
21
22export function parseNumstat(out: string, statuses = new Map<string, string>()): FileChange[] {
23 return out
24 .split('\0')
25 .filter(Boolean)
26 .map(record => {
27 const [added, removed, ...rest] = record.split('\t')
28 const path = rest.join('\t')
29 return {
30 path,
31 status: statuses.get(path) ?? 'M',
32 added: added === '-' ? null : Number(added),
33 removed: removed === '-' ? null : Number(removed),
34 isUntracked: false,
35 }
36 })
37}
38
39export function parseNameStatus(out: string): Map<string, string> {
40 const fields = out.split('\0')
41 const statuses = new Map<string, string>()
42 for (let i = 0; i + 1 < fields.length; i += 2) {
43 if (fields[i]) statuses.set(fields[i + 1]!, fields[i]![0]!)
44 }
45 return statuses
46}
47
48export function fitHunks(diff: string, limit: number): { source: string; isCut: boolean } | undefined {
49 const at = diff.search(/^@@ /m)
50 if (at === -1) return undefined
51 let source = ''
52 const hunks = diff.slice(at).split(/(?=^@@ )/m)
53 for (const hunk of hunks) {
54 if (source.length + hunk.length > limit) break
55 source += hunk
56 }
57 return { source, isCut: source.length < diff.length - at }
58}
59
60async function baseRef(git: Git, root: string): Promise<string | undefined> {
61 const origin = (await git(root, ['rev-parse', '--abbrev-ref', 'origin/HEAD']))?.trim()
62 if (origin) return origin
63 for (const name of ['main', 'master']) {
64 if (await git(root, ['rev-parse', '--verify', '--quiet', name])) return name
65 }
66 return undefined
67}
68
69export async function snapshot(git: Git, root: string): Promise<RepoSnapshot | undefined> {
70 const base = await baseRef(git, root)
71 const mergeBase = base && (await git(root, ['merge-base', 'HEAD', base]))?.trim()
72 if (!base || !mergeBase) return undefined
73 const statuses = parseNameStatus((await git(root, ['diff', '--name-status', '-z', '--no-renames', mergeBase])) ?? '')
74 const tracked = parseNumstat((await git(root, ['diff', '--numstat', '-z', '--no-renames', mergeBase])) ?? '', statuses)
75 const untracked = ((await git(root, ['ls-files', '-z', '--others', '--exclude-standard'])) ?? '')
76 .split('\0')
77 .filter(Boolean)
78 .map(path => ({ path, status: '?', added: null, removed: null, isUntracked: true }))
79 const files = [...tracked, ...untracked]
80 if (files.length === 0) return undefined
81 return {
82 root,
83 base,
84 mergeBase,
85 files,
86 branch: (await git(root, ['branch', '--show-current']))?.trim() || '(detached)',
87 ahead: Number((await git(root, ['rev-list', '--count', `${mergeBase}..HEAD`]))?.trim() ?? 0),
88 }
89}
90
91type DiffLine = { hunk: number; kind: string; text: string; old: number; new: number }
92
93function diffLines(diff: string): DiffLine[] {
94 const at = diff.search(/^@@ /m)
95 if (at === -1) return []
96 const lines: DiffLine[] = []
97 diff
98 .slice(at)
99 .split(/(?=^@@ )/m)
100 .forEach((hunk, i) => {
101 const [header = '', ...body] = hunk.split('\n')
102 const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(header)
103 if (!m) return
104 let old = Number(m[1])
105 let now = Number(m[2])
106 for (const line of body) {
107 const kind = line[0]
108 if (kind !== ' ' && kind !== '+' && kind !== '-') continue
109 lines.push({ hunk: i, kind, text: line.slice(1), old, new: now })
110 if (kind !== '+') old++
111 if (kind !== '-') now++
112 }
113 })
114 return lines
115}
116
117const GUTTER = /^\s*(\d+\s+){0,2}[+-]?\s?/
118
119function isSameLine(picked: string, line: string): boolean {
120 const p = picked.trim()
121 const l = line.trim()
122 if (!l) return !/[^\d\s+-]/.test(p)
123 if (!p) return false
124 const bare = p.replace(GUTTER, '').trim()
125 return p.includes(l) || (bare.length > 0 && l.includes(bare))
126}
127
128export type PickedLines = { source: string; from: number; to: number; isOld: boolean }
129
130export function hunkRange(hunk: string): PickedLines | undefined {
131 const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(hunk)
132 if (!m) return undefined
133 const old = Number(m[1])
134 const olds = m[2] === undefined ? 1 : Number(m[2])
135 const now = Number(m[3])
136 const news = m[4] === undefined ? 1 : Number(m[4])
137 const source = hunk.endsWith('\n') ? hunk : `${hunk}\n`
138 return news > 0 ? { source, from: now, to: now + news - 1, isOld: false } : { source, from: old, to: old + olds - 1, isOld: true }
139}
140
141export function pickLines(diff: string, picked: string): PickedLines | undefined {
142 const lines = diffLines(diff)
143 const want = picked
144 .split('\n')
145 .filter(l => !l.startsWith('@@'))
146 .join('\n')
147 .replace(/^\s*\n|\n\s*$/g, '')
148 .split('\n')
149 if (!want.join('').trim()) return undefined
150 const start = lines.findIndex((_, i) => want.every((w, j) => lines[i + j] && isSameLine(w, lines[i + j]!.text)))
151 if (start === -1) return undefined
152 const chosen = lines.slice(start, start + want.length)
153 let source = ''
154 for (const hunk of new Set(chosen.map(l => l.hunk))) {
155 const part = chosen.filter(l => l.hunk === hunk)
156 const olds = part.filter(l => l.kind !== '+').length
157 const news = part.filter(l => l.kind !== '-').length
158 const old = olds ? part[0]!.old : part[0]!.old - 1
159 const now = news ? part[0]!.new : part[0]!.new - 1
160 source += `@@ -${old},${olds} +${now},${news} @@\n${part.map(l => l.kind + l.text + '\n').join('')}`
161 }
162 const added = chosen.filter(l => l.kind !== '-')
163 const side = added.length ? added.map(l => l.new) : chosen.map(l => l.old)
164 return { source, from: side[0]!, to: side[side.length - 1]!, isOld: added.length === 0 }
165}
166types/index.d.ts 14 lines1export type FileChange = { path: string; status: string; added: number | null; removed: number | null; isUntracked: boolean }
2
3export type RepoSnapshot = { root: string; branch: string; base: string; ahead: number; mergeBase: string; files: FileChange[] }
4
5export type Asked = { root: string; path: string; range?: { source: string; from: number; to: number; isOld: boolean } }
6
7export type Selection = { root: string; path: string; isUntracked: boolean; diff: string | null }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'unmerged': { repos: string[]; snapshots: RepoSnapshot[]; selected: Selection | null; isOpen: boolean; asked: Asked | null; expanded: Record<string, boolean> }
12 }
13}
14