feat: pre-populate commit dialog with heuristic message from git diff
Generate a commit message from modified files (e.g. "Update winter-2026" or "Update 12 notes") and pre-fill the CommitDialog textarea so users can commit immediately or edit the suggestion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import { useViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { isEmoji } from './utils/emoji'
|
||||
import { generateCommitMessage } from './utils/commitMessage'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
@@ -211,6 +212,7 @@ function App() {
|
||||
}, [resolvedPath])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
|
||||
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
|
||||
|
||||
const entryActions = useEntryActions({
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
@@ -468,7 +470,7 @@ function App() {
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
fileStates={conflictResolver.fileStates}
|
||||
|
||||
@@ -78,4 +78,30 @@ describe('CommitDialog', () => {
|
||||
const { container } = render(<CommitDialog open={false} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
|
||||
expect(container.querySelector('textarea')).toBeNull()
|
||||
})
|
||||
|
||||
it('pre-populates message with suggestedMessage', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha, beta" onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
expect(textarea).toHaveValue('Update alpha, beta')
|
||||
})
|
||||
|
||||
it('enables Commit button when suggestedMessage is provided', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
expect(getCommitButton()).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('submits suggestedMessage on Cmd+Enter without user edits', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
||||
expect(onCommit).toHaveBeenCalledWith('Update alpha')
|
||||
})
|
||||
|
||||
it('allows user to edit the suggested message', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.change(textarea, { target: { value: 'fix: corrected typo in alpha' } })
|
||||
fireEvent.click(getCommitButton())
|
||||
expect(onCommit).toHaveBeenCalledWith('fix: corrected typo in alpha')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,20 +6,21 @@ import { Badge } from '@/components/ui/badge'
|
||||
interface CommitDialogProps {
|
||||
open: boolean
|
||||
modifiedCount: number
|
||||
suggestedMessage?: string
|
||||
onCommit: (message: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitDialogProps) {
|
||||
export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit, onClose }: CommitDialogProps) {
|
||||
const [message, setMessage] = useState('')
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMessage('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setMessage(suggestedMessage ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps -- only reset when dialog opens
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = message.trim()
|
||||
|
||||
73
src/utils/commitMessage.test.ts
Normal file
73
src/utils/commitMessage.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateCommitMessage } from './commitMessage'
|
||||
import type { ModifiedFile } from '../types'
|
||||
|
||||
function file(relativePath: string, status: ModifiedFile['status'] = 'modified'): ModifiedFile {
|
||||
return { path: `/vault/${relativePath}`, relativePath, status }
|
||||
}
|
||||
|
||||
describe('generateCommitMessage', () => {
|
||||
it('returns empty string for no files', () => {
|
||||
expect(generateCommitMessage([])).toBe('')
|
||||
})
|
||||
|
||||
it('uses note title for a single modified file', () => {
|
||||
expect(generateCommitMessage([file('winter-2026.md')])).toBe('Update winter-2026')
|
||||
})
|
||||
|
||||
it('strips .md extension from the name', () => {
|
||||
expect(generateCommitMessage([file('thoughts-on-testing.md')])).toBe('Update thoughts-on-testing')
|
||||
})
|
||||
|
||||
it('uses basename for files in subdirectories', () => {
|
||||
expect(generateCommitMessage([file('notes/deep/idea.md')])).toBe('Update idea')
|
||||
})
|
||||
|
||||
it('lists 2 files comma-separated', () => {
|
||||
const msg = generateCommitMessage([file('alpha.md'), file('beta.md')])
|
||||
expect(msg).toBe('Update alpha, beta')
|
||||
})
|
||||
|
||||
it('lists 3 files comma-separated', () => {
|
||||
const msg = generateCommitMessage([
|
||||
file('alpha.md'),
|
||||
file('beta.md'),
|
||||
file('gamma.md'),
|
||||
])
|
||||
expect(msg).toBe('Update alpha, beta, gamma')
|
||||
})
|
||||
|
||||
it('uses count for 4+ files', () => {
|
||||
const msg = generateCommitMessage([
|
||||
file('a.md'),
|
||||
file('b.md'),
|
||||
file('c.md'),
|
||||
file('d.md'),
|
||||
])
|
||||
expect(msg).toBe('Update 4 notes')
|
||||
})
|
||||
|
||||
it('says "Add" for a single new/untracked file', () => {
|
||||
expect(generateCommitMessage([file('new-idea.md', 'untracked')])).toBe('Add new-idea')
|
||||
})
|
||||
|
||||
it('says "Add" for a single added file', () => {
|
||||
expect(generateCommitMessage([file('new-idea.md', 'added')])).toBe('Add new-idea')
|
||||
})
|
||||
|
||||
it('says "Delete" for a single deleted file', () => {
|
||||
expect(generateCommitMessage([file('old-note.md', 'deleted')])).toBe('Delete old-note')
|
||||
})
|
||||
|
||||
it('says "Rename" for a single renamed file', () => {
|
||||
expect(generateCommitMessage([file('renamed.md', 'renamed')])).toBe('Rename renamed')
|
||||
})
|
||||
|
||||
it('uses "Update" when statuses are mixed', () => {
|
||||
const msg = generateCommitMessage([
|
||||
file('alpha.md', 'modified'),
|
||||
file('beta.md', 'untracked'),
|
||||
])
|
||||
expect(msg).toBe('Update alpha, beta')
|
||||
})
|
||||
})
|
||||
33
src/utils/commitMessage.ts
Normal file
33
src/utils/commitMessage.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ModifiedFile } from '../types'
|
||||
|
||||
const VERB_MAP: Record<string, string> = {
|
||||
modified: 'Update',
|
||||
added: 'Add',
|
||||
untracked: 'Add',
|
||||
deleted: 'Delete',
|
||||
renamed: 'Rename',
|
||||
}
|
||||
|
||||
const MAX_LISTED_FILES = 3
|
||||
|
||||
function noteName(relativePath: string): string {
|
||||
const basename = relativePath.split('/').pop() ?? relativePath
|
||||
return basename.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function verb(files: ModifiedFile[]): string {
|
||||
const statuses = new Set(files.map((f) => f.status))
|
||||
if (statuses.size === 1) return VERB_MAP[files[0].status] ?? 'Update'
|
||||
return 'Update'
|
||||
}
|
||||
|
||||
/** Generate a heuristic commit message from modified files. */
|
||||
export function generateCommitMessage(files: ModifiedFile[]): string {
|
||||
if (files.length === 0) return ''
|
||||
const action = verb(files)
|
||||
if (files.length <= MAX_LISTED_FILES) {
|
||||
const names = files.map((f) => noteName(f.relativePath)).join(', ')
|
||||
return `${action} ${names}`
|
||||
}
|
||||
return `${action} ${files.length} notes`
|
||||
}
|
||||
Reference in New Issue
Block a user