Add mobile image-diff previews via shared data-URI builder

- Extracts a `buildImageDataUri` helper (src/shared/image-data-uri.ts) shared by
  the desktop ImageViewer and mobile, so both trim whitespace-wrapped base64 and
  skip non-previewable mimes (e.g. application/pdf) the same way.
- Adds mobile-diff-image-preview.ts to render binary git.diff results (add/modify/
  delete) as images instead of falling back to "Binary preview unavailable".
- Extracts resolveMobileFileTabDoc to consolidate the session file-tab loading
  logic (diff/image/html/text) out of the route file for testability.
This commit is contained in:
Jinjing
2026-07-16 16:42:07 -07:00
parent fc6570da67
commit 6fffc55102
7 changed files with 232 additions and 90 deletions
+6 -88
View File
@@ -141,7 +141,6 @@ import {
saveCustomKeys,
type CustomKey
} from '../../../../src/components/CustomKeyModal'
import { buildMobileDiffLines } from '../../../../src/session/mobile-diff-lines'
import {
addMobileDiffComment,
formatDiffComments,
@@ -177,7 +176,7 @@ import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-termi
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
@@ -2043,93 +2042,12 @@ export default function SessionScreen() {
}
setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
if (tab.diffSource === 'staged' || tab.diffSource === 'unstaged') {
const response = await client.sendRequest('git.diff', {
worktree: `id:${worktreeId}`,
filePath: tab.relativePath,
staged: tab.diffSource === 'staged'
})
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
const result = (response as RpcSuccess).result as
| {
kind: 'text'
originalContent: string
modifiedContent: string
}
| { kind: 'binary' }
if (result.kind !== 'text') {
throw new Error('binary_file')
}
const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
kind: 'diff',
lines: diff.lines,
truncated: diff.truncated
})
)
return
}
const artifactKind = classifyMobileArtifact(tab.relativePath)
if (artifactKind === 'image') {
const preview = await client.sendRequest('files.readPreview', {
worktree: `id:${worktreeId}`,
relativePath: tab.relativePath
})
if (!preview.ok) {
throw new Error((preview as RpcFailure).error.message)
}
const result = (preview as RpcSuccess).result as {
content: string
isImage?: boolean
mimeType?: string
}
if (!result.isImage || !result.mimeType || result.content.length === 0) {
throw new Error('binary_file')
}
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
kind: 'image',
dataUri: `data:${result.mimeType};base64,${result.content}`
})
)
return
}
const response = await client.sendRequest('files.read', {
worktree: `id:${worktreeId}`,
relativePath: tab.relativePath
const doc = await resolveMobileFileTabDoc(client, {
worktreeId,
relativePath: tab.relativePath,
diffSource: tab.diffSource
})
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
const result = (response as RpcSuccess).result as {
content: string
truncated: boolean
byteLength: number
}
if (artifactKind === 'html') {
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
kind: 'html',
content: result.content
})
)
return
}
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
kind: 'file',
content: result.content,
truncated: result.truncated,
byteLength: result.byteLength
})
)
setFileDocs((prev) => new Map(prev).set(tab.id, doc))
} catch (err) {
const message = err instanceof Error ? err.message : ''
const previewMessage =
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
describe('mobileDiffImageDataUri', () => {
it('renders a modified image diff from the post-change bytes', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: 'bmV3',
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
})
it('renders an added image diff (no original) from the modified bytes', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: 'bmV3',
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
})
it('falls back to the original bytes for a deleted image', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: '',
isImage: true,
mimeType: 'image/jpeg'
})
).toBe('data:image/jpeg;base64,b2xk')
})
it('returns null for a non-previewable binary (no isImage/mimeType)', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: 'AAAA'
})
).toBeNull()
})
it('returns null when flagged as image but carrying no bytes', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: '',
isImage: true,
mimeType: 'image/png'
})
).toBeNull()
})
})
@@ -0,0 +1,22 @@
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
// A git.diff result for a binary file. The host (git.diff) already sends base64
// image bytes plus isImage/mimeType for previewable formats, so mobile can render
// image diffs instead of falling back to "Binary preview unavailable".
export type MobileBinaryDiffResult = {
kind: 'binary'
originalContent?: string
modifiedContent?: string
isImage?: boolean
mimeType?: string
}
// Builds a data URI for a binary image diff, preferring the modified (post-change)
// bytes and falling back to the original for deletions. Returns null for
// non-previewable binaries, which stay "Binary preview unavailable".
export function mobileDiffImageDataUri(result: MobileBinaryDiffResult): string | null {
if (result.isImage !== true) {
return null
}
return buildImageDataUri(result.mimeType, result.modifiedContent || result.originalContent || '')
}
+95
View File
@@ -0,0 +1,95 @@
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import { buildMobileDiffLines, type MobileDiffLine } from '../session/mobile-diff-lines'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import { mobileDiffImageDataUri, type MobileBinaryDiffResult } from './mobile-diff-image-preview'
type FileTabDocClient = Pick<RpcClient, 'sendRequest'>
// The ready doc a session file tab renders. Mirrors the ready arm of the route's
// FileDocState; kept in src so the loader stays testable without the route.
export type MobileFileTabDoc =
| { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number }
| { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean }
| { status: 'ready'; kind: 'image'; dataUri: string }
| { status: 'ready'; kind: 'html'; content: string }
export type MobileFileTabDocRequest = {
worktreeId: string
relativePath: string
diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
}
// Loads the previewable doc for a session file tab: git diffs (text or image),
// previewable images, HTML source, or plain text. Throws 'binary_file',
// 'file_too_large', or the RPC error message; callers map those to error docs.
export async function resolveMobileFileTabDoc(
client: FileTabDocClient,
request: MobileFileTabDocRequest
): Promise<MobileFileTabDoc> {
const worktree = `id:${request.worktreeId}`
const { relativePath } = request
if (request.diffSource === 'staged' || request.diffSource === 'unstaged') {
const response = await client.sendRequest('git.diff', {
worktree,
filePath: relativePath,
staged: request.diffSource === 'staged'
})
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
const result = (response as RpcSuccess).result as
| { kind: 'text'; originalContent: string; modifiedContent: string }
| MobileBinaryDiffResult
if (result.kind !== 'text') {
// Render image diffs (add/modify/delete) from the base64 the host already
// sends; only non-previewable binaries stay unavailable.
const dataUri = mobileDiffImageDataUri(result)
if (!dataUri) {
throw new Error('binary_file')
}
return { status: 'ready', kind: 'image', dataUri }
}
const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
return { status: 'ready', kind: 'diff', lines: diff.lines, truncated: diff.truncated }
}
const artifactKind = classifyMobileArtifact(relativePath)
if (artifactKind === 'image') {
const preview = await client.sendRequest('files.readPreview', { worktree, relativePath })
if (!preview.ok) {
throw new Error((preview as RpcFailure).error.message)
}
const result = (preview as RpcSuccess).result as {
content: string
isImage?: boolean
mimeType?: string
}
const dataUri = result.isImage ? buildImageDataUri(result.mimeType, result.content) : null
if (!dataUri) {
throw new Error('binary_file')
}
return { status: 'ready', kind: 'image', dataUri }
}
const response = await client.sendRequest('files.read', { worktree, relativePath })
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
const result = (response as RpcSuccess).result as {
content: string
truncated: boolean
byteLength: number
}
if (artifactKind === 'html') {
return { status: 'ready', kind: 'html', content: result.content }
}
return {
status: 'ready',
kind: 'file',
content: result.content,
truncated: result.truncated,
byteLength: result.byteLength
}
}
@@ -20,6 +20,7 @@ import {
getZoomedImageLayoutSize
} from './image-viewer-zoom'
import { translate } from '@/i18n/i18n'
import { buildImageDataUri } from '../../../../shared/image-data-uri'
const FALLBACK_IMAGE_MIME_TYPE = 'image/png'
@@ -59,8 +60,8 @@ export default function ImageViewer({
const isPdf = mimeType === 'application/pdf'
const isIntrinsicLayout = layout === 'intrinsic'
const previewSrc = useMemo(
() => (cleanedContent && !isPdf ? `data:${mimeType};base64,${cleanedContent}` : null),
[cleanedContent, isPdf, mimeType]
() => buildImageDataUri(mimeType, cleanedContent),
[cleanedContent, mimeType]
)
const imageError = previewSrc !== null && failedPreviewSrc === previewSrc
const estimatedSize = useMemo(() => {
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { buildImageDataUri } from './image-data-uri'
describe('buildImageDataUri', () => {
it('builds a data URI from base64 image bytes', () => {
expect(buildImageDataUri('image/png', 'bmV3')).toBe('data:image/png;base64,bmV3')
})
it('strips whitespace from line-wrapped base64 payloads', () => {
expect(buildImageDataUri('image/png', 'bm\nV3\t bmV3\r\n')).toBe(
'data:image/png;base64,bmV3bmV3'
)
})
it('returns null for an empty payload', () => {
expect(buildImageDataUri('image/png', ' \n')).toBeNull()
})
it('returns null for a missing mime type', () => {
expect(buildImageDataUri(undefined, 'bmV3')).toBeNull()
})
it('returns null for application/pdf (not an <img> source)', () => {
expect(buildImageDataUri('application/pdf', 'JVBER')).toBeNull()
})
})
+18
View File
@@ -0,0 +1,18 @@
// Builds an inline `data:` URI for base64 image bytes, shared by the desktop
// editor ImageViewer and the mobile file preview so both decode images the same
// way. Strips whitespace from the payload (base64 from git diffs and SSH streams
// can arrive line-wrapped) and returns null when there is nothing an <img>/RN
// <Image> can show — empty content or a non-image mime such as application/pdf.
export function buildImageDataUri(
mimeType: string | undefined,
base64Content: string
): string | null {
if (!mimeType || mimeType === 'application/pdf') {
return null
}
const cleaned = base64Content.replace(/\s/g, '')
if (!cleaned) {
return null
}
return `data:${mimeType};base64,${cleaned}`
}