diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx index f46f3137..4cf9fb81 100644 --- a/web/src/components/AssistantChat/context.tsx +++ b/web/src/components/AssistantChat/context.tsx @@ -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') } diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 2774cd58..308052be 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -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 +export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode, remarkFilePathLinks] satisfies NonNullable export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable 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) => { + 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 ( + + ) +} + +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 + } return ( { + 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) + }) +}) diff --git a/web/src/lib/remark-file-path-links.ts b/web/src/lib/remark-file-path-links.ts new file mode 100644 index 00000000..1bd407af --- /dev/null +++ b/web/src/lib/remark-file-path-links.ts @@ -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) +} diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index ab73fa74..5c4948ed 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -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 = { 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()) + 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) => { + 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) => { + 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) => { + 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) => { + 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 ( -
- {props.label} - {props.fileName} -
+ <> + + + {viewerOpen ? ( +
+
+
{props.fileName}
+ + + + +
+
+ {props.label} +
+
+ ) : null} + ) }