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 }