diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index d92199758..8477bc614 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -59,6 +59,10 @@ import {
loadCustomKeys,
type CustomKey
} from '../../../../src/components/CustomKeyModal'
+import {
+ buildMobileDiffLines,
+ type MobileDiffLine
+} from '../../../../src/session/mobile-diff-lines'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
type Terminal = {
@@ -95,6 +99,8 @@ type MobileSessionTab =
filePath: string
relativePath: string
language?: string
+ mode?: 'edit' | 'diff'
+ diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
isDirty: boolean
isActive: boolean
}
@@ -126,7 +132,8 @@ type MarkdownDocState =
type FileDocState =
| { status: 'loading' }
- | { status: 'ready'; content: string; truncated: boolean; byteLength: number }
+ | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number }
+ | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean }
| { status: 'error'; message: string }
type DirtyMarkdownDraft = {
@@ -414,6 +421,44 @@ function FileReader({ doc, title }: { doc: FileDocState | undefined; title: stri
)
}
+ if (doc.kind === 'diff') {
+ return (
+
+
+ {doc.lines.map((line, index) => (
+
+
+ {line.oldLineNumber ?? line.newLineNumber ?? ''}
+
+
+ {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
+ {line.text}
+
+
+ ))}
+
+
+ )
+ }
+
return (
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 response = await client.sendRequest('files.read', {
worktree: `id:${worktreeId}`,
relativePath: tab.relativePath
@@ -1128,6 +1203,7 @@ export default function SessionScreen() {
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
+ kind: 'file',
content: result.content,
truncated: result.truncated,
byteLength: result.byteLength
@@ -1140,7 +1216,9 @@ export default function SessionScreen() {
? 'Binary preview unavailable'
: message === 'file_too_large'
? 'File too large for mobile preview'
- : "Couldn't load file preview"
+ : tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
+ ? "Couldn't load diff preview"
+ : "Couldn't load file preview"
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
@@ -3135,6 +3213,42 @@ const styles = StyleSheet.create({
lineHeight: 22,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
+ diffLine: {
+ flexDirection: 'row',
+ borderLeftWidth: 2,
+ borderLeftColor: colors.bgBase,
+ paddingRight: spacing.sm
+ },
+ diffLineAdded: {
+ backgroundColor: colors.bgPanel,
+ borderLeftColor: colors.statusGreen
+ },
+ diffLineDeleted: {
+ backgroundColor: colors.bgPanel,
+ borderLeftColor: colors.statusRed
+ },
+ diffGutter: {
+ width: 42,
+ paddingRight: spacing.sm,
+ textAlign: 'right',
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ lineHeight: 22,
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
+ },
+ diffText: {
+ flex: 1,
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ lineHeight: 22,
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
+ },
+ diffTextAdded: {
+ color: colors.statusGreen
+ },
+ diffTextDeleted: {
+ color: colors.statusRed
+ },
markdownRefreshButton: {
alignSelf: 'flex-start',
flexDirection: 'row',
diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
index 7186bc64f..11a293f53 100644
--- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
@@ -578,12 +578,19 @@ export default function MobileSourceControlScreen() {
setOpeningPath(entry.path)
try {
setActionError(null)
- const response = await client.sendRequest('files.open', {
+ let response = await client.sendRequest('files.openDiff', {
worktree: `id:${worktreeId}`,
- relativePath: entry.path
+ relativePath: entry.path,
+ staged: entry.area === 'staged'
})
+ if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) {
+ response = await client.sendRequest('files.open', {
+ worktree: `id:${worktreeId}`,
+ relativePath: entry.path
+ })
+ }
if (!response.ok) {
- throw new Error(response.error?.message || 'Unable to open file')
+ throw new Error(response.error?.message || 'Unable to open diff')
}
if (!mountedRef.current) return
triggerSelection()
@@ -602,7 +609,7 @@ export default function MobileSourceControlScreen() {
} catch (err) {
if (!mountedRef.current) return
triggerError()
- setActionError(err instanceof Error ? err.message : 'Unable to open file')
+ setActionError(err instanceof Error ? err.message : 'Unable to open diff')
} finally {
if (openingPathRef.current === entry.path) {
openingPathRef.current = null
diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts
index 64660ff4d..72cd9db0d 100644
--- a/mobile/scripts/mock-server.ts
+++ b/mobile/scripts/mock-server.ts
@@ -317,12 +317,36 @@ function handleRequest(
send(success(request.id, { ok: true }))
break
+ case 'git.diff':
+ send(
+ success(request.id, {
+ kind: 'text',
+ originalContent: 'const status = "old"\\n',
+ modifiedContent: 'const status = "new"\\n',
+ originalIsBinary: false,
+ modifiedIsBinary: false
+ })
+ )
+ break
+
case 'git.push':
fakeHasUpstream = true
fakeAhead = 0
send(success(request.id, { ok: true }))
break
+ case 'files.open':
+ case 'files.openDiff':
+ send(
+ success(request.id, {
+ worktree: request.params?.worktree ?? 'id:mock',
+ relativePath: request.params?.relativePath ?? '',
+ kind: 'text',
+ opened: true
+ })
+ )
+ break
+
default:
send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`))
}
diff --git a/mobile/src/session/mobile-diff-lines.test.ts b/mobile/src/session/mobile-diff-lines.test.ts
new file mode 100644
index 000000000..1b66d9b76
--- /dev/null
+++ b/mobile/src/session/mobile-diff-lines.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { buildMobileDiffLines } from './mobile-diff-lines'
+
+describe('buildMobileDiffLines', () => {
+ it('marks added, deleted, and unchanged lines', () => {
+ const result = buildMobileDiffLines('one\ntwo\nthree\n', 'one\nTWO\nthree\nfour\n')
+
+ expect(result.truncated).toBe(false)
+ expect(result.lines).toEqual([
+ { kind: 'context', text: 'one', oldLineNumber: 1, newLineNumber: 1 },
+ { kind: 'delete', text: 'two', oldLineNumber: 2 },
+ { kind: 'add', text: 'TWO', newLineNumber: 2 },
+ { kind: 'context', text: 'three', oldLineNumber: 3, newLineNumber: 3 },
+ { kind: 'add', text: 'four', newLineNumber: 4 }
+ ])
+ })
+})
diff --git a/mobile/src/session/mobile-diff-lines.ts b/mobile/src/session/mobile-diff-lines.ts
new file mode 100644
index 000000000..e887daa7b
--- /dev/null
+++ b/mobile/src/session/mobile-diff-lines.ts
@@ -0,0 +1,161 @@
+export type MobileDiffLineKind = 'context' | 'add' | 'delete'
+
+export type MobileDiffLine = {
+ kind: MobileDiffLineKind
+ text: string
+ oldLineNumber?: number
+ newLineNumber?: number
+}
+
+const MAX_DIFF_CELLS = 200_000
+const MAX_MOBILE_DIFF_LINES = 2_500
+const TRUNCATED_LINE: MobileDiffLine = {
+ kind: 'context',
+ text: '... diff truncated for mobile preview ...'
+}
+
+export function buildMobileDiffLines(
+ originalContent: string,
+ modifiedContent: string
+): { lines: MobileDiffLine[]; truncated: boolean } {
+ const originalLines = splitContentLines(originalContent)
+ const modifiedLines = splitContentLines(modifiedContent)
+ // Why: the LCS table is quadratic. Large generated files still need a
+ // responsive mobile preview, so fall back to prefix/suffix diffing.
+ const lines =
+ originalLines.length * modifiedLines.length <= MAX_DIFF_CELLS
+ ? buildLcsDiffLines(originalLines, modifiedLines)
+ : buildPrefixSuffixDiffLines(originalLines, modifiedLines)
+
+ if (lines.length <= MAX_MOBILE_DIFF_LINES) {
+ return { lines, truncated: false }
+ }
+ return { lines: [...lines.slice(0, MAX_MOBILE_DIFF_LINES), TRUNCATED_LINE], truncated: true }
+}
+
+function splitContentLines(content: string): string[] {
+ if (content.length === 0) {
+ return []
+ }
+ const lines = content.split(/\r?\n/)
+ if (content.endsWith('\n')) {
+ lines.pop()
+ }
+ return lines
+}
+
+function buildLcsDiffLines(originalLines: string[], modifiedLines: string[]): MobileDiffLine[] {
+ const rowWidth = modifiedLines.length + 1
+ const dp = new Uint32Array((originalLines.length + 1) * rowWidth)
+ for (let i = originalLines.length - 1; i >= 0; i -= 1) {
+ for (let j = modifiedLines.length - 1; j >= 0; j -= 1) {
+ dp[i * rowWidth + j] =
+ originalLines[i] === modifiedLines[j]
+ ? dp[(i + 1) * rowWidth + j + 1] + 1
+ : Math.max(dp[(i + 1) * rowWidth + j], dp[i * rowWidth + j + 1])
+ }
+ }
+
+ const lines: MobileDiffLine[] = []
+ let originalIndex = 0
+ let modifiedIndex = 0
+ while (originalIndex < originalLines.length && modifiedIndex < modifiedLines.length) {
+ if (originalLines[originalIndex] === modifiedLines[modifiedIndex]) {
+ lines.push({
+ kind: 'context',
+ text: originalLines[originalIndex] ?? '',
+ oldLineNumber: originalIndex + 1,
+ newLineNumber: modifiedIndex + 1
+ })
+ originalIndex += 1
+ modifiedIndex += 1
+ } else if (
+ dp[(originalIndex + 1) * rowWidth + modifiedIndex] >=
+ dp[originalIndex * rowWidth + modifiedIndex + 1]
+ ) {
+ lines.push({
+ kind: 'delete',
+ text: originalLines[originalIndex] ?? '',
+ oldLineNumber: originalIndex + 1
+ })
+ originalIndex += 1
+ } else {
+ lines.push({
+ kind: 'add',
+ text: modifiedLines[modifiedIndex] ?? '',
+ newLineNumber: modifiedIndex + 1
+ })
+ modifiedIndex += 1
+ }
+ }
+
+ while (originalIndex < originalLines.length) {
+ lines.push({
+ kind: 'delete',
+ text: originalLines[originalIndex] ?? '',
+ oldLineNumber: originalIndex + 1
+ })
+ originalIndex += 1
+ }
+ while (modifiedIndex < modifiedLines.length) {
+ lines.push({
+ kind: 'add',
+ text: modifiedLines[modifiedIndex] ?? '',
+ newLineNumber: modifiedIndex + 1
+ })
+ modifiedIndex += 1
+ }
+
+ return lines
+}
+
+function buildPrefixSuffixDiffLines(
+ originalLines: string[],
+ modifiedLines: string[]
+): MobileDiffLine[] {
+ let prefixLength = 0
+ while (
+ prefixLength < originalLines.length &&
+ prefixLength < modifiedLines.length &&
+ originalLines[prefixLength] === modifiedLines[prefixLength]
+ ) {
+ prefixLength += 1
+ }
+
+ let suffixLength = 0
+ while (
+ suffixLength + prefixLength < originalLines.length &&
+ suffixLength + prefixLength < modifiedLines.length &&
+ originalLines[originalLines.length - suffixLength - 1] ===
+ modifiedLines[modifiedLines.length - suffixLength - 1]
+ ) {
+ suffixLength += 1
+ }
+
+ const lines: MobileDiffLine[] = []
+ for (let i = 0; i < prefixLength; i += 1) {
+ lines.push({
+ kind: 'context',
+ text: originalLines[i] ?? '',
+ oldLineNumber: i + 1,
+ newLineNumber: i + 1
+ })
+ }
+ for (let i = prefixLength; i < originalLines.length - suffixLength; i += 1) {
+ lines.push({ kind: 'delete', text: originalLines[i] ?? '', oldLineNumber: i + 1 })
+ }
+ for (let i = prefixLength; i < modifiedLines.length - suffixLength; i += 1) {
+ lines.push({ kind: 'add', text: modifiedLines[i] ?? '', newLineNumber: i + 1 })
+ }
+ for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) {
+ const modifiedIndex =
+ modifiedLines.length - suffixLength + (i - (originalLines.length - suffixLength))
+ lines.push({
+ kind: 'context',
+ text: originalLines[i] ?? '',
+ oldLineNumber: i + 1,
+ newLineNumber: modifiedIndex + 1
+ })
+ }
+ return lines
+}
diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts
index e18e287f1..66dc75086 100644
--- a/src/main/runtime/orca-runtime-files.test.ts
+++ b/src/main/runtime/orca-runtime-files.test.ts
@@ -70,6 +70,32 @@ describe('RuntimeFileCommands', () => {
vi.useRealTimers()
})
+ it('opens source control diffs through the renderer host', async () => {
+ const openDiff = vi.fn()
+ const commands = new RuntimeFileCommands({
+ getRuntimeId: () => 'runtime-1',
+ requireStore: () => ({ getRepo: vi.fn(() => undefined) }),
+ resolveWorktreeSelector: vi.fn(async () => ({
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/repo'
+ })),
+ resolveRuntimeGitTarget: vi.fn(),
+ openFile: vi.fn(),
+ openDiff
+ } as never)
+
+ const result = await commands.openMobileDiff('id:wt-1', 'docs/readme.md', true)
+
+ expect(openDiff).toHaveBeenCalledWith('wt-1', '/repo/docs/readme.md', 'docs/readme.md', true)
+ expect(result).toEqual({
+ worktree: 'wt-1',
+ relativePath: 'docs/readme.md',
+ kind: 'markdown',
+ opened: true
+ })
+ })
+
it('uses a conservative Node watcher for Windows runtime file watches', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts
index 2346e4c02..de05bf41e 100644
--- a/src/main/runtime/orca-runtime-files.ts
+++ b/src/main/runtime/orca-runtime-files.ts
@@ -116,6 +116,7 @@ export type RuntimeFileCommandHost = {
selector: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }>
openFile(worktreeId: string, filePath: string, relativePath: string): void
+ openDiff(worktreeId: string, filePath: string, relativePath: string, staged: boolean): void
}
export class RuntimeFileCommands {
@@ -171,6 +172,25 @@ export class RuntimeFileCommands {
return { worktree: worktree.id, relativePath, kind, opened: true }
}
+ async openMobileDiff(
+ worktreeSelector: string,
+ relativePath: string,
+ staged: boolean
+ ): Promise {
+ const worktree = await this.host.resolveWorktreeSelector(worktreeSelector)
+ if (!isSafeMobileRelativePath(relativePath)) {
+ throw new Error('invalid_relative_path')
+ }
+ const kind = isMobileBinaryPath(relativePath)
+ ? 'binary'
+ : isMobileMarkdownPath(relativePath)
+ ? 'markdown'
+ : 'text'
+ const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
+ this.host.openDiff(worktree.id, filePath, relativePath, staged)
+ return { worktree: worktree.id, relativePath, kind, opened: true }
+ }
+
async readMobileFile(
worktreeSelector: string,
relativePath: string
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 7dead6096..c197143cb 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -433,6 +433,7 @@ type RuntimeNotifier = {
focusEditorTab?(tabId: string, worktreeId: string): void
closeSessionTab?(tabId: string, worktreeId: string): void
openFile?(worktreeId: string, filePath: string, relativePath: string): void
+ openDiff?(worktreeId: string, filePath: string, relativePath: string, staged: boolean): void
readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise
saveMobileMarkdownTab?(
worktreeId: string,
@@ -1173,6 +1174,12 @@ export class OrcaRuntimeService {
throw new Error('renderer_unavailable')
}
this.notifier.openFile(worktreeId, filePath, relativePath)
+ },
+ openDiff: (worktreeId, filePath, relativePath, staged) => {
+ if (!this.notifier?.openDiff) {
+ throw new Error('renderer_unavailable')
+ }
+ this.notifier.openDiff(worktreeId, filePath, relativePath, staged)
}
})
@@ -1182,6 +1189,9 @@ export class OrcaRuntimeService {
openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind(
this.fileCommands
)
+ openMobileDiff: RuntimeFileCommands['openMobileDiff'] = this.fileCommands.openMobileDiff.bind(
+ this.fileCommands
+ )
readMobileFile: RuntimeFileCommands['readMobileFile'] = this.fileCommands.readMobileFile.bind(
this.fileCommands
)
diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts
index 708121e96..4d7322ecf 100644
--- a/src/main/runtime/rpc/methods/files.test.ts
+++ b/src/main/runtime/rpc/methods/files.test.ts
@@ -56,6 +56,33 @@ describe('file RPC methods', () => {
})
})
+ it('opens a source control diff for a selected worktree', async () => {
+ const runtime = {
+ getRuntimeId: () => 'test-runtime',
+ openMobileDiff: vi.fn().mockResolvedValue({
+ worktree: 'wt-1',
+ relativePath: 'docs/readme.md',
+ kind: 'markdown',
+ opened: true
+ })
+ } as unknown as OrcaRuntimeService
+ const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
+
+ const response = await dispatcher.dispatch(
+ makeRequest('files.openDiff', {
+ worktree: 'id:wt-1',
+ relativePath: 'docs/readme.md',
+ staged: true
+ })
+ )
+
+ expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true)
+ expect(response).toMatchObject({
+ ok: true,
+ result: { kind: 'markdown', opened: true }
+ })
+ })
+
it('streams file watch changes until the subscription is cleaned up', async () => {
vi.useFakeTimers()
try {
diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts
index c97d211fe..3c5c65f9a 100644
--- a/src/main/runtime/rpc/methods/files.ts
+++ b/src/main/runtime/rpc/methods/files.ts
@@ -1,3 +1,4 @@
+/* oxlint-disable max-lines -- Why: file RPC routing coverage stays together so the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */
import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { createFileWatchEventBatcher } from './file-watch-event-batcher'
@@ -18,6 +19,10 @@ const FileOpen = WorktreeSelector.extend({
.pipe(z.string().min(1, 'Missing relative path'))
})
+const FileOpenDiff = FileOpen.extend({
+ staged: z.boolean().optional()
+})
+
const FileTreePath = WorktreeSelector.extend({
relativePath: z
.unknown()
@@ -116,6 +121,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime }) =>
runtime.openMobileFile(params.worktree, params.relativePath)
}),
+ defineMethod({
+ name: 'files.openDiff',
+ params: FileOpenDiff,
+ handler: async (params, { runtime }) =>
+ runtime.openMobileDiff(params.worktree, params.relativePath, params.staged === true)
+ }),
defineMethod({
name: 'files.read',
params: FileOpen,
diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts
index 2ed6eaf58..1681cc8b4 100644
--- a/src/main/runtime/runtime-rpc.test.ts
+++ b/src/main/runtime/runtime-rpc.test.ts
@@ -603,6 +603,19 @@ describe('OrcaRuntimeRpcServer', () => {
.mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0 })
const bulkStageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
const bulkUnstageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
+ const getRuntimeGitDiff = vi.fn().mockResolvedValue({
+ kind: 'text',
+ originalContent: 'before\n',
+ modifiedContent: 'after\n',
+ originalIsBinary: false,
+ modifiedIsBinary: false
+ })
+ const openMobileDiff = vi.fn().mockResolvedValue({
+ worktree: 'wt-1',
+ relativePath: 'docs/readme.md',
+ kind: 'markdown',
+ opened: true
+ })
const runtime = {
getRuntimeId: () => 'test-runtime',
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }),
@@ -614,7 +627,9 @@ describe('OrcaRuntimeRpcServer', () => {
getRuntimeGitStatus,
getRuntimeGitUpstreamStatus,
bulkStageRuntimeGitPaths,
- bulkUnstageRuntimeGitPaths
+ bulkUnstageRuntimeGitPaths,
+ getRuntimeGitDiff,
+ openMobileDiff
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
@@ -730,6 +745,26 @@ describe('OrcaRuntimeRpcServer', () => {
(response) => replies.push(JSON.parse(response) as Record),
() => {}
)
+ await server['handleWebSocketMessage'](
+ JSON.stringify({
+ id: 'req_files_open_diff',
+ method: 'files.openDiff',
+ deviceToken: mobile.token,
+ params: { worktree: 'id:wt-1', relativePath: 'docs/readme.md', staged: true }
+ }),
+ (response) => replies.push(JSON.parse(response) as Record),
+ () => {}
+ )
+ await server['handleWebSocketMessage'](
+ JSON.stringify({
+ id: 'req_git_diff',
+ method: 'git.diff',
+ deviceToken: mobile.token,
+ params: { worktree: 'id:wt-1', filePath: 'docs/readme.md', staged: false }
+ }),
+ (response) => replies.push(JSON.parse(response) as Record),
+ () => {}
+ )
expect(replies).toContainEqual(
expect.objectContaining({
@@ -749,6 +784,8 @@ describe('OrcaRuntimeRpcServer', () => {
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true }))
+ expect(replies).toContainEqual(expect.objectContaining({ id: 'req_files_open_diff', ok: true }))
+ expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_diff', ok: true }))
expect(replies).toContainEqual(
expect.objectContaining({
id: 'req_remove_claude',
@@ -764,6 +801,8 @@ describe('OrcaRuntimeRpcServer', () => {
expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1')
expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts'])
expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts'])
+ expect(openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true)
+ expect(getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', false, undefined)
expect(removeClaudeAccount).not.toHaveBeenCalled()
})
diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts
index c0fc8256f..ffa6c4758 100644
--- a/src/main/runtime/runtime-rpc.ts
+++ b/src/main/runtime/runtime-rpc.ts
@@ -125,11 +125,13 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'accounts.unsubscribe',
'files.list',
'files.open',
+ 'files.openDiff',
'files.read',
'git.bulkStage',
'git.bulkUnstage',
'git.commit',
'git.discard',
+ 'git.diff',
'git.fetch',
'git.pull',
'git.push',
diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts
index db2c5bd0f..e38b1237f 100644
--- a/src/main/window/attach-main-window-services.ts
+++ b/src/main/window/attach-main-window-services.ts
@@ -288,6 +288,8 @@ function registerRuntimeWindowLifecycle(
closeSessionTab: (tabId, worktreeId) => send('ui:closeSessionTab', { tabId, worktreeId }),
openFile: (worktreeId, filePath, relativePath) =>
send('ui:openFileFromMobile', { worktreeId, filePath, relativePath }),
+ openDiff: (worktreeId, filePath, relativePath, staged) =>
+ send('ui:openDiffFromMobile', { worktreeId, filePath, relativePath, staged }),
readMobileMarkdownTab: (worktreeId, tabId) =>
requestMobileMarkdownFromRenderer(mainWindow, {
operation: 'read',
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index cb542b32d..10e555860 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1624,6 +1624,14 @@ export type PreloadApi = {
onOpenFileFromMobile: (
callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void
) => () => void
+ onOpenDiffFromMobile: (
+ callback: (data: {
+ worktreeId: string
+ filePath: string
+ relativePath: string
+ staged: boolean
+ }) => void
+ ) => () => void
onMobileMarkdownRequest: (
callback: (request: RuntimeMobileMarkdownRequest) => void
) => () => void
diff --git a/src/preload/index.ts b/src/preload/index.ts
index af201cd9c..384678139 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -2400,6 +2400,21 @@ const api = {
ipcRenderer.on('ui:openFileFromMobile', listener)
return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener)
},
+ onOpenDiffFromMobile: (
+ callback: (data: {
+ worktreeId: string
+ filePath: string
+ relativePath: string
+ staged: boolean
+ }) => void
+ ): (() => void) => {
+ const listener = (
+ _event: Electron.IpcRendererEvent,
+ data: { worktreeId: string; filePath: string; relativePath: string; staged: boolean }
+ ) => callback(data)
+ ipcRenderer.on('ui:openDiffFromMobile', listener)
+ return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener)
+ },
onMobileMarkdownRequest: (
callback: (request: RuntimeMobileMarkdownRequest) => void
): (() => void) => {
diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts
index c2f39aa8e..d9ffcb89a 100644
--- a/src/renderer/src/hooks/useIpcEvents.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents.test.ts
@@ -183,6 +183,7 @@ describe('useIpcEvents updater integration', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
@@ -395,6 +396,7 @@ describe('useIpcEvents updater integration', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
@@ -654,6 +656,7 @@ describe('useIpcEvents updater integration', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
@@ -971,6 +974,7 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
@@ -1178,6 +1182,7 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
@@ -1380,6 +1385,7 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
@@ -1600,6 +1606,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
@@ -1797,6 +1804,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
+ onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onNewBrowserTab: () => () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index f4066d33c..54b0f7fd0 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -818,6 +818,22 @@ export function useIpcEvents(): void {
})
)
+ unsubs.push(
+ window.api.ui.onOpenDiffFromMobile(({ worktreeId, filePath, relativePath, staged }) => {
+ const store = useAppStore.getState()
+ const language = detectLanguage(relativePath)
+ store.setActiveWorktree(worktreeId)
+ store.markWorktreeVisited(worktreeId)
+ store.setActiveView('terminal')
+ // Why: mobile renders diff tabs from diff metadata. The desktop
+ // markdown Changes-mode shortcut is editor-local and would publish
+ // plain markdown content back to mobile.
+ store.openDiff(worktreeId, filePath, relativePath, language, staged)
+ store.setActiveTabType('editor')
+ store.revealWorktreeInSidebar(worktreeId)
+ })
+ )
+
unsubs.push(
window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => {
if (paneRuntimeId != null) {
diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts
index d28ea2f97..b7782c596 100644
--- a/src/renderer/src/runtime/sync-runtime-graph.test.ts
+++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts
@@ -235,6 +235,64 @@ describe('getRuntimeMobileSessionSyncKey', () => {
})
describe('buildMobileSessionTabSnapshots', () => {
+ it('preserves source-control diff metadata for mobile file tabs', () => {
+ const diffId = 'wt-1::diff::unstaged::src/app.ts'
+ const state = makeState({
+ browserTabsByWorktree: {},
+ tabBarOrderByWorktree: { 'wt-1': [diffId] },
+ openFiles: [
+ {
+ id: diffId,
+ filePath: '/repo/src/app.ts',
+ relativePath: 'src/app.ts',
+ worktreeId: 'wt-1',
+ language: 'typescript',
+ mode: 'diff',
+ diffSource: 'unstaged',
+ isDirty: false
+ }
+ ]
+ })
+
+ const snapshot = buildMobileSessionTabSnapshots(state)[0]
+
+ expect(snapshot?.tabs).toMatchObject([
+ {
+ type: 'file',
+ id: diffId,
+ mode: 'diff',
+ diffSource: 'unstaged',
+ relativePath: 'src/app.ts'
+ }
+ ])
+ })
+
+ it('omits unsupported branch and commit diff metadata from mobile file tabs', () => {
+ const diffId = 'wt-1::diff::branch::src/app.ts'
+ const state = makeState({
+ browserTabsByWorktree: {},
+ tabBarOrderByWorktree: { 'wt-1': [diffId] },
+ openFiles: [
+ {
+ id: diffId,
+ filePath: '/repo/src/app.ts',
+ relativePath: 'src/app.ts',
+ worktreeId: 'wt-1',
+ language: 'typescript',
+ mode: 'diff',
+ diffSource: 'branch',
+ isDirty: false
+ }
+ ]
+ })
+
+ const snapshot = buildMobileSessionTabSnapshots(state)[0]
+ const tab = snapshot?.tabs[0]
+
+ expect(tab).toMatchObject({ type: 'file', mode: 'diff', relativePath: 'src/app.ts' })
+ expect(tab).not.toHaveProperty('diffSource')
+ })
+
it('keeps duplicate file ids scoped to their worktree', () => {
const sharedRemotePath = '/home/dev/project/README.md'
const previewId = `markdown-preview::${sharedRemotePath}`
diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts
index 864d25682..7b8e904c4 100644
--- a/src/renderer/src/runtime/sync-runtime-graph.ts
+++ b/src/renderer/src/runtime/sync-runtime-graph.ts
@@ -246,6 +246,7 @@ function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles'])
worktreeId: file.worktreeId,
language: file.language,
mode: file.mode,
+ diffSource: file.diffSource,
isDirty: file.isDirty,
isUntitled: file.isUntitled,
markdownPreviewSourceFileId: file.markdownPreviewSourceFileId
@@ -586,6 +587,7 @@ function buildMobileFileTab(
unifiedTabId?: string
): RuntimeMobileSessionFileTab {
const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File'
+ const diffSource = isMobileFileDiffSource(file.diffSource) ? file.diffSource : undefined
return {
type: 'file',
@@ -594,6 +596,8 @@ function buildMobileFileTab(
filePath: file.filePath,
relativePath: file.relativePath,
language: file.language,
+ mode: file.mode === 'diff' ? 'diff' : 'edit',
+ ...(diffSource ? { diffSource } : {}),
isDirty: file.isDirty,
isActive: unifiedTabId
? state.groupsByWorktree[file.worktreeId]?.some(
@@ -603,6 +607,12 @@ function buildMobileFileTab(
}
}
+function isMobileFileDiffSource(
+ diffSource: AppState['openFiles'][number]['diffSource']
+): diffSource is 'staged' | 'unstaged' {
+ return diffSource === 'staged' || diffSource === 'unstaged'
+}
+
function stableHashString(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1) {
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 352e4743e..4efed3a3e 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -797,6 +797,7 @@ function createWebUiApi(): NonNullable['ui']> {
onFocusEditorTab: () => noopUnsubscribe,
onCloseSessionTab: () => noopUnsubscribe,
onOpenFileFromMobile: () => noopUnsubscribe,
+ onOpenDiffFromMobile: () => noopUnsubscribe,
onMobileMarkdownRequest: () => noopUnsubscribe,
respondMobileMarkdownRequest: () => {},
onCloseTerminal: () => noopUnsubscribe,
diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts
index 3b26b55c1..b35dd19d8 100644
--- a/src/shared/runtime-types.ts
+++ b/src/shared/runtime-types.ts
@@ -124,6 +124,8 @@ export type RuntimeMobileSessionFileTab = {
filePath: string
relativePath: string
language: string
+ mode?: 'edit' | 'diff'
+ diffSource?: 'staged' | 'unstaged'
isDirty: boolean
isActive: boolean
}