Merge pull request #58 from refactoringhq/task/nuova-nota-lenta

fix: debounce Cmd+N to prevent slow note creation on rapid keystrokes
This commit is contained in:
Luca Rossi
2026-02-25 00:03:00 +01:00
committed by GitHub
6 changed files with 121 additions and 15 deletions

View File

@@ -12,7 +12,7 @@ import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions, generateUntitledName } from './hooks/useNoteActions'
import { useNoteActions } from './hooks/useNoteActions'
import { useEditorSave } from './hooks/useEditorSave'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useAppKeyboard } from './hooks/useAppKeyboard'
@@ -105,12 +105,7 @@ function App() {
setToastMessage,
})
// Immediate note creation — no dialog, just create and open
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
notes.handleCreateNote(generateUntitledName(vault.entries, noteType), noteType)
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
}, [vault.entries, notes])
const handleCreateNoteImmediate = notes.handleCreateNoteImmediate
const handleSwitchVault = useCallback((path: string) => {
setVaultPath(path)

View File

@@ -129,6 +129,17 @@ describe('generateUntitledName', () => {
it('uses type name in lowercase', () => {
expect(generateUntitledName([], 'Project')).toBe('Untitled project')
})
it('avoids names in the pending set', () => {
const pending = new Set(['Untitled note'])
expect(generateUntitledName([], 'Note', pending)).toBe('Untitled note 2')
})
it('avoids both existing and pending names', () => {
const entries = [makeEntry({ title: 'Untitled note' })]
const pending = new Set(['Untitled note 2'])
expect(generateUntitledName(entries, 'Note', pending)).toBe('Untitled note 3')
})
})
describe('entryMatchesTarget', () => {
@@ -373,6 +384,48 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
it('handleCreateNoteImmediate creates note with auto-generated title', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateNoteImmediate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.title).toBe('Untitled note')
expect(createdEntry.isA).toBe('Note')
})
it('handleCreateNoteImmediate generates unique names on rapid calls', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
})
expect(addEntry).toHaveBeenCalledTimes(3)
const titles = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.title)
expect(titles[0]).toBe('Untitled note')
expect(titles[1]).toBe('Untitled note 2')
expect(titles[2]).toBe('Untitled note 3')
})
it('handleCreateNoteImmediate accepts custom type', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateNoteImmediate('Project')
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.title).toBe('Untitled project')
expect(createdEntry.isA).toBe('Project')
})
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))

View File

@@ -49,6 +49,15 @@ async function loadNoteContent(path: string): Promise<string> {
: mockInvoke<string>('get_note_content', { path })
}
/** Fire-and-forget: persist a newly created note to disk. */
function persistNewNote(path: string, content: string): void {
if (isTauri()) {
invoke('save_note_content', { path, content }).catch((err) =>
console.error('Failed to persist new note:', err),
)
}
}
export function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry {
const now = Math.floor(Date.now() / 1000)
return {
@@ -64,10 +73,11 @@ export function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
/** Generate a unique "Untitled <type>" name by checking existing entries. */
export function generateUntitledName(entries: VaultEntry[], type: string): string {
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): string {
const baseName = `Untitled ${type.toLowerCase()}`
const existingTitles = new Set(entries.map(e => e.title))
if (pending) pending.forEach(n => existingTitles.add(n))
let title = baseName
let counter = 2
while (existingTitles.has(title)) {
@@ -195,7 +205,7 @@ async function reloadTabsAfterRename(
export function useNoteActions(config: NoteActionsConfig) {
const { addEntry, updateContent, entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
const { setTabs, handleSelectNote, activeTabPathRef, handleSwitchTab } = tabMgmt
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
const tabsRef = useRef(tabMgmt.tabs)
// eslint-disable-next-line react-hooks/refs
tabsRef.current = tabMgmt.tabs
@@ -211,17 +221,31 @@ export function useNoteActions(config: NoteActionsConfig) {
else console.warn(`Navigation target not found: ${target}`)
}, [entries, handleSelectNote])
const pendingNamesRef = useRef<Set<string>>(new Set())
const handleCreateNote = useCallback((title: string, type: string) => {
const { entry, content } = resolveNewNote(title, type)
addEntryWithMock(entry, content, addEntry)
handleSelectNote(entry)
}, [handleSelectNote, addEntry])
openTabWithContent(entry, content)
persistNewNote(entry.path, content)
}, [openTabWithContent, addEntry])
/** Create a note immediately with an auto-generated unique title. Dedup-safe for rapid calls. */
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
handleCreateNote(title, noteType)
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
setTimeout(() => pendingNamesRef.current.delete(title), 500)
}, [entries, handleCreateNote])
const handleCreateType = useCallback((typeName: string) => {
const { entry, content } = resolveNewType(typeName)
addEntryWithMock(entry, content, addEntry)
handleSelectNote(entry)
}, [handleSelectNote, addEntry])
openTabWithContent(entry, content)
persistNewNote(entry.path, content)
}, [openTabWithContent, addEntry])
const runFrontmatterOp = useCallback(async (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue) => {
try {
@@ -267,6 +291,7 @@ export function useNoteActions(config: NoteActionsConfig) {
...tabMgmt,
handleNavigateWikilink,
handleCreateNote,
handleCreateNoteImmediate,
handleCreateType,
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),

View File

@@ -126,6 +126,13 @@ export function useTabManagement() {
setTabs((prev) => { const next = reorderArray(prev, fromIndex, toIndex); saveTabOrder(next); return next })
}, [])
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
setTabs((prev) => addTabIfAbsent(prev, entry, content))
setActiveTabPath(entry.path)
}, [])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return }
const currentPath = activeTabPathRef.current
@@ -158,6 +165,7 @@ export function useTabManagement() {
activeTabPathRef,
handleCloseTabRef,
handleSelectNote,
openTabWithContent,
handleCloseTab,
handleSwitchTab,
handleReorderTabs,

View File

@@ -98,6 +98,28 @@ describe('useVaultLoader', () => {
expect(result.current.entries[0].title).toBe('New Note')
expect(result.current.allContent['/vault/note/new.md']).toBe('# New Note')
})
it('ignores duplicate entry with same path', async () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.entries).toHaveLength(1)
})
const newEntry: VaultEntry = {
...mockEntries[0],
path: '/vault/note/new.md',
filename: 'new.md',
title: 'New Note',
}
act(() => {
result.current.addEntry(newEntry, '# New Note')
result.current.addEntry(newEntry, '# New Note')
})
expect(result.current.entries).toHaveLength(2)
})
})
describe('updateContent', () => {

View File

@@ -77,7 +77,10 @@ export function useVaultLoader(vaultPath: string) {
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
const addEntry = useCallback((entry: VaultEntry, content: string) => {
setEntries((prev) => [entry, ...prev])
setEntries((prev) => {
if (prev.some(e => e.path === entry.path)) return prev
return [entry, ...prev]
})
setAllContent((prev) => ({ ...prev, [entry.path]: content }))
tracker.trackNew(entry.path)
}, [tracker])