Files
orca/mobile/src/source-control/mobile-source-control-screen-state.ts
T
JinjingandOrca a03a3dd51b 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>
2026-07-16 18:21:08 -07:00

153 lines
4.1 KiB
TypeScript

import {
ArrowDown,
ArrowDownUp,
ArrowUp,
Check,
CloudUpload,
GitBranch,
GitPullRequestArrow,
History,
RefreshCw,
type LucideIcon
} from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import type { MobileSourceControlActionIcon } from './mobile-source-control-actions'
import type { MobileDiffLine } from '../session/mobile-diff-lines'
import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax'
import type {
MobileGitBranchChangeEntry,
MobileGitBranchCompareResult,
MobileGitBranchCompareSummary
} from './mobile-branch-compare'
import {
canOpenMobileGitStatusEntry,
isMobileGitDiscardableEntry,
isMobileGitStageableEntry,
type MobileGitFileStatus,
type MobileGitStatusEntry,
type MobileGitStatusResult
} from './mobile-git-status'
export type ScreenState =
| { kind: 'loading' }
| { kind: 'ready'; status: MobileGitStatusResult }
| { kind: 'unavailable'; message: string }
| { kind: 'error'; message: string }
export type LoadStatusOptions = {
preserveReadyOnFailure?: boolean
clearActionErrorOnSuccess?: boolean
force?: boolean
}
export type StatusLoadInFlight = {
key: string
client: unknown
promise: Promise<boolean>
}
export type GitRequestError = Error & { code?: string }
export type GitCommitResult = { success: boolean; error?: string }
export type MobileGitStatusEntryView = MobileGitStatusEntry & {
canDiscard: boolean
canOpen: boolean
canStage: boolean
discardActionId: string
stageActionId: string
unstageActionId: string
}
// Decorate raw status entries with the row-level capability/action-id fields the
// 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: canOpenMobileGitStatusEntry(entry),
canStage: isMobileGitStageableEntry(entry),
discardActionId: `discard:${entry.path}`,
stageActionId: `stage:${entry.path}`,
unstageActionId: `unstage:${entry.path}`
}))
}
export type MobileBranchCompareState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; result: MobileGitBranchCompareResult }
| { kind: 'error'; message: string }
export type MobileBranchEntryView = MobileGitBranchChangeEntry & {
canOpen: boolean
}
export type MobileBranchDiffPreviewState =
| { kind: 'loading'; entry: MobileGitBranchChangeEntry }
| {
kind: 'ready'
entry: MobileGitBranchChangeEntry
summary: MobileGitBranchCompareSummary
lines: MobileHighlightedDiffLine<MobileDiffLine>[]
truncated: boolean
}
| { kind: 'error'; entry: MobileGitBranchChangeEntry; message: string }
export type GitDiffTextResult = {
kind: 'text'
originalContent: string
modifiedContent: string
}
export const KEYBOARD_COMMIT_BAR_CLEARANCE = 10
export const SOURCE_CONTROL_ACTION_ICONS: Record<MobileSourceControlActionIcon, LucideIcon> = {
commit: Check,
push: ArrowUp,
pull: ArrowDown,
sync: ArrowDownUp,
fetch: RefreshCw,
publish: CloudUpload,
rebase: GitBranch,
pr: GitPullRequestArrow,
branch: GitBranch,
history: History
}
export const SELECTOR_RETRY_COUNT = 3
export const SELECTOR_RETRY_DELAY_MS = 250
export function firstParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
}
export function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function formatBranchLabel(branch: string | undefined, head: string | undefined): string {
if (branch?.startsWith('refs/heads/')) {
return branch.slice('refs/heads/'.length)
}
return branch || head?.slice(0, 7) || 'No branch'
}
export function statusColor(status: MobileGitFileStatus): string {
switch (status) {
case 'added':
case 'copied':
return colors.statusGreen
case 'deleted':
return colors.statusRed
case 'renamed':
return colors.accentBlue
case 'untracked':
return colors.statusAmber
case 'modified':
default:
return colors.textSecondary
}
}