SLOPSHOPPER

code

Web Project MVP 1

spinnerrowsguardprompt
★ 10v?AGPL-3.0updated 2026-10-26bichikim/web/packages/codex-code-viewer/claude/code
A shopper browsing a rack in a slop shop
README

Winter Love Web

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

Wallaby.js

저장소 기여자는 Wallaby.js OSS License를 사용할 수 있다.

Source 18 files
hooks/register.js 331 lines
1/* 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}
331
hooks/can-approve-open.ts 93 lines
1export 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}
93
hooks/read-tool-location.ts 76 lines
1interface 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}
76
hooks/read-file-links.ts 105 lines
1import {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('/')}`)
105
hooks/read-browser-tab.ts 29 lines
1import {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}
29
hooks/is-project-file.ts 16 lines
1const 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}
16
hooks/read-browser-messages.ts 79 lines
1import {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 lines
1import {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 lines
1import {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 lines
1const 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 lines
1import 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 lines
1export const MAX_TEXT_BYTES = 33554432
2export const SOURCE_CHUNK_LENGTH = 1048576
3