mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
@@ -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 || '')
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user