Render png on mobile (#9087)

* 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.

* Fix stale binary image fallback for empty modified diffs and relay reads

- mobileDiffImageDataUri now distinguishes a true deletion (modified
  side absent) from a modify whose binary bytes arrived empty
  (relay/size-cap cases), returning null instead of the stale
  pre-change image
- readWorkingDiffFile passes the file path to bufferToBlob so relay
  working-tree reads can detect previewable image extensions instead
  of always reporting empty binary content
- add mobile-file-tab-doc.test.ts covering diff/image/binary/text
  resolution paths

* Regenerate skill bundle manifest for 1.4.144-rc.2

Co-authored-by: Orca <help@stably.ai>

* fix(review): trim comments to AGENTS.md's one/two-line why-only rule

Comments in mobile-diff-image-preview.ts and mobile-file-tab-doc.ts ran
3-6 lines and narrated mechanism instead of stating only the non-obvious
reason, per AGENTS.md's "Code Comments: Document the Why, Briefly" rule.

Co-authored-by: Orca <help@stably.ai>

* Distinguish read failures from true deletions in binary diff results

- Working-tree stat/readFile errors and relay reads previously collapsed
  onto the same empty-content signal as a genuine deletion, letting
  previewers fall back to stale original bytes on a failed read.
- Add modifiedDeleted/missing flags through status.ts, git-handler-ops,
  and git-working-file-read so only proven deletions trigger the
  original-bytes fallback; failed reads now return null.
- Tighten buildImageDataUri to accept only image/* mimes instead of
  special-casing application/pdf.

* fix(relay): expect missing:false on index blob maxBuffer overflow

readBlobAtIndex now returns a missing flag so staged deletions are
distinct from size-capped binary reads; update the overflow test.

* Allow opening deleted files to show pre-delete text or image diffs

Deleted files can now be opened to view their pre-delete content via
git.diff (including images via modifiedDeleted). Only unresolved conflicts
remain unopenable. Centralizes the canOpen rule in canOpenMobileGitStatusEntry()
to keep opener guards consistent across the mobile source control UI.

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-16 18:21:08 -07:00
committed by GitHub
co-authored by Orca
parent cdc7a5ca46
commit a03a3dd51b
21 changed files with 631 additions and 129 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,123 @@
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 proven deletion (modifiedDeleted)', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: false,
modifiedDeleted: true,
isImage: true,
mimeType: 'image/jpeg'
})
).toBe('data:image/jpeg;base64,b2xk')
})
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
// side with modifiedIsBinary false and no modifiedDeleted flag. Without a proven
// deletion, falling back to the original would show a stale pre-change image, so
// "unavailable" (null) is the honest result.
it('returns null on a read failure (empty modified, no modifiedDeleted flag)', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: false,
isImage: true,
mimeType: 'image/png'
})
).toBeNull()
})
// Guards the >size-cap case: the modified side IS a binary image but its bytes
// arrive empty. Falling back to the original would render the stale pre-change
// image; "unavailable" is the honest result.
it('returns null for a modify whose binary modified side is empty (no stale fallback)', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: true,
isImage: true,
mimeType: 'image/png'
})
).toBeNull()
})
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()
})
it('returns null for application/pdf even when flagged as previewable', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: 'JVBER',
isImage: true,
mimeType: 'application/pdf'
})
).toBeNull()
})
it('returns null when flagged as image but mimeType is missing', () => {
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: 'bmV3',
isImage: true
})
).toBeNull()
})
})
@@ -0,0 +1,32 @@
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
// modifiedDeleted marks a proven deletion (modified side genuinely absent); an
// empty modifiedContent alone can't, since a relay/SSH read failure also arrives
// empty with modifiedIsBinary false.
export type MobileBinaryDiffResult = {
kind: 'binary'
originalContent?: string
modifiedContent?: string
originalIsBinary?: boolean
modifiedIsBinary?: boolean
modifiedDeleted?: boolean
isImage?: boolean
mimeType?: string
}
// Falls back to the original bytes only for a proven deletion; a read failure or
// size-capped modify — also empty on the modified side — returns null instead of
// the stale pre-change image.
export function mobileDiffImageDataUri(result: MobileBinaryDiffResult): string | null {
if (result.isImage !== true) {
return null
}
const modified = result.modifiedContent || ''
if (modified) {
return buildImageDataUri(result.mimeType, modified)
}
if (result.modifiedDeleted === true) {
return buildImageDataUri(result.mimeType, result.originalContent || '')
}
return null
}
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '../transport/types'
import { resolveMobileFileTabDoc } from './mobile-file-tab-doc'
function ok(result: unknown): RpcResponse {
return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } }
}
function fail(code: string, message: string): RpcResponse {
return { id: 'x', ok: false, error: { code, message }, _meta: { runtimeId: 'r' } }
}
// Fake client that returns a canned response per RPC method.
function clientOf(byMethod: Record<string, RpcResponse>): {
sendRequest: (method: string) => Promise<RpcResponse>
calls: string[]
} {
const calls: string[] = []
return {
calls,
sendRequest: (method: string) => {
calls.push(method)
const response = byMethod[method]
if (!response) {
throw new Error(`unexpected method ${method}`)
}
return Promise.resolve(response)
}
}
}
const WT = { worktreeId: 'wt1' }
describe('resolveMobileFileTabDoc', () => {
it('renders a staged text diff', async () => {
const client = clientOf({
'git.diff': ok({ kind: 'text', originalContent: 'a\n', modifiedContent: 'a\nb\n' })
})
const doc = await resolveMobileFileTabDoc(client, {
...WT,
relativePath: 'a.ts',
diffSource: 'staged'
})
expect(doc.kind).toBe('diff')
expect(client.calls).toEqual(['git.diff'])
})
it('renders an unstaged image diff from the modified bytes', async () => {
const client = clientOf({
'git.diff': ok({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: 'bmV3',
modifiedIsBinary: true,
isImage: true,
mimeType: 'image/png'
})
})
const doc = await resolveMobileFileTabDoc(client, {
...WT,
relativePath: 'm1.png',
diffSource: 'unstaged'
})
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
})
it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => {
const client = clientOf({
'git.diff': ok({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: '',
modifiedIsBinary: true,
isImage: true,
mimeType: 'image/png'
})
})
await expect(
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'm1.png', diffSource: 'unstaged' })
).rejects.toThrow('binary_file')
})
it('throws binary_file for a non-image binary diff', async () => {
const client = clientOf({ 'git.diff': ok({ kind: 'binary', modifiedContent: 'AAAA' }) })
await expect(
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.bin', diffSource: 'unstaged' })
).rejects.toThrow('binary_file')
})
it('renders a live image preview via files.readPreview', async () => {
const client = clientOf({
'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
expect(client.calls).toEqual(['files.readPreview'])
})
it('throws binary_file when readPreview returns no image bytes', async () => {
const client = clientOf({
'files.readPreview': ok({ content: '', isImage: true, mimeType: 'image/png' })
})
await expect(
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
).rejects.toThrow('binary_file')
})
it('renders html source via files.read', async () => {
const client = clientOf({
'files.read': ok({ content: '<h1>hi</h1>', truncated: false, byteLength: 11 })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'page.html' })
expect(doc).toEqual({ status: 'ready', kind: 'html', content: '<h1>hi</h1>' })
})
it('renders a plain text file via files.read', async () => {
const client = clientOf({
'files.read': ok({ content: 'hello', truncated: true, byteLength: 5 })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' })
expect(doc).toEqual({
status: 'ready',
kind: 'file',
content: 'hello',
truncated: true,
byteLength: 5
})
})
it('propagates the RPC error message when a read fails', async () => {
const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') })
await expect(
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' })
).rejects.toThrow('file_too_large')
})
})
+94
View File
@@ -0,0 +1,94 @@
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'
}
// Throws 'binary_file'/'file_too_large'/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
}
}
@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import {
buildMobileSourceControlSections,
canOpenMobileGitStatusEntry,
countStagedEntries,
countUnstagedEntries,
getStageablePaths,
@@ -65,6 +66,31 @@ describe('mobile source control status helpers', () => {
expect(isMobileGitDiscardableEntry(conflictedEntries[2])).toBe(false)
})
it('allows opening deleted files so pre-delete text/image diffs can load', () => {
expect(
canOpenMobileGitStatusEntry({ path: 'deleted.png', status: 'deleted', area: 'unstaged' })
).toBe(true)
expect(
canOpenMobileGitStatusEntry({ path: 'logo.png', status: 'modified', area: 'unstaged' })
).toBe(true)
expect(
canOpenMobileGitStatusEntry({
path: 'conflict.ts',
status: 'modified',
area: 'unstaged',
conflictStatus: 'unresolved'
})
).toBe(false)
expect(
canOpenMobileGitStatusEntry({
path: 'resolved.ts',
status: 'modified',
area: 'unstaged',
conflictStatus: 'resolved_locally'
})
).toBe(true)
})
it('sorts entries by desktop-compatible conflict rank, then path', () => {
const sections = buildMobileSourceControlSections([
{ path: 'zeta.ts', status: 'modified', area: 'unstaged' },
@@ -90,6 +90,12 @@ export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolea
return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally'
}
// Why: unresolved conflicts are not a stable file to open. Deletions are —
// git.diff still returns the pre-delete side (text or image via modifiedDeleted).
export function canOpenMobileGitStatusEntry(entry: MobileGitStatusEntry): boolean {
return entry.conflictStatus !== 'unresolved'
}
export function isMobileGitUnavailable(code: string | undefined, message: string | undefined) {
return (
code === 'forbidden' ||
@@ -20,6 +20,7 @@ import type {
MobileGitBranchCompareSummary
} from './mobile-branch-compare'
import {
canOpenMobileGitStatusEntry,
isMobileGitDiscardableEntry,
isMobileGitStageableEntry,
type MobileGitFileStatus,
@@ -58,15 +59,14 @@ export type MobileGitStatusEntryView = MobileGitStatusEntry & {
}
// Decorate raw status entries with the row-level capability/action-id fields the
// file list needs. Deleted/unresolved entries are not openable (matches the
// opener guards).
// file list needs. Opener guards must use the same canOpen rule.
export function buildMobileGitStatusEntryViews(
entries: readonly MobileGitStatusEntry[]
): MobileGitStatusEntryView[] {
return entries.map((entry) => ({
...entry,
canDiscard: isMobileGitDiscardableEntry(entry),
canOpen: entry.status !== 'deleted' && entry.conflictStatus !== 'unresolved',
canOpen: canOpenMobileGitStatusEntry(entry),
canStage: isMobileGitStageableEntry(entry),
discardActionId: `discard:${entry.path}`,
stageActionId: `stage:${entry.path}`,
@@ -12,7 +12,11 @@ import {
canOpenMobileBranchCompareDiff,
type MobileGitBranchChangeEntry
} from './mobile-branch-compare'
import { isMobileGitUnavailable, type MobileGitStatusEntry } from './mobile-git-status'
import {
canOpenMobileGitStatusEntry,
isMobileGitUnavailable,
type MobileGitStatusEntry
} from './mobile-git-status'
import { buildMobileReviewFileRoute } from './mobile-review-route'
import type {
GitDiffTextResult,
@@ -70,7 +74,9 @@ export function useMobileSourceControlOpeners(params: Params) {
const openFile = useCallback(
async (entry: MobileGitStatusEntry) => {
if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') {
// Deletions are openable (pre-delete text/image via git.diff); only block
// unresolved conflicts, matching canOpenMobileGitStatusEntry / row UI.
if (!canOpenMobileGitStatusEntry(entry)) {
return
}
if (openingPathRef.current || busyActionRef.current) {
+31
View File
@@ -476,6 +476,37 @@ describe('getDiff', () => {
})
})
it('flags a deleted image so previewers can fall back to the original bytes', async () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer })
statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const result = await getDiff('/repo', 'assets/deleted.png', false)
expect(result.kind).toBe('binary')
if (result.kind !== 'binary') {
throw new Error('expected binary diff result')
}
expect(result.modifiedDeleted).toBe(true)
expect(result.originalContent).toBe(pngBuffer.toString('base64'))
expect(result.modifiedContent).toBe('')
})
it('does not treat an unreadable working-tree image as a deletion', async () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer })
statMock.mockResolvedValueOnce({ isFile: () => true, size: 5 })
readFileMock.mockRejectedValueOnce(new Error('EIO'))
const result = await getDiff('/repo', 'assets/unreadable.png', false)
expect(result.kind).toBe('binary')
if (result.kind !== 'binary') {
throw new Error('expected binary diff result')
}
expect(result.modifiedDeleted).toBeUndefined()
})
it('coalesces concurrent identical staged diff reads while in flight', async () => {
const leftBlob = deferredBuffer('head-content\n')
const rightBlob = deferredBuffer('index-content\n')
+32 -10
View File
@@ -1329,6 +1329,7 @@ async function loadDiff(
let modifiedContent = ''
let originalIsBinary = false
let modifiedIsBinary = false
let modifiedDeleted = false
try {
const leftBlob = staged
@@ -1343,22 +1344,30 @@ async function loadDiff(
const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options)
modifiedContent = rightBlob.content
modifiedIsBinary = rightBlob.isBinary
modifiedDeleted = !rightBlob.exists
} else {
const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath))
modifiedContent = workingTreeBlob.content
modifiedIsBinary = workingTreeBlob.isBinary
modifiedDeleted = !workingTreeBlob.exists
}
} catch {
// Fallback
}
return buildDiffResult(
const result = buildDiffResult(
originalContent,
modifiedContent,
originalIsBinary,
modifiedIsBinary,
filePath
)
// Why: mark a proven deletion so previewers can fall back to the original bytes
// without mistaking a read failure's empty modified side for a deletion.
if (result.kind === 'binary' && modifiedDeleted) {
return { ...result, modifiedDeleted: true }
}
return result
}
export async function getBranchCompare(
@@ -1859,20 +1868,33 @@ async function readGitBlobAtOidPath(
}
async function readWorkingTreeFile(filePath: string): Promise<GitBlobReadResult> {
let fileStat
try {
const fileStat = await stat(filePath)
if (!fileStat.isFile()) {
return { content: '', isBinary: false, exists: false }
}
if (fileStat.size > MAX_GIT_SHOW_BYTES) {
// Why: git blob reads are capped through maxBuffer; mirror that bound for
// unstaged working-tree content before readFile can pull in huge assets.
return { content: '', isBinary: true, exists: true }
fileStat = await stat(filePath)
} catch (error) {
// Why: ENOENT means the working-tree file is genuinely gone (a deletion);
// any other stat error is a read failure, which must not be reported as an
// absence since callers fall back to the original bytes only for deletions.
return {
content: '',
isBinary: false,
exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT'
}
}
if (!fileStat.isFile()) {
return { content: '', isBinary: false, exists: false }
}
if (fileStat.size > MAX_GIT_SHOW_BYTES) {
// Why: git blob reads are capped through maxBuffer; mirror that bound for
// unstaged working-tree content before readFile can pull in huge assets.
return { content: '', isBinary: true, exists: true }
}
try {
const buffer = await readFile(filePath)
return bufferToBlob(buffer, filePath)
} catch {
return { content: '', isBinary: false, exists: false }
// Why: the file exists but could not be read — a read failure, not a deletion.
return { content: '', isBinary: false, exists: true }
}
}
+1 -2
View File
@@ -22,8 +22,7 @@ const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul'
// (`` -> `Γ¥»`). Switch the console to UTF-8, then exec the normal interactive
// login shell; cmd.exe and PowerShell already do the equivalent. The `;` (not
// `&&`) keeps startup working even if chcp.com is missing.
const GIT_BASH_UTF8_LOGIN_COMMAND =
'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'
const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'
/** Result of resolving a Windows shell to its launch args + effective cwd.
*
+2 -1
View File
@@ -44,6 +44,7 @@ describe('git blob readers', () => {
const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log')
expect(result).toEqual({ content: '', isBinary: true })
// Why: overflow is size-capped content, not a staged deletion (missing: false).
expect(result).toEqual({ content: '', isBinary: true, missing: false })
})
})
+16 -5
View File
@@ -52,17 +52,19 @@ export async function readBlobAtIndex(
gitBuffer: GitBufferExec,
cwd: string,
filePath: string
): Promise<{ content: string; isBinary: boolean }> {
): Promise<{ content: string; isBinary: boolean; missing: boolean }> {
// Why: Git's `:<path>` syntax expects forward slashes even on Windows.
const gitPath = filePath.replace(/\\/g, '/')
try {
const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd)
return bufferToBlob(buf, filePath)
return { ...bufferToBlob(buf, filePath), missing: false }
} catch (error) {
if (isGitBufferOverflowError(error)) {
return { content: '', isBinary: true }
return { content: '', isBinary: true, missing: false }
}
return { content: '', isBinary: false }
// Why: a non-overflow failure means the path is absent from the index (a
// staged deletion), distinct from the size-capped case handled above.
return { content: '', isBinary: false, missing: true }
}
}
@@ -91,6 +93,7 @@ export async function computeDiff(
let modifiedContent = ''
let originalIsBinary = false
let modifiedIsBinary = false
let modifiedDeleted = false
try {
if (staged) {
@@ -101,6 +104,7 @@ export async function computeDiff(
const right = await readBlobAtIndex(git, worktreePath, filePath)
modifiedContent = right.content
modifiedIsBinary = right.isBinary
modifiedDeleted = right.missing
} else {
const left = compareAgainstHead
? await readBlobAtOid(git, worktreePath, 'HEAD', filePath)
@@ -111,18 +115,25 @@ export async function computeDiff(
const right = await readWorkingDiffFile(path.join(worktreePath, filePath))
modifiedContent = right.content
modifiedIsBinary = right.isBinary
modifiedDeleted = right.missing
}
} catch {
// Fallback to empty
}
return buildDiffResult(
const result = buildDiffResult(
originalContent,
modifiedContent,
originalIsBinary,
modifiedIsBinary,
filePath
)
// Why: mark a proven deletion so previewers can fall back to the original bytes
// without mistaking a read failure's empty modified side for a deletion.
if (result.kind === 'binary' && modifiedDeleted) {
return { ...result, modifiedDeleted: true }
}
return result
}
// ─── Branch compare ──────────────────────────────────────────────────
+32 -4
View File
@@ -21,7 +21,8 @@ describe('readWorkingDiffFile', () => {
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: 'hello',
isBinary: false
isBinary: false,
missing: false
})
})
@@ -32,7 +33,32 @@ describe('readWorkingDiffFile', () => {
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: '',
isBinary: true
isBinary: true,
missing: false
})
})
it('base64-encodes a previewable image by its extension', async () => {
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
const filePath = path.join(tmpDir, 'icon.png')
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01])
await writeFile(filePath, pngBytes)
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: pngBytes.toString('base64'),
isBinary: true,
missing: false
})
})
it('reports an absent working-tree file as a deletion', async () => {
tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-'))
const filePath = path.join(tmpDir, 'deleted.png')
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: '',
isBinary: false,
missing: true
})
})
@@ -48,7 +74,8 @@ describe('readWorkingDiffFile', () => {
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: pngBuffer.toString('base64'),
isBinary: true
isBinary: true,
missing: false
})
})
@@ -59,7 +86,8 @@ describe('readWorkingDiffFile', () => {
await expect(readWorkingDiffFile(filePath)).resolves.toEqual({
content: '',
isBinary: true
isBinary: true,
missing: false
})
})
})
+23 -11
View File
@@ -5,19 +5,31 @@ const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024
export async function readWorkingDiffFile(
absPath: string
): Promise<{ content: string; isBinary: boolean }> {
): Promise<{ content: string; isBinary: boolean; missing: boolean }> {
let fileStat
try {
fileStat = await stat(absPath)
} catch (error) {
// Why: ENOENT means the working-tree file is genuinely gone (a deletion);
// any other stat error is a read failure we must not mistake for one, since
// callers fall back to the original bytes only for a proven deletion.
const missing = (error as NodeJS.ErrnoException)?.code === 'ENOENT'
return { content: '', isBinary: false, missing }
}
if (!fileStat.isFile()) {
return { content: '', isBinary: false, missing: true }
}
if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) {
// Why: mirror local git diff reads, which cap blob transfer at 10MB.
return { content: '', isBinary: true, missing: false }
}
try {
const fileStat = await stat(absPath)
if (!fileStat.isFile()) {
return { content: '', isBinary: false }
}
if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) {
// Why: mirror local git diff reads, which cap blob transfer at 10MB.
return { content: '', isBinary: true }
}
const buffer = await readFile(absPath)
return bufferToBlob(buffer, absPath)
// Why: bufferToBlob needs the path's extension to know an image is
// previewable; omitting it made every relay-side binary diff empty.
return { ...bufferToBlob(buffer, absPath), missing: false }
} catch {
return { content: '', isBinary: false }
// Why: the file exists but could not be read — a read failure, not a deletion.
return { content: '', isBinary: false, missing: false }
}
}
@@ -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(() => {
@@ -23,7 +23,6 @@ export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHA
export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
* later question bodies/Enters). Safe to call after the send completes. */
export type NativeChatSendHandle = {
+30
View File
@@ -0,0 +1,30 @@
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()
})
it('returns null for a non-image mime such as application/octet-stream', () => {
expect(buildImageDataUri('application/octet-stream', 'AAAA')).toBeNull()
})
})
+20
View File
@@ -0,0 +1,20 @@
// 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 (PDF, octet-stream, …).
export function buildImageDataUri(
mimeType: string | undefined,
base64Content: string
): string | null {
// Only image/* renders in an <img>/RN <Image>; reject every other mime
// (application/pdf, application/octet-stream, …), not just PDF.
if (!mimeType?.startsWith('image/')) {
return null
}
const cleaned = base64Content.replace(/\s/g, '')
if (!cleaned) {
return null
}
return `data:${mimeType};base64,${cleaned}`
}
+7
View File
@@ -3742,6 +3742,13 @@ export type GitDiffBinaryResult = {
isImage?: boolean
/** MIME type for binary preview rendering, e.g. "image/png" or "application/pdf" */
mimeType?: string
/**
* True only when the modified side is a proven deletion (working-tree file gone
* or absent from the index) — distinct from an empty modified side caused by a
* read failure or size cap. Lets previewers fall back to the original bytes for
* a deletion without showing a stale image on a failed read.
*/
modifiedDeleted?: boolean
} & (
| { originalIsBinary: true; modifiedIsBinary: boolean }
| { originalIsBinary: boolean; modifiedIsBinary: true }