mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
Harden mobile tab and markdown sync
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -10,6 +10,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'mobile/src/**/*.test.ts']
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,4 +108,27 @@ describe('mobile rpc-client connection timeout', () => {
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('sends session tabs unsubscribe when a session tab stream is disposed', () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const socket = mockSockets[0]!
|
||||
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
|
||||
const unsubscribe = client.subscribe(
|
||||
'session.tabs.subscribe',
|
||||
{ worktree: 'id:wt-1' },
|
||||
() => {}
|
||||
)
|
||||
unsubscribe()
|
||||
|
||||
expect(
|
||||
socket.sent.some((payload) => payload.includes('"method":"session.tabs.unsubscribe"'))
|
||||
).toBe(true)
|
||||
expect(socket.sent.some((payload) => payload.includes('"worktree":"id:wt-1"'))).toBe(true)
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -893,6 +893,18 @@ export function connect(
|
||||
...(clientId ? { client: { id: clientId } } : {})
|
||||
}
|
||||
})
|
||||
} else if (
|
||||
stream?.method === 'session.tabs.subscribe' &&
|
||||
stream.params &&
|
||||
typeof stream.params === 'object' &&
|
||||
typeof (stream.params as { worktree?: unknown }).worktree === 'string'
|
||||
) {
|
||||
sendEncrypted({
|
||||
id: nextId(),
|
||||
deviceToken,
|
||||
method: 'session.tabs.unsubscribe',
|
||||
params: { worktree: (stream.params as { worktree: string }).worktree }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('session tab RPC methods', () => {
|
||||
it('unsubscribes a session tabs stream using the resolved worktree id and connection id', async () => {
|
||||
const cleanupSubscription = vi.fn()
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listMobileSessionTabs: vi.fn().mockResolvedValue({
|
||||
worktree: 'wt-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}),
|
||||
cleanupSubscription
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
const messages: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('session.tabs.unsubscribe', { worktree: 'id:wt-1' }),
|
||||
(message) => messages.push(message),
|
||||
{ connectionId: 'conn-1' }
|
||||
)
|
||||
|
||||
expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:wt-1')
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({
|
||||
ok: true,
|
||||
result: { unsubscribed: true }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -52,11 +52,21 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
||||
subscriptionId,
|
||||
() => {
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'session.tabs.unsubscribe',
|
||||
params: WorktreeTabSelector,
|
||||
handler: async (params, { runtime, connectionId }) => {
|
||||
const snapshot = await runtime.listMobileSessionTabs(params.worktree)
|
||||
runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${snapshot.worktree}`)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'markdown.readTab',
|
||||
params: ActivateTab,
|
||||
|
||||
@@ -43,6 +43,8 @@ import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
|
||||
import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent'
|
||||
import { useUnreadDockBadge } from './hooks/useUnreadDockBadge'
|
||||
import {
|
||||
getRuntimeMobileSessionSyncKey,
|
||||
scheduleRuntimeGraphSync,
|
||||
setRuntimeGraphStoreStateGetter,
|
||||
setRuntimeGraphSyncEnabled
|
||||
} from './runtime/sync-runtime-graph'
|
||||
@@ -403,6 +405,32 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
|
||||
return useAppStore.subscribe((state, previousState) => {
|
||||
if (
|
||||
state.tabsByWorktree === previousState.tabsByWorktree &&
|
||||
state.groupsByWorktree === previousState.groupsByWorktree &&
|
||||
state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree &&
|
||||
state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree &&
|
||||
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
|
||||
state.activeFileId === previousState.activeFileId &&
|
||||
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
|
||||
state.openFiles === previousState.openFiles &&
|
||||
state.editorDrafts === previousState.editorDrafts &&
|
||||
state.activeTabId === previousState.activeTabId
|
||||
) {
|
||||
return
|
||||
}
|
||||
const nextKey = getRuntimeMobileSessionSyncKey(state)
|
||||
if (nextKey === previousKey) {
|
||||
return
|
||||
}
|
||||
previousKey = nextKey
|
||||
scheduleRuntimeGraphSync()
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => registerUpdaterBeforeUnloadBypass(), [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -251,6 +251,43 @@ describe('mobile markdown bridge', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous desktop draft when save verification fails after write', async () => {
|
||||
openMarkdownFile()
|
||||
const state = useAppStore.getState()
|
||||
state.setEditorDraft('/repo/README.md', 'desktop draft')
|
||||
state.markFileDirty('/repo/README.md', true)
|
||||
let diskContent = 'desktop draft'
|
||||
const readFile = vi.fn().mockImplementation(async () => ({
|
||||
content: 'verified mismatch',
|
||||
isBinary: false
|
||||
}))
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content }) => {
|
||||
diskContent = content
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-verify-fail',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('desktop draft'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ id: 'save-verify-fail', ok: false })
|
||||
expect(diskContent).toBe('mobile edit')
|
||||
expect(useAppStore.getState().editorDrafts['/repo/README.md']).toBe('desktop draft')
|
||||
expect(useAppStore.getState().openFiles[0]?.isDirty).toBe(true)
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes saves so duplicate base versions do not both write', async () => {
|
||||
openMarkdownFile()
|
||||
let diskContent = 'original'
|
||||
|
||||
@@ -145,11 +145,12 @@ function restoreFailedMobileSaveDraft(
|
||||
previousDirty: boolean
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
if (state.editorDrafts[fileId] !== injectedContent) {
|
||||
const currentDraft = state.editorDrafts[fileId]
|
||||
if (currentDraft !== undefined && currentDraft !== injectedContent) {
|
||||
return
|
||||
}
|
||||
// Why: mobile save injects a desktop draft only to reuse the editor save path.
|
||||
// If that save fails, put the desktop editor back exactly as it was before.
|
||||
// If save or verification fails, put the desktop editor back exactly as it was before.
|
||||
if (previousDraft === undefined) {
|
||||
state.clearEditorDraft(fileId)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRuntimeMobileSessionSyncKey } from './sync-runtime-graph'
|
||||
import type { AppState } from '../store/types'
|
||||
|
||||
function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
return {
|
||||
tabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
activeFileId: null,
|
||||
activeFileIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
activeTabId: null,
|
||||
...overrides
|
||||
} as AppState
|
||||
}
|
||||
|
||||
describe('getRuntimeMobileSessionSyncKey', () => {
|
||||
it('changes when mobile markdown tab state changes', () => {
|
||||
const base = makeState({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/repo/README.md',
|
||||
filePath: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const cleanKey = getRuntimeMobileSessionSyncKey(base)
|
||||
const dirtyKey = getRuntimeMobileSessionSyncKey(
|
||||
makeState({
|
||||
...base,
|
||||
openFiles: [{ ...base.openFiles[0]!, isDirty: true }],
|
||||
editorDrafts: { '/repo/README.md': '# draft' }
|
||||
})
|
||||
)
|
||||
const activatedKey = getRuntimeMobileSessionSyncKey(
|
||||
makeState({ ...base, activeFileId: '/repo/README.md' })
|
||||
)
|
||||
|
||||
expect(dirtyKey).not.toBe(cleanKey)
|
||||
expect(activatedKey).not.toBe(cleanKey)
|
||||
})
|
||||
|
||||
it('changes when legacy tab bar order changes', () => {
|
||||
const base = makeState({
|
||||
tabBarOrderByWorktree: { 'wt-1': ['term-1', '/repo/README.md'] }
|
||||
})
|
||||
|
||||
const reordered = getRuntimeMobileSessionSyncKey(
|
||||
makeState({
|
||||
...base,
|
||||
tabBarOrderByWorktree: { 'wt-1': ['/repo/README.md', 'term-1'] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(reordered).not.toBe(getRuntimeMobileSessionSyncKey(base))
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,45 @@ export function scheduleRuntimeGraphSync(): void {
|
||||
})
|
||||
}
|
||||
|
||||
export function getRuntimeMobileSessionSyncKey(state: AppState): string {
|
||||
return JSON.stringify({
|
||||
tabsByWorktree: Object.fromEntries(
|
||||
Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [
|
||||
worktreeId,
|
||||
tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
customTitle: tab.customTitle,
|
||||
active: state.activeTabId === tab.id
|
||||
}))
|
||||
])
|
||||
),
|
||||
groupsByWorktree: state.groupsByWorktree,
|
||||
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
|
||||
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
|
||||
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
|
||||
activeFileId: state.activeFileId,
|
||||
activeFileIdByWorktree: state.activeFileIdByWorktree,
|
||||
openFiles: state.openFiles.map((file) => ({
|
||||
id: file.id,
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
language: file.language,
|
||||
mode: file.mode,
|
||||
isDirty: file.isDirty,
|
||||
isUntitled: file.isUntitled,
|
||||
markdownPreviewSourceFileId: file.markdownPreviewSourceFileId
|
||||
})),
|
||||
editorDrafts: Object.fromEntries(
|
||||
Object.entries(state.editorDrafts).map(([fileId, content]) => [
|
||||
fileId,
|
||||
stableHashString(content)
|
||||
])
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function syncRuntimeGraph(): Promise<void> {
|
||||
if (!syncEnabled || !getStoreState) {
|
||||
return
|
||||
@@ -233,11 +272,12 @@ function buildMobileMarkdownTab(
|
||||
sourceFileId: sourceFile.id,
|
||||
sourceFilePath: sourceFile.filePath,
|
||||
sourceRelativePath: sourceFile.relativePath,
|
||||
documentVersion: draftContent !== undefined ? hashString(draftContent) : `file:${sourceFile.id}`
|
||||
documentVersion:
|
||||
draftContent !== undefined ? stableHashString(draftContent) : `file:${sourceFile.id}`
|
||||
}
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
function stableHashString(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i)
|
||||
|
||||
@@ -54,12 +54,12 @@ export type RuntimeMarkdownSaveTabResult = {
|
||||
}
|
||||
|
||||
export function hashMarkdownContent(content: string): string {
|
||||
let hash = 2166136261
|
||||
let hash = 0xcbf29ce484222325n
|
||||
for (let i = 0; i < content.length; i += 1) {
|
||||
hash ^= content.charCodeAt(i)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
hash ^= BigInt(content.charCodeAt(i))
|
||||
hash = BigInt.asUintN(64, hash * 0x100000001b3n)
|
||||
}
|
||||
return `content:${utf8ByteLength(content)}:${(hash >>> 0).toString(16)}`
|
||||
return `content:${utf8ByteLength(content)}:${hash.toString(16).padStart(16, '0')}`
|
||||
}
|
||||
|
||||
export function utf8ByteLength(content: string): number {
|
||||
|
||||
Reference in New Issue
Block a user