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:
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user