feat(mobile): keep commit, PR and review drafts across app kills (#40114)

* feat(mobile): keep commit, PR and review drafts across kills

* refactor(mobile): build commit, PR and review drafts on the shared draft fields, and clear only the text that was sent

* fix(mobile): keep a toggle made during Write with AI, the edit note while its sheet closes, and a newer PR edit after an earlier save

* test(mobile): wait for the saved review note row instead of sleeping

* perf(mobile): keep a hash of the PR text an edit was typed against, not a second copy
This commit is contained in:
Kelvin Amoaba
2026-11-09 14:02:12 +00:00
committed by GitHub
parent a68d614010
commit 7f63600a23
24 changed files with 596 additions and 193 deletions
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { Text, View } from 'react-native'
import { GitMerge } from 'lucide-react-native'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
@@ -12,7 +12,7 @@ import { SheetPrimaryButton } from '../SheetPrimaryButton'
import { ListGroup, ListRow } from '../list/ListGroup'
import { ConfirmModal } from '../ConfirmModal'
import { presentPrMerge } from './pr-actions-state'
import { PrCreateSheet, type PrCreateKept } from './PrCreateSheet'
import { PrCreateSheet } from './PrCreateSheet'
import { PrReviewersSheet } from './PrReviewersSheet'
import {
livePrDetails,
@@ -22,17 +22,14 @@ import {
} from './use-mobile-pr-tab'
import { prSheetStyles as sheetStyles } from './pr-tab-styles'
import { FieldTextInput } from '../composer-input/FieldTextInput'
type PrEditKept = { key: string; base: string; text: string }
import { draftTextHash, workspaceDraftRowKey } from '../../drafts/durable-draft-rows'
import { useDurableDraftField } from '../../drafts/durable-draft-fields'
import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host'
// Sheets for the PR tab's decisions; one Modal swaps between them so iOS never stacks presentations.
export function MobilePrTabSheets({ tab }: { tab: MobilePrTab }) {
useMobileColorScheme()
const t = useMobileTranslation()
// Each sheet unmounts on Cancel; typed text waits here for the next open.
const [createKept, setCreateKept] = useState<PrCreateKept | null>(null)
const [titleKept, setTitleKept] = useState<PrEditKept | null>(null)
const [bodyKept, setBodyKept] = useState<PrEditKept | null>(null)
return (
<>
<KeyedBottomDrawer
@@ -55,27 +52,11 @@ export function MobilePrTabSheets({ tab }: { tab: MobilePrTab }) {
case 'reviewers':
return <PrReviewersSheet tab={tab} request={request} />
case 'editTitle':
return (
<PrEditTextSheet
tab={tab}
request={request}
field="title"
kept={titleKept}
onKeep={setTitleKept}
/>
)
return <PrEditTextSheet tab={tab} request={request} field="title" />
case 'editDescription':
return (
<PrEditTextSheet
tab={tab}
request={request}
field="body"
kept={bodyKept}
onKeep={setBodyKept}
/>
)
return <PrEditTextSheet tab={tab} request={request} field="body" />
case 'createPr':
return <PrCreateSheet tab={tab} kept={createKept} onKeep={setCreateKept} />
return <PrCreateSheet tab={tab} />
}
}}
</KeyedBottomDrawer>
@@ -182,55 +163,56 @@ function PrMergeSheet({ tab, request }: { tab: MobilePrTab; request: PrTabSheetR
function PrEditTextSheet({
tab,
request,
field,
kept,
onKeep
field
}: {
tab: MobilePrTab
request: PrTabSheetRequest
field: 'title' | 'body'
kept: PrEditKept | null
onKeep: (kept: PrEditKept | null) => void
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const executionHost = useWorkspaceExecutionHost()
const data = readyPrData(tab)
const details = livePrDetails(data?.details ?? null)
const current =
field === 'title' ? (details?.item.title ?? data?.pr.title ?? '') : (details?.body ?? '')
const keepKey = request.scope.resource
const resource = request.scope.resource
// An unsaved edit waits through Cancel and the app being killed, with the PR text it was typed against.
const keptKey = resource
? workspaceDraftRowKey(
'prEdit',
{ hostId: tab.hostId, executionHost, worktreeId: tab.worktreeId },
`${resource}:${field}`
)
: null
const [keptAgainst, setKeptAgainst] = useDurableDraftField(keptKey, 'against')
const [keptText, setKeptText] = useDurableDraftField(keptKey, 'text')
// An edit typed against text the PR still has comes back; once the PR text changes it would revert someone's edit.
const [base] = useState(current)
const [draft, setDraft] = useState(() =>
kept && kept.key === keepKey && kept.base === current ? kept.text : current
)
// Closing keeps a changed, unsaved draft for the next open, and drops this PR's kept one otherwise.
const savedRef = useRef(false)
const closeRef = useRef({ keepKey, base, draft, kept, onKeep })
useLayoutEffect(() => {
closeRef.current = { keepKey, base, draft, kept, onKeep }
})
useEffect(
() => () => {
const close = closeRef.current
if (!close.keepKey) {
return
}
if (!savedRef.current && close.draft !== close.base) {
close.onKeep({ key: close.keepKey, base: close.base, text: close.draft })
} else if (close.kept?.key === close.keepKey) {
close.onKeep(null)
}
},
[]
)
// A fingerprint, never empty, so an emptied PR description still marks an edit (an empty field is no field).
const [against] = useState(() => draftTextHash(base))
const draft = keptAgainst === against ? keptText : base
const setDraft = (text: string) => {
const edited = text !== base
setKeptText(edited ? text : '')
setKeptAgainst(edited ? against : '')
}
const { textAction } = tab
const save = async () => {
if (!tab.ownsSheet(request)) {
return
}
if (await textAction.save(field, draft, current)) {
savedRef.current = true
const sent = draft
if (await textAction.save(field, sent, current)) {
// Only the saved edit; one typed since in a reopened sheet stays.
let saved = false
setKeptText((text) => {
saved = text === sent
return saved ? '' : text
})
if (saved) {
setKeptAgainst('')
}
tab.closeSheetIf(request)
}
}
@@ -29,6 +29,8 @@ export type PRCommentCardActions = {
isResolveBusy: (threadId: string) => boolean
isEditBusy: (commentId: number) => boolean
isDeleteBusy: (commentId: number) => boolean
// Where a reply to a comment waits until it is posted.
draftKey: (replyTo: number) => string | null
// Repo slug for the slug-addressed edit/delete RPCs; gates the affordances when absent.
prRepo: PRCommentRepoSlug | null
}
@@ -296,6 +298,7 @@ export const PRCommentCard = memo(function PRCommentCard({
placeholder={t('pullRequest.commentCard.replyPlaceholder', 'Write a reply…')}
submitLabel={t('pullRequest.commentCard.reply', 'Reply')}
submitting={replyBusy}
draftKey={actions.draftKey(comment.id)}
onSubmit={submitReply}
onCancel={() => setReplyOpen(false)}
autoFocus
@@ -7,6 +7,7 @@ import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { isSubmittableCommentBody } from '../../source-control/review/pr-comment-actions'
import { MessageBox } from '../composer-input/MessageBox'
import { composerInputStyles } from '../composer-input/composer-input-styles'
import { useDurableDraftField } from '../../drafts/durable-draft-fields'
type Props = {
// Share plain-text composer across root comment, reply and edit.
@@ -15,6 +16,8 @@ type Props = {
submitting: boolean
// Seeds the field for the edit case; the reply/add cases leave it empty.
initialBody?: string
// Where a new comment or reply waits until it is posted, through closing and the app being killed.
draftKey?: string | null
// Resolves to true on success; the composer clears + collapses (caller-driven via key remount or onSubmitted).
onSubmit: (body: string) => Promise<boolean>
onCancel?: () => void
@@ -27,6 +30,7 @@ export function PRCommentComposer({
submitLabel,
submitting,
initialBody,
draftKey = null,
onSubmit,
onCancel,
autoFocus,
@@ -34,7 +38,9 @@ export function PRCommentComposer({
}: Props) {
useMobileColorScheme()
const t = useMobileTranslation()
const [body, setBody] = useState(initialBody ?? '')
const [edited, setEdited] = useState(initialBody ?? '')
const [kept, setKept] = useDurableDraftField(draftKey, 'body')
const [body, setBody] = draftKey === null ? [edited, setEdited] : [kept, setKept]
// Guard synchronously against double-submit before parent’s async submitting state rerenders.
const inFlightRef = useRef(false)
const canSubmit = isSubmittableCommentBody(body) && !submitting
@@ -45,9 +51,11 @@ export function PRCommentComposer({
}
inFlightRef.current = true
try {
const ok = await onSubmit(body.trim())
const posted = body.trim()
const ok = await onSubmit(posted)
// A reopened composer shares the draft; text typed there since stays.
if (ok) {
setBody('')
setBody((current) => (current.trim() === posted ? '' : current))
}
} finally {
inFlightRef.current = false
@@ -239,6 +239,7 @@ export function PRCommentsSection({
)}
submitLabel={t('pullRequest.commentsSection.comment', 'Comment')}
submitting={actions.isRootBusy}
draftKey={actions.draftKey(null)}
onSubmit={actions.addRootComment}
/>
</View>
@@ -267,6 +268,7 @@ export function usePRCommentCardActions(
isResolveBusy: actions.isResolveBusy,
isEditBusy: actions.isEditBusy,
isDeleteBusy: actions.isDeleteBusy,
draftKey: actions.draftKey,
prRepo: prRepo ?? null
}
: undefined,
@@ -91,6 +91,7 @@ export function PrCommentsScreen({ tab, onBack, bottomInset = 0, onOpenFile }: S
isResolveBusy: commentActions.isResolveBusy,
isEditBusy: commentActions.isEditBusy,
isDeleteBusy: commentActions.isDeleteBusy,
draftKey: commentActions.draftKey,
prRepo: data?.pr.prRepo ?? null
}
: undefined,
@@ -195,6 +196,7 @@ export function PrCommentsScreen({ tab, onBack, bottomInset = 0, onOpenFile }: S
})}
submitLabel={t('pullRequest.commentsSection.comment', 'Comment')}
submitting={commentActions.isRootBusy}
draftKey={commentActions.draftKey(null)}
onSubmit={commentActions.addRootComment}
inline
/>
@@ -1,11 +1,4 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type Dispatch,
type SetStateAction
} from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronsUpDown, Sparkles } from 'lucide-react-native'
import { colors, pressedStyle, spacing } from '../../theme/mobile-theme'
@@ -30,8 +23,7 @@ import {
import {
cancelMobilePrFields,
generateMobilePrFields,
loadMobilePrCreateDraft,
type MobilePrCreateDraft
loadMobilePrCreateDraft
} from '../../source-control/mobile-pr-create-draft'
import {
getMobileCommitFailureStagedEntries,
@@ -43,33 +35,52 @@ import type { MobilePrTab } from './use-mobile-pr-tab'
import { prSheetStyles as sheetStyles } from './pr-tab-styles'
import { PrBasePicker } from './PrBasePicker'
import { FieldTextInput } from '../composer-input/FieldTextInput'
import { workspaceDraftRowKey } from '../../drafts/durable-draft-rows'
import { updateDurableDraftField, useDurableDraftField } from '../../drafts/durable-draft-fields'
import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host'
type Fields = { title: string; body: string; base: string; draft: boolean }
// The provider rides along so a kept draft reopens without asking the host again.
type Fields = { title: string; body: string; base: string; draft: boolean; provider: string }
/** What Cancel leaves for the next open on the same workspace branch. */
export type PrCreateKept = { key: string; draft: MobilePrCreateDraft; fields: Fields }
function keepCreateField(key: string, patch: Partial<Fields>): void {
for (const [name, value] of Object.entries(patch)) {
updateDurableDraftField(key, name, value === true ? 'yes' : value === false ? '' : value)
}
}
// The first edit keeps the whole form. The provider is never empty, so it is what marks a kept draft.
function keepCreateFields(key: string, fields: Fields | null): void {
keepCreateField(key, fields ?? { provider: '', title: '', body: '', base: '', draft: false })
}
function useKeptCreateFields(key: string | null): Fields | null {
const [provider] = useDurableDraftField(key, 'provider')
const [title] = useDurableDraftField(key, 'title')
const [body] = useDurableDraftField(key, 'body')
const [base] = useDurableDraftField(key, 'base')
const [draft] = useDurableDraftField(key, 'draft')
return useMemo(
() => (provider ? { title, body, base, draft: draft === 'yes', provider } : null),
[base, body, draft, provider, title]
)
}
// One Create flow for every entry point and all five providers: seed from commits, push if needed, land on the PR tab.
export function PrCreateSheet({
tab,
kept,
onKeep
}: {
tab: MobilePrTab
kept: PrCreateKept | null
onKeep: Dispatch<SetStateAction<PrCreateKept | null>>
}) {
export function PrCreateSheet({ tab }: { tab: MobilePrTab }) {
useMobileColorScheme()
const t = useMobileTranslation()
const { client, worktreeId, gitBranch } = tab
const { client, hostId, worktreeId, gitBranch } = tab
const executionHost = useWorkspaceExecutionHost()
const creating = tab.creatingPr
const setCreating = tab.setCreatingPr
const keepKey = gitBranch ? `${worktreeId}\u0000${gitBranch}` : null
// Reopening on the same branch restores what was typed instead of re-seeding from commits.
const [restored] = useState(() => (kept && kept.key === keepKey ? kept : null))
const [draft, setDraft] = useState<MobilePrCreateDraft | null>(restored?.draft ?? null)
const [fields, setFields] = useState<Fields | null>(restored?.fields ?? null)
const editedRef = useRef(restored !== null)
// What was typed waits, per workspace branch, through Cancel and the app being killed.
const keptKey = gitBranch
? workspaceDraftRowKey('prCreate', { hostId, executionHost, worktreeId }, gitBranch)
: null
const kept = useKeptCreateFields(keptKey)
const [seeded, setSeeded] = useState<Fields | null>(null)
const [seedError, setSeedError] = useState<string | null>(null)
const fields = kept ?? seeded
const [pickingBase, setPickingBase] = useState(false)
const [generating, setGenerating] = useState(false)
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null)
@@ -89,11 +100,11 @@ export function PrCreateSheet({
useLayoutEffect(() => {
commitsAheadRef.current = tab.commitsAhead
})
// Seed once per open: a reconnect or a status reload must not overwrite what the user typed.
const seededRef = useRef(restored !== null)
// Seed once per open, and not over a kept draft: a reconnect or a status reload must not overwrite what was typed.
const seedRef = useRef(kept !== null)
useEffect(() => {
if (!client || !gitBranch || seededRef.current) {
if (!client || !gitBranch || seedRef.current) {
return
}
let cancelled = false
@@ -104,14 +115,19 @@ export function PrCreateSheet({
})
.then((loaded) => {
if (!cancelled) {
seededRef.current = true
setDraft(loaded)
setFields({ title: loaded.title, body: loaded.body, base: loaded.base, draft: false })
seedRef.current = true
setSeeded({
title: loaded.title,
body: loaded.body,
base: loaded.base,
draft: false,
provider: loaded.prefill.provider
})
}
})
.catch((err: unknown) => {
if (!cancelled) {
setMessage({ text: err instanceof Error ? err.message : String(err), error: true })
setSeedError(err instanceof Error ? err.message : String(err))
}
})
return () => {
@@ -119,13 +135,25 @@ export function PrCreateSheet({
}
}, [client, gitBranch, worktreeId])
const copy = hostedReviewCopy(draft?.prefill.provider)
const copy = hostedReviewCopy(fields?.provider)
const title = t('pullRequest.createSheet.title', 'Create {{reviewLabel}}', {
reviewLabel: copy.reviewLabel
})
// The first edit turns the seeded fields into a kept draft. Later ones write only what they change, so a
// late Write with AI answer can't undo a toggle made meanwhile.
const keptRef = useRef(kept)
useLayoutEffect(() => {
keptRef.current = kept
})
const update = (patch: Partial<Fields>) => {
editedRef.current = true
setFields((current) => (current ? { ...current, ...patch } : current))
if (!keptKey || !fields) {
return
}
if (keptRef.current) {
keepCreateField(keptKey, patch)
} else {
keepCreateFields(keptKey, { ...fields, ...patch })
}
}
// Closing the sheet cancels a running generation, drops its late answer and keeps typed fields; a reconnect does none.
@@ -134,9 +162,9 @@ export function PrCreateSheet({
useLayoutEffect(() => {
generatingRef.current = generating
})
const closeTargetRef = useRef({ client, worktreeId, keepKey, draft, fields, onKeep })
const closeTargetRef = useRef({ client, worktreeId })
useLayoutEffect(() => {
closeTargetRef.current = { client, worktreeId, keepKey, draft, fields, onKeep }
closeTargetRef.current = { client, worktreeId }
})
useEffect(
() => () => {
@@ -145,25 +173,19 @@ export function PrCreateSheet({
if (generatingRef.current && target.client) {
void cancelMobilePrFields(target.client, target.worktreeId).catch(() => {})
}
if (editedRef.current && target.keepKey && target.draft && target.fields) {
target.onKeep({ key: target.keepKey, draft: target.draft, fields: target.fields })
}
},
[]
)
const writeWithAi = async () => {
if (!client || !fields || !draft || generating) {
if (!client || !fields || generating) {
return
}
const generation = ++generationRef.current
setGenerating(true)
setMessage(null)
try {
const result = await generateMobilePrFields(client, worktreeId, {
...fields,
provider: draft.prefill.provider
})
const result = await generateMobilePrFields(client, worktreeId, fields)
if (generation !== generationRef.current) {
return
}
@@ -190,7 +212,7 @@ export function PrCreateSheet({
return
}
createInFlightRef.current = true
const createdKey = keepKey
const createdKey = keptKey
setCreating(true)
setMessage(null)
setCommitFailure(null)
@@ -226,10 +248,12 @@ export function PrCreateSheet({
}
triggerSuccess()
setCreating(false)
// The PR exists now, so nothing carries over to the next Create on this branch, even text kept
// when a branch or client change closed this sheet mid-run.
editedRef.current = false
onKeep((current) => (current?.key === createdKey ? null : current))
// The PR exists now, so nothing carries over to the next Create on this branch, even when a branch or
// client change closed this sheet mid-run.
setSeeded(fields)
if (createdKey) {
keepCreateFields(createdKey, null)
}
const created = {
url: outcome.url,
warning: outcome.warning ?? null,
@@ -281,8 +305,8 @@ export function PrCreateSheet({
'Check out a branch before creating a pull request.'
)}
</Text>
) : message ? (
<Text style={sheetStyles.error}>{message.text}</Text>
) : seedError ? (
<Text style={sheetStyles.error}>{seedError}</Text>
) : (
<ActivityIndicator color={colors.textSecondary} />
)}
@@ -44,6 +44,8 @@ export type PrThreadActions = {
isResolveBusy: (threadId: string) => boolean
isEditBusy: (commentId: number) => boolean
isDeleteBusy: (commentId: number) => boolean
// Where a reply to a comment waits until it is posted.
draftKey: (replyTo: number) => string | null
// Repo slug for the slug-addressed edit/delete RPCs; gates the affordances when absent.
prRepo: PRCommentRepoSlug | null
}
@@ -114,6 +114,7 @@ export function PrThreadScreen({
placeholder={t('pullRequest.screens.replyPlaceholder', 'Reply…')}
submitLabel={t('pullRequest.commentCard.reply', 'Reply')}
submitting={actions.isReplyBusy(root.id)}
draftKey={actions.draftKey(root.id)}
onSubmit={(body) => actions.reply(root, body)}
autoFocus={focusReply}
inline
@@ -0,0 +1,99 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const hosts = vi.hoisted(() => {
const Box = (props: { children?: ReactNode }): ReactNode => props.children ?? null
Box.displayName = 'MessageBox'
return { Box }
})
vi.mock('react-native', () => ({ Pressable: () => null }))
vi.mock('lucide-react-native', () => ({ X: () => null }))
vi.mock('../../theme/use-mobile-color-scheme', () => ({ useMobileColorScheme: () => 'light' }))
vi.mock('../composer-input/MessageBox', () => ({ MessageBox: hosts.Box }))
vi.mock('../composer-input/composer-input-styles', () => ({ composerInputStyles: {} }))
import { publishPageDeviceStore } from '../../mobile-web-shell/bridge/page-device-store'
import { resetDurableDraftFieldsForTests } from '../../drafts/durable-draft-fields'
import { publishInMemoryDeviceStore } from '../../drafts/in-memory-device-store.test-support'
import { PRCommentComposer } from './PRCommentComposer'
// The shell's draft table outlives the page; nothing else does.
const table = new Map<string, string>()
let renderer: ReactTestRenderer | null = null
let onSubmit: (body: string) => Promise<boolean> = async () => true
const KEY = 'draft:prComment:wt-1:host-1::/acme/app#12#new'
async function open(): Promise<void> {
await act(async () => {
renderer = create(
createElement(PRCommentComposer, {
placeholder: 'Comment',
submitLabel: 'Comment',
submitting: false,
draftKey: KEY,
onSubmit: (body: string) => onSubmit(body)
})
)
await vi.advanceTimersByTimeAsync(0)
})
}
function box(): { value: string; onChangeText: (text: string) => void; onSend: () => void } {
const props = renderer?.root.findByType(hosts.Box).props
return { value: props?.value, onChangeText: props?.onChangeText, onSend: props?.onSend }
}
async function type(text: string): Promise<void> {
act(() => box().onChangeText(text))
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
}
// A kill: the page and its memory go, the store stays.
function kill(): void {
act(() => renderer?.unmount())
renderer = null
resetDurableDraftFieldsForTests()
}
beforeEach(() => {
vi.useFakeTimers()
publishInMemoryDeviceStore(table)
})
afterEach(() => {
kill()
table.clear()
publishPageDeviceStore(null)
onSubmit = async () => true
vi.useRealTimers()
})
it('brings an unposted comment back after the app is killed', async () => {
await open()
await type('Can we keep the retry?')
kill()
await open()
expect(box().value).toBe('Can we keep the retry?')
})
it('keeps text typed in a reopened composer when the earlier post lands', async () => {
let land: (ok: boolean) => void = () => {}
onSubmit = () => new Promise((resolve) => (land = resolve))
await open()
await type('first thought')
act(() => box().onSend())
act(() => renderer?.unmount())
await open()
await type('a second thought')
await act(async () => {
land(true)
await vi.advanceTimersByTimeAsync(1000)
})
expect(box().value).toBe('a second thought')
})
@@ -0,0 +1,154 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const hosts = vi.hoisted(() => {
const make = (name: string) => {
const Host = (props: { children?: ReactNode }): ReactNode => props.children ?? null
Host.displayName = name
return Host
}
return {
View: make('View'),
Text: make('Text'),
Pressable: make('Pressable'),
Field: make('FieldTextInput'),
Button: make('SheetPrimaryButton')
}
})
vi.mock('react-native', () => ({
View: hosts.View,
Text: hosts.Text,
Pressable: hosts.Pressable,
ActivityIndicator: hosts.View,
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1, absoluteFillObject: {} },
Platform: { OS: 'ios', select: (options: Record<string, unknown>) => options.ios }
}))
vi.mock('lucide-react-native', () => ({ ChevronsUpDown: hosts.View, Sparkles: hosts.View }))
vi.mock('../../theme/use-mobile-color-scheme', () => ({ useMobileColorScheme: () => 'light' }))
vi.mock('../nav-bar/SheetHeader', () => ({ SheetHeader: hosts.View }))
vi.mock('../SheetPrimaryButton', () => ({ SheetPrimaryButton: hosts.Button }))
vi.mock('../list/ListGroup', () => ({ ListGroup: hosts.View, ListRow: hosts.View }))
vi.mock('../MobileSwitch', () => ({ MobileSwitch: hosts.View }))
vi.mock('./PrBasePicker', () => ({ PrBasePicker: hosts.View }))
vi.mock('../composer-input/FieldTextInput', () => ({ FieldTextInput: hosts.Field }))
vi.mock('../../source-control/MobileCommitFailurePanel', () => ({
MobileCommitFailurePanel: hosts.View
}))
vi.mock('../../source-control/use-mobile-commit-failure-recovery', () => ({
useMobileCommitFailureRecovery: () => null
}))
const host = vi.hoisted(() => ({
seed: vi.fn(),
create: vi.fn()
}))
vi.mock('../../source-control/mobile-pr-create-draft', () => ({
loadMobilePrCreateDraft: host.seed,
generateMobilePrFields: vi.fn(),
cancelMobilePrFields: vi.fn(async () => {})
}))
vi.mock('../../source-control/mobile-hosted-review-create-intent-runner', () => ({
runMobileHostedReviewCreateIntent: host.create,
isMobileHostedReviewCommitFailure: () => false
}))
import { publishPageDeviceStore } from '../../mobile-web-shell/bridge/page-device-store'
import { resetDurableDraftFieldsForTests } from '../../drafts/durable-draft-fields'
import { publishInMemoryDeviceStore } from '../../drafts/in-memory-device-store.test-support'
import { createFakeRpcClient } from '../../mobile-web-shell/bridge-host-test-fakes'
import { PrCreateSheet } from './PrCreateSheet'
import type { MobilePrTab } from './use-mobile-pr-tab'
import { mountFixture } from '../../test-support/rpc-recording/recorder-fixture-shape'
// The shell's draft table outlives the page; nothing else does.
const table = new Map<string, string>()
let renderer: ReactTestRenderer | null = null
// The sheet reads only these fields of the PR tab.
const tab = mountFixture<MobilePrTab>({
client: createFakeRpcClient(),
hostId: 'host-1',
worktreeId: 'repo::/work/feature',
gitBranch: 'feature/login',
gitStatus: null,
commitsAhead: null,
connState: 'connected',
workspaceLabel: 'feature',
creatingPr: false,
setCreatingPr: () => {},
closeSheet: () => {},
closeSheetThen: () => {}
})
async function open(): Promise<void> {
await act(async () => {
renderer = create(createElement(PrCreateSheet, { tab }))
await vi.advanceTimersByTimeAsync(0)
})
}
function titleField(): ReactTestInstance | undefined {
return renderer?.root.findAllByType(hosts.Field)[0]
}
async function typeTitle(title: string): Promise<void> {
act(() => titleField()?.props.onChangeText(title))
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
}
// A kill: the page and its memory go, the store stays.
function kill(): void {
act(() => renderer?.unmount())
renderer = null
resetDurableDraftFieldsForTests()
}
beforeEach(() => {
vi.useFakeTimers()
host.seed.mockResolvedValue({
prefill: { provider: 'github', base: 'main', title: '', body: '' },
title: 'Seed from commits',
body: '',
base: 'main'
})
publishInMemoryDeviceStore(table)
})
afterEach(() => {
kill()
table.clear()
publishPageDeviceStore(null)
host.seed.mockReset()
host.create.mockReset()
vi.useRealTimers()
})
it('brings typed fields back after the app is killed instead of the commit seed', async () => {
await open()
expect(titleField()?.props.value).toBe('Seed from commits')
await typeTitle('Fix the login race')
kill()
await open()
expect(titleField()?.props.value).toBe('Fix the login race')
})
it('keeps nothing for the next Create once a Create lands after its sheet closed', async () => {
let land: (outcome: unknown) => void = () => {}
host.create.mockReturnValue(new Promise((resolve) => (land = resolve)))
await open()
await typeTitle('Fix the login race')
act(() => renderer?.root.findByType(hosts.Button).props.onPress())
// A branch or client change closes the sheet while the Create is still running.
act(() => renderer?.unmount())
await act(async () => {
land({ ok: true, url: 'https://example.test/pr/1', prefill: { provider: 'github' } })
await vi.advanceTimersByTimeAsync(1000)
})
kill()
await open()
expect(titleField()?.props.value).toBe('Seed from commits')
})
+7 -13
View File
@@ -1,11 +1,12 @@
import * as z from 'zod'
import {
durableDraftRow,
durableDraftRowKey,
fitsDeviceStore,
pageDraftRowBackend,
resetDurableDraftRowsForTests,
type DurableDraftRow
workspaceDraftRowKey,
type DurableDraftRow,
type WorkspaceDraftScope
} from './durable-draft-rows'
/** Unsaved edits to a file or Markdown tab, with the hash of the host's version they were made against. */
@@ -23,19 +24,12 @@ type Slot = {
}
const slots = new Map<string, Slot>()
/** The host and remote runtime a worktree lives on, so two hosts' same-named documents never share edits. */
export type DocumentEditScope = { hostId: string; executionHost?: string; worktreeId: string }
function scopedId(scope: DocumentEditScope, id: string): string {
return `${scope.hostId}:${scope.executionHost ?? ''}:${id}`
export function markdownTabDraftKey(scope: WorkspaceDraftScope, tabId: string): string {
return workspaceDraftRowKey('markdownTab', scope, tabId)
}
export function markdownTabDraftKey(scope: DocumentEditScope, tabId: string): string {
return durableDraftRowKey('markdownTab', scope.worktreeId, scopedId(scope, tabId))
}
export function fileEditDraftKey(scope: DocumentEditScope, path: string): string {
return durableDraftRowKey('fileEdit', scope.worktreeId, scopedId(scope, path))
export function fileEditDraftKey(scope: WorkspaceDraftScope, path: string): string {
return workspaceDraftRowKey('fileEdit', scope, path)
}
function slotFor(key: string): Slot {
+21 -1
View File
@@ -74,6 +74,11 @@ function fnv1a(text: string, seed: number): string {
return hash.toString(16).padStart(8, '0')
}
/** A 64-bit fingerprint of `text`, for keys and for telling whether text a draft was typed against changed. */
export function draftTextHash(text: string): string {
return `${fnv1a(text, 0x811c9dc5)}${fnv1a(text, 0x01000193)}`
}
/** `draft:<namespace>:<worktree or ''>:<id>`; the shell binds the host. */
export function durableDraftRowKey(namespace: string, worktreeId: string, id: string): string {
const key = `draft:${namespace}:${worktreeId}:${id}`
@@ -81,7 +86,22 @@ export function durableDraftRowKey(namespace: string, worktreeId: string, id: st
return key
}
const rest = `${worktreeId}:${id}`
return `draft:${namespace}:#${fnv1a(rest, 0x811c9dc5)}${fnv1a(rest, 0x01000193)}`
return `draft:${namespace}:#${draftTextHash(rest)}`
}
/** The host and remote runtime a worktree lives on, so two hosts' same-named worktrees never share a draft. */
export type WorkspaceDraftScope = { hostId: string; executionHost?: string; worktreeId: string }
export function workspaceDraftRowKey(
namespace: string,
scope: WorkspaceDraftScope,
id: string
): string {
return durableDraftRowKey(
namespace,
scope.worktreeId,
`${scope.hostId}:${scope.executionHost ?? ''}:${id}`
)
}
export function deviceStoreDraftBackend(
@@ -35,6 +35,7 @@ import type { FileDocState, FileSyntaxState } from './session-document-state'
import { DiffLineRow } from '../../source-control/review/MobileSessionDiffLineRow'
import { sendableMobileDiffComments } from '../../source-control/review/mobile-diff-comments'
import { MobileLoadErrorState, MobileStateView } from '../../components/MobileStateView'
import { useDurableDraftField } from '../../drafts/durable-draft-fields'
type FileReaderProps = {
doc: FileDocState | undefined
@@ -44,6 +45,8 @@ type FileReaderProps = {
documentId?: string
language?: string
diffCommentActions?: DiffCommentActions
// Where an unsaved note waits through leaving the file and the app being killed.
noteDraftKey?: string
}
export function FileReader(props: FileReaderProps) {
@@ -58,7 +61,8 @@ function FileReaderDocument({
title,
relativePath,
language,
diffCommentActions
diffCommentActions,
noteDraftKey
}: FileReaderProps) {
useMobileColorScheme()
const t = useMobileTranslation()
@@ -69,7 +73,11 @@ function FileReaderDocument({
const [fileSyntax, setFileSyntax] = useState<FileSyntaxState | null>(null)
const [diffSyntax, setDiffSyntax] = useState<DiffSyntaxState | null>(null)
const [activeCommentLine, setActiveCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const [keptNote, setKeptNote] = useDurableDraftField(noteDraftKey ?? null, 'note')
const [localNote, setLocalNote] = useState('')
const [commentDraft, setCommentDraft] = noteDraftKey
? [keptNote, setKeptNote]
: [localNote, setLocalNote]
const plainDiffLines = useMemo(
() =>
doc?.status === 'ready' && doc.kind === 'diff'
@@ -105,10 +113,12 @@ function FileReaderDocument({
if (!diffCommentActions) {
return
}
void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => {
const note = commentDraft
void diffCommentActions.onAdd(relativePath, lineNumber, note).then((added) => {
if (added) {
setActiveCommentLine(null)
setCommentDraft('')
// Only the saved text; a note typed since in a reopened reader stays.
setCommentDraft((current) => (current === note ? '' : current))
}
})
},
@@ -31,6 +31,7 @@ import { FileReader } from '../files/session-documents/MobileSessionFileReader'
import { MobileSessionContentState } from './MobileSessionContentState'
import { MarkdownReader } from '../files/session-documents/MobileSessionMarkdownReader'
import { markdownTabDraftKey } from '../drafts/document-edit-draft-rows'
import { workspaceDraftRowKey } from '../drafts/durable-draft-rows'
import { loadMarkdownPreviewImage } from '../files/mobile-markdown-image-source'
export function MobileSessionActiveContent({
@@ -143,6 +144,9 @@ export function MobileSessionActiveContent({
controller
)
const { presentedConnState, showOfflineState, forceReconnectHost } = controller
// Review notes are for working-tree diffs only.
const fileTabTakesNotes =
activeFileTab?.diffSource === 'staged' || activeFileTab?.diffSource === 'unstaged'
const toast = toastMessage ? (
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
<Text style={styles.toastText}>{toastMessage}</Text>
@@ -195,8 +199,17 @@ export function MobileSessionActiveContent({
title={activeFileTab.title || t('session.activeContent.fileTitle', 'File')}
relativePath={activeFileTab.relativePath}
language={activeFileTab.language}
noteDraftKey={
fileTabTakesNotes
? workspaceDraftRowKey(
'fileNote',
{ hostId, executionHost, worktreeId },
activeFileTab.relativePath
)
: undefined
}
diffCommentActions={
activeFileTab.diffSource === 'staged' || activeFileTab.diffSource === 'unstaged'
fileTabTakesNotes
? {
comments: diffComments,
sendingCommentIds: sendingDiffCommentIds,
@@ -24,7 +24,7 @@ import {
reviewDescriptorFromItem
} from './mobile-diff-review-screen-model'
import type { ReviewSheetIntents } from './mobile-diff-review-sheets'
import { useMobileDiffReviewNoteDraft } from './use-mobile-diff-review-note-draft'
import type { MobileDiffReviewNoteDraft } from './use-mobile-diff-review-note-draft'
type CommentActionsInput = {
client: RpcClient | null
@@ -43,9 +43,12 @@ type CommentActionsInput = {
navigationRef: { readonly current: number }
/** GitHub's viewed mark for a PR file, or null when the file's viewed state lives on the phone. */
remoteViewed: (item: MobileDiffReviewQueueItem, viewed: boolean) => Promise<boolean> | null
setComposerBody: Dispatch<SetStateAction<string>>
noteDraft: Pick<
MobileDiffReviewNoteDraft,
'openNewNoteSheet' | 'openEditComposer' | 'clearSavedNote'
>
setActionError: Dispatch<SetStateAction<string | null>>
sheets: Pick<ReviewSheetIntents, 'openSheet' | 'openSheetWhenIdle' | 'closeSheet'>
sheets: Pick<ReviewSheetIntents, 'openSheetWhenIdle' | 'closeSheet'>
}
export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
@@ -64,11 +67,12 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
selectIndex,
navigationRef,
remoteViewed,
setComposerBody,
noteDraft,
setActionError,
sheets
} = input
const { openSheet, openSheetWhenIdle, closeSheet } = sheets
const { openSheetWhenIdle, closeSheet } = sheets
const { openNewNoteSheet, openEditComposer, clearSavedNote } = noteDraft
const persistMetadata = useCallback(
async (
@@ -147,12 +151,6 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
[persistMetadata, screenState, setActionError, setScreenState, updateReadyState, worktreeId]
)
const { openNewNoteSheet, openEditComposer, clearSavedNote } = useMobileDiffReviewNoteDraft({
composerBody,
setComposerBody,
openSheet
})
const openComposer = useCallback(
(lineNumber: number) =>
openNewNoteSheet({ kind: 'composer', composer: { mode: 'create', lineNumber } }),
@@ -6,6 +6,9 @@ import { createFakeRpcClient } from '../../mobile-web-shell/bridge-host-test-fak
import type { ReviewScreenState } from './mobile-diff-review-screen-model'
import { useMobileDiffReviewController } from './use-mobile-diff-review-controller'
import { documentQueryClient } from '../../test-support/rpc-recording/hook-mount'
import { publishPageDeviceStore } from '../../mobile-web-shell/bridge/page-device-store'
import { resetDurableDraftFieldsForTests } from '../../drafts/durable-draft-fields'
import { publishInMemoryDeviceStore } from '../../drafts/in-memory-device-store.test-support'
const loadSnapshot = vi.hoisted(() => vi.fn())
// Each build's notes-and-files read; status comes from the shared git.status query, held pending here.
@@ -92,6 +95,8 @@ describe('useMobileDiffReviewController', () => {
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
resetDurableDraftFieldsForTests()
publishPageDeviceStore(null)
})
describe('Stage · next file', () => {
@@ -204,5 +209,44 @@ describe('useMobileDiffReviewController', () => {
act(() => controller?.openComposer(3))
expect(controller?.composerBody).toBe('')
})
it('brings an unsaved new note back after the app is killed, not the text of an edit', async () => {
// The shell's draft table outlives the page; nothing else does.
const table = new Map<string, string>()
publishInMemoryDeviceStore(table)
const openReview = async () => {
loadSnapshot.mockResolvedValueOnce(unstaged(['c.ts']))
await act(async () => {
renderer = create(queries.wrap(createElement(StageProbe)))
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
await openReview()
act(() => controller?.openComposer(3))
act(() => controller?.setComposerBody('rename this'))
act(() => controller?.closeComposer())
act(() =>
controller?.openEditComposer({
id: 'saved',
worktreeId: 'wt-1',
filePath: 'c.ts',
lineNumber: 1,
side: 'modified',
body: 'older note',
createdAt: 1
})
)
act(() => controller?.setComposerBody('older note, edited'))
// The draft row saves once typing settles.
await act(async () => {
await vi.waitFor(() => expect(table.size).toBe(1))
})
act(() => renderer?.unmount())
resetDurableDraftFieldsForTests()
await openReview()
act(() => controller?.openComposer(3))
expect(controller?.composerBody).toBe('rename this')
})
})
})
@@ -27,6 +27,9 @@ import { useMobilePrSidebarController } from './use-mobile-pr-sidebar-controller
import { useMobileDiffReviewGitHub } from './use-mobile-diff-review-github'
import { mobileDiffReviewThreadsForItem } from './mobile-diff-review-github'
import { mobileDiffReviewNotesForItem } from './mobile-diff-comments'
import { useMobileDiffReviewNoteDraft } from './use-mobile-diff-review-note-draft'
import { workspaceDraftRowKey } from '../../drafts/durable-draft-rows'
import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host'
const NO_COMMENTS: readonly DiffComment[] = []
@@ -74,7 +77,12 @@ export function useMobileDiffReviewController(input: ControllerInput) {
const [sheets, dispatchSheets] = useReducer(reduceReviewSheets, NO_REVIEW_SHEETS)
const sheetIntents = useMemo(() => reviewSheetIntents(dispatchSheets), [])
const composer = reviewComposer(sheets)
const [composerBody, setComposerBody] = useState('')
const executionHost = useWorkspaceExecutionHost()
const noteDraft = useMobileDiffReviewNoteDraft({
draftKey: workspaceDraftRowKey('reviewNote', { hostId, executionHost, worktreeId }, ''),
openSheet: sheetIntents.openSheet
})
const { composerBody, setComposerBody } = noteDraft
const [actionError, setActionError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
@@ -182,7 +190,7 @@ export function useMobileDiffReviewController(input: ControllerInput) {
selectKey,
moveSelection,
remoteViewed: setRemoteViewed,
setComposerBody,
noteDraft,
setActionError,
setBusyAction,
sheets: sheetIntents,
@@ -6,6 +6,7 @@ import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
import type { ComposerState, ReviewScreenState } from './mobile-diff-review-screen-model'
import type { ReviewSheet, ReviewSheetIntents } from './mobile-diff-review-sheets'
import type { MobileReviewTerminalTab } from './review-terminal-reply-schema'
import type { MobileDiffReviewNoteDraft } from './use-mobile-diff-review-note-draft'
import { sourceFileDiffOpenRun } from '../mobile-source-file-open-operations'
import { refusedRpcMessageOrFallback } from '../../transport/rpc/rpc-refusal-message'
import { useMobileDiffReviewCommentActions } from './use-mobile-diff-review-comment-actions'
@@ -33,7 +34,7 @@ type InteractionInput = {
selectKey: (key: string) => void
moveSelection: (direction: 'next' | 'previous') => void
remoteViewed: (item: MobileDiffReviewQueueItem, viewed: boolean) => Promise<boolean> | null
setComposerBody: Dispatch<SetStateAction<string>>
noteDraft: MobileDiffReviewNoteDraft
setActionError: Dispatch<SetStateAction<string | null>>
setBusyAction: Dispatch<SetStateAction<string | null>>
sheets: ReviewSheetIntents
@@ -61,7 +62,7 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) {
selectKey,
moveSelection,
remoteViewed,
setComposerBody,
noteDraft,
setActionError,
setBusyAction,
sheets,
@@ -99,7 +100,7 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) {
selectIndex,
navigationRef,
remoteViewed,
setComposerBody,
noteDraft,
setActionError,
sheets
})
@@ -1,10 +1,11 @@
import { useCallback, useRef, type Dispatch, type SetStateAction } from 'react'
import { useCallback, useState, type SetStateAction } from 'react'
import type { DiffComment } from '../../../../src/shared/diff-comment-types'
import { useDurableDraftField } from '../../drafts/durable-draft-fields'
import type { ReviewSheet, ReviewSheetIntents } from './mobile-diff-review-sheets'
type NoteDraftInput = {
composerBody: string
setComposerBody: Dispatch<SetStateAction<string>>
// Where the new note waits through leaving the review and the app being killed.
draftKey: string
openSheet: ReviewSheetIntents['openSheet']
}
@@ -13,45 +14,46 @@ type NewNoteSheet =
| { kind: 'composer'; composer: { mode: 'create'; lineNumber: number } }
/** The review's unsaved new note: kept across Cancel, reopen and picking other lines, cleared only once saved. */
export function useMobileDiffReviewNoteDraft({
composerBody,
setComposerBody,
openSheet
}: NoteDraftInput) {
// Set while an edit borrows the text field; the new note comes back when a new-note sheet opens.
const setAsideNoteRef = useRef<string | null>(null)
export function useMobileDiffReviewNoteDraft({ draftKey, openSheet }: NoteDraftInput) {
const [newNote, setNewNote] = useDurableDraftField(draftKey, 'note')
// An edit borrows the text field with its own text, until a new-note sheet opens; the new note is untouched
// underneath. Kept past the edit sheet closing, so its text stays while it slides away.
const [editBody, setEditBody] = useState<string | null>(null)
const setEditText = useCallback(
(next: SetStateAction<string>) =>
setEditBody((current) => (typeof next === 'function' ? next(current ?? '') : next)),
[]
)
const openNewNoteSheet = useCallback(
(sheet: NewNoteSheet) => {
if (setAsideNoteRef.current !== null) {
setComposerBody(setAsideNoteRef.current)
setAsideNoteRef.current = null
}
setEditBody(null)
openSheet(sheet)
},
[openSheet, setComposerBody]
[openSheet]
)
// The user may have moved on while a note was saving; drop only the text that was saved.
const clearSavedNote = useCallback(
(saved: string) => {
const isSaved = (text: string) => text.trim() === saved.trim()
if (setAsideNoteRef.current === null) {
setComposerBody((current) => (isSaved(current) ? '' : current))
} else if (isSaved(setAsideNoteRef.current)) {
setAsideNoteRef.current = ''
}
},
[setComposerBody]
(saved: string) => setNewNote((current) => (current.trim() === saved.trim() ? '' : current)),
[setNewNote]
)
const openEditComposer = useCallback(
(comment: DiffComment) => {
setAsideNoteRef.current ??= composerBody
openSheet({ kind: 'composer', composer: { mode: 'edit', comment } })
setComposerBody(comment.body)
setEditBody(comment.body)
},
[composerBody, openSheet, setComposerBody]
[openSheet]
)
return { openNewNoteSheet, openEditComposer, clearSavedNote }
return {
composerBody: editBody ?? newNote,
setComposerBody: editBody === null ? setNewNote : setEditText,
openNewNoteSheet,
openEditComposer,
clearSavedNote
}
}
export type MobileDiffReviewNoteDraft = ReturnType<typeof useMobileDiffReviewNoteDraft>
@@ -15,6 +15,8 @@ import {
} from './pr-comment-actions'
import { prCommentTarget, prCommentWrite, type PrCommentWrite } from './pr-comment-writes'
import type { PrQueryScope } from './mobile-pr-queries'
import { workspaceDraftRowKey } from '../../drafts/durable-draft-rows'
import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host'
export type PrCommentActionsInput = {
hostId: string
@@ -52,6 +54,22 @@ export function useMobilePrCommentActions(input: PrCommentActionsInput) {
const { mutate, blockedOffline } = useHostMutation(prCommentWrite, { hostId, client })
const ready = client !== null && connState === 'connected' && !blockedOffline
const target = useMemo(() => prCommentTarget(queries), [queries])
const executionHost = useWorkspaceExecutionHost()
// A fork's #N and upstream #N keep separate drafts; null is a new root comment.
const draftKey = useCallback(
(replyTo: number | null): string | null => {
if (prNumber <= 0) {
return null
}
const repo = prRepo ? `${prRepo.host ?? ''}/${prRepo.owner}/${prRepo.repo}` : ''
return workspaceDraftRowKey(
'prComment',
{ hostId, executionHost, worktreeId },
`${repo}#${prNumber}#${replyTo ?? 'new'}`
)
},
[executionHost, hostId, prNumber, prRepo, worktreeId]
)
const setBusy = useCallback((key: string, busy: boolean) => {
setBusyKeys((prev) => {
@@ -160,6 +178,7 @@ export function useMobilePrCommentActions(input: PrCommentActionsInput) {
[busyKeys]
),
isRootBusy: busyKeys.has(ROOT_KEY),
draftKey,
reply,
toggleResolve,
addRootComment,
@@ -1,4 +1,4 @@
import { useCallback, type MutableRefObject } from 'react'
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { translate } from '../i18n/mobile-i18n'
import { triggerError, triggerSuccess } from '../platform/haptics'
import type { LoadStatusOptions } from './mobile-source-control-screen-state'
@@ -12,7 +12,8 @@ type SendGitRequest = <T>(method: string, params?: Record<string, unknown>) => P
type RunGitWorkflow = (
actionId: string,
runner: () => Promise<void>,
options?: { clearCommitMessage?: boolean }
// The committed message; the field is cleared only while it still holds that text.
options?: { clearCommitMessage?: string }
) => Promise<boolean>
type Params = {
@@ -28,7 +29,7 @@ type Params = {
busyActionRef: MutableRefObject<string | null>
setBusyAction: (next: string | null) => void
setActionError: (next: string | null) => void
setCommitMessage: (next: string) => void
setCommitMessage: Dispatch<SetStateAction<string>>
recordCommitFailure: RecordMobileCommitFailure
}
@@ -80,7 +81,7 @@ export function useMobileSourceControlCommitRunners(params: Params) {
throw err
}
},
{ clearCommitMessage: true }
{ clearCommitMessage: message }
)
}, [commitMessage, recordCommitFailure, runGitWorkflow, sendCommitRequest, stagedEntries])
@@ -101,11 +102,12 @@ export function useMobileSourceControlCommitRunners(params: Params) {
try {
await sendCommitRequest(message)
didCommit = true
// The commit landed: a kill during the push must not bring its message back, and text typed since stays.
setCommitMessage((current) => (current.trim() === message ? '' : current))
await afterCommit()
if (!mountedRef.current) {
return false
}
setCommitMessage('')
triggerSuccess()
await loadStatus({ force: true })
return true
@@ -122,7 +124,6 @@ export function useMobileSourceControlCommitRunners(params: Params) {
recordCommitFailure({ error: errorMessage, commitMessage: message, stagedEntries })
}
if (didCommit) {
setCommitMessage('')
await loadStatus({
clearActionErrorOnSuccess: false,
force: true
@@ -83,7 +83,7 @@ export function useMobileSourceControlRunners(params: Params) {
async (
actionId: string,
runner: () => Promise<void>,
options?: { clearCommitMessage?: boolean; refreshOnError?: boolean }
options?: { clearCommitMessage?: string; refreshOnError?: boolean }
) => {
if (busyActionRef.current) {
return false
@@ -94,12 +94,14 @@ export function useMobileSourceControlRunners(params: Params) {
recordCommitFailure(null)
try {
await runner()
// The commit landed, so its message must not come back, even when the screen has left.
const committed = options?.clearCommitMessage
if (committed !== undefined) {
setCommitMessage((current) => (current.trim() === committed ? '' : current))
}
if (!mountedRef.current) {
return false
}
if (options?.clearCommitMessage) {
setCommitMessage('')
}
triggerSuccess()
await loadStatus({ force: true })
return true
@@ -148,7 +150,7 @@ export function useMobileSourceControlRunners(params: Params) {
)
const runGitSequence = useCallback(
async (actionId: string, steps: GitStep[], options?: { clearCommitMessage?: boolean }) => {
async (actionId: string, steps: GitStep[], options?: { clearCommitMessage?: string }) => {
return await runGitWorkflow(
actionId,
async () => {
@@ -4,6 +4,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useForceReconnect } from '../transport/connection/host-client-hooks'
import { useWorkspaceClient } from '../worktree/use-workspace-client'
import { getWorktreeLabel } from '../worktree/worktree-label'
import { workspaceDraftRowKey } from '../drafts/durable-draft-rows'
import { useDurableDraftField } from '../drafts/durable-draft-fields'
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
import { useMobileGitRequests } from './use-mobile-git-requests'
import { useMobileSourceControlLoaders } from './use-mobile-source-control-loaders'
import { useMobileSourceControlOpeners } from './use-mobile-source-control-openers'
@@ -48,7 +51,12 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const { client, state: connState } = useWorkspaceClient(hostId)
const forceReconnect = useForceReconnect()
const [busyAction, setBusyAction] = useState<string | null>(null)
const [commitMessage, setCommitMessage] = useState('')
const executionHost = useWorkspaceExecutionHost()
// The message waits through leaving the screen and the app being killed; a commit clears it.
const [commitMessage, setCommitMessage] = useDurableDraftField(
workspaceDraftRowKey('commit', { hostId, executionHost, worktreeId }, ''),
'message'
)
const [generatingMessage, setGeneratingMessage] = useState(false)
const [showBranchPicker, setShowBranchPicker] = useState(false)
const [localBranches, setLocalBranches] = useState<RuntimeGitLocalBranches | null>(null)
@@ -106,7 +106,13 @@ export function diffReviewActionMountAdapters(
moveSelection: () => {},
// Viewed state stays on the phone here; GitHub-viewed files have their own recordings.
remoteViewed: () => null,
setComposerBody: () => {},
noteDraft: {
composerBody: '',
setComposerBody: () => {},
openNewNoteSheet: () => {},
openEditComposer: () => {},
clearSavedNote: () => {}
},
setActionError: (update) => {
actionError = typeof update === 'function' ? update(actionError) : update
},