mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user