Open mobile source control diffs in editor tabs (#2202)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-17 23:24:46 -04:00 committed by GitHub
parent e4a794ec68
commit 2f7da8109e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 585 additions and 7 deletions

View File

@ -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 (
<View style={styles.markdownEditor}>
<ScrollView
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
>
{doc.lines.map((line, index) => (
<View
key={`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`}
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>
{line.oldLineNumber ?? line.newLineNumber ?? ''}
</Text>
<Text
selectable
style={[
styles.diffText,
line.kind === 'add' && styles.diffTextAdded,
line.kind === 'delete' && styles.diffTextDeleted
]}
accessibilityLabel={`${title} diff line`}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
{line.text}
</Text>
</View>
))}
</ScrollView>
</View>
)
}
return (
<View style={styles.markdownEditor}>
<ScrollView
@ -1113,6 +1158,36 @@ export default function SessionScreen() {
if (!client) return
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 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',

View File

@ -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

View File

@ -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}`))
}

View File

@ -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 }
])
})
})

View File

@ -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
}

View File

@ -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,

View File

@ -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<RuntimeFileOpenResult> {
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

View File

@ -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<RuntimeMarkdownReadTabResult>
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
)

View File

@ -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 {

View File

@ -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,

View File

@ -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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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()
})

View File

@ -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',

View File

@ -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',

View File

@ -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

View File

@ -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) => {

View File

@ -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: () => () => {},

View File

@ -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) {

View File

@ -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}`

View File

@ -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) {

View File

@ -797,6 +797,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onFocusEditorTab: () => noopUnsubscribe,
onCloseSessionTab: () => noopUnsubscribe,
onOpenFileFromMobile: () => noopUnsubscribe,
onOpenDiffFromMobile: () => noopUnsubscribe,
onMobileMarkdownRequest: () => noopUnsubscribe,
respondMobileMarkdownRequest: () => {},
onCloseTerminal: () => noopUnsubscribe,

View File

@ -124,6 +124,8 @@ export type RuntimeMobileSessionFileTab = {
filePath: string
relativePath: string
language: string
mode?: 'edit' | 'diff'
diffSource?: 'staged' | 'unstaged'
isDirty: boolean
isActive: boolean
}