295 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|