Web Project MVP 1

SolidStart 앱과 공용 TypeScript 패키지를 함께 관리하는 pnpm·Turborepo 모노레포다.
pnpm install
설치가 끝나면 루트 postinstall이 turbo prepare-build를 실행해 워크스페이스 빌드 준비 작업을 수행한다.
대표 개발 서버는 필터를 지정해 실행한다.
pnpm --filter @apps/pomo dev
pnpm --filter @apps/coong dev
pnpm --filter @apps/pomo-audio-gateway dev
앱별 환경 설정과 추가 명령은 해당 디렉터리의 README 또는 package.json에서 확인한다.
pnpm typecheck # 전체 워크스페이스 타입 검사
pnpm lint # Oxlint 검사
pnpm format # Oxfmt 적용
pnpm format:check # 포맷 검사
pnpm test:vitest # unit 프로젝트 테스트
pnpm test # unit·빌드 통합·Storybook 테스트
pnpm storybook:dev # 공용 Storybook 개발 서버
pnpm generate # Turborepo generator 실행
Pull request에서는 GitHub Actions가 타입 검사, Oxlint, 포맷 검사와 테스트를 실행한다.
Pomo 개발 환경도 자동 배포되지 않는다. dev에 변경을 반영한 뒤 GitHub Actions에서 다음 순서로 수동 배포한다.
Deploy Pomo and Audio Gateway 선택
→ Run workflow
→ Branch: dev 선택
→ Run workflow 실행
이 workflow는 Pomo와 Pomo Audio Gateway를 함께 개발 환경에 배포한다.
dev는 통합·검증 브랜치이고 main은 유일한 운영 릴리스 소스다. main에 병합해도 자동 배포되지 않으며, GitHub Actions에서 main을 선택해 다음 workflow를 필요한 시점에 각각 실행한다.
Release packages: public npm 패키지 배포Release Pomo: Pomo 운영 배포Release Coong: Coong 운영 배포세 대상은 서로 독립적이므로 같은 main commit을 배포할 필요가 없다. 버전 규칙, prerelease, 필수 secret·variable과 실패 복구 절차는 릴리스 설명서에서 확인한다.
저장소 기여자는 Wallaby.js OSS License를 사용할 수 있다.
hooks/register.js 331 lines1/* eslint-disable id-length -- Claude Mods validates the literal $ API parameter. */
2import {canApproveOpen} from './can-approve-open.ts'
3import {readToolLocation} from './read-tool-location.ts'
4import {readFileLinks} from './read-file-links.ts'
5import {readBrowserTab} from './read-browser-tab.ts'
6import {isProjectFile} from './is-project-file.ts'
7import {readBrowserMessages} from './read-browser-messages.ts'
8
9const MAX_MARKDOWN_CHARS = 10000
10const MAX_FILE_LINKS = 256
11let desktop = false
12let workspace = ''
13let browser
14let operations = Promise.resolve()
15let text = readBrowserMessages(undefined)
16const messages = new Map()
17/** @type {import('./can-approve-open.ts').OpenPhase} */
18let phase = {kind: 'idle'}
19
20const guidance = [
21 'Code Viewer uses the original web editor in the Browser panel.',
22 'Do not open or navigate the viewer automatically when a session starts or when reading or editing files.',
23 'The Mod tracks successful main-conversation Read/Edit/Write calls and updates an already opened viewer.',
24 'Use clickable Markdown links for project file references, including a 1-based line when relevant.',
25 'The user can click a file link or the Code Viewer open button to open it on demand.',
26 'If explicitly asked to open the viewer, use code.browser with the requested path and line.',
27 'An open viewer updates in place. Do not reload it; preserve unsaved drafts and attachments.',
28 'The original Browser stages user-selected attachment snapshots in code.attachments.',
29 'User-selected Browser attachments are included with composer messages as retained snapshots.',
30 'Use code.attachments to inspect those snapshots again when needed.',
31 'It takes no session argument. Do not call another extension’s code.panel to read Browser attachments.',
32 'Read the attached snapshots, including unsaved changes; disk content is not a substitute.',
33].join(' ')
34
35async function refreshMessages($) {
36 let locale
37 try {
38 const home = await $.env.get('HOME')
39 if (home !== undefined) {
40 const config = JSON.parse(
41 await $.fs.read(`${home}/Library/Application Support/Claude/config.json`),
42 )
43 const {locale: configured} = config ?? {}
44 if (typeof configured === 'string' && configured.trim() !== '') {
45 locale = configured
46 }
47 }
48 } catch {
49 $.ui.log('Code Viewer language settings are unavailable; using English.', {to: 'debug'})
50 }
51 text = readBrowserMessages(locale)
52}
53
54function enqueue($, action) {
55 const next = operations.then(action)
56 operations = next.catch((error) => {
57 $.ui.log(`Code Viewer: ${error instanceof Error ? error.message : String(error)}`, {
58 to: 'debug',
59 })
60 })
61 return next
62}
63
64async function viewerLocation($, location) {
65 const directory = await $.session.cwd()
66 const connection = await $.mcp.connect('viewer')
67 if (!connection.isConnected) {
68 throw new Error(connection.message)
69 }
70 phase =
71 location === undefined
72 ? {kind: 'viewer', workspace: directory}
73 : {kind: 'follow', location: {...location, workspace: directory}}
74 try {
75 const response = await $.tool.call({
76 tool: 'mcp__plugin_code-viewer_viewer__code_browser',
77 ...location,
78 workspace: directory,
79 })
80 if (response.isError || response.deny !== undefined) {
81 throw new Error(response.deny ?? response.text ?? text.fileError)
82 }
83 const value =
84 typeof response.result === 'string' ? JSON.parse(response.result) : response.result
85 if (typeof value?.url !== 'string') {
86 throw new Error(text.urlError)
87 }
88 const address = new URL(value.url)
89 const token = new URLSearchParams(address.hash.slice(1)).get('token')
90 if (
91 address.protocol !== 'http:' ||
92 address.hostname !== '127.0.0.1' ||
93 !/^[a-f0-9]{64}$/u.test(token ?? '')
94 ) {
95 throw new Error(text.urlInvalid)
96 }
97 return value
98 } finally {
99 phase = {kind: 'idle'}
100 }
101}
102
103async function presentViewer($, value, isUserClick) {
104 const context = await $.tool.call({tool: 'mcp__Claude_Browser__tabs_context'})
105 if (context.isError || context.deny !== undefined) {
106 throw new Error(context.deny ?? text.browserTabsError)
107 }
108 const tab = readBrowserTab(context.result, value.url)
109 if (tab === undefined) {
110 phase = isUserClick ? {kind: 'reopen', url: value.url} : {kind: 'idle'}
111 try {
112 const result = await $.tool.call({tool: 'mcp__Claude_Browser__preview_start', url: value.url})
113 if (result.isError || result.deny !== undefined) {
114 throw new Error(result.deny ?? text.browserError)
115 }
116 } finally {
117 phase = {kind: 'idle'}
118 }
119 } else if (!tab.isActive) {
120 const result = await $.tool.call({tabId: tab.tabId, tool: 'mcp__Claude_Browser__tabs_select'})
121 if (result.isError || result.deny !== undefined) {
122 throw new Error(result.deny ?? text.selectError)
123 }
124 }
125 if (isUserClick) {
126 // Existing MCP processes advertise the same native runtime through clipboard support.
127 const nativeReveal = value.capabilities?.reveal ?? value.capabilities?.clipboard
128 if (nativeReveal === true) {
129 const session = await $.session.id()
130 const result = await $.process.run([
131 `${$.plugin.root}/runtime/node`,
132 `${$.plugin.root}/dist/reveal.js`,
133 session,
134 value.url,
135 ])
136 if (result.exitCode !== 0) {
137 throw new Error(result.stderr.trim() || text.revealError)
138 }
139 } else {
140 $.ui.toast(text.revealHint)
141 }
142 }
143 browser = value
144}
145
146async function openViewer($, location, isUserClick = false) {
147 try {
148 await enqueue($, async () => {
149 const value = await viewerLocation($, location)
150 await presentViewer($, value, isUserClick)
151 })
152 } catch (error) {
153 $.ui.toast(`Code Viewer: ${error instanceof Error ? error.message : String(error)}`)
154 }
155}
156
157async function approveOpen(_engine, event, next) {
158 const decision = await next(event)
159
160 return canApproveOpen({check: event, decision, origin: next.origin, phase})
161 ? {
162 ...decision,
163 decision: 'allow',
164 reason:
165 phase.kind === 'attachments'
166 ? 'Read user-selected Code Viewer snapshots for this composer message.'
167 : 'Requested Code Viewer opening and in-place following.',
168 }
169 : decision
170}
171
172async function readAttachments($) {
173 const connection = await $.mcp.connect('viewer')
174 if (!connection.isConnected) {
175 throw new Error(connection.message)
176 }
177 phase = {kind: 'attachments'}
178 try {
179 const result = await $.mcp.call(connection.server, 'code.attachments', {})
180 if (result.isError) {
181 throw new Error(text.attachmentError)
182 }
183 return result.content.flatMap((entry) => (entry.type === 'text' ? [entry.text] : []))
184 } finally {
185 phase = {kind: 'idle'}
186 }
187}
188
189/** Registers on-demand Browser opening, location following and retained chat snapshots. */
190export function register(on) {
191 on('prompt.submit', async ($, event, next) => {
192 if (!desktop || event.origin.kind !== 'composer' || browser === undefined) {
193 return next(event)
194 }
195 let attachments
196 try {
197 attachments = await enqueue($, () => readAttachments($))
198 } catch (error) {
199 $.ui.log(`Code Viewer attachments: ${String(error)}`, {to: 'debug'})
200 $.ui.toast(`Code Viewer: ${error instanceof Error ? error.message : String(error)}`)
201 return {drop: text.sendError}
202 }
203 return next({...event, context: [...(event.context ?? []), ...attachments]})
204 })
205 on('session.start', async ($, event, next) => {
206 const result = await next(event)
207 desktop = event.surface === 'desktop' || (await $.session.surfaces()).includes('desktop')
208 if (desktop) {
209 await refreshMessages($)
210 }
211 workspace = result.cwd ?? (await $.session.cwd())
212 if ((await $.ui.panes()).some((pane) => pane.id === 'code-viewer-pane')) {
213 await $.ui.close({id: 'code-viewer-pane'})
214 }
215 return result
216 })
217 on('session.attach', async ($, event, next) => {
218 const result = await next(event)
219 if (event.surface === 'desktop') {
220 desktop = true
221 await refreshMessages($)
222 }
223 return result
224 })
225 on('command.run', {command: 'code-viewer:open'}, async ($) => {
226 await openViewer($)
227 return {}
228 })
229 on(
230 'ui.press',
231 {element: ['code-viewer:open', 'code-viewer:links'], plugin: 'code-viewer'},
232 async ($, event, next) => {
233 if (event.surface !== 'desktop') {
234 return next(event)
235 }
236 const target =
237 event.element === 'code-viewer:open'
238 ? undefined
239 : messages.get(event.requestId)?.find((link) => link.href === event.link?.href)?.location
240 if (event.element === 'code-viewer:links' && target === undefined) {
241 return next(event)
242 }
243 await openViewer($, target, true)
244 return {element: event.element}
245 },
246 )
247 on('ui.render', {component: 'SessionMode'}, async ($, event, next) => {
248 const original = await next(event)
249 if (event.surface !== 'desktop') {
250 return original
251 }
252 const {Box, Button} = $.ui.resolve(event)
253 await refreshMessages($)
254 return Box({
255 children: [
256 original,
257 Button({key: 'code-viewer:open', label: text.openViewer, onPress: () => undefined}),
258 ],
259 flexDirection: 'row',
260 })
261 })
262 on('ui.render', {component: 'AssistantMessage'}, async ($, event, next) => {
263 if (event.surface !== 'desktop') {
264 return next(event)
265 }
266 const {Markdown} = $.ui.resolve(event)
267 workspace = await $.session.cwd()
268 const message =
269 event.props.text.length <= MAX_MARKDOWN_CHARS
270 ? readFileLinks(event.props.text, workspace)
271 : {links: [], text: event.props.text}
272 messages.set(event.requestId, message.links)
273 const original =
274 message.links.length > 0 &&
275 message.links.length <= MAX_FILE_LINKS &&
276 message.text.length <= MAX_MARKDOWN_CHARS
277 ? Markdown({
278 key: 'code-viewer:links',
279 onLinkPress: () => undefined,
280 pressableLinks: message.links.map((link) => link.href),
281 text: message.text,
282 })
283 : await next(event)
284 return original
285 })
286 on('tool.call', {tool: ['Read', 'Edit', 'Write']}, async ($, event, next) => {
287 const result = await next(event)
288 const location =
289 desktop && next.origin.plugin === 'engine' ? readToolLocation(event, result) : undefined
290 if (location !== undefined && isProjectFile(location.path, await $.session.cwd())) {
291 if (browser !== undefined) {
292 await enqueue($, () => viewerLocation($, location)).catch(() => undefined)
293 }
294 }
295 return result
296 })
297 on(
298 'tool.call',
299 {tool: 'mcp__plugin_code-viewer_viewer__code_browser'},
300 async (_engine, event, next) => {
301 const result = await next(event)
302 if (
303 desktop &&
304 next.origin.plugin === 'engine' &&
305 !result.isError &&
306 result.deny === undefined
307 ) {
308 const value = typeof result.result === 'string' ? JSON.parse(result.result) : result.result
309 if (typeof value?.url === 'string') {
310 browser = value
311 }
312 }
313 return result
314 },
315 )
316 on('prompt.context', async (_engine, event, next) => {
317 const result = await next(event)
318 return desktop
319 ? {...result, blocks: [...result.blocks, {name: 'codeViewer', text: guidance}]}
320 : result
321 })
322 on('tool.check', {tool: 'mcp__plugin_code-viewer_viewer__code_browser'}, approveOpen).catch(
323 approveOpen,
324 )
325 on('tool.check', {tool: 'mcp__Claude_Browser__navigate'}, approveOpen).catch(approveOpen)
326 on('tool.check', {tool: 'mcp__Claude_Browser__preview_start'}, approveOpen).catch(approveOpen)
327 on('tool.check', {tool: 'mcp__plugin_code-viewer_viewer__code_attachments'}, approveOpen).catch(
328 approveOpen,
329 )
330}
331hooks/can-approve-open.ts 93 lines1export type OpenPhase =
2 | {readonly kind: 'idle'}
3 | {readonly kind: 'attachments'}
4 | {readonly kind: 'viewer'; readonly workspace?: string}
5 | {readonly kind: 'browser'; readonly url: string}
6 | {readonly kind: 'reopen'; readonly url: string}
7 | {
8 readonly kind: 'follow'
9 readonly location: {
10 readonly path: string
11 readonly line: number
12 readonly column: number
13 readonly workspace?: string
14 }
15 }
16
17interface OpenPermission {
18 readonly phase: OpenPhase
19 readonly origin: {readonly plugin: string}
20 readonly check: {
21 readonly tool: string
22 readonly input: unknown
23 readonly agentId?: string
24 readonly ceiling?: string
25 }
26 readonly decision: {
27 readonly decision: string
28 readonly rule?: string
29 readonly hook?: string
30 readonly ceiling?: string
31 }
32}
33
34const matchesLocation = (input: object, location: object): boolean => {
35 const fields = Object.entries(location)
36 return (
37 Object.keys(input).length === fields.length &&
38 fields.every(([key, value]) => Object.hasOwn(input, key) && Reflect.get(input, key) === value)
39 )
40}
41
42const viewerInput = (workspace?: string): object => (workspace === undefined ? {} : {workspace})
43const browserTools = {
44 browser: 'mcp__Claude_Browser__navigate',
45 reopen: 'mcp__Claude_Browser__preview_start',
46} as const
47
48const hasPermissionRestriction = ({origin, check, decision}: OpenPermission): boolean =>
49 origin.plugin !== 'code-viewer' ||
50 check.agentId !== undefined ||
51 check.ceiling !== undefined ||
52 decision.ceiling !== undefined ||
53 decision.decision !== 'ask' ||
54 decision.rule !== undefined ||
55 decision.hook !== undefined
56
57/** Matches only this Mod's pending viewer call without an explicit permission restriction. */
58export const canApproveOpen = (permission: OpenPermission): boolean => {
59 const {phase, check} = permission
60 if (
61 hasPermissionRestriction(permission) ||
62 typeof check.input !== 'object' ||
63 check.input === null ||
64 Array.isArray(check.input)
65 ) {
66 return false
67 }
68 switch (phase.kind) {
69 case 'idle':
70 return false
71 case 'attachments':
72 return (
73 check.tool === 'mcp__plugin_code-viewer_viewer__code_attachments' &&
74 matchesLocation(check.input, {})
75 )
76 case 'viewer':
77 return (
78 check.tool === 'mcp__plugin_code-viewer_viewer__code_browser' &&
79 matchesLocation(check.input, viewerInput(phase.workspace))
80 )
81 case 'follow':
82 return (
83 check.tool === 'mcp__plugin_code-viewer_viewer__code_browser' &&
84 matchesLocation(check.input, phase.location)
85 )
86 case 'browser':
87 case 'reopen':
88 return (
89 check.tool === browserTools[phase.kind] && matchesLocation(check.input, {url: phase.url})
90 )
91 }
92}
93hooks/read-tool-location.ts 76 lines1interface FollowInput {
2 readonly tool: string
3 readonly file_path?: unknown
4 readonly offset?: unknown
5 readonly agentId?: unknown
6}
7interface FollowLocation {
8 readonly path: string
9 readonly line: number
10 readonly column: number
11}
12const isRecord = (value: unknown): value is Record<string, unknown> =>
13 typeof value === 'object' && value !== null && !Array.isArray(value)
14const positiveLine = (value: unknown): value is number =>
15 typeof value === 'number' && Number.isInteger(value) && value > 0
16const changedLine = (patch: unknown): number | undefined => {
17 if (!Array.isArray(patch)) {
18 return undefined
19 }
20 for (const hunk of patch) {
21 if (isRecord(hunk) && positiveLine(hunk.newStart) && Array.isArray(hunk.lines)) {
22 const changed = hunk.lines.findIndex(
23 (text) => typeof text === 'string' && (text.startsWith('+') || text.startsWith('-')),
24 )
25 if (changed >= 0) {
26 return (
27 hunk.newStart +
28 hunk.lines
29 .slice(0, changed)
30 .filter((text) => typeof text === 'string' && text.startsWith(' ')).length
31 )
32 }
33 }
34 }
35 return undefined
36}
37
38/** Resolves the displayed location of a successful main-thread text read or file change. */
39export const readToolLocation = (
40 input: FollowInput,
41 outcome: unknown,
42): FollowLocation | undefined => {
43 if (
44 input.agentId !== undefined ||
45 !isRecord(outcome) ||
46 outcome.deny !== undefined ||
47 outcome.isError
48 ) {
49 return undefined
50 }
51 const {result} = outcome
52 if (!isRecord(result) || result.staged === true) {
53 return undefined
54 }
55 let path = result.filePath ?? input.file_path
56 let line: unknown
57 switch (input.tool) {
58 case 'Read':
59 if (result.type !== 'text' || !isRecord(result.file)) {
60 return undefined
61 }
62 path = result.file.filePath ?? input.file_path
63 line = result.file.startLine ?? input.offset ?? 1
64 break
65 case 'Edit':
66 case 'Write':
67 line = changedLine(result.structuredPatch) ?? 1
68 break
69 default:
70 return undefined
71 }
72 return typeof path === 'string' && path.length > 0 && positiveLine(line)
73 ? {column: 1, line, path}
74 : undefined
75}
76hooks/read-file-links.ts 105 lines1import {fromMarkdown} from 'mdast-util-from-markdown'
2import type {RootContent} from 'mdast'
3import {parseFileInput} from '../../../src/viewer/parse-file-input'
4import type {CodeLocation} from '../../../src/shared/contracts'
5import {TEXT_FILE_NAMES} from '../../../src/shared/file-formats'
6import {isProjectFile} from './is-project-file'
7
8interface FileLink {
9 readonly href: string
10 readonly location: CodeLocation
11}
12interface FileLinks {
13 readonly text: string
14 readonly links: readonly FileLink[]
15}
16
17/** Resolves project file links while leaving remote, out-of-project and code examples untouched. */
18export const readFileLinks = (text: string, workspace: string): FileLinks => {
19 const links: FileLink[] = []
20 const replacements: {start: number; end: number; text: string}[] = []
21 const root = fromMarkdown(text)
22 const definitions = new Map(
23 root.children.flatMap((node) =>
24 node.type === 'definition' ? [[node.identifier, node.url] as const] : [],
25 ),
26 )
27 const visit = (node: RootContent): void => {
28 const href =
29 node.type === 'link'
30 ? node.url
31 : node.type === 'linkReference'
32 ? definitions.get(node.identifier)
33 : undefined
34 const location = href === undefined ? undefined : readLocation(href, workspace)
35 if (location !== undefined && (node.type === 'link' || node.type === 'linkReference')) {
36 const address = fileAddress(location.path)
37 address.hash = `L${location.line}C${location.column}`
38 const target = address.href
39 const start = node.position?.start.offset
40 const end = node.position?.end.offset
41 const first = node.children[0]?.position?.start.offset
42 const last = node.children.at(-1)?.position?.end.offset
43 if (start !== undefined && end !== undefined && first !== undefined && last !== undefined) {
44 replacements.push({end, start, text: `[${text.slice(first, last)}](${target})`})
45 links.push({href: target, location})
46 }
47 } else if ('children' in node) {
48 node.children.forEach(visit)
49 }
50 }
51 root.children.forEach(visit)
52 return {
53 links,
54 text: replacements
55 .toReversed()
56 .reduce(
57 (value, entry) => value.slice(0, entry.start) + entry.text + value.slice(entry.end),
58 text,
59 ),
60 }
61}
62
63const fileTarget = (href: string): string => {
64 const filename = /^(?<filename>[^/?#:]+):\d+(?::\d+)?(?:-\d+(?::\d+)?)?$/u.exec(href)?.groups
65 ?.filename
66 return filename !== undefined && (filename.includes('.') || TEXT_FILE_NAMES.includes(filename))
67 ? `./${href}`
68 : href
69}
70
71const readLocation = (href: string, workspace: string): CodeLocation | undefined => {
72 try {
73 const target = fileTarget(href)
74 if (workspace === '' || target.startsWith('//') || /^(?!file:)[a-z][\w+.-]*:/iu.test(target)) {
75 return undefined
76 }
77 const address = new URL(target, `file://${workspace.replace(/\/$/u, '')}/`)
78 if (address.protocol !== 'file:' || address.hostname !== '' || address.search !== '') {
79 return undefined
80 }
81 const hash = /^#L(?<line>\d+)(?:C(?<column>\d+))?(?:-L?\d+(?:C\d+)?)?$/u.exec(address.hash)
82 if (address.hash !== '' && hash === null) {
83 return undefined
84 }
85 const parsed = parseFileInput(decodeURIComponent(address.pathname))
86 if (parsed.kind !== 'path') {
87 return undefined
88 }
89 if (!isProjectFile(parsed.location.path, workspace)) {
90 return undefined
91 }
92 return {
93 ...parsed.location,
94 ...(hash === null
95 ? {}
96 : {column: Number(hash.groups?.column ?? 1), line: Number(hash.groups?.line)}),
97 }
98 } catch {
99 return undefined
100 }
101}
102
103const fileAddress = (path: string): URL =>
104 new URL(`file://${path.split('/').map(encodeURIComponent).join('/')}`)
105hooks/read-browser-tab.ts 29 lines1import {z} from 'zod'
2
3interface BrowserTab {
4 readonly tabId: string
5 readonly origin: string
6 readonly isActive: boolean
7}
8const contextSchema = z.object({
9 tabs: z.array(z.object({isActive: z.boolean(), origin: z.string(), tabId: z.string()})),
10})
11const blocksSchema = z.array(z.object({text: z.string(), type: z.literal('text')}))
12
13/** Reads the viewer's tab from Browser context without treating invalid context as a closed tab. */
14export const readBrowserTab = (input: unknown, url: string): BrowserTab | undefined => {
15 const content = Array.isArray(input)
16 ? blocksSchema
17 .parse(input)
18 .map((block) => block.text)
19 .join('\n')
20 : input
21 const value =
22 typeof content === 'string'
23 ? JSON.parse(content.split(/\r?\nThe Browser pane /u, 1)[0])
24 : content
25 const {tabs} = contextSchema.parse(value)
26 const {origin} = new URL(url)
27 return tabs.find((tab) => tab.origin === origin)
28}
29hooks/is-project-file.ts 16 lines1const encodePath = (path: string): string => path.split('/').map(encodeURIComponent).join('/')
2
3/** Matches a local file beneath the current project after resolving relative path segments. */
4export const isProjectFile = (path: string, workspace: string): boolean => {
5 if (!workspace.startsWith('/')) {
6 return false
7 }
8 try {
9 const directory = new URL(`file://${encodePath(workspace).replace(/\/+$/u, '')}/`)
10 const target = new URL(encodePath(path), directory)
11 return target.hostname === '' && target.pathname.startsWith(directory.pathname)
12 } catch {
13 return false
14 }
15}
16hooks/read-browser-messages.ts 79 lines1import {resolveViewerLanguage} from '../../../src/viewer/locale/resolve-viewer-language'
2
3const english = {
4 attachmentError: 'Could not retrieve attachments.',
5 browserError: 'Could not open Browser.',
6 browserTabsError: 'Could not check Browser tabs.',
7 fileError: 'Could not open the file.',
8 openViewer: 'Open Code Viewer',
9 revealError: 'Could not expand Browser.',
10 revealHint: 'If Browser is collapsed, click Browser at the top.',
11 selectError: 'Could not select the Code Viewer tab.',
12 sendError: 'Could not retrieve Code Viewer attachments. Please send again.',
13 urlError: 'Could not retrieve the viewer URL.',
14 urlInvalid: 'The viewer URL is not an authenticated local address.',
15}
16
17const korean: Readonly<Record<keyof typeof english, string>> = {
18 attachmentError: '첨부를 가져오지 못했습니다.',
19 browserError: 'Browser를 열지 못했습니다.',
20 browserTabsError: 'Browser 탭을 확인하지 못했습니다.',
21 fileError: '파일을 열지 못했습니다.',
22 openViewer: '코드 뷰어 열기',
23 revealError: 'Browser를 펼치지 못했습니다.',
24 revealHint: 'Browser가 접혀 있으면 상단 Browser를 누르세요.',
25 selectError: 'Code Viewer 탭을 선택하지 못했습니다.',
26 sendError: 'Code Viewer 첨부를 가져오지 못했습니다. 다시 보내 주세요.',
27 urlError: '뷰어 주소를 받지 못했습니다.',
28 urlInvalid: '인증된 로컬 뷰어 주소가 아닙니다.',
29}
30
31const japanese: Readonly<Record<keyof typeof english, string>> = {
32 attachmentError: '添付を取得できませんでした。',
33 browserError: 'Browserを開けませんでした。',
34 browserTabsError: 'Browserのタブを確認できませんでした。',
35 fileError: 'ファイルを開けませんでした。',
36 openViewer: 'コードビューアーを開く',
37 revealError: 'Browserを展開できませんでした。',
38 revealHint: 'Browserが折りたたまれている場合は、上部のBrowserをクリックしてください。',
39 selectError: 'Code Viewerのタブを選択できませんでした。',
40 sendError: 'Code Viewerの添付を取得できませんでした。もう一度送信してください。',
41 urlError: 'ビューアーのURLを取得できませんでした。',
42 urlInvalid: 'ビューアーのURLが認証済みのローカルアドレスではありません。',
43}
44
45const chinese: Readonly<Record<keyof typeof english, string>> = {
46 attachmentError: '无法获取附件。',
47 browserError: '无法打开 Browser。',
48 browserTabsError: '无法检查 Browser 标签页。',
49 fileError: '无法打开文件。',
50 openViewer: '打开代码查看器',
51 revealError: '无法展开 Browser。',
52 revealHint: '如果 Browser 已折叠,请点击顶部的 Browser。',
53 selectError: '无法选择 Code Viewer 标签页。',
54 sendError: '无法获取 Code Viewer 附件。请重新发送。',
55 urlError: '无法获取查看器地址。',
56 urlInvalid: '查看器地址不是经过验证的本地地址。',
57}
58
59const spanish: Readonly<Record<keyof typeof english, string>> = {
60 attachmentError: 'No se han podido obtener los archivos adjuntos.',
61 browserError: 'No se ha podido abrir Browser.',
62 browserTabsError: 'No se han podido comprobar las pestañas de Browser.',
63 fileError: 'No se ha podido abrir el archivo.',
64 openViewer: 'Abrir visor de código',
65 revealError: 'No se ha podido expandir Browser.',
66 revealHint: 'Si Browser está contraído, pulsa Browser en la parte superior.',
67 selectError: 'No se ha podido seleccionar la pestaña de Code Viewer.',
68 sendError:
69 'No se han podido obtener los archivos adjuntos de Code Viewer. Vuelve a enviar el mensaje.',
70 urlError: 'No se ha podido obtener la URL del visor.',
71 urlInvalid: 'La URL del visor no es una dirección local autenticada.',
72}
73
74const translations = {en: english, es: spanish, ja: japanese, ko: korean, 'zh-CN': chinese}
75
76export const readBrowserMessages = (
77 locale: string | undefined,
78): Readonly<Record<keyof typeof english, string>> => translations[resolveViewerLanguage(locale)]
79../../src/viewer/parse-file-input.ts 47 lines1import {TEXT_FILE_NAMES} from '../shared/file-formats'
2import type {CodeLocation} from '../shared/contracts'
3
4export type FileInput =
5 | {kind: 'path'; location: CodeLocation; restoreView: boolean}
6 | {kind: 'search'; query: string}
7
8const prefix = '(?:/|\\.\\.?/|[^/\\s()\\[\\]\\x22\\x27\\x60,]+/)'
9const filename = `(?:.*?\\.[\\p{L}\\p{N}_-]+|(?:[^/]+/)*(?:${TEXT_FILE_NAMES.join('|')}))`
10const position = '(?::(?<line>\\d+)(?::(?<column>\\d+))?)?(?:-\\d+(?::\\d+)?)?'
11const directPath = new RegExp(`^(?<path>${prefix}${filename})${position}$`, 'iu')
12const quotedPath = new RegExp(`^(?<path>(?:/|\\.\\.?/|[^/\\r\\n]+/)${filename})${position}$`, 'iu')
13const quotedContent = /(?<quote>["'`])(?<content>.*?)\k<quote>/gu
14const embeddedPath = new RegExp(
15 `(?:^|[\\s(\\[\\x22\\x27\\x60])` +
16 `(?<path>${prefix}[^\\s()\\[\\]\\x22\\x27\\x60]*?(?:\\.[\\p{L}\\p{N}_-]+|${TEXT_FILE_NAMES.join('|')}))` +
17 `${position}(?=$|[\\s)\\]\\x22\\x27\\x60,])`,
18 'iu',
19)
20
21export const parseFileInput = (input: string): FileInput => {
22 const query = input.trim()
23 const candidate = query.replace(/^(?<quote>["'`])(?<content>[\s\S]+)\k<quote>$/u, '$<content>')
24 const quotedMatch = Array.from(query.matchAll(quotedContent), (entry) =>
25 quotedPath.exec(entry.groups?.content ?? ''),
26 ).find((entry) => entry !== null)
27 const explicitPath =
28 /^(?<path>(?:\/|\.\.?\/).+?)(?::(?<line>\d+)(?::(?<column>\d+))?)?(?:-\d+(?::\d+)?)?$/u
29 const match =
30 explicitPath.exec(candidate) ??
31 directPath.exec(candidate) ??
32 quotedMatch ??
33 embeddedPath.exec(candidate)
34 const path = match?.groups?.path
35 return path === undefined
36 ? {kind: 'search', query}
37 : {
38 kind: 'path',
39 location: {
40 column: Math.max(1, Number(match?.groups?.column ?? 1)),
41 line: Math.max(1, Number(match?.groups?.line ?? 1)),
42 path,
43 },
44 restoreView: match?.groups?.line === undefined,
45 }
46}
47../../src/shared/contracts.ts 181 lines1import {MAX_TEXT_BYTES, SOURCE_CHUNK_LENGTH} from './reading-limits'
2import {MAX_CODE_BYTES} from './editing-limits'
3import {z} from 'zod'
4import {treeStateSchema} from './tree-state'
5
6export const locationSchema = z.object({
7 column: z.number().int().positive(),
8 line: z.number().int().positive(),
9 path: z.string(),
10})
11export const tokenSchema = z.object({
12 kind: z.enum(['plain', 'keyword', 'string', 'comment', 'number', 'identifier']),
13 navigation: z.enum(['definition', 'path']).nullable(),
14 offset: z.number().int().nonnegative(),
15 text: z.string(),
16})
17export const documentSchema = z.object({
18 lineCount: z.number().int().positive().optional(),
19 lines: z.array(z.array(tokenSchema)),
20 location: locationSchema,
21 media: z
22 .object({
23 kind: z.enum(['image', 'video', 'audio', 'pdf', 'word', 'spreadsheet']),
24 mimeType: z.string(),
25 size: z.number().int().nonnegative(),
26 })
27 .optional(),
28 readOnly: z.boolean().optional(),
29 revision: z.string(),
30 source: z.string(),
31 sourceLength: z.number().int().nonnegative().max(MAX_TEXT_BYTES).optional(),
32})
33export const sourceChunkSchema = z.object({
34 data: z.string().max(SOURCE_CHUNK_LENGTH),
35 next: z.number().int().nonnegative(),
36})
37export const mediaChunkSchema = z.object({data: z.string(), next: z.number().int().nonnegative()})
38export const workspaceSessionSchema = z.object({
39 session: z.string(),
40 treeState: treeStateSchema.optional(),
41 workspace: z.string(),
42})
43export const sessionSchema = workspaceSessionSchema.extend({document: documentSchema})
44export const connectionSchema = z.union([sessionSchema, workspaceSessionSchema.strict()])
45export const MAX_REFERENCE_PREVIEW = 400
46export const MAX_NAVIGATION_PREVIEW_LINES = 5
47export const navigationLocationSchema = locationSchema.extend({
48 preview: z
49 .string()
50 .max((MAX_REFERENCE_PREVIEW + 1) * MAX_NAVIGATION_PREVIEW_LINES - 1)
51 .optional(),
52})
53export type NavigationLocation = z.infer<typeof navigationLocationSchema>
54export const navigationGroupSchema = z.object({
55 count: z.number().int().positive(),
56 path: z.string(),
57})
58export type NavigationGroup = z.infer<typeof navigationGroupSchema>
59export const navigationSchema = z.object({
60 groups: z.array(navigationGroupSchema).optional(),
61 kind: z.enum(['definition', 'references']).optional(),
62 locations: z.array(navigationLocationSchema),
63})
64export type NavigationKind = NonNullable<z.infer<typeof navigationSchema>['kind']>
65export const navigationResultSchema = navigationSchema.required({kind: true})
66export type NavigationResult = z.infer<typeof navigationResultSchema>
67export const codeSourceSchema = z.object({path: z.string(), source: z.string().max(MAX_CODE_BYTES)})
68export type CodeSource = z.infer<typeof codeSourceSchema>
69export const navigationInputSchema = z.object({
70 navigation: z.enum(['definition', 'path']),
71 offset: z.number().int().nonnegative(),
72 path: z.string(),
73 revision: z.string(),
74 sources: z.array(codeSourceSchema).optional(),
75})
76export type NavigationInput = z.infer<typeof navigationInputSchema>
77export const filesSchema = z.object({paths: z.array(z.string())})
78export const workspaceFileSchema = z.object({openable: z.boolean(), path: z.string()})
79export const treeSchema = z.object({
80 directories: z.array(z.string()).optional(),
81 files: z.array(workspaceFileSchema),
82 truncated: z.boolean(),
83})
84export const entrySchema = z.object({kind: z.enum(['file', 'directory']), path: z.string()})
85export const entrySnapshotSchema = entrySchema.extend({revision: z.string()})
86export type EntrySnapshot = z.infer<typeof entrySnapshotSchema>
87export type WorkspaceEntry = z.infer<typeof entrySchema>
88export type WorkspaceTree = z.infer<typeof treeSchema>
89export type WorkspaceFile = z.infer<typeof workspaceFileSchema>
90export const scanBatchSchema = z.object({
91 complete: z.boolean(),
92 directories: z.array(z.string()),
93 directory: z.string(),
94 failed: z.boolean().optional(),
95 files: z.array(workspaceFileSchema),
96 snapshot: z.boolean().optional(),
97 version: z.string().optional(),
98})
99export type ScanBatch = z.infer<typeof scanBatchSchema>
100export const streamEndpointSchema = z.object({
101 url: z.url().refine((value) => {
102 const url = new URL(value)
103 if (url.username !== '' || url.password !== '') {
104 return false
105 }
106 if (url.protocol === 'code-viewer:') {
107 return (
108 url.hostname === 'scan' &&
109 url.port === '' &&
110 /^\/[a-zA-Z0-9-]+$/u.test(url.pathname) &&
111 url.search === '' &&
112 url.hash === ''
113 )
114 }
115 return (
116 url.protocol === 'http:' &&
117 (url.hostname === '127.0.0.1' ||
118 (typeof globalThis.location !== 'undefined' && url.origin === globalThis.location.origin))
119 )
120 }),
121})
122export const errorSchema = z.object({
123 code: z.enum([
124 'clipboard-unavailable',
125 'composer-permission-required',
126 'composer-target-missing',
127 'composer-attachment-failed',
128 'not-found',
129 'outside-workspace',
130 'unsupported-file',
131 'too-large',
132 'text-too-large',
133 'context-too-large',
134 'media-too-large',
135 'host-path-missing',
136 'thread-unavailable',
137 'invalid-workspace',
138 'workspace-write-failed',
139 'session-expired',
140 'invalid-position',
141 'read-failed',
142 'stale-document',
143 'write-conflict',
144 'write-failed',
145 'invalid-name',
146 'already-exists',
147 'create-failed',
148 'file-operation-failed',
149 'entry-changed',
150 'protected-entry',
151 'operation-too-large',
152 'invalid-destination',
153 'rust-analyzer-unavailable',
154 'rust-analysis-failed',
155 'python-analyzer-unavailable',
156 'python-analysis-failed',
157 'ruby-analyzer-unavailable',
158 'ruby-analysis-failed',
159 ]),
160})
161export type CodeLocation = z.infer<typeof locationSchema>
162export type CodeToken = z.infer<typeof tokenSchema>
163export type CodeDocument = z.infer<typeof documentSchema>
164export type ViewerSession = z.infer<typeof sessionSchema>
165export type WorkspaceSession = z.infer<typeof workspaceSessionSchema>
166export type ViewerConnection = z.infer<typeof connectionSchema>
167export type ViewerError = z.infer<typeof errorSchema>
168
169export interface Success<Value> {
170 readonly ok: true
171 readonly value: Value
172}
173export interface Failure {
174 readonly error: ViewerError
175 readonly ok: false
176}
177export type Result<Value> = Success<Value> | Failure
178
179export const failure = (code: ViewerError['code']): Failure => ({error: {code}, ok: false})
180export const success = <Value>(value: Value): Success<Value> => ({ok: true, value})
181../../src/shared/file-formats.ts 205 lines1const code = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']
2export type SyntaxLanguage =
3 | 'rust'
4 | 'python'
5 | 'ruby'
6 | 'yaml'
7 | 'toml'
8 | 'json'
9 | 'jsonc'
10 | 'json5'
11 | 'html'
12 | 'css'
13 | 'scss'
14 | 'sass'
15 | 'less'
16 | 'vue'
17 | 'svelte'
18 | 'astro'
19 | 'xml'
20 | 'ini'
21 | 'properties'
22const syntax = new Map<string, SyntaxLanguage>([
23 ['.rs', 'rust'],
24 ['.py', 'python'],
25 ['.pyi', 'python'],
26 ['.rb', 'ruby'],
27 ['.rake', 'ruby'],
28 ['.gemspec', 'ruby'],
29 ['.yaml', 'yaml'],
30 ['.yml', 'yaml'],
31 ['.toml', 'toml'],
32 ['.json', 'json'],
33 ['.jsonc', 'jsonc'],
34 ['.json5', 'json5'],
35 ['.html', 'html'],
36 ['.htm', 'html'],
37 ['.css', 'css'],
38 ['.scss', 'scss'],
39 ['.sass', 'sass'],
40 ['.less', 'less'],
41 ['.vue', 'vue'],
42 ['.svelte', 'svelte'],
43 ['.astro', 'astro'],
44 ['.xml', 'xml'],
45 ['.ini', 'ini'],
46 ['.properties', 'properties'],
47 ['.conf', 'ini'],
48 ['.cfg', 'ini'],
49])
50const namedSyntax = new Map<string, SyntaxLanguage>([
51 ['Cargo.lock', 'toml'],
52 ['Gemfile', 'ruby'],
53 ['Rakefile', 'ruby'],
54])
55const markdown = ['.md', '.markdown', '.mdx']
56const table = ['.csv', '.tsv']
57export const TEXT_FILE_NAMES = [
58 'Dockerfile',
59 'Containerfile',
60 'Makefile',
61 'Justfile',
62 'LICENSE',
63 'NOTICE',
64 'README',
65]
66export const HIDDEN_TEXT_FILES = [
67 '.dockerignore',
68 '.gitignore',
69 '.gitattributes',
70 '.gitmodules',
71 '.editorconfig',
72 '.prettierignore',
73 '.eslintignore',
74 '.npmignore',
75 '.browserslistrc',
76]
77const text = [
78 '.txt',
79 '.text',
80 '.lock',
81 '.log',
82 '.sh',
83 '.bash',
84 '.zsh',
85 '.fish',
86 '.sql',
87 '.go',
88 '.java',
89 '.kt',
90 '.c',
91 '.h',
92 '.cpp',
93 '.hpp',
94 '.cs',
95 '.swift',
96 '.graphql',
97 '.gql',
98 '.proto',
99]
100const image = new Map([
101 ['.png', 'image/png'],
102 ['.jpg', 'image/jpeg'],
103 ['.jpeg', 'image/jpeg'],
104 ['.gif', 'image/gif'],
105 ['.webp', 'image/webp'],
106 ['.svg', 'image/svg+xml'],
107 ['.avif', 'image/avif'],
108 ['.bmp', 'image/bmp'],
109 ['.ico', 'image/x-icon'],
110])
111const video = new Map([
112 ['.mp4', 'video/mp4'],
113 ['.m4v', 'video/mp4'],
114 ['.webm', 'video/webm'],
115 ['.mov', 'video/quicktime'],
116 ['.ogv', 'video/ogg'],
117])
118const audio = new Map([
119 ['.mp3', 'audio/mpeg'],
120 ['.wav', 'audio/wav'],
121 ['.m4a', 'audio/mp4'],
122 ['.aac', 'audio/aac'],
123 ['.ogg', 'audio/ogg'],
124 ['.oga', 'audio/ogg'],
125 ['.opus', 'audio/ogg'],
126 ['.flac', 'audio/flac'],
127 ['.weba', 'audio/webm'],
128])
129export const FILE_EXTENSIONS = [
130 ...code,
131 ...syntax.keys(),
132 ...markdown,
133 ...table,
134 ...text,
135 ...HIDDEN_TEXT_FILES,
136 ...image.keys(),
137 ...video.keys(),
138 ...audio.keys(),
139 '.pdf',
140 '.docx',
141 '.xlsx',
142]
143
144interface TextFormat {
145 readonly kind: 'code' | 'markdown' | 'table' | 'text'
146}
147interface SyntaxFormat {
148 readonly kind: 'syntax'
149 readonly language: SyntaxLanguage
150}
151interface MediaFormat {
152 readonly kind: 'image' | 'video' | 'audio' | 'pdf' | 'word' | 'spreadsheet'
153 readonly mimeType: string
154}
155export type FileFormat = TextFormat | SyntaxFormat | MediaFormat
156
157export const fileFormat = (path: string): FileFormat | undefined => {
158 const basename = path.split(/[/\\]/u).at(-1) ?? ''
159 if (basename.startsWith('.') && !HIDDEN_TEXT_FILES.includes(basename)) {
160 return undefined
161 }
162 const extension = basename.slice(basename.lastIndexOf('.')).toLowerCase()
163 const language = namedSyntax.get(basename) ?? syntax.get(extension)
164 if (language !== undefined) {
165 return {kind: 'syntax', language}
166 }
167 if (code.includes(extension)) {
168 return {kind: 'code'}
169 }
170 if (markdown.includes(extension)) {
171 return {kind: 'markdown'}
172 }
173 if (table.includes(extension)) {
174 return {kind: 'table'}
175 }
176 if (text.includes(extension)) {
177 return {kind: 'text'}
178 }
179 const imageType = image.get(extension)
180 if (extension === '.pdf') {
181 return {kind: 'pdf', mimeType: 'application/pdf'}
182 }
183 if (extension === '.docx') {
184 return {
185 kind: 'word',
186 mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
187 }
188 }
189 if (extension === '.xlsx') {
190 return {
191 kind: 'spreadsheet',
192 mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
193 }
194 }
195 if (imageType !== undefined) {
196 return {kind: 'image', mimeType: imageType}
197 }
198 const audioType = audio.get(extension)
199 if (audioType !== undefined) {
200 return {kind: 'audio', mimeType: audioType}
201 }
202 const videoType = video.get(extension)
203 return videoType === undefined ? {kind: 'text'} : {kind: 'video', mimeType: videoType}
204}
205../../src/viewer/locale/resolve-viewer-language.ts 22 lines1import type {ViewerLanguage} from './types'
2import {isViewerLanguage} from './is-viewer-language'
3import {DEFAULT_VIEWER_LANGUAGE} from './languages'
4
5export const resolveViewerLanguage = (locale: string | undefined): ViewerLanguage => {
6 if (locale === undefined) {
7 return DEFAULT_VIEWER_LANGUAGE
8 }
9 try {
10 const {language, region, script} = new Intl.Locale(locale.trim().replaceAll('_', '-'))
11 if (language === 'zh') {
12 if (script !== undefined) {
13 return script === 'Hans' ? 'zh-CN' : DEFAULT_VIEWER_LANGUAGE
14 }
15 return ['TW', 'HK', 'MO'].includes(region ?? '') ? DEFAULT_VIEWER_LANGUAGE : 'zh-CN'
16 }
17 return isViewerLanguage(language) ? language : DEFAULT_VIEWER_LANGUAGE
18 } catch {
19 return DEFAULT_VIEWER_LANGUAGE
20 }
21}
22../../src/shared/reading-limits.ts 3 lines1export const MAX_TEXT_BYTES = 33554432
2export const SOURCE_CHUNK_LENGTH = 1048576
3