From 873f2909bb4d02ddacec4f164e766b4810233cf7 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 30 Mar 2026 16:30:54 +0200 Subject: [PATCH] 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) --- src/App.tsx | 4 +- src/components/CommitDialog.test.tsx | 26 ++++++++++ src/components/CommitDialog.tsx | 7 +-- src/utils/commitMessage.test.ts | 73 ++++++++++++++++++++++++++++ src/utils/commitMessage.ts | 33 +++++++++++++ 5 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/utils/commitMessage.test.ts create mode 100644 src/utils/commitMessage.ts diff --git a/src/App.tsx b/src/App.tsx index 0884e4e3..81ee6ee1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - + { const { container } = render() expect(container.querySelector('textarea')).toBeNull() }) + + it('pre-populates message with suggestedMessage', () => { + render() + const textarea = screen.getByPlaceholderText('Commit message...') + expect(textarea).toHaveValue('Update alpha, beta') + }) + + it('enables Commit button when suggestedMessage is provided', () => { + render() + expect(getCommitButton()).not.toBeDisabled() + }) + + it('submits suggestedMessage on Cmd+Enter without user edits', () => { + render() + 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() + 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') + }) }) diff --git a/src/components/CommitDialog.tsx b/src/components/CommitDialog.tsx index c27d6fe2..195de9e0 100644 --- a/src/components/CommitDialog.tsx +++ b/src/components/CommitDialog.tsx @@ -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(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() diff --git a/src/utils/commitMessage.test.ts b/src/utils/commitMessage.test.ts new file mode 100644 index 00000000..a53596f7 --- /dev/null +++ b/src/utils/commitMessage.test.ts @@ -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') + }) +}) diff --git a/src/utils/commitMessage.ts b/src/utils/commitMessage.ts new file mode 100644 index 00000000..38d25aac --- /dev/null +++ b/src/utils/commitMessage.ts @@ -0,0 +1,33 @@ +import type { ModifiedFile } from '../types' + +const VERB_MAP: Record = { + 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` +}