diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0251da70490..d6bf1187811 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 = diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts new file mode 100644 index 00000000000..7a705112c32 --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.test.ts @@ -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() + }) +}) diff --git a/mobile/src/files/mobile-diff-image-preview.ts b/mobile/src/files/mobile-diff-image-preview.ts new file mode 100644 index 00000000000..745cb513a2c --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.ts @@ -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 || '') +} diff --git a/mobile/src/files/mobile-file-tab-doc.ts b/mobile/src/files/mobile-file-tab-doc.ts new file mode 100644 index 00000000000..d4ed498afd2 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc.ts @@ -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 + +// 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 { + 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 + } +} diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 408afd795b3..88b57b092b6 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -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(() => { diff --git a/src/shared/image-data-uri.test.ts b/src/shared/image-data-uri.test.ts new file mode 100644 index 00000000000..a5e29446561 --- /dev/null +++ b/src/shared/image-data-uri.test.ts @@ -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 source)', () => { + expect(buildImageDataUri('application/pdf', 'JVBER')).toBeNull() + }) +}) diff --git a/src/shared/image-data-uri.ts b/src/shared/image-data-uri.ts new file mode 100644 index 00000000000..9217c6b9154 --- /dev/null +++ b/src/shared/image-data-uri.ts @@ -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 /RN +// 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}` +}