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`
+}