320 lines
9.7 KiB
TypeScript
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>
|
||
|
|
)
|
||
|
|
}
|