Add mobile markdown editing

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-12 17:26:49 -04:00
co-authored by Orca
parent f1812786c0
commit c4c8723e1e
13 changed files with 1298 additions and 141 deletions
+342 -79
View File
@@ -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<MobileSessionTab, { type: 'markdown' }>
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 (
<ScrollView style={styles.markdownReader} contentContainerStyle={styles.markdownContent}>
<View style={styles.markdownEditor}>
<View style={styles.markdownHeader}>
<Text style={styles.markdownTitle} numberOfLines={1}>
{tab.relativePath || tab.title}
</Text>
{(tab.isDirty || doc.stale) && <Text style={styles.markdownMeta}>Updated on desktop</Text>}
{(tab.isDirty || doc.stale) && (
<View style={styles.markdownTitleRow}>
<View style={styles.markdownTitleBlock}>
<Text style={styles.markdownTitle} numberOfLines={1}>
{tab.relativePath || tab.title}
</Text>
{doc.saveError ? (
<Text style={styles.markdownError} numberOfLines={2}>
{doc.saveError}
</Text>
) : doc.readOnlyReason ? (
<Text style={styles.markdownMeta}>Read only</Text>
) : doc.stale ? (
<Text style={styles.markdownMeta}>Changed on desktop</Text>
) : doc.isDirty ? (
<Text style={styles.markdownMeta}>Unsaved on phone</Text>
) : null}
</View>
<View style={styles.markdownActions}>
{(doc.saveError || !doc.editable) && (
<Pressable style={styles.markdownRefreshButton} onPress={onCopy}>
<Text style={styles.markdownRefreshText}>Copy</Text>
</Pressable>
)}
{doc.isDirty && (
<Pressable style={styles.markdownRefreshButton} onPress={onDiscard}>
<Text style={styles.markdownRefreshText}>Discard</Text>
</Pressable>
)}
<Pressable
style={[
styles.markdownRefreshButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownRefreshText}>Save</Text>
)}
</Pressable>
</View>
</View>
{(doc.stale && !doc.isDirty) || !doc.editable ? (
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Refresh</Text>
</Pressable>
)}
) : null}
</View>
{lines.map((line, index) => {
const key = `${index}:${line}`
if (line.startsWith('# ')) {
return (
<Text key={key} style={styles.markdownH1}>
{line.slice(2)}
</Text>
)
}
if (line.startsWith('## ')) {
return (
<Text key={key} style={styles.markdownH2}>
{line.slice(3)}
</Text>
)
}
if (line.startsWith('```')) {
return null
}
if (line.startsWith('- ') || line.startsWith('* ')) {
return (
<Text key={key} style={styles.markdownBody}>
{'\u2022 '}
{line.slice(2)}
</Text>
)
}
if (line.trim().length === 0) {
return <View key={key} style={styles.markdownSpacer} />
}
return (
<Text key={key} style={styles.markdownBody}>
{line}
</Text>
)
})}
</ScrollView>
<TextInput
style={styles.markdownTextInput}
value={doc.localContent}
onChangeText={onChange}
editable={doc.editable && !doc.saving}
multiline
textAlignVertical="top"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
/>
</View>
)
}
@@ -341,6 +375,8 @@ export default function SessionScreen() {
const activeHandleRef = useRef<string | null>(null)
const activeSessionTabTypeRef = useRef<'terminal' | 'markdown' | null>(null)
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
const subscribeSeqRef = useRef<Map<string, number>>(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<MobileSessionTab, { type: 'markdown' }> =>
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<MobileSessionTab, { type: 'markdown' }>) => {
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<MobileSessionTab, { type: 'markdown' }>) => {
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() {
<View style={styles.sessionTopBar}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => router.back()}
onPress={requestLeaveSession}
hitSlop={8}
accessibilityLabel="Back to worktrees"
>
@@ -1713,11 +1933,15 @@ export default function SessionScreen() {
</Pressable>
</View>
) : activeMarkdownTab ? (
<View style={styles.markdownFrame}>
<View style={[styles.markdownFrame, { paddingBottom: keyboardLift }]}>
<MarkdownReader
tab={activeMarkdownTab}
doc={markdownDocs.get(activeMarkdownTab.id)}
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
onChange={(content) => updateMarkdownLocalContent(activeMarkdownTab.id, content)}
onSave={() => void saveMarkdownTab(activeMarkdownTab)}
onCopy={() => void copyMarkdownLocalContent(activeMarkdownTab.id)}
onDiscard={() => discardMarkdownLocalContent(activeMarkdownTab)}
/>
{toastMessage && (
<Animated.View
@@ -1980,7 +2204,7 @@ export default function SessionScreen() {
const target = markdownActionTarget
setMarkdownActionTarget(null)
if (target) {
void readMarkdownTab(target)
discardMarkdownLocalContent(target)
}
}
},
@@ -2163,14 +2387,37 @@ const styles = StyleSheet.create({
markdownReader: {
flex: 1
},
markdownEditor: {
flex: 1,
padding: spacing.lg,
gap: spacing.md
},
markdownContent: {
padding: spacing.lg,
paddingBottom: spacing.xl * 2
},
markdownHeader: {
marginBottom: spacing.lg,
gap: spacing.xs
},
markdownTitleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: spacing.md
},
markdownTitleBlock: {
flex: 1,
minWidth: 0,
gap: spacing.xs
},
markdownActions: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: spacing.xs,
maxWidth: '58%'
},
markdownTitle: {
color: colors.textPrimary,
fontSize: 16,
@@ -2214,6 +2461,19 @@ const styles = StyleSheet.create({
color: colors.statusRed,
fontSize: typography.bodySize
},
markdownTextInput: {
flex: 1,
minHeight: 0,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
padding: spacing.md,
fontSize: typography.bodySize,
lineHeight: 22,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
markdownRefreshButton: {
alignSelf: 'flex-start',
flexDirection: 'row',
@@ -2226,6 +2486,9 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
markdownButtonDisabled: {
opacity: 0.45
},
markdownRefreshText: {
color: colors.textPrimary,
fontSize: 13,
+44 -52
View File
@@ -11,7 +11,7 @@ import { gitExecFileAsync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { readFile, stat, rm } from 'fs/promises'
import { rm } from 'fs/promises'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
import type {
@@ -51,6 +51,7 @@ import type {
RuntimeSyncedLeaf,
RuntimeSyncedTab,
RuntimeMarkdownReadTabResult,
RuntimeMarkdownSaveTabResult,
RuntimeMobileSessionClientTab,
RuntimeMobileSessionMarkdownTab,
RuntimeMobileSessionTabsResult,
@@ -143,7 +144,6 @@ import {
areWorktreePathsEqual
} from '../ipc/worktree-logic'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import { killAllProcessesForWorktree } from './worktree-teardown'
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
@@ -295,7 +295,14 @@ type RuntimeNotifier = {
): void
renameTerminal(tabId: string, title: string | null): void
focusTerminal(tabId: string, worktreeId: string): void
focusEditorTab(tabId: string, worktreeId: string): void
focusEditorTab?(tabId: string, worktreeId: string): void
readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise<RuntimeMarkdownReadTabResult>
saveMobileMarkdownTab?(
worktreeId: string,
tabId: string,
baseVersion: string,
content: string
): Promise<RuntimeMarkdownSaveTabResult>
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<RuntimeMarkdownReadTabResult> {
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<RuntimeMarkdownSaveTabResult> {
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<string> {
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<string> {
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 {
@@ -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
)
})
]
@@ -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<RuntimeMarkdownReadTabResult>,
saveMobileMarkdownTab: (worktreeId, tabId, baseVersion, content) =>
requestMobileMarkdownFromRenderer(mainWindow, {
operation: 'save',
worktreeId,
tabId,
baseVersion,
content
}) as Promise<RuntimeMarkdownSaveTabResult>,
closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }),
sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }),
terminalFitOverrideChanged: (ptyId, mode, cols, rows) =>
@@ -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' })
})
})
@@ -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<Request, 'id'>
: never
: never
export async function requestMobileMarkdownFromRenderer(
mainWindow: BrowserWindow,
request: RendererMobileMarkdownRequest
): Promise<RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult> {
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 })
})
}
+8
View File
@@ -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
+15
View File
@@ -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) => {
+3
View File
@@ -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()
@@ -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<typeof vi.fn>
}
fs: {
readFile: ReturnType<typeof vi.fn>
writeFile: ReturnType<typeof vi.fn>
}
}
}
let mobileMarkdownHandler: ((request: RuntimeMobileMarkdownRequest) => void) | null = null
function setupWindow({
readFile,
writeFile = vi.fn().mockResolvedValue(undefined)
}: {
readFile: ReturnType<typeof vi.fn>
writeFile?: ReturnType<typeof vi.fn>
}): { 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<unknown> {
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<typeof vi.fn>
).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<void>; resolve: () => void } {
let resolve: () => void = () => {}
const promise = new Promise<void>((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()
}
})
})
@@ -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<string, Promise<void>>()
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<void> {
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<RuntimeMarkdownReadTabResult> {
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<RuntimeMarkdownSaveTabResult> {
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<T>(fileId: string, save: () => Promise<T>): Promise<T> {
const previous = saveQueues.get(fileId) ?? Promise.resolve()
let releaseQueue: () => void = () => {}
const current = new Promise<void>((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<string> {
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<void> {
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<void>((resolve, reject) => {
timeout = window.setTimeout(() => {
cleanup()
reject(new Error('save_timeout'))
}, 20_000)
onSaved = (event: Event): void => {
const detail = (event as CustomEvent<EditorFileSavedDetail>).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)
}
+67
View File
@@ -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
}
+6 -10
View File
@@ -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