feat(web): preview linked session files (#627)

This commit is contained in:
MapleStoryIdle
2026-05-17 14:16:07 +08:00
committed by GitHub
parent 6a81597724
commit e9b27fec02
5 changed files with 499 additions and 16 deletions
+5 -1
View File
@@ -27,8 +27,12 @@ export function HappyChatProvider(props: { value: HappyChatContextValue; childre
)
}
export function useOptionalHappyChatContext(): HappyChatContextValue | null {
return useContext(HappyChatContext)
}
export function useHappyChatContext(): HappyChatContextValue {
const ctx = useContext(HappyChatContext)
const ctx = useOptionalHappyChatContext()
if (!ctx) {
throw new Error('HappyChatContext is missing')
}
@@ -1,6 +1,6 @@
import '@assistant-ui/react-markdown/styles/dot.css'
import type { ComponentPropsWithoutRef } from 'react'
import type { ComponentPropsWithoutRef, MouseEvent } from 'react'
import {
MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
@@ -11,16 +11,19 @@ import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code'
import { useNavigate } from '@tanstack/react-router'
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
import { cn } from '@/lib/utils'
import { cn, encodeBase64 } from '@/lib/utils'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links'
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode, remarkFilePathLinks] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<MarkdownTextPrimitiveProps['rehypePlugins']>
export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]'
export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {
@@ -89,8 +92,47 @@ function Code(props: ComponentPropsWithoutRef<'code'>) {
)
}
function A(props: ComponentPropsWithoutRef<'a'>) {
function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) {
const navigate = useNavigate()
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
const search = new URLSearchParams({ path: encodeBase64(props.filePath) }).toString()
const href = `/sessions/${encodeURIComponent(props.sessionId)}/file?${search}`
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
props.onClick?.(event)
if (event.defaultPrevented) return
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
void navigate({
to: '/sessions/$sessionId/file',
params: { sessionId: props.sessionId },
search: { path: encodeBase64(props.filePath) }
})
}
return (
<a
{...props}
href={href}
rel={rel}
onClick={handleClick}
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
/>
)
}
function A(props: ComponentPropsWithoutRef<'a'>) {
const chat = useOptionalHappyChatContext()
const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
if (filePath) {
if (!chat) {
return <>{props.children}</>
}
return <FilePathAnchor {...props} filePath={filePath} sessionId={chat.sessionId} />
}
return (
<a
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links'
type TestNode = {
type: string
value?: string
url?: string
children?: TestNode[]
}
function transform(text: string): TestNode[] {
const tree: TestNode = {
type: 'root',
children: [{ type: 'paragraph', children: [{ type: 'text', value: text }] }]
}
remarkFilePathLinks()(tree)
return tree.children?.[0]?.children ?? []
}
function linkedPath(node: TestNode): string | null {
return typeof node.url === 'string' ? decodeFilePathHref(node.url) : null
}
describe('remarkFilePathLinks', () => {
it('links relative code paths and strips line suffixes from the target path', () => {
const nodes = transform('Open web/src/router.tsx:42 please')
const link = nodes.find((node) => node.type === 'link')
expect(link?.children?.[0]?.value).toBe('web/src/router.tsx:42')
expect(linkedPath(link!)).toBe('web/src/router.tsx')
})
it('links image and markdown filenames for preview', () => {
const nodes = transform('See screenshot.png and README.md')
const links = nodes.filter((node) => node.type === 'link')
expect(links.map(linkedPath)).toEqual(['screenshot.png', 'README.md'])
})
it('does not link paths that are outside the session workspace', () => {
const nodes = transform('Skip /Users/dev/project/a.png, ~/a.png, ../a.png and C:\\tmp\\a.png')
expect(nodes.some((node) => node.type === 'link')).toBe(false)
})
it('does not rewrite ordinary urls', () => {
const nodes = transform('Visit https://example.com/web/src/router.tsx')
expect(nodes.some((node) => node.type === 'link')).toBe(false)
})
})
+142
View File
@@ -0,0 +1,142 @@
const FILE_PATH_HREF_PREFIX = 'hapi-file:'
const PATH_PATTERN = /(?:\.\/|[A-Za-z0-9_.-]+\/)[^\s`"\'<>]*?\.(?:[A-Za-z0-9]{1,12}|lock)(?::\d+(?::\d+)?)?|(?:[A-Za-z0-9_.-]+\.(?:[A-Za-z0-9]{1,12}|lock))(?::\d+(?::\d+)?)?/g
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?'])
const COMMON_FILE_EXTENSIONS = new Set([
'avif', 'bmp', 'c', 'cjs', 'cpp', 'css', 'gif', 'go', 'h', 'hpp', 'html', 'ico', 'java',
'jpeg', 'jpg', 'js', 'json', 'jsx', 'kt', 'lock', 'md', 'mdx', 'mjs', 'png', 'py', 'rs',
'scss', 'sh', 'sql', 'svg', 'swift', 'toml', 'ts', 'tsx', 'txt', 'vue', 'webp', 'xml',
'yaml', 'yml', 'zsh'
])
type MarkdownNode = {
type?: string
value?: string
url?: string
title?: string | null
children?: MarkdownNode[]
}
function createFileHref(path: string): string {
return `${FILE_PATH_HREF_PREFIX}${encodeURIComponent(path)}`
}
export function decodeFilePathHref(href: string): string | null {
if (!href.startsWith(FILE_PATH_HREF_PREFIX)) return null
try {
return decodeURIComponent(href.slice(FILE_PATH_HREF_PREFIX.length))
} catch {
return null
}
}
function splitTrailingPunctuation(value: string): { path: string; trailing: string } {
let path = value
let trailing = ''
while (path.length > 0) {
const last = path[path.length - 1]
if (TRAILING_PUNCTUATION.has(last)) {
trailing = last + trailing
path = path.slice(0, -1)
continue
}
if (last === ')' && path.split('(').length <= path.split(')').length) {
trailing = last + trailing
path = path.slice(0, -1)
continue
}
if (last === ']' || last === '}') {
trailing = last + trailing
path = path.slice(0, -1)
continue
}
break
}
return { path, trailing }
}
function stripLineSuffix(value: string): string {
return value.replace(/:\d+(?::\d+)?$/, '')
}
function hasKnownFileExtension(value: string): boolean {
const path = stripLineSuffix(value).toLowerCase()
const ext = path.slice(path.lastIndexOf('.') + 1)
return COMMON_FILE_EXTENSIONS.has(ext)
}
function shouldLinkPath(value: string): boolean {
if (value.includes('://')) return false
const path = stripLineSuffix(value)
if (path.length < 3) return false
if (path.startsWith('/') || path.startsWith('~/')) return false
if (path.startsWith('../') || path.includes('/../')) return false
if (/^[A-Za-z]:[\\/]/.test(path)) return false
if (path.includes('/')) return hasKnownFileExtension(path)
return hasKnownFileExtension(path)
}
function linkTextNode(node: MarkdownNode): MarkdownNode[] {
const value = node.value ?? ''
const parts: MarkdownNode[] = []
let lastIndex = 0
PATH_PATTERN.lastIndex = 0
let match: RegExpExecArray | null
while ((match = PATH_PATTERN.exec(value)) !== null) {
const rawMatch = match[0]
const previousChar = match.index > 0 ? value[match.index - 1] : ''
if (previousChar === ':' || previousChar === '/' || previousChar === '\\' || previousChar === '.') {
continue
}
const { path: displayPath, trailing } = splitTrailingPunctuation(rawMatch)
const filePath = stripLineSuffix(displayPath)
if (!shouldLinkPath(filePath)) {
continue
}
if (match.index > lastIndex) {
parts.push({ type: 'text', value: value.slice(lastIndex, match.index) })
}
parts.push({
type: 'link',
url: createFileHref(filePath),
title: null,
children: [{ type: 'text', value: displayPath }]
})
if (trailing) {
parts.push({ type: 'text', value: trailing })
}
lastIndex = match.index + rawMatch.length
}
if (parts.length === 0) return [node]
if (lastIndex < value.length) {
parts.push({ type: 'text', value: value.slice(lastIndex) })
}
return parts
}
function visit(node: MarkdownNode, parentType: string | null = null): void {
if (!node.children) return
if (parentType === 'link' || parentType === 'linkReference') return
const nextChildren: MarkdownNode[] = []
for (const child of node.children) {
if (child.type === 'text') {
nextChildren.push(...linkTextNode(child))
continue
}
visit(child, child.type ?? null)
nextChildren.push(child)
}
node.children = nextChildren
}
export function remarkFilePathLinks() {
return (tree: MarkdownNode) => visit(tree)
}
+254 -11
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent, type WheelEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useParams, useSearch } from '@tanstack/react-router'
import type { GitCommandResponse } from '@/types/api'
import { FileIcon } from '@/components/FileIcon'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { CopyIcon, CheckIcon, CloseIcon } from '@/components/icons'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
@@ -14,6 +14,9 @@ import { useTranslation } from '@/lib/use-translation'
import { decodeBase64 } from '@/lib/utils'
const MAX_COPYABLE_FILE_BYTES = 1_000_000
const MIN_IMAGE_SCALE = 0.25
const MAX_IMAGE_SCALE = 8
const IMAGE_SCALE_STEP = 0.25
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
apng: 'image/apng',
avif: 'image/avif',
@@ -140,17 +143,257 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
return result.error ?? result.stderr ?? 'Failed to load diff'
}
function clampImageScale(value: number): number {
return Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value))
}
type ImagePoint = { x: number; y: number }
function getPointDistance(a: ImagePoint, b: ImagePoint): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint {
return {
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2
}
}
function ImagePreview(props: { dataUrl: string; fileName: string; label: string }) {
const [viewerOpen, setViewerOpen] = useState(false)
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const scaleRef = useRef(scale)
const offsetRef = useRef(offset)
const activePointersRef = useRef(new Map<number, ImagePoint>())
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: ImagePoint; origin: ImagePoint } | null>(null)
const updateScale = useCallback((next: number | ((current: number) => number)) => {
setScale((current) => {
const value = typeof next === 'function' ? next(current) : next
scaleRef.current = value
return value
})
}, [])
const updateOffset = useCallback((next: ImagePoint) => {
offsetRef.current = next
setOffset(next)
}, [])
const resetView = useCallback(() => {
updateScale(1)
updateOffset({ x: 0, y: 0 })
}, [updateOffset, updateScale])
const closeViewer = useCallback(() => {
setViewerOpen(false)
activePointersRef.current.clear()
dragRef.current = null
pinchRef.current = null
resetView()
}, [resetView])
const zoomBy = useCallback((delta: number) => {
updateScale((current) => clampImageScale(current + delta))
}, [updateScale])
const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
const delta = event.deltaY < 0 ? IMAGE_SCALE_STEP : -IMAGE_SCALE_STEP
zoomBy(delta)
}, [zoomBy])
const beginPinch = useCallback(() => {
const pointers = Array.from(activePointersRef.current.values())
if (pointers.length < 2) return
const [first, second] = pointers
pinchRef.current = {
startDistance: getPointDistance(first, second),
startScale: scaleRef.current,
startCenter: getPointCenter(first, second),
origin: offsetRef.current
}
dragRef.current = null
}, [])
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (activePointersRef.current.size >= 2) {
beginPinch()
return
}
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: offsetRef.current.x,
originY: offsetRef.current.y
}
}, [beginPinch])
const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (!activePointersRef.current.has(event.pointerId)) return
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (activePointersRef.current.size >= 2 && pinchRef.current) {
const pointers = Array.from(activePointersRef.current.values())
const [first, second] = pointers
const distance = getPointDistance(first, second)
const center = getPointCenter(first, second)
const pinch = pinchRef.current
const nextScale = pinch.startDistance > 0
? clampImageScale(pinch.startScale * (distance / pinch.startDistance))
: pinch.startScale
updateScale(nextScale)
updateOffset({
x: pinch.origin.x + center.x - pinch.startCenter.x,
y: pinch.origin.y + center.y - pinch.startCenter.y
})
return
}
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
updateOffset({
x: drag.originX + event.clientX - drag.startX,
y: drag.originY + event.clientY - drag.startY
})
}, [updateOffset, updateScale])
const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
activePointersRef.current.delete(event.pointerId)
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null
}
pinchRef.current = null
const remainingPointer = activePointersRef.current.entries().next().value as [number, ImagePoint] | undefined
if (remainingPointer) {
dragRef.current = {
pointerId: remainingPointer[0],
startX: remainingPointer[1].x,
startY: remainingPointer[1].y,
originX: offsetRef.current.x,
originY: offsetRef.current.y
}
}
}, [])
useEffect(() => {
if (!viewerOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeViewer()
}
if (event.key === '0') {
resetView()
}
if (event.key === '+' || event.key === '=') {
zoomBy(IMAGE_SCALE_STEP)
}
if (event.key === '-') {
zoomBy(-IMAGE_SCALE_STEP)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [closeViewer, resetView, viewerOpen, zoomBy])
return (
<div className="flex min-h-[18rem] items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3">
<img
src={props.dataUrl}
alt={props.label}
className="max-h-[calc(100vh-14rem)] max-w-full object-contain"
draggable={false}
/>
<span className="sr-only">{props.fileName}</span>
</div>
<>
<button
type="button"
onClick={() => setViewerOpen(true)}
className="group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left"
title="Click to zoom"
>
<img
src={props.dataUrl}
alt={props.label}
className="max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]"
draggable={false}
/>
<span className="sr-only">{props.fileName}</span>
</button>
{viewerOpen ? (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white"
role="dialog"
aria-modal="true"
aria-label={props.label}
>
<div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 py-2">
<div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div>
<button
type="button"
onClick={() => zoomBy(-IMAGE_SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale <= MIN_IMAGE_SCALE}
title="Zoom out"
>
</button>
<button
type="button"
onClick={resetView}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
title="Reset zoom"
>
{Math.round(scale * 100)}%
</button>
<button
type="button"
onClick={() => zoomBy(IMAGE_SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale >= MAX_IMAGE_SCALE}
title="Zoom in"
>
+
</button>
<button
type="button"
onClick={closeViewer}
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
title="Close"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<div
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={resetView}
>
<img
src={props.dataUrl}
alt={props.label}
draggable={false}
className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain"
style={{
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
transformOrigin: 'center center'
}}
/>
</div>
</div>
) : null}
</>
)
}