orca/mobile/src/components/MobileDiffReviewDrawers.tsx

295 lines
10 KiB
TypeScript

import { useMemo } from 'react'
import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'
import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/types'
import { colors } from '../theme/mobile-theme'
import type { ActionSheetAction } from './ActionSheetModal'
import { ActionSheetModal } from './ActionSheetModal'
import { BottomDrawer } from './BottomDrawer'
import { ConfirmModal } from './ConfirmModal'
import { mobileReviewCountLabel } from '../session/mobile-diff-review-screen-model'
import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
type Props = {
controller: ReturnType<typeof useMobileDiffReviewController>
}
export function MobileDiffReviewDrawers({ controller }: Props) {
const sendActions = useSendActions(controller)
const overflowActions = useOverflowActions(controller)
return (
<>
<ActionSheetModal
visible={controller.showOverflow}
title="Review Actions"
message={
controller.reviewedUnstagedCount > 0
? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged`
: undefined
}
actions={overflowActions}
onClose={() => controller.setShowOverflow(false)}
/>
<ActionSheetModal
visible={controller.sendSheet !== null}
title="Send Notes"
message={sendSheetMessage(controller)}
actions={sendActions}
onClose={() => controller.setSendSheet(null)}
/>
<ConfirmModal
visible={controller.discardTarget !== null}
title="Discard File"
message={
controller.discardTarget
? `Discard changes to "${controller.discardTarget.filePath}"? This cannot be undone.`
: undefined
}
confirmLabel="Discard"
destructive
onConfirm={() => {
const target = controller.discardTarget
controller.setDiscardTarget(null)
if (target) {
void controller.runGitMutation('git.discard', target)
}
}}
onCancel={() => controller.setDiscardTarget(null)}
/>
<NoteComposerDrawer controller={controller} />
<CompletionDrawer controller={controller} />
</>
)
}
function useSendActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
return useMemo<ActionSheetAction[]>(() => {
const comments = controller.unsentComments
const terminalActions =
controller.sendSheet?.kind === 'ready' || controller.sendSheet?.kind === 'error'
? controller.sendSheet.terminals.map((terminal) => ({
label: `${terminal.title || 'Terminal'} (${terminal.terminal.slice(0, 6)})`,
icon: Send,
disabled: comments.length === 0,
skipAutoClose: true,
onPress: () => void controller.sendPromptToTerminal(terminal.terminal, comments)
}))
: []
return [
...terminalActions,
{
label: 'New Agent Session',
icon: Plus,
disabled: comments.length === 0,
skipAutoClose: true,
onPress: () => void controller.createTerminalAndSend(comments)
},
{
label: 'Copy Notes',
icon: Copy,
disabled:
controller.screenState.kind !== 'ready' || controller.screenState.comments.length === 0,
onPress: () => void controller.copyNotes()
}
]
}, [controller])
}
function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
return useMemo<ActionSheetAction[]>(
() => [
{
label: 'Copy Notes',
icon: Copy,
disabled:
controller.screenState.kind !== 'ready' || controller.screenState.comments.length === 0,
onPress: () => void controller.copyNotes()
},
{
label: 'Send Unsent Notes',
icon: Send,
disabled: controller.unsentComments.length === 0,
skipAutoClose: true,
onPress: () => void controller.openSendSheet()
},
{
label: 'Clear Sent Notes',
icon: Trash2,
disabled:
controller.screenState.kind !== 'ready' ||
controller.screenState.comments.every((comment) => comment.sentAt === undefined),
skipAutoClose: true,
onPress: () => void controller.clearSentNotes()
},
{
label: 'Stage Reviewed Files',
icon: Check,
disabled: controller.reviewedUnstagedCount === 0 || controller.busyAction !== null,
skipAutoClose: true,
onPress: () => void controller.stageReviewedFiles()
},
{
label: 'Mark Unreviewed',
icon: X,
disabled:
controller.screenState.kind !== 'ready' ||
!controller.currentItem ||
!controller.currentItem.isReviewed,
skipAutoClose: true,
onPress: () => void controller.markUnreviewed()
},
{
label: 'Open in Session',
icon: FileText,
disabled: !controller.currentItem || controller.currentItem.scope === 'branch',
onPress: () => void controller.openInSession()
}
],
[controller]
)
}
function sendSheetMessage(
controller: ReturnType<typeof useMobileDiffReviewController>
): string | undefined {
return controller.sendSheet?.kind === 'loading'
? 'Loading agent sessions...'
: controller.sendSheet?.kind === 'error'
? controller.sendSheet.message
: `${controller.unsentComments.length} unsent notes`
}
function NoteComposerDrawer({ controller }: Props) {
const composer = controller.composer
return (
<BottomDrawer visible={composer !== null} onClose={controller.closeComposer}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={styles.composerHeader}>
<View>
<Text style={styles.drawerTitle}>
{composer?.mode === 'edit' ? 'Edit Note' : 'Add Note'}
</Text>
<Text style={styles.drawerSubtitle}>
{composer?.mode === 'create' && composer.lineNumber > 0
? `Line ${composer.lineNumber}`
: 'File note'}
</Text>
</View>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={controller.closeComposer}
accessibilityRole="button"
accessibilityLabel="Cancel note"
>
<X size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
</View>
<TextInput
style={styles.composerInput}
value={controller.composerBody}
onChangeText={controller.setComposerBody}
multiline
autoFocus
placeholder="Review note"
placeholderTextColor={colors.textMuted}
accessibilityLabel={composerLabel(composer)}
/>
<View style={styles.drawerButtonRow}>
{composer?.mode === 'edit' ? (
<DeleteNoteButton onPress={controller.deleteComment} />
) : null}
<SaveNoteButton controller={controller} composer={composer} />
</View>
</KeyboardAvoidingView>
</BottomDrawer>
)
}
function composerLabel(
composer: { mode: 'create'; lineNumber: number } | { mode: 'edit'; comment: DiffComment } | null
): string {
return composer?.mode === 'create' && composer.lineNumber > 0
? `Save note on line ${composer.lineNumber}`
: 'Review note'
}
function DeleteNoteButton({ onPress }: { onPress: () => Promise<void> }) {
return (
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
onPress={() => void onPress()}
accessibilityRole="button"
accessibilityLabel="Delete note"
>
<Trash2 size={14} color={colors.statusRed} strokeWidth={2.2} />
<Text style={styles.destructiveText}>Delete</Text>
</Pressable>
)
}
function SaveNoteButton({
controller,
composer
}: {
controller: ReturnType<typeof useMobileDiffReviewController>
composer: ReturnType<typeof useMobileDiffReviewController>['composer']
}) {
const disabled = controller.composerBody.trim().length === 0
return (
<Pressable
style={({ pressed }) => [
styles.primaryButton,
disabled && styles.buttonDisabled,
pressed && styles.buttonPressed
]}
disabled={disabled}
onPress={() => void controller.saveComposer()}
accessibilityRole="button"
accessibilityLabel={composerLabel(composer)}
>
<Check size={14} color={colors.bgBase} strokeWidth={2.2} />
<Text style={styles.primaryButtonText}>Save</Text>
</Pressable>
)
}
function CompletionDrawer({ controller }: Props) {
const noteCount =
controller.screenState.kind === 'ready' ? controller.screenState.comments.length : 0
return (
<BottomDrawer
visible={controller.showCompletion}
onClose={() => controller.setShowCompletion(false)}
>
<Text style={styles.drawerTitle}>Review Complete</Text>
<Text style={styles.drawerSubtitle}>
{mobileReviewCountLabel(controller.queue.length, 'file', 'files')} reviewed,{' '}
{mobileReviewCountLabel(noteCount, 'note', 'notes')}
</Text>
<View style={styles.drawerButtonRow}>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
disabled={controller.reviewedUnstagedCount === 0}
onPress={() => void controller.stageReviewedFiles()}
accessibilityRole="button"
accessibilityLabel="Stage reviewed files"
>
<Check size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.secondaryButtonText}>Stage Reviewed</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
disabled={controller.unsentComments.length === 0}
onPress={() => void controller.openSendSheet()}
accessibilityRole="button"
accessibilityLabel="Send notes to agent"
>
<Send size={14} color={colors.bgBase} strokeWidth={2.2} />
<Text style={styles.primaryButtonText}>Send Notes</Text>
</Pressable>
</View>
</BottomDrawer>
)
}