Claude가 만들거나 고친 이미지·문서 파일을 패널에서 바로 미리보기

Claude가 만들거나 고친 이미지·문서 파일을 "파일 미리보기" 패널에서 바로 보여주는 mod입니다.
Write·Edit로 쓴 파일과, Bash 명령에 나온 경로 중 실제로 있는 파일을 목록에 올리고 패널을 엽니다.기본 앱으로 열기 버튼 제공목록에서 빼기로 정리/preview <경로>: 원하는 파일을 직접 미리보기/preview on · /preview off: 새 파일이 생길 때 패널 자동 열기 켜기/끄기(기본 켬, 세션이 바뀌어도 유지)hooks/register.tsx 201 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PreviewFile } from '../types'
5import { MIME, baseName, headerBytes, extOf, formatSize, kindOf, pathsInCommand, pngSize, rasterSvg, tableMarkdown } from './files'
6
7const PANE = 'file-preview'
8const TITLE = '파일 미리보기'
9const STORE_AUTO = 'isAuto'
10const SVG_LIMIT = 131072
11const TEXT_LIMIT = 20000
12const TERMINAL_IMAGE_LIMIT = 2 * 1024 * 1024
13
14const files = atom({ plugin: 'file-preview', key: 'files' } as const, [] as PreviewFile[])
15const selected = atom({ plugin: 'file-preview', key: 'selected' } as const, null as string | null)
16const isAuto = atom({ plugin: 'file-preview', key: 'isAuto' } as const, true)
17
18async function track($: EngineInterface, path: string) {
19 const kind = kindOf(path)
20 if (kind === undefined) return
21 const stat = await $.fs.stat(path).catch(() => undefined)
22 if (stat?.kind !== 'file') return
23 const at = await $.clock.now()
24 await update($, files, list => [{ path, kind, at }, ...list.filter(f => f.path !== path)].slice(0, 30))
25 await update($, selected, () => path)
26 if (await read($, isAuto)) await $.ui.open({ id: PANE, title: TITLE })
27}
28
29async function openExternally($: EngineInterface, path: string) {
30 const mac = await $.process.run(['open', path]).catch(() => undefined)
31 if (mac?.exitCode === 0) return
32 const linux = await $.process.run(['xdg-open', path]).catch(() => undefined)
33 if (linux?.exitCode !== 0) $.ui.toast(`열 수 없습니다: ${baseName(path)}`)
34}
35
36async function runPreview($: EngineInterface, args: string): Promise<string> {
37 const arg = args.trim()
38 if (arg === 'on' || arg === 'off') {
39 const value = arg === 'on'
40 await $.store.set(STORE_AUTO, value)
41 await update($, isAuto, () => value)
42
43 return `새 파일 자동 미리보기를 ${value ? '켰' : '껐'}습니다.`
44 }
45 if (arg !== '') {
46 const path = arg.replace(/^['"]|['"]$/g, '')
47 if (kindOf(path) === undefined) return `미리보기를 지원하지 않는 형식입니다: ${baseName(path)}`
48 if (!(await $.fs.exists(path))) return `파일이 없습니다: ${path}`
49 await track($, path)
50 }
51 const { isPlaced } = await $.ui.open({ id: PANE, title: TITLE })
52
53 return isPlaced
54 ? '파일 미리보기 패널을 열었습니다.'
55 : '파일 미리보기 패널을 열려고 했지만, 지금 화면(앱)이 mod 패널을 표시하지 않습니다.'
56}
57
58export const register: Register = on => {
59 on('session.start', async ($, e, next) => {
60 await $.command.register({
61 name: 'preview',
62 description: '파일 미리보기: /preview <경로>로 열기, /preview on|off로 자동 열기 켜기/끄기',
63 })
64 const stored = await $.store.get(STORE_AUTO)
65 await update($, isAuto, () => stored !== false)
66
67 return next(e)
68 })
69
70 on('command.run', { command: 'preview' }, async ($, e) => ({ text: await runPreview($, e.args) }))
71
72 // 슬래시 명령이 command.run으로 오지 않고 프롬프트로 들어올 때의 대체 처리: "/preview <경로>"
73 on('prompt.submit', async ($, e, next) => {
74 const m = /^\s*\/preview(?:\s+([\s\S]*))?$/.exec(e.text)
75 if (m === null) return next(e)
76 return { drop: await runPreview($, m[1] ?? '') }
77 }).catch(($, e, next) => next(e))
78
79 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
80 const ran = await next(e)
81 if (ran.deny === undefined && ran.isError !== true) await track($, e.file_path)
82 return ran
83 }).catch(($, e, next) => next(e))
84
85 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
86 const ran = await next(e)
87 if (ran.deny === undefined && ran.isError !== true) await track($, e.file_path)
88 return ran
89 }).catch(($, e, next) => next(e))
90
91 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
92 const ran = await next(e)
93 if (ran.deny === undefined && ran.isError !== true) {
94 for (const path of pathsInCommand(e.command)) await track($, path)
95 }
96 return ran
97 }).catch(($, e, next) => next(e))
98
99 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
100 const table = $.ui.resolve(e)
101 const { Box, Text, Button, Markdown, Code, Link } = table
102 const Svg = 'Svg' in table ? table.Svg : undefined
103 const list = await read($, files)
104 const chosen = (await read($, selected)) ?? list[0]?.path ?? null
105 const file = list.find(f => f.path === chosen)
106 const columns = e.props.bodyColumns
107
108 if (file === undefined) {
109 return (
110 <Box flexDirection="column">
111 <Text dimColor>아직 미리볼 파일이 없습니다.</Text>
112 <Text dimColor>Claude가 이미지·문서를 만들면 여기에 나타납니다. /preview 경로 로 직접 열 수도 있습니다.</Text>
113 </Box>
114 )
115 }
116
117 const stat = await $.fs.stat(file.path).catch(() => undefined)
118 const ext = extOf(file.path)
119 let body: JSX.Element
120
121 if (stat === undefined) {
122 body = <Text color="red">파일이 없어졌습니다.</Text>
123 } else if (file.kind === 'image') {
124 const tooBig = stat.size > 4 * 1024 * 1024
125 const bytes = tooBig ? undefined : await $.fs.read(file.path, { as: 'bytes' }).catch(() => undefined)
126 if (bytes === undefined) {
127 body = <Text dimColor>이미지가 너무 커서 미리보기를 생략했습니다. 아래 버튼으로 여세요.</Text>
128 } else if (e.surface === 'terminal') {
129 const { Image } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
130 body =
131 ext === 'png' && stat.size <= TERMINAL_IMAGE_LIMIT ? (
132 <Image source={{ png: bytes.base64 }} columns={Math.min(columns, 120)} rows={24} alt={baseName(file.path)} />
133 ) : (
134 <Text dimColor>터미널에서는 PNG만 그립니다. 아래 버튼으로 여세요.</Text>
135 )
136 } else {
137 const size = ext === 'png' ? pngSize(headerBytes(bytes.base64)) : undefined
138 const svg = rasterSvg(bytes.base64, MIME[ext] ?? 'image/png', size)
139 body =
140 Svg !== undefined && svg.length <= SVG_LIMIT ? (
141 <Svg source={svg} alt={baseName(file.path)} />
142 ) : (
143 <Text dimColor>이미지가 커서({formatSize(stat.size)}) 패널에 그리지 못했습니다. 아래 버튼으로 여세요.</Text>
144 )
145 }
146 } else if (file.kind === 'svg') {
147 const text = await $.fs.read(file.path).catch(() => '')
148 body =
149 Svg !== undefined && text.length <= SVG_LIMIT ? (
150 <Svg source={text} alt={baseName(file.path)} />
151 ) : (
152 <Code source={text.slice(0, TEXT_LIMIT)} language="xml" />
153 )
154 } else if (file.kind === 'markdown') {
155 const text = await $.fs.read(file.path).catch(() => '')
156 body = <Markdown text={text.slice(0, TEXT_LIMIT)} />
157 } else if (file.kind === 'table') {
158 const text = await $.fs.read(file.path).catch(() => '')
159 body = <Markdown text={tableMarkdown(text.slice(0, TEXT_LIMIT), ext === 'tsv' ? '\t' : ',')} />
160 } else if (file.kind === 'code' || file.kind === 'html') {
161 const text = await $.fs.read(file.path).catch(() => '')
162 body = <Code source={text.slice(0, TEXT_LIMIT)} language={ext === 'htm' ? 'html' : ext} path={file.path} />
163 } else {
164 body = <Text dimColor>{ext.toUpperCase()} 문서는 패널에서 그리지 않습니다. 아래 버튼으로 기본 앱에서 여세요.</Text>
165 }
166
167 return (
168 <Box flexDirection="column">
169 <Box flexDirection="row" flexWrap="wrap">
170 {list.slice(0, 8).map((f, i) => (
171 <Button
172 key={`f${i}`}
173 label={baseName(f.path)}
174 variant={f.path === file.path ? 'primary' : 'secondary'}
175 onPress={() => update($, selected, () => f.path)}
176 />
177 ))}
178 </Box>
179 <Text bold>{baseName(file.path)}</Text>
180 <Text dimColor>
181 {file.path}
182 {stat !== undefined ? ` · ${formatSize(stat.size)}` : ''}
183 </Text>
184 {body}
185 <Box flexDirection="row">
186 <Button key="open" label="기본 앱으로 열기" onPress={() => openExternally($, file.path)} />
187 {file.kind === 'html' && <Link href={`file://${file.path}`} label="브라우저로 열기" />}
188 <Button
189 key="remove"
190 label="목록에서 빼기"
191 onPress={async () => {
192 await update($, files, l => l.filter(f => f.path !== file.path))
193 await update($, selected, () => null)
194 }}
195 />
196 </Box>
197 </Box>
198 )
199 })
200}
201hooks/files.ts 73 lines1import type { PreviewKind } from '../types'
2
3const KINDS: Record<string, PreviewKind> = {
4 png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image',
5 svg: 'svg',
6 md: 'markdown', markdown: 'markdown',
7 csv: 'table', tsv: 'table',
8 json: 'code', yaml: 'code', yml: 'code', txt: 'code', log: 'code', xml: 'code',
9 swift: 'code', ts: 'code', tsx: 'code', js: 'code', py: 'code', kt: 'code', sh: 'code',
10 html: 'html', htm: 'html',
11 pdf: 'document', docx: 'document', pptx: 'document', xlsx: 'document',
12 key: 'document', pages: 'document', numbers: 'document',
13}
14
15export const MIME: Record<string, string> = {
16 png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
17}
18
19export const extOf = (path: string): string => {
20 const name = baseName(path)
21 const dot = name.lastIndexOf('.')
22 return dot < 1 ? '' : name.slice(dot + 1).toLowerCase()
23}
24
25export const baseName = (path: string): string => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
26
27export const kindOf = (path: string): PreviewKind | undefined => KINDS[extOf(path)]
28
29/** Bash 명령에 등장하는, 미리보기 가능한 확장자의 경로 후보들. */
30export function pathsInCommand(command: string): string[] {
31 const found = new Set<string>()
32 for (const m of command.matchAll(/"([^"]+)"|'([^']+)'|([^\s;|&<>()'"]+)/g)) {
33 const token = (m[1] ?? m[2] ?? m[3] ?? '').replace(/^-[a-zA-Z-]+=/, '')
34 if (token.length > 0 && !token.startsWith('-') && !token.includes('*') && kindOf(token) !== undefined) found.add(token)
35 }
36 return [...found]
37}
38
39/** PNG 헤더에서 가로·세로 픽셀. PNG가 아니면 undefined. */
40export function pngSize(bytes: Uint8Array): { width: number; height: number } | undefined {
41 const sig = [0x89, 0x50, 0x4e, 0x47]
42 if (bytes.length < 24 || sig.some((b, i) => bytes[i] !== b)) return undefined
43 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
44 return { width: view.getUint32(16), height: view.getUint32(20) }
45}
46
47/** 래스터 이미지를 Svg 요소가 그릴 수 있는 SVG 문서로 감싼다. */
48export function rasterSvg(base64: string, mime: string, size?: { width: number; height: number }): string {
49 const w = size?.width ?? 800
50 const h = size?.height ?? 600
51 return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}"><image width="${w}" height="${h}" href="data:${mime};base64,${base64}" xlink:href="data:${mime};base64,${base64}"/></svg>`
52}
53
54/** CSV/TSV 앞부분을 마크다운 표로. */
55export function tableMarkdown(text: string, separator: string, maxRows = 20): string {
56 const rows = text.split(/\r?\n/).filter(r => r.length > 0).slice(0, maxRows + 1)
57 if (rows.length === 0) return '_빈 파일_'
58 const cells = rows.map(r => r.split(separator).map(c => c.trim().replace(/^"|"$/g, '').replace(/\|/g, '\\|')))
59 const width = Math.max(...cells.map(c => c.length))
60 const pad = (c: string[]) => `| ${[...c, ...Array(width - c.length).fill('')].join(' | ')} |`
61 const [head = [], ...body] = cells
62 return [pad(head), `| ${Array(width).fill('---').join(' | ')} |`, ...body.map(pad)].join('\n')
63}
64
65export const formatSize = (bytes: number): string =>
66 bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
67
68/** base64 앞부분만 풀어 헤더 바이트를 얻는다(이미지 크기 판별용). */
69export function headerBytes(base64: string, count = 32): Uint8Array {
70 const binary = atob(base64.slice(0, Math.ceil(count / 3) * 4))
71 return Uint8Array.from(binary, c => c.charCodeAt(0))
72}
73types/index.d.ts 10 lines1export type PreviewKind = 'image' | 'svg' | 'markdown' | 'table' | 'code' | 'html' | 'document'
2
3export type PreviewFile = { path: string; kind: PreviewKind; at: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'file-preview': { files: PreviewFile[]; selected: string | null; isAuto: boolean }
8 }
9}
10