From c4c8723e1e2f79beaeb56e2384cc73b27dfb95db Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 11 May 2026 20:38:45 -0400 Subject: [PATCH] Add mobile markdown editing Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 421 ++++++++++++++---- src/main/runtime/orca-runtime.ts | 96 ++-- src/main/runtime/rpc/methods/session-tabs.ts | 19 + .../window/attach-main-window-services.ts | 19 + .../mobile-markdown-request-relay.test.ts | 70 +++ .../window/mobile-markdown-request-relay.ts | 54 +++ src/preload/api-types.ts | 8 + src/preload/index.ts | 15 + src/renderer/src/hooks/useIpcEvents.ts | 3 + .../runtime/mobile-markdown-bridge.test.ts | 347 +++++++++++++++ .../src/runtime/mobile-markdown-bridge.ts | 304 +++++++++++++ src/shared/mobile-markdown-document.ts | 67 +++ src/shared/runtime-types.ts | 16 +- 13 files changed, 1298 insertions(+), 141 deletions(-) create mode 100644 src/main/window/mobile-markdown-request-relay.test.ts create mode 100644 src/main/window/mobile-markdown-request-relay.ts create mode 100644 src/renderer/src/runtime/mobile-markdown-bridge.test.ts create mode 100644 src/renderer/src/runtime/mobile-markdown-bridge.ts create mode 100644 src/shared/mobile-markdown-document.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 3c7fee11883..60e9152bee7 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Animated, AppState, type AppStateStatus } from 'react-native' +import { Alert, Animated, AppState, type AppStateStatus } from 'react-native' import * as Clipboard from 'expo-clipboard' import { + BackHandler, View, Text, StyleSheet, @@ -29,7 +30,7 @@ import { import type { RpcClient } from '../../../../src/transport/rpc-client' import { loadHosts } from '../../../../src/transport/host-store' import { useHostClient } from '../../../../src/transport/client-context' -import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types' +import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { triggerMediumImpact, triggerSelection, @@ -87,7 +88,18 @@ type SessionTabsResult = { type MarkdownDocState = | { status: 'loading' } - | { status: 'ready'; content: string; version: string; isDirty: boolean; stale?: boolean } + | { + status: 'ready' + content: string + localContent: string + baseVersion: string + isDirty: boolean + editable: boolean + stale?: boolean + saving?: boolean + saveError?: string + readOnlyReason?: string + } | { status: 'error'; message: string } type TerminalCreateResult = { @@ -191,11 +203,19 @@ function TerminalPaneView({ function MarkdownReader({ doc, tab, - onRefresh + onRefresh, + onChange, + onSave, + onCopy, + onDiscard }: { doc: MarkdownDocState | undefined tab: Extract onRefresh: () => void + onChange: (content: string) => void + onSave: () => void + onCopy: () => void + onDiscard: () => void }) { if (!doc || doc.status === 'loading') { return ( @@ -216,58 +236,72 @@ function MarkdownReader({ ) } - const lines = doc.content.split(/\r?\n/) return ( - + - - {tab.relativePath || tab.title} - - {(tab.isDirty || doc.stale) && Updated on desktop} - {(tab.isDirty || doc.stale) && ( + + + + {tab.relativePath || tab.title} + + {doc.saveError ? ( + + {doc.saveError} + + ) : doc.readOnlyReason ? ( + Read only + ) : doc.stale ? ( + Changed on desktop + ) : doc.isDirty ? ( + Unsaved on phone + ) : null} + + + {(doc.saveError || !doc.editable) && ( + + Copy + + )} + {doc.isDirty && ( + + Discard + + )} + + {doc.saving ? ( + + ) : ( + Save + )} + + + + {(doc.stale && !doc.isDirty) || !doc.editable ? ( Refresh - )} + ) : null} - {lines.map((line, index) => { - const key = `${index}:${line}` - if (line.startsWith('# ')) { - return ( - - {line.slice(2)} - - ) - } - if (line.startsWith('## ')) { - return ( - - {line.slice(3)} - - ) - } - if (line.startsWith('```')) { - return null - } - if (line.startsWith('- ') || line.startsWith('* ')) { - return ( - - {'\u2022 '} - {line.slice(2)} - - ) - } - if (line.trim().length === 0) { - return - } - return ( - - {line} - - ) - })} - + + ) } @@ -341,6 +375,8 @@ export default function SessionScreen() { const activeHandleRef = useRef(null) const activeSessionTabTypeRef = useRef<'terminal' | 'markdown' | null>(null) const pendingActiveSessionTabIdRef = useRef(null) + const markdownSaveSeqRef = useRef>(new Map()) + const markdownSaveInFlightRef = useRef>(new Set()) const subscribeSeqRef = useRef>(new Map()) // Why: server-side layout state machine emits a monotonic seq on every // applyLayout. Track the highest seq we've observed per handle and drop @@ -359,6 +395,23 @@ export default function SessionScreen() { const canSend = connState === 'connected' && activeHandle != null && activeSessionTab?.type !== 'markdown' + const showToast = useCallback((message: string, durationMs = 1200) => { + setToastMessage(message) + Animated.timing(toastOpacityRef.current, { + toValue: 1, + duration: 150, + useNativeDriver: true + }).start(() => { + setTimeout(() => { + Animated.timing(toastOpacityRef.current, { + toValue: 0, + duration: 200, + useNativeDriver: true + }).start(() => setToastMessage(null)) + }, durationMs) + }) + }, []) + useEffect(() => { activeSessionTabTypeRef.current = activeSessionTab?.type ?? null }, [activeSessionTab]) @@ -735,7 +788,26 @@ export default function SessionScreen() { const applySessionTabs = useCallback( (result: SessionTabsResult) => { - const nextTabs = result.tabs + let nextTabs = result.tabs + const presentTabIds = new Set(nextTabs.map((tab) => tab.id)) + const orphanedDraftTabs: MobileSessionTab[] = [] + for (const [tabId, doc] of markdownDocs) { + if (doc.status !== 'ready' || !doc.isDirty || presentTabIds.has(tabId)) { + continue + } + const draftTab = sessionTabs.find( + (tab): tab is Extract => + tab.type === 'markdown' && tab.id === tabId + ) + if (draftTab) { + // Why: save-only mobile edits live only on the phone until Save. If the + // desktop tab disappears, keep every local draft reachable for copy/discard. + orphanedDraftTabs.push({ ...draftTab, isActive: tabId === activeSessionTabId }) + } + } + if (orphanedDraftTabs.length > 0) { + nextTabs = [...orphanedDraftTabs, ...nextTabs] + } setSessionTabs(nextTabs) const terminalTabs = nextTabs .filter( @@ -787,7 +859,7 @@ export default function SessionScreen() { setActiveHandle(null) } }, - [subscribeToTerminal, unsubscribeTerminal] + [activeSessionTabId, markdownDocs, sessionTabs, subscribeToTerminal, unsubscribeTerminal] ) const readMarkdownTab = useCallback( @@ -806,13 +878,19 @@ export default function SessionScreen() { content: string version: string isDirty: boolean + editable?: boolean + readOnlyReason?: string } setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'ready', content: result.content, - version: result.version, - isDirty: result.isDirty + localContent: result.content, + baseVersion: result.version, + isDirty: false, + editable: result.editable === true, + stale: result.isDirty, + readOnlyReason: result.readOnlyReason }) ) } catch { @@ -827,6 +905,166 @@ export default function SessionScreen() { [client, worktreeId] ) + const updateMarkdownLocalContent = useCallback((tabId: string, content: string) => { + setMarkdownDocs((prev) => { + const current = prev.get(tabId) + if (current?.status !== 'ready') return prev + const next = new Map(prev) + next.set(tabId, { + ...current, + localContent: content, + isDirty: content !== current.content, + saveError: undefined + }) + return next + }) + }, []) + + const copyMarkdownLocalContent = useCallback( + async (tabId: string) => { + const current = markdownDocs.get(tabId) + if (current?.status !== 'ready') return + await Clipboard.setStringAsync(current.localContent) + triggerSuccess() + showToast('Copied') + }, + [markdownDocs, showToast] + ) + + const getDirtyMarkdownDrafts = useCallback(() => { + const drafts: Array<{ tabId: string; title: string; content: string }> = [] + for (const [tabId, doc] of markdownDocs) { + if (doc.status === 'ready' && doc.isDirty) { + const tab = sessionTabs.find((candidate) => candidate.id === tabId) + drafts.push({ tabId, title: tab?.title || 'Markdown', content: doc.localContent }) + } + } + return drafts + }, [markdownDocs, sessionTabs]) + + const requestLeaveSession = useCallback(() => { + const dirtyDrafts = getDirtyMarkdownDrafts() + if (dirtyDrafts.length === 0) { + router.back() + return + } + Alert.alert('Unsaved markdown changes', 'Copy or discard phone drafts before leaving.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Copy All & Leave', + onPress: () => { + const combined = dirtyDrafts + .map((draft) => `# ${draft.title}\n\n${draft.content}`) + .join('\n\n---\n\n') + void Clipboard.setStringAsync(combined).finally(() => router.back()) + } + }, + { + text: 'Discard', + style: 'destructive', + onPress: () => router.back() + } + ]) + }, [getDirtyMarkdownDrafts, router]) + + useEffect(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + if (getDirtyMarkdownDrafts().length === 0) { + return false + } + requestLeaveSession() + return true + }) + return () => subscription.remove() + }, [getDirtyMarkdownDrafts, requestLeaveSession]) + + const discardMarkdownLocalContent = useCallback( + (tab: Extract) => { + const current = markdownDocs.get(tab.id) + if (current?.status !== 'ready') return + if (!current.isDirty) { + void readMarkdownTab(tab) + return + } + Alert.alert('Discard changes?', 'This will replace the phone draft with the desktop file.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Discard', + style: 'destructive', + onPress: () => void readMarkdownTab(tab) + } + ]) + }, + [markdownDocs, readMarkdownTab] + ) + + const saveMarkdownTab = useCallback( + async (tab: Extract) => { + if (!client) return + const current = markdownDocs.get(tab.id) + if (current?.status !== 'ready' || current.saving || !current.editable) return + if (markdownSaveInFlightRef.current.has(tab.id)) return + markdownSaveInFlightRef.current.add(tab.id) + const saveSeq = (markdownSaveSeqRef.current.get(tab.id) ?? 0) + 1 + markdownSaveSeqRef.current.set(tab.id, saveSeq) + setMarkdownDocs((prev) => { + const existing = prev.get(tab.id) + if (existing?.status !== 'ready') return prev + return new Map(prev).set(tab.id, { ...existing, saving: true, saveError: undefined }) + }) + try { + const response = await client.sendRequest('markdown.saveTab', { + worktree: `id:${worktreeId}`, + tabId: tab.id, + baseVersion: current.baseVersion, + content: current.localContent + }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + const result = (response as RpcSuccess).result as { + content: string + version: string + isDirty: false + } + if (markdownSaveSeqRef.current.get(tab.id) !== saveSeq) { + return + } + setMarkdownDocs((prev) => + new Map(prev).set(tab.id, { + status: 'ready', + content: result.content, + localContent: result.content, + baseVersion: result.version, + isDirty: false, + editable: true + }) + ) + markdownSaveSeqRef.current.delete(tab.id) + triggerSuccess() + showToast('Saved') + } catch (error) { + triggerError() + const message = error instanceof Error ? error.message : 'Save failed' + if (markdownSaveSeqRef.current.get(tab.id) !== saveSeq) { + return + } + setMarkdownDocs((prev) => { + const existing = prev.get(tab.id) + if (existing?.status !== 'ready') return prev + return new Map(prev).set(tab.id, { + ...existing, + saving: false, + saveError: message || 'Save failed' + }) + }) + } finally { + markdownSaveInFlightRef.current.delete(tab.id) + } + }, + [client, markdownDocs, showToast, worktreeId] + ) + const fetchSessionTabsInFlightRef = useRef(false) const fetchSessionTabs = useCallback(async () => { @@ -1064,10 +1302,7 @@ export default function SessionScreen() { if (activeMarkdown) { setMarkdownDocs((prev) => { const current = prev.get(activeMarkdown.id) - if ( - current?.status === 'ready' && - current.version !== activeMarkdown.documentVersion - ) { + if (current?.status === 'ready' && activeMarkdown.isDirty && !current.isDirty) { const next = new Map(prev) next.set(activeMarkdown.id, { ...current, stale: true }) return next @@ -1158,9 +1393,11 @@ export default function SessionScreen() { .catch(() => {}) } const cached = markdownDocs.get(tab.id) - if (cached?.status === 'ready' && !cached.stale && cached.version === tab.documentVersion) { + if (cached?.status === 'ready' && cached.isDirty) { return } + // Why: desktop clean saves do not carry a reliable content version in the + // lightweight tab list. Re-read on revisit unless the phone has a draft. void readMarkdownTab(tab) }, [client, markdownDocs, readMarkdownTab, switchTab, unsubscribeTerminal, worktreeId] @@ -1319,23 +1556,6 @@ export default function SessionScreen() { return () => stopAccessoryRepeat() }, [stopAccessoryRepeat]) - const showToast = useCallback((message: string, durationMs = 1200) => { - setToastMessage(message) - Animated.timing(toastOpacityRef.current, { - toValue: 1, - duration: 150, - useNativeDriver: true - }).start(() => { - setTimeout(() => { - Animated.timing(toastOpacityRef.current, { - toValue: 0, - duration: 200, - useNativeDriver: true - }).start(() => setToastMessage(null)) - }, durationMs) - }) - }, []) - const handleSelectionMode = useCallback((handle: string, active: boolean) => { if (handle !== activeHandleRef.current) return setSelectModeActive(active) @@ -1607,7 +1827,7 @@ export default function SessionScreen() { [styles.backButton, pressed && styles.backButtonPressed]} - onPress={() => router.back()} + onPress={requestLeaveSession} hitSlop={8} accessibilityLabel="Back to worktrees" > @@ -1713,11 +1933,15 @@ export default function SessionScreen() { ) : activeMarkdownTab ? ( - + void readMarkdownTab(activeMarkdownTab)} + onChange={(content) => updateMarkdownLocalContent(activeMarkdownTab.id, content)} + onSave={() => void saveMarkdownTab(activeMarkdownTab)} + onCopy={() => void copyMarkdownLocalContent(activeMarkdownTab.id)} + onDiscard={() => discardMarkdownLocalContent(activeMarkdownTab)} /> {toastMessage && ( + saveMobileMarkdownTab?( + worktreeId: string, + tabId: string, + baseVersion: string, + content: string + ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void sleepWorktree(worktreeId: string): void terminalFitOverrideChanged( @@ -887,7 +894,7 @@ export class OrcaRuntimeService { if (tab.type === 'terminal') { this.notifier?.focusTerminal(tab.terminalTabId, worktreeId) } else { - this.notifier?.focusEditorTab(tab.id, worktreeId) + this.notifier?.focusEditorTab?.(tab.id, worktreeId) } return this.getMobileSessionTabsForWorktree(worktreeId) } @@ -896,30 +903,24 @@ export class OrcaRuntimeService { worktreeSelector: string, tabId: string ): Promise { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const snapshot = this.mobileSessionTabsByWorktree.get(worktree.id) - const tab = snapshot?.tabs.find( - (candidate): candidate is RuntimeMobileSessionMarkdownTab => - candidate.type === 'markdown' && candidate.id === tabId - ) - if (!tab) { - throw new Error('tab_not_found') + const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) + if (!this.notifier?.readMobileMarkdownTab) { + throw new Error('renderer_unavailable') } + return await this.notifier.readMobileMarkdownTab(worktreeId, tabId) + } - if (tab.isDirty) { - throw new Error('draft_unavailable') - } - - const content = await this.readMarkdownFileForMobile(worktree, tab.sourceFilePath) - return { - tabId: tab.id, - filePath: tab.sourceFilePath, - relativePath: tab.sourceRelativePath, - content, - isDirty: false, - version: `file:${content.length}`, - source: 'file' + async saveMobileMarkdownTab( + worktreeSelector: string, + tabId: string, + baseVersion: string, + content: string + ): Promise { + const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) + if (!this.notifier?.saveMobileMarkdownTab) { + throw new Error('renderer_unavailable') } + return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) } onMobileSessionTabsChanged( @@ -4971,6 +4972,24 @@ export class OrcaRuntimeService { return this.toMobileSessionTabsResult(snapshot) } + private async resolveMobileMarkdownWorktreeId( + worktreeSelector: string, + tabId: string + ): Promise { + const worktreeId = + getExplicitWorktreeIdSelector(worktreeSelector) ?? + (await this.resolveWorktreeSelector(worktreeSelector)).id + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const tab = snapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionMarkdownTab => + candidate.type === 'markdown' && candidate.id === tabId + ) + if (!tab) { + throw new Error('tab_not_found') + } + return worktreeId + } + private toMobileSessionTabsResult( snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsResult { @@ -5008,33 +5027,6 @@ export class OrcaRuntimeService { } } - private async readMarkdownFileForMobile( - worktree: ResolvedWorktree, - filePath: string - ): Promise { - if (!isPathInsideWorktree(filePath, worktree.path)) { - throw new Error('path_outside_worktree') - } - const repo = this.store?.getRepo(worktree.repoId) - if (repo?.connectionId) { - const provider = getSshFilesystemProvider(repo.connectionId) - if (!provider) { - throw new Error('filesystem_provider_unavailable') - } - const result = await provider.readFile(filePath) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } - - const stats = await stat(filePath) - if (stats.size > 512 * 1024) { - throw new Error('file_too_large') - } - return await readFile(filePath, 'utf8') - } - // Why: group address resolution (Section 4.5) needs to query per-handle agent // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 9bc36a415c9..23c84b7be9d 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -15,6 +15,14 @@ const ActivateTab = WorktreeTabSelector.extend({ .pipe(z.string().min(1, 'Missing tab id')) }) +const SaveMarkdownTab = ActivateTab.extend({ + baseVersion: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing base version')), + content: z.string() +}) + export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', @@ -54,5 +62,16 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ params: ActivateTab, handler: async (params, { runtime }) => runtime.readMobileMarkdownTab(params.worktree, params.tabId) + }), + defineMethod({ + name: 'markdown.saveTab', + params: SaveMarkdownTab, + handler: async (params, { runtime }) => + runtime.saveMobileMarkdownTab( + params.worktree, + params.tabId, + params.baseVersion, + params.content + ) }) ] diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 34edb4b345e..76ab7089095 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -27,6 +27,11 @@ import { scheduleHistoryGc } from '../terminal-history' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' +import type { + RuntimeMarkdownReadTabResult, + RuntimeMarkdownSaveTabResult +} from '../../shared/mobile-markdown-document' +import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' export function attachMainWindowServices( mainWindow: BrowserWindow, @@ -271,6 +276,20 @@ function registerRuntimeWindowLifecycle( renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }), focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), + readMobileMarkdownTab: (worktreeId, tabId) => + requestMobileMarkdownFromRenderer(mainWindow, { + operation: 'read', + worktreeId, + tabId + }) as Promise, + saveMobileMarkdownTab: (worktreeId, tabId, baseVersion, content) => + requestMobileMarkdownFromRenderer(mainWindow, { + operation: 'save', + worktreeId, + tabId, + baseVersion, + content + }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => diff --git a/src/main/window/mobile-markdown-request-relay.test.ts b/src/main/window/mobile-markdown-request-relay.test.ts new file mode 100644 index 00000000000..3f3f610498e --- /dev/null +++ b/src/main/window/mobile-markdown-request-relay.test.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from 'node:events' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcEmitter = new EventEmitter() +const ipcMainMock = { + on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.on(channel, listener) + }), + removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.removeListener(channel, listener) + }) +} + +vi.mock('electron', () => ({ + ipcMain: ipcMainMock +})) + +describe('requestMobileMarkdownFromRenderer', () => { + beforeEach(() => { + ipcEmitter.removeAllListeners() + ipcMainMock.on.mockClear() + ipcMainMock.removeListener.mockClear() + }) + + it('ignores markdown responses from other renderer processes', async () => { + const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay') + const mainWebContents = { + send: vi.fn() + } + const otherWebContents = {} + const mainWindow = { + isDestroyed: () => false, + webContents: mainWebContents + } + + const pending = requestMobileMarkdownFromRenderer(mainWindow as never, { + operation: 'read', + worktreeId: 'wt-1', + tabId: 'tab-md' + }) + const sentRequest = mainWebContents.send.mock.calls[0]?.[1] as { id: string } + + ipcEmitter.emit( + 'ui:mobileMarkdownResponse', + { sender: otherWebContents }, + { id: sentRequest.id, ok: false, error: 'wrong_renderer' } + ) + ipcEmitter.emit( + 'ui:mobileMarkdownResponse', + { sender: mainWebContents }, + { + id: sentRequest.id, + ok: true, + result: { + tabId: 'tab-md', + filePath: '/repo/README.md', + relativePath: 'README.md', + content: '# ok', + isDirty: false, + version: 'v1', + source: 'file', + editable: true + } + } + ) + + await expect(pending).resolves.toMatchObject({ content: '# ok' }) + }) +}) diff --git a/src/main/window/mobile-markdown-request-relay.ts b/src/main/window/mobile-markdown-request-relay.ts new file mode 100644 index 00000000000..d9cd408bc54 --- /dev/null +++ b/src/main/window/mobile-markdown-request-relay.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto' + +import { ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' +import type { + RuntimeMarkdownReadTabResult, + RuntimeMarkdownSaveTabResult, + RuntimeMobileMarkdownRequest, + RuntimeMobileMarkdownResponse +} from '../../shared/mobile-markdown-document' + +const MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS = 20_000 + +type RendererMobileMarkdownRequest = RuntimeMobileMarkdownRequest extends infer Request + ? Request extends { id: string } + ? Omit + : never + : never + +export async function requestMobileMarkdownFromRenderer( + mainWindow: BrowserWindow, + request: RendererMobileMarkdownRequest +): Promise { + if (mainWindow.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const id = randomUUID() + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse) + reject(new Error('renderer_timeout')) + }, MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS) + const onResponse = ( + event: Electron.IpcMainEvent, + response: RuntimeMobileMarkdownResponse + ): void => { + if (event.sender !== mainWindow.webContents) { + return + } + if (response.id !== id) { + return + } + clearTimeout(timeout) + ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse) + if (response.ok) { + resolve(response.result) + } else { + reject(new Error(response.error)) + } + } + ipcMain.on('ui:mobileMarkdownResponse', onResponse) + mainWindow.webContents.send('ui:mobileMarkdownRequest', { id, ...request }) + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 65fe799091f..840f4aad12b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -124,6 +124,10 @@ import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { AgentStatusIpcPayload } from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' +import type { + RuntimeMobileMarkdownRequest, + RuntimeMobileMarkdownResponse +} from '../shared/mobile-markdown-document' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -1123,6 +1127,10 @@ export type PreloadApi = { onFocusEditorTab: ( callback: (data: { tabId: string; worktreeId: string }) => void ) => () => void + onMobileMarkdownRequest: ( + callback: (request: RuntimeMobileMarkdownRequest) => void + ) => () => void + respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse) => void onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 49480cddf78..5ea8c1900f7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -31,6 +31,10 @@ import type { WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' +import type { + RuntimeMobileMarkdownRequest, + RuntimeMobileMarkdownResponse +} from '../shared/mobile-markdown-document' import type { RateLimitState } from '../shared/rate-limit-types' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { @@ -1942,6 +1946,17 @@ const api = { ipcRenderer.on('ui:focusEditorTab', listener) return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) }, + onMobileMarkdownRequest: ( + callback: (request: RuntimeMobileMarkdownRequest) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => + callback(request) + ipcRenderer.on('ui:mobileMarkdownRequest', listener) + return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) + }, + respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { + ipcRenderer.send('ui:mobileMarkdownResponse', response) + }, onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ): (() => void) => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 16a48718242..b89840e9146 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -28,6 +28,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' +import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' export { resolveZoomTarget } from './resolve-zoom-target' @@ -37,6 +38,8 @@ export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] + unsubs.push(attachMobileMarkdownBridge()) + unsubs.push( window.api.repos.onChanged(() => { useAppStore.getState().fetchRepos() diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts new file mode 100644 index 00000000000..0fad460e7cb --- /dev/null +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -0,0 +1,347 @@ +/* eslint-disable max-lines */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + hashMarkdownContent, + type RuntimeMobileMarkdownRequest +} from '../../../shared/mobile-markdown-document' +import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller' +import { registerPendingEditorFlush } from '../components/editor/editor-pending-flush' +import { useAppStore } from '../store' +import { attachMobileMarkdownBridge } from './mobile-markdown-bridge' + +vi.mock('@/components/tab-bar/group-tab-order', () => ({ + getActiveTabNavOrder: () => [{ type: 'editor', id: '/repo/README.md', tabId: 'tab-md' }] +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: () => null +})) + +type WindowStub = { + addEventListener: Window['addEventListener'] + removeEventListener: Window['removeEventListener'] + dispatchEvent: Window['dispatchEvent'] + setTimeout: Window['setTimeout'] + clearTimeout: Window['clearTimeout'] + api: { + ui: { + onMobileMarkdownRequest: ( + callback: (request: RuntimeMobileMarkdownRequest) => void + ) => () => void + respondMobileMarkdownRequest: ReturnType + } + fs: { + readFile: ReturnType + writeFile: ReturnType + } + } +} + +let mobileMarkdownHandler: ((request: RuntimeMobileMarkdownRequest) => void) | null = null + +function setupWindow({ + readFile, + writeFile = vi.fn().mockResolvedValue(undefined) +}: { + readFile: ReturnType + writeFile?: ReturnType +}): { responses: unknown[] } { + const eventTarget = new EventTarget() + const responses: unknown[] = [] + mobileMarkdownHandler = null + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + ui: { + onMobileMarkdownRequest: (callback) => { + mobileMarkdownHandler = callback + return () => { + mobileMarkdownHandler = null + } + }, + respondMobileMarkdownRequest: vi.fn((response) => responses.push(response)) + }, + fs: { readFile, writeFile } + } + } satisfies WindowStub) + return { responses } +} + +function resetEditorState(): void { + useAppStore.setState({ + openFiles: [], + editorDrafts: {}, + worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo', branch: 'main' }] }, + repos: [{ id: 'repo', path: '/repo', displayName: 'repo', kind: 'git' }] + } as never) +} + +function openMarkdownFile(): void { + useAppStore.getState().openFile({ + filePath: '/repo/README.md', + relativePath: 'README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) +} + +async function sendRequest(request: RuntimeMobileMarkdownRequest): Promise { + expect(mobileMarkdownHandler).not.toBeNull() + mobileMarkdownHandler?.(request) + for (let i = 0; i < 20; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + const response = ( + window.api.ui.respondMobileMarkdownRequest as ReturnType + ).mock.calls + .map((call) => call[0]) + .find((candidate) => candidate?.id === request.id) + if (response) { + return response + } + } + throw new Error(`No response for ${request.id}`) +} + +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve: () => void = () => {} + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('mobile markdown bridge', () => { + beforeEach(() => { + resetEditorState() + }) + + afterEach(() => { + vi.unstubAllGlobals() + mobileMarkdownHandler = null + }) + + it('flushes pending rich markdown changes before read', async () => { + openMarkdownFile() + setupWindow({ + readFile: vi.fn().mockResolvedValue({ content: 'disk', isBinary: false }) + }) + const detach = attachMobileMarkdownBridge() + const unregisterFlush = registerPendingEditorFlush('/repo/README.md', () => { + useAppStore.getState().setEditorDraft('/repo/README.md', '# pending\n') + useAppStore.getState().markFileDirty('/repo/README.md', true) + }) + + try { + const response = await sendRequest({ + id: 'read-1', + operation: 'read', + worktreeId: 'wt-1', + tabId: 'tab-md' + }) + + expect(response).toMatchObject({ + id: 'read-1', + ok: true, + result: { content: '# pending\n', source: 'draft', editable: true } + }) + } finally { + unregisterFlush() + detach() + } + }) + + it('rejects save when a clean file changed after mobile read', async () => { + openMarkdownFile() + const writeFile = vi.fn().mockResolvedValue(undefined) + setupWindow({ + readFile: vi.fn().mockResolvedValue({ content: 'changed on disk', isBinary: false }), + writeFile + }) + const detach = attachMobileMarkdownBridge() + + try { + const response = await sendRequest({ + id: 'save-1', + operation: 'save', + worktreeId: 'wt-1', + tabId: 'tab-md', + baseVersion: hashMarkdownContent('original'), + content: 'mobile edit' + }) + + expect(response).toMatchObject({ id: 'save-1', ok: false, error: 'conflict' }) + expect(writeFile).not.toHaveBeenCalled() + } finally { + detach() + } + }) + + it('saves through the editor save controller and verifies written content', async () => { + openMarkdownFile() + let diskContent = 'original' + const readFile = vi.fn().mockImplementation(async () => ({ + content: diskContent, + 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-2', + operation: 'save', + worktreeId: 'wt-1', + tabId: 'tab-md', + baseVersion: hashMarkdownContent('original'), + content: 'mobile edit' + }) + + expect(writeFile).toHaveBeenCalledWith({ + filePath: '/repo/README.md', + content: 'mobile edit' + }) + expect(response).toMatchObject({ + id: 'save-2', + ok: true, + result: { content: 'mobile edit', isDirty: false } + }) + } finally { + detachAutosave() + detachBridge() + } + }) + + it('restores the previous desktop draft when a mobile save write fails', async () => { + openMarkdownFile() + const state = useAppStore.getState() + state.setEditorDraft('/repo/README.md', 'desktop draft') + state.markFileDirty('/repo/README.md', true) + setupWindow({ + readFile: vi.fn().mockResolvedValue({ content: 'desktop draft', isBinary: false }), + writeFile: vi.fn().mockRejectedValue(new Error('disk full')) + }) + const detachBridge = attachMobileMarkdownBridge() + const detachAutosave = attachEditorAutosaveController(useAppStore as never) + + try { + const response = await sendRequest({ + id: 'save-fail', + operation: 'save', + worktreeId: 'wt-1', + tabId: 'tab-md', + baseVersion: hashMarkdownContent('desktop draft'), + content: 'mobile edit' + }) + + expect(response).toMatchObject({ id: 'save-fail', ok: false }) + 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' + const firstWrite = createDeferred() + const readFile = vi.fn().mockImplementation(async () => ({ + content: diskContent, + isBinary: false + })) + const writeFile = vi.fn().mockImplementation(async ({ content }) => { + if (content === 'first edit') { + await firstWrite.promise + } + diskContent = content + }) + setupWindow({ readFile, writeFile }) + const detachBridge = attachMobileMarkdownBridge() + const detachAutosave = attachEditorAutosaveController(useAppStore as never) + + try { + const first = sendRequest({ + id: 'save-a', + operation: 'save', + worktreeId: 'wt-1', + tabId: 'tab-md', + baseVersion: hashMarkdownContent('original'), + content: 'first edit' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + const second = sendRequest({ + id: 'save-b', + operation: 'save', + worktreeId: 'wt-1', + tabId: 'tab-md', + baseVersion: hashMarkdownContent('original'), + content: 'second edit' + }) + firstWrite.resolve() + + await expect(first).resolves.toMatchObject({ id: 'save-a', ok: true }) + await expect(second).resolves.toMatchObject({ id: 'save-b', ok: false, error: 'conflict' }) + expect(diskContent).toBe('first edit') + } finally { + detachAutosave() + detachBridge() + } + }) + + it('treats a duplicate same-content save as idempotent success', async () => { + openMarkdownFile() + let diskContent = 'original' + const firstWrite = createDeferred() + const readFile = vi.fn().mockImplementation(async () => ({ + content: diskContent, + isBinary: false + })) + const writeFile = vi.fn().mockImplementation(async ({ content }) => { + if (content === 'mobile edit' && diskContent === 'original') { + await firstWrite.promise + } + diskContent = content + }) + setupWindow({ readFile, writeFile }) + const detachBridge = attachMobileMarkdownBridge() + const detachAutosave = attachEditorAutosaveController(useAppStore as never) + + try { + const first = sendRequest({ + id: 'save-a', + operation: 'save', + worktreeId: 'wt-1', + tabId: 'tab-md', + baseVersion: hashMarkdownContent('original'), + content: 'mobile edit' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + const second = sendRequest({ + id: 'save-b', + operation: 'save', + worktreeId: 'wt-1', + tabId: 'tab-md', + baseVersion: hashMarkdownContent('original'), + content: 'mobile edit' + }) + firstWrite.resolve() + + await expect(first).resolves.toMatchObject({ id: 'save-a', ok: true }) + await expect(second).resolves.toMatchObject({ id: 'save-b', ok: true }) + expect(diskContent).toBe('mobile edit') + } finally { + detachAutosave() + detachBridge() + } + }) +}) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts new file mode 100644 index 00000000000..02e1d1b006d --- /dev/null +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -0,0 +1,304 @@ +import { getActiveTabNavOrder } from '@/components/tab-bar/group-tab-order' +import { + ORCA_EDITOR_FILE_SAVED_EVENT, + requestEditorFileSave, + requestEditorSaveQuiesce, + type EditorFileSavedDetail +} from '@/components/editor/editor-autosave' +import { flushPendingEditorChange } from '@/components/editor/editor-pending-flush' +import { getConnectionId } from '@/lib/connection-context' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { + hashMarkdownContent, + MOBILE_MARKDOWN_EDIT_MAX_BYTES, + utf8ByteLength, + type RuntimeMarkdownReadTabResult, + type RuntimeMarkdownSaveTabResult, + type RuntimeMobileMarkdownRequest, + type RuntimeMobileMarkdownResponse +} from '../../../shared/mobile-markdown-document' + +const MOBILE_MARKDOWN_READ_MAX_BYTES = 512 * 1024 +const saveQueues = new Map>() + +type FileContent = { + content: string + isBinary: boolean +} + +export function attachMobileMarkdownBridge(): () => void { + if (typeof window.api.ui.onMobileMarkdownRequest !== 'function') { + return () => {} + } + return window.api.ui.onMobileMarkdownRequest((request) => { + void handleMobileMarkdownRequest(request) + }) +} + +async function handleMobileMarkdownRequest(request: RuntimeMobileMarkdownRequest): Promise { + try { + const result = + request.operation === 'read' + ? await readMobileMarkdownTab(request.worktreeId, request.tabId) + : await saveMobileMarkdownTab( + request.worktreeId, + request.tabId, + request.baseVersion, + request.content + ) + respond({ id: request.id, ok: true, result }) + } catch (error) { + respond({ + id: request.id, + ok: false, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +async function readMobileMarkdownTab( + worktreeId: string, + tabId: string +): Promise { + const target = resolveMarkdownTarget(worktreeId, tabId) + flushEditorState(target.sourceFile.id) + const { content, source } = await readCurrentContent(target.sourceFile) + const readOnlyReason = getReadOnlyReason(target.tab, target.sourceFile, content) + return { + tabId, + filePath: target.sourceFile.filePath, + relativePath: target.sourceFile.relativePath, + content, + isDirty: target.sourceFile.isDirty || source === 'draft', + version: hashMarkdownContent(content), + source, + editable: readOnlyReason === undefined, + ...(readOnlyReason ? { readOnlyReason } : {}) + } +} + +async function saveMobileMarkdownTab( + worktreeId: string, + tabId: string, + baseVersion: string, + content: string +): Promise { + if (utf8ByteLength(content) > MOBILE_MARKDOWN_EDIT_MAX_BYTES) { + throw new Error('file_too_large') + } + const target = resolveMarkdownTarget(worktreeId, tabId) + return await enqueueMarkdownSave(target.sourceFile.id, async () => { + const freshTarget = resolveMarkdownTarget(worktreeId, tabId) + const readOnlyReason = getReadOnlyReason(freshTarget.tab, freshTarget.sourceFile, content) + if (readOnlyReason) { + throw new Error(readOnlyReason) + } + + flushEditorState(freshTarget.sourceFile.id) + const current = await readCurrentContent(freshTarget.sourceFile) + const currentVersion = hashMarkdownContent(current.content) + if (currentVersion !== baseVersion) { + if (current.content === content) { + // Why: duplicate mobile save taps can race behind the first successful + // write; treat an already-saved identical file as success, not conflict. + return { + tabId, + version: currentVersion, + isDirty: false, + content: current.content + } + } + throw new Error('conflict') + } + + const state = useAppStore.getState() + const previousDraft = state.editorDrafts[freshTarget.sourceFile.id] + const previousDirty = freshTarget.sourceFile.isDirty + state.setEditorDraft(freshTarget.sourceFile.id, content) + state.markFileDirty(freshTarget.sourceFile.id, true) + let verified: string + try { + await waitForPositiveSave(freshTarget.sourceFile, content) + verified = await readFileContent(freshTarget.sourceFile) + if (verified !== content) { + throw new Error('save_verification_failed') + } + } catch (error) { + restoreFailedMobileSaveDraft(freshTarget.sourceFile.id, content, previousDraft, previousDirty) + throw error + } + + return { + tabId, + version: hashMarkdownContent(verified), + isDirty: false, + content: verified + } + }) +} + +function restoreFailedMobileSaveDraft( + fileId: string, + injectedContent: string, + previousDraft: string | undefined, + previousDirty: boolean +): void { + const state = useAppStore.getState() + if (state.editorDrafts[fileId] !== 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 (previousDraft === undefined) { + state.clearEditorDraft(fileId) + } else { + state.setEditorDraft(fileId, previousDraft) + } + state.markFileDirty(fileId, previousDirty) +} + +async function enqueueMarkdownSave(fileId: string, save: () => Promise): Promise { + const previous = saveQueues.get(fileId) ?? Promise.resolve() + let releaseQueue: () => void = () => {} + const current = new Promise((resolve) => { + releaseQueue = resolve + }) + const queued = previous.catch(() => undefined).then(() => current) + saveQueues.set(fileId, queued) + await previous.catch(() => undefined) + try { + return await save() + } finally { + releaseQueue() + if (saveQueues.get(fileId) === queued) { + saveQueues.delete(fileId) + } + } +} + +function resolveMarkdownTarget( + worktreeId: string, + tabId: string +): { tab: OpenFile; sourceFile: OpenFile } { + const state = useAppStore.getState() + const orderItem = getActiveTabNavOrder(state, worktreeId).find( + (item) => item.type === 'editor' && (item.tabId === tabId || item.id === tabId) + ) + const tabFileId = orderItem?.type === 'editor' ? orderItem.id : tabId + const tab = state.openFiles.find( + (file) => file.worktreeId === worktreeId && (file.id === tabFileId || file.id === tabId) + ) + if (!tab || !isMarkdownTab(tab)) { + throw new Error('tab_not_found') + } + const sourceFile = + tab.mode === 'markdown-preview' && tab.markdownPreviewSourceFileId + ? (state.openFiles.find( + (file) => file.worktreeId === worktreeId && file.id === tab.markdownPreviewSourceFileId + ) ?? tab) + : tab + return { tab, sourceFile } +} + +function isMarkdownTab(file: OpenFile): boolean { + if (file.mode !== 'edit' && file.mode !== 'markdown-preview') { + return false + } + return file.language === 'markdown' || file.mode === 'markdown-preview' +} + +function getReadOnlyReason( + tab: OpenFile, + sourceFile: OpenFile, + content: string +): RuntimeMarkdownReadTabResult['readOnlyReason'] { + if (tab.mode === 'markdown-preview') { + return 'unsupported_preview' + } + if (sourceFile.isUntitled) { + return 'unsupported_untitled' + } + if (utf8ByteLength(content) > MOBILE_MARKDOWN_EDIT_MAX_BYTES) { + return 'file_too_large' + } + return undefined +} + +async function readCurrentContent( + file: OpenFile +): Promise<{ content: string; source: 'draft' | 'file' }> { + const draft = useAppStore.getState().editorDrafts[file.id] + if (draft !== undefined) { + return { content: draft, source: 'draft' } + } + return { content: await readFileContent(file), source: 'file' } +} + +async function readFileContent(file: OpenFile): Promise { + const connectionId = getConnectionId(file.worktreeId) ?? undefined + const result = (await window.api.fs.readFile({ + filePath: file.filePath, + connectionId + })) as FileContent + if (result.isBinary) { + throw new Error('binary_file') + } + if (utf8ByteLength(result.content) > MOBILE_MARKDOWN_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + return result.content +} + +function flushEditorState(fileId: string): void { + // Why: rich markdown serializes through a debounce. Mobile reads/saves must + // observe desktop-visible text, not a stale draft from before that debounce. + flushPendingEditorChange(fileId) +} + +async function waitForPositiveSave(file: OpenFile, content: string): Promise { + let timeout: number | null = null + let onSaved: ((event: Event) => void) | null = null + const cleanup = (): void => { + if (timeout !== null) { + window.clearTimeout(timeout) + timeout = null + } + if (onSaved) { + window.removeEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved as EventListener) + onSaved = null + } + } + const saved = new Promise((resolve, reject) => { + timeout = window.setTimeout(() => { + cleanup() + reject(new Error('save_timeout')) + }, 20_000) + onSaved = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.fileId !== file.id || detail.content !== content) { + return + } + cleanup() + resolve() + } + window.addEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved as EventListener) + }) + + try { + await requestEditorSaveQuiesce({ fileId: file.id }) + const liveFile = useAppStore.getState().openFiles.find((openFile) => openFile.id === file.id) + if (!liveFile) { + throw new Error('tab_not_found') + } + await requestEditorFileSave({ fileId: file.id, fallbackContent: content }) + await saved + } catch (error) { + cleanup() + throw error + } +} + +function respond(response: RuntimeMobileMarkdownResponse): void { + window.api.ui.respondMobileMarkdownRequest(response) +} diff --git a/src/shared/mobile-markdown-document.ts b/src/shared/mobile-markdown-document.ts new file mode 100644 index 00000000000..e1b196330a6 --- /dev/null +++ b/src/shared/mobile-markdown-document.ts @@ -0,0 +1,67 @@ +export const MOBILE_MARKDOWN_EDIT_MAX_BYTES = 256 * 1024 + +export type RuntimeMarkdownReadOnlyReason = + | 'unsupported_preview' + | 'unsupported_tab' + | 'unsupported_untitled' + | 'file_too_large' + +export type RuntimeMobileMarkdownRequest = + | { + id: string + operation: 'read' + worktreeId: string + tabId: string + } + | { + id: string + operation: 'save' + worktreeId: string + tabId: string + baseVersion: string + content: string + } + +export type RuntimeMobileMarkdownResponse = + | { + id: string + ok: true + result: RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult + } + | { + id: string + ok: false + error: string + } + +export type RuntimeMarkdownReadTabResult = { + tabId: string + filePath: string + relativePath: string + content: string + isDirty: boolean + version: string + source: 'draft' | 'file' + editable: boolean + readOnlyReason?: RuntimeMarkdownReadOnlyReason +} + +export type RuntimeMarkdownSaveTabResult = { + tabId: string + version: string + isDirty: false + content: string +} + +export function hashMarkdownContent(content: string): string { + let hash = 2166136261 + for (let i = 0; i < content.length; i += 1) { + hash ^= content.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return `content:${utf8ByteLength(content)}:${(hash >>> 0).toString(16)}` +} + +export function utf8ByteLength(content: string): number { + return new TextEncoder().encode(content).byteLength +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 516bcd3cf24..c414da52a09 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -1,6 +1,12 @@ /* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */ import type { TerminalPaneLayoutNode } from './types' import type { BrowserSessionProfile, GitWorktreeInfo, Repo } from './types' +import type { + RuntimeMarkdownReadTabResult, + RuntimeMarkdownSaveTabResult +} from './mobile-markdown-document' + +export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable' @@ -121,16 +127,6 @@ export type RuntimeMobileSessionTabsResult = { tabs: RuntimeMobileSessionClientTab[] } -export type RuntimeMarkdownReadTabResult = { - tabId: string - filePath: string - relativePath: string - content: string - isDirty: boolean - version: string - source: 'draft' | 'file' -} - export type RuntimeTerminalSummary = { handle: string worktreeId: string