Open mobile source control diffs in editor tabs (#2202)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
e4a794ec68
commit
2f7da8109e
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: () => () => {},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -797,6 +797,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onFocusEditorTab: () => noopUnsubscribe,
|
||||
onCloseSessionTab: () => noopUnsubscribe,
|
||||
onOpenFileFromMobile: () => noopUnsubscribe,
|
||||
onOpenDiffFromMobile: () => noopUnsubscribe,
|
||||
onMobileMarkdownRequest: () => noopUnsubscribe,
|
||||
respondMobileMarkdownRequest: () => {},
|
||||
onCloseTerminal: () => noopUnsubscribe,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ export type RuntimeMobileSessionFileTab = {
|
|||
filePath: string
|
||||
relativePath: string
|
||||
language: string
|
||||
mode?: 'edit' | 'diff'
|
||||
diffSource?: 'staged' | 'unstaged'
|
||||
isDirty: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue