Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 39 additions & 22 deletions frontend/src/components/CommitDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { GitCommit } from 'lucide-react';
import { formatKeyDisplay } from '../utils/hotkeyUtils';
import { composeCommitMessage } from '../utils/commitMessage';
import { Modal, ModalHeader, ModalBody, ModalFooter } from './ui/Modal';
import { Button } from './ui/Button';
import { Textarea } from './ui/Textarea';
import { Input, Textarea } from './ui/Input';
import { areKeyboardShortcutsEnabled, useConfigStore } from '../stores/configStore';

interface CommitDialogProps {
Expand All @@ -19,46 +20,47 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
onCommit,
fileCount
}) => {
const [commitMessage, setCommitMessage] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isCommitting, setIsCommitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));

// Set default message
useEffect(() => {
if (isOpen) {
const defaultMessage = `Update ${fileCount} file${fileCount > 1 ? 's' : ''}`;
setCommitMessage(defaultMessage);
setTitle(`Update ${fileCount} file${fileCount > 1 ? 's' : ''}`);
setDescription('');
setError(null);
// Focus and select all text after a short delay
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
if (titleRef.current) {
titleRef.current.focus();
titleRef.current.select();
}
}, 100);
}
}, [isOpen, fileCount]);

const handleCommit = useCallback(async () => {
if (!commitMessage.trim()) {
setError('Please enter a commit message');
if (!title.trim()) {
setError('Please enter a title');
return;
}

setIsCommitting(true);
setError(null);

try {
await onCommit(commitMessage);
await onCommit(composeCommitMessage(title, description));
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to commit changes');
} finally {
setIsCommitting(false);
}
}, [commitMessage, onCommit, onClose]);
}, [description, onCommit, onClose, title]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (keyboardShortcutsEnabled && e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
Expand All @@ -82,15 +84,30 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
Committing {fileCount} file{fileCount > 1 ? 's' : ''} with changes
</p>

<Textarea
ref={textareaRef}
value={commitMessage}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setCommitMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter commit message..."
rows={4}
error={error}
/>
<div className="space-y-4">
<Input
ref={titleRef}
label="Title"
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
placeholder="Enter commit title..."
error={error ?? undefined}
fullWidth
/>
<Textarea
label="Description (optional)"
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add more details..."
rows={4}
fullWidth
/>
</div>

<p className="mt-2 text-xs text-text-tertiary">
Press {formatKeyDisplay('mod+enter')} to commit
Expand All @@ -107,7 +124,7 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
</Button>
<Button
onClick={handleCommit}
disabled={isCommitting || !commitMessage.trim()}
disabled={isCommitting || !title.trim()}
variant="primary"
loading={isCommitting}
loadingText="Committing..."
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1992,7 +1992,6 @@ export const SessionView = memo(() => {
dialogType={hook.dialogType}
gitCommands={hook.gitCommands}
commitMessage={hook.commitMessage}
setCommitMessage={hook.setCommitMessage}
shouldSquash={hook.shouldSquash}
setShouldSquash={hook.setShouldSquash}
onConfirm={(message) => {
Expand Down
78 changes: 55 additions & 23 deletions frontend/src/components/session/CommitMessageDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React from 'react';
import { GitCommands } from '../../types/session';
import React, { useCallback, useEffect, useState } from 'react';
import type { GitCommands } from '../../types/session';
import { areKeyboardShortcutsEnabled, useConfigStore } from '../../stores/configStore';
import { composeCommitMessage, splitCommitMessage } from '../../utils/commitMessage';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/Modal';
import { Button } from '../ui/Button';
import { Checkbox, Textarea } from '../ui/Input';
import { Checkbox, Input, Textarea } from '../ui/Input';
import { Card } from '../ui/Card';

interface CommitMessageDialogProps {
Expand All @@ -11,7 +13,6 @@ interface CommitMessageDialogProps {
dialogType: 'squash' | 'rebase' | 'commit';
gitCommands: GitCommands | null;
commitMessage: string;
setCommitMessage: (message: string) => void;
shouldSquash: boolean;
setShouldSquash: (should: boolean) => void;
onConfirm: (message: string) => void;
Expand All @@ -26,15 +27,43 @@ export const CommitMessageDialog: React.FC<CommitMessageDialogProps> = ({
dialogType,
gitCommands,
commitMessage,
setCommitMessage,
shouldSquash,
setShouldSquash,
onConfirm,
onMergeAndArchive,
isMerging,
isMergingAndArchiving,
}) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const isProcessing = isMerging || isMergingAndArchiving;
const isMessageDisabled = dialogType === 'squash' && !shouldSquash;
const isMessageRequired = dialogType === 'commit' || shouldSquash;
const composedMessage = composeCommitMessage(title, description);
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));
const canConfirm = !isProcessing && (!isMessageRequired || !!title.trim());

const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (
keyboardShortcutsEnabled
&& !event.repeat
&& event.key === 'Enter'
&& (event.ctrlKey || event.metaKey)
&& canConfirm
) {
event.preventDefault();
onConfirm(composedMessage);
}
}, [canConfirm, composedMessage, keyboardShortcutsEnabled, onConfirm]);

useEffect(() => {
if (!isOpen) return;

const parts = splitCommitMessage(commitMessage);
setTitle(parts.title);
setDescription(parts.description);
}, [commitMessage, isOpen]);

return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalHeader
Expand All @@ -46,7 +75,7 @@ export const CommitMessageDialog: React.FC<CommitMessageDialogProps> = ({
/>

<ModalBody>
<div className="space-y-4">
<div className="space-y-4" onKeyDown={handleKeyDown}>
{dialogType === 'squash' && (
<Card variant="bordered" padding="md" className="bg-surface-secondary">
<div className="flex items-center space-x-3">
Expand All @@ -64,22 +93,25 @@ export const CommitMessageDialog: React.FC<CommitMessageDialogProps> = ({
</Card>
)}

<Input
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isMessageDisabled}
placeholder={isMessageDisabled ? "Not needed when preserving commits" : "Enter commit title..."}
fullWidth
/>

<Textarea
label="Commit Message"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={8}
disabled={dialogType === 'squash' && !shouldSquash}
placeholder={
dialogType === 'commit'
? "Enter commit message..."
: dialogType === 'squash'
? (shouldSquash ? "Enter commit message..." : "Not needed when preserving commits")
: "Enter commit message..."
}
label="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={6}
disabled={isMessageDisabled}
placeholder={isMessageDisabled ? "Not needed when preserving commits" : "Add more details..."}
helperText={
dialogType === 'commit'
? 'All changes will be staged and committed with this message.'
? 'All changes will be staged and committed.'
: dialogType === 'squash'
? (shouldSquash
? `This message will be used for the merge commit.`
Expand Down Expand Up @@ -119,17 +151,17 @@ export const CommitMessageDialog: React.FC<CommitMessageDialogProps> = ({
</Button>
{dialogType === 'squash' && onMergeAndArchive && (
<Button
onClick={() => onMergeAndArchive(commitMessage)}
disabled={(shouldSquash && !commitMessage.trim()) || isProcessing}
onClick={() => onMergeAndArchive(composedMessage)}
disabled={(shouldSquash && !title.trim()) || isProcessing}
loading={isMergingAndArchiving}
variant="secondary"
>
{isMergingAndArchiving ? 'Merging...' : 'Merge & Archive'}
</Button>
)}
<Button
onClick={() => onConfirm(commitMessage)}
disabled={(!commitMessage.trim() && (dialogType === 'commit' || shouldSquash)) || isProcessing}
onClick={() => onConfirm(composedMessage)}
disabled={(isMessageRequired && !title.trim()) || isProcessing}
loading={isMerging}
>
{isMerging
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/utils/commitMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { composeCommitMessage, splitCommitMessage } from './commitMessage';

describe('commitMessage', () => {
it('composes a title and description using the conventional blank line separator', () => {
expect(composeCommitMessage('Add commit title field', 'Explain the change clearly.')).toBe(
'Add commit title field\n\nExplain the change clearly.',
);
});

it('omits the separator when the optional description is empty', () => {
expect(composeCommitMessage('Add commit title field', ' ')).toBe('Add commit title field');
});

it('splits an existing multiline commit message into title and description', () => {
expect(splitCommitMessage('Add commit title field\r\n\r\nExplain the change.\r\nKeep existing behavior.')).toEqual({
title: 'Add commit title field',
description: 'Explain the change.\nKeep existing behavior.',
});
});

it('supports existing messages without a blank separator', () => {
expect(splitCommitMessage('Add commit title field\nExplain the change.')).toEqual({
title: 'Add commit title field',
description: 'Explain the change.',
});
});
});
26 changes: 26 additions & 0 deletions frontend/src/utils/commitMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export interface CommitMessageParts {
title: string;
description: string;
}

export function splitCommitMessage(message: string): CommitMessageParts {
const [title = '', ...descriptionLines] = message.replace(/\r\n?/g, '\n').split('\n');

while (descriptionLines[0]?.trim() === '') {
descriptionLines.shift();
}

return {
title: title.trim(),
description: descriptionLines.join('\n').trim(),
};
}

export function composeCommitMessage(title: string, description: string): string {
const normalizedTitle = title.trim();
const normalizedDescription = description.trim();

return normalizedDescription
? `${normalizedTitle}\n\n${normalizedDescription}`
: normalizedTitle;
}