hololake-platform/src/components/CommitDialog.tsx

320 lines
9.7 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import { Sparkle } from '@phosphor-icons/react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import type { CommitMode } from '../hooks/useCommitFlow'
import { GitRepositorySelect } from './GitRepositorySelect'
import type { GitRepositoryOption } from '../utils/gitRepositories'
import { translate, type AppLocale } from '../lib/i18n'
import type { GitAuthorIdentity } from '../types'
type CommitDialogCopy = {
title: string
description: string
actionLabel: string
shortcutHint: string
}
const getDialogCopy = (commitMode: CommitMode): CommitDialogCopy => {
const submitShortcut = formatShortcutDisplay({ display: '⌘↵' })
if (commitMode === 'local') {
return {
title: 'Commit',
description: 'This vault has no git remote configured. Tolaria will create a local commit only.',
actionLabel: 'Commit',
shortcutHint: `${submitShortcut} to commit locally`,
}
}
return {
title: 'Commit & Push',
description: 'Review changed files and enter a commit message before committing and pushing.',
actionLabel: 'Commit & Push',
shortcutHint: `${submitShortcut} to commit`,
}
}
const changedFilesLabel = (modifiedCount: number): string => (
`${modifiedCount} file${modifiedCount !== 1 ? 's' : ''} changed`
)
const isSubmitShortcut = (event: React.KeyboardEvent): boolean => (
event.key === 'Enter' && (event.metaKey || event.ctrlKey)
)
const isCloseShortcut = (event: React.KeyboardEvent): boolean => event.key === 'Escape'
const formatAuthorIdentity = (identity: GitAuthorIdentity): string => (
`${identity.name} <${identity.email}>`
)
const formatOptionalAuthorIdentity = (identity: GitAuthorIdentity | null): string => (
identity ? formatAuthorIdentity(identity) : ''
)
const focusMessageInput = (inputRef: React.RefObject<HTMLTextAreaElement | null>): void => {
setTimeout(() => inputRef.current?.focus(), 50)
}
const generationButtonDisabled = ({
isGenerating,
modifiedCount,
onGenerateMessage,
}: {
isGenerating: boolean
modifiedCount: number
onGenerateMessage?: () => Promise<string> | string
}): boolean => !onGenerateMessage || isGenerating || modifiedCount === 0
const authorWarningText = (identity: GitAuthorIdentity, locale: AppLocale): string | null => {
if (identity.warning === 'local_overrides_global') {
return translate(locale, 'git.author.warning.localOverridesGlobal')
}
return null
}
const CommitAuthorIdentity = ({
identity,
locale,
}: {
identity: GitAuthorIdentity | null
locale: AppLocale
}) => {
const hidden = identity === null
const warningText = identity ? authorWarningText(identity, locale) : null
return (
<div className="space-y-1 text-xs" data-testid="commit-author-identity" hidden={hidden} aria-hidden={hidden}>
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-muted-foreground">{translate(locale, 'git.author.label')}</span>
<span className="truncate text-right font-mono text-[11px]">{formatOptionalAuthorIdentity(identity)}</span>
</div>
{warningText && (
<p className="text-[11px] leading-4 text-amber-700 dark:text-amber-300">{warningText}</p>
)}
</div>
)
}
function useGeneratedCommitMessage({
generatedMessage,
generatedMessageKey,
inputRef,
open,
setMessage,
}: {
generatedMessage?: string
generatedMessageKey: number
inputRef: React.RefObject<HTMLTextAreaElement | null>
open: boolean
setMessage: (message: string) => void
}) {
useEffect(() => {
if (!open || generatedMessageKey === 0 || !generatedMessage) return
setMessage(generatedMessage)
focusMessageInput(inputRef)
}, [generatedMessage, generatedMessageKey, inputRef, open, setMessage])
}
function CommitMessageGenerateButton({
isGeneratingMessage,
locale,
modifiedCount,
onGenerateMessage,
onGeneratedMessage,
}: {
isGeneratingMessage: boolean
locale: AppLocale
modifiedCount: number
onGenerateMessage?: () => Promise<string> | string
onGeneratedMessage: (message: string) => void
}) {
const handleGenerateMessage = async () => {
const generated = await onGenerateMessage?.()
if (generated) onGeneratedMessage(generated)
}
return (
<Button
type="button"
variant="outline"
onClick={() => { void handleGenerateMessage() }}
disabled={generationButtonDisabled({ isGenerating: isGeneratingMessage, modifiedCount, onGenerateMessage })}
aria-label={translate(locale, 'git.commitMessage.generateFromDiff')}
title={translate(locale, 'git.commitMessage.generateFromDiff')}
>
<Sparkle size={14} weight="fill" className="shrink-0" />
{translate(locale, isGeneratingMessage ? 'git.commitMessage.generating' : 'git.commitMessage.generate')}
</Button>
)
}
function CommitDialogActions({
actionLabel,
isGeneratingMessage,
locale,
message,
modifiedCount,
onClose,
onGenerateMessage,
onGeneratedMessage,
onSubmit,
shortcutHint,
}: CommitDialogCopy & {
isGeneratingMessage: boolean
locale: AppLocale
message: string
modifiedCount: number
onClose: () => void
onGenerateMessage?: () => Promise<string> | string
onGeneratedMessage: (message: string) => void
onSubmit: () => void
}) {
return (
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">{shortcutHint}</span>
<div className="flex gap-2">
<CommitMessageGenerateButton
isGeneratingMessage={isGeneratingMessage}
locale={locale}
modifiedCount={modifiedCount}
onGenerateMessage={onGenerateMessage}
onGeneratedMessage={onGeneratedMessage}
/>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={!message.trim()}>
{actionLabel}
</Button>
</div>
</DialogFooter>
)
}
interface CommitDialogProps {
open: boolean
modifiedCount: number
authorIdentity?: GitAuthorIdentity | null
commitMode?: CommitMode
locale?: AppLocale
repositories?: GitRepositoryOption[]
selectedRepositoryPath?: string
generatedMessage?: string
generatedMessageKey?: number
isGeneratingMessage?: boolean
suggestedMessage?: string
onGenerateMessage?: () => Promise<string> | string
onRepositoryChange?: (path: string) => void
onCommit: (message: string) => void
onClose: () => void
}
export function CommitDialog(props: CommitDialogProps) {
const {
open,
modifiedCount,
authorIdentity = null,
commitMode = 'push',
locale = 'en',
repositories = [],
selectedRepositoryPath = '',
generatedMessage,
generatedMessageKey = 0,
isGeneratingMessage = false,
suggestedMessage,
onGenerateMessage,
onRepositoryChange,
onCommit,
onClose,
} = props
const [message, setMessage] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const suggestedMessageRef = useRef(suggestedMessage)
const copy = getDialogCopy(commitMode)
useEffect(() => {
suggestedMessageRef.current = suggestedMessage
}, [suggestedMessage])
useEffect(() => {
if (open) {
setMessage(suggestedMessageRef.current ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
focusMessageInput(inputRef)
}
}, [open])
useGeneratedCommitMessage({ generatedMessage, generatedMessageKey, inputRef, open, setMessage })
const handleSubmit = () => {
const trimmed = message.trim()
if (!trimmed) return
onCommit(trimmed)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (isSubmitShortcut(e)) {
e.preventDefault()
handleSubmit()
} else if (isCloseShortcut(e)) {
onClose()
}
}
const handleGeneratedMessage = (generated: string) => {
setMessage(generated)
focusMessageInput(inputRef)
}
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>{copy.title}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{changedFilesLabel(modifiedCount)}
</Badge>
</div>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
{onRepositoryChange && selectedRepositoryPath && (
<GitRepositorySelect
label={translate(locale, 'git.repository.select')}
repositories={repositories}
selectedPath={selectedRepositoryPath}
onChange={onRepositoryChange}
testId="commit-repository-select"
/>
)}
<CommitAuthorIdentity identity={authorIdentity} locale={locale} />
<Textarea
ref={inputRef}
className="min-h-[84px] resize-y bg-[var(--bg-input)] py-2.5 text-[13px]"
placeholder="Commit message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
rows={3}
/>
<CommitDialogActions
{...copy}
isGeneratingMessage={isGeneratingMessage}
locale={locale}
message={message}
modifiedCount={modifiedCount}
onClose={onClose}
onGenerateMessage={onGenerateMessage}
onGeneratedMessage={handleGeneratedMessage}
onSubmit={handleSubmit}
/>
</DialogContent>
</Dialog>
)
}