diff --git a/web/src/components/ImagePreview.tsx b/web/src/components/ImagePreview.tsx
new file mode 100644
index 00000000..6e189338
--- /dev/null
+++ b/web/src/components/ImagePreview.tsx
@@ -0,0 +1,268 @@
+import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react'
+import { CloseIcon } from '@/components/icons'
+
+const MIN_IMAGE_SCALE = 0.25
+const MAX_IMAGE_SCALE = 8
+const IMAGE_SCALE_STEP = 0.25
+
+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
+ }
+}
+
+export function ImagePreview(props: {
+ src: string
+ fileName: string
+ label: string
+ buttonClassName?: string
+ imageClassName?: string
+ caption?: ReactNode
+}) {
+ 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 (
+ <>
+
+
+ {viewerOpen ? (
+
+
+
{props.fileName}
+
+
+
+
+
+
+

+
+
+ ) : null}
+ >
+ )
+}
diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx
index 5c4948ed..a2327404 100644
--- a/web/src/routes/sessions/file.tsx
+++ b/web/src/routes/sessions/file.tsx
@@ -1,9 +1,9 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent, type WheelEvent } from 'react'
+import { useEffect, useMemo, useState } 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, CloseIcon } from '@/components/icons'
+import { CopyIcon, CheckIcon } from '@/components/icons'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
@@ -12,11 +12,9 @@ import { queryKeys } from '@/lib/query-keys'
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
import { useTranslation } from '@/lib/use-translation'
import { decodeBase64 } from '@/lib/utils'
+import { ImagePreview } from '@/components/ImagePreview'
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',
@@ -143,260 +141,6 @@ 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 (
- <>
-
-
- {viewerOpen ? (
-
-
-
{props.fileName}
-
-
-
-
-
-
-

-
-
- ) : null}
- >
- )
-}
-
export default function FilePage() {
const { api } = useAppContext()
const { t } = useTranslation()
@@ -560,7 +304,7 @@ export default function FilePage() {
) : displayMode === 'file' ? (
imagePreviewUrl ? (