Merge branch 'main' of https://github.com/refactoringhq/laputa-app
This commit is contained in:
@@ -182,7 +182,7 @@ function App() {
|
||||
// Read at callback time, so it's always current when user presses Cmd+N.
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
|
||||
|
||||
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
|
||||
@@ -241,8 +241,8 @@ describe('DEFAULT_TEMPLATES', () => {
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
it('uses TYPE_FOLDER_MAP for known types', () => {
|
||||
const { entry, content } = resolveNewNote('My Project', 'Project')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/project/my-project.md')
|
||||
const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/project/my-project.md')
|
||||
expect(entry.isA).toBe('Project')
|
||||
expect(entry.status).toBe('Active')
|
||||
expect(content).toContain('type: Project')
|
||||
@@ -250,31 +250,43 @@ describe('resolveNewNote', () => {
|
||||
})
|
||||
|
||||
it('falls back to slugified type for custom types', () => {
|
||||
const { entry } = resolveNewNote('First Recipe', 'Recipe')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/recipe/first-recipe.md')
|
||||
const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/recipe/first-recipe.md')
|
||||
})
|
||||
|
||||
it('omits status for Topic type', () => {
|
||||
const { entry, content } = resolveNewNote('Machine Learning', 'Topic')
|
||||
const { entry, content } = resolveNewNote('Machine Learning', 'Topic', '/my/vault')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).not.toContain('status:')
|
||||
})
|
||||
|
||||
it('omits status for Person type', () => {
|
||||
const { entry } = resolveNewNote('John Doe', 'Person')
|
||||
const { entry } = resolveNewNote('John Doe', 'Person', '/my/vault')
|
||||
expect(entry.status).toBeNull()
|
||||
})
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
const { entry } = resolveNewNote('Test', 'Note', '/other/vault')
|
||||
expect(entry.path).toBe('/other/vault/note/test.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
it('creates a type entry in the type folder', () => {
|
||||
const { entry, content } = resolveNewType('Recipe')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/type/recipe.md')
|
||||
const { entry, content } = resolveNewType('Recipe', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/type/recipe.md')
|
||||
expect(entry.isA).toBe('Type')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).toContain('type: Type')
|
||||
expect(content).toContain('# Recipe')
|
||||
})
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
const { entry } = resolveNewType('Responsibility', '/other/vault')
|
||||
expect(entry.path).toBe('/other/vault/type/responsibility.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('frontmatterToEntryPatch', () => {
|
||||
@@ -365,8 +377,8 @@ describe('buildDailyNoteContent', () => {
|
||||
|
||||
describe('resolveDailyNote', () => {
|
||||
it('creates entry in journal folder with date as filename', () => {
|
||||
const { entry } = resolveDailyNote('2026-03-02')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
|
||||
const { entry } = resolveDailyNote('2026-03-02', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/journal/2026-03-02.md')
|
||||
expect(entry.filename).toBe('2026-03-02.md')
|
||||
expect(entry.title).toBe('2026-03-02')
|
||||
expect(entry.isA).toBe('Journal')
|
||||
@@ -374,10 +386,16 @@ describe('resolveDailyNote', () => {
|
||||
})
|
||||
|
||||
it('returns content with daily note template', () => {
|
||||
const { content } = resolveDailyNote('2026-03-02')
|
||||
const { content } = resolveDailyNote('2026-03-02', '/my/vault')
|
||||
expect(content).toContain('type: Journal')
|
||||
expect(content).toContain('## Intentions')
|
||||
})
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
const { entry } = resolveDailyNote('2026-03-02', '/other/vault')
|
||||
expect(entry.path).toBe('/other/vault/journal/2026-03-02.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDailyNote', () => {
|
||||
@@ -413,7 +431,7 @@ describe('useNoteActions hook', () => {
|
||||
const setToastMessage = vi.fn()
|
||||
|
||||
const makeConfig = (entries: VaultEntry[] = []): NoteActionsConfig => ({
|
||||
addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry,
|
||||
addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, vaultPath: '/test/vault',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface NoteActionsConfig {
|
||||
entries: VaultEntry[]
|
||||
setToastMessage: (msg: string | null) => void
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
vaultPath: string
|
||||
addPendingSave?: (path: string) => void
|
||||
removePendingSave?: (path: string) => void
|
||||
trackUnsaved?: (path: string) => void
|
||||
@@ -188,17 +189,17 @@ export function buildNoteContent(title: string, type: string, status: string | n
|
||||
return `${lines.join('\n')}\n\n# ${title}\n${body}`
|
||||
}
|
||||
|
||||
export function resolveNewNote(title: string, type: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
|
||||
const slug = slugify(title)
|
||||
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${folder}/${slug}.md`, slug, title, type, status })
|
||||
return { entry, content: buildNoteContent(title, type, status, template) }
|
||||
}
|
||||
|
||||
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewType(typeName: string, vaultPath: string): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(typeName)
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
|
||||
}
|
||||
|
||||
@@ -211,8 +212,8 @@ export function buildDailyNoteContent(date: string): string {
|
||||
return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n`
|
||||
}
|
||||
|
||||
export function resolveDailyNote(date: string): { entry: VaultEntry; content: string } {
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
|
||||
export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } {
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
|
||||
return { entry, content: buildDailyNoteContent(date) }
|
||||
}
|
||||
|
||||
@@ -224,11 +225,11 @@ export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry |
|
||||
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
|
||||
|
||||
/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */
|
||||
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn): void {
|
||||
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void {
|
||||
const date = todayDateString()
|
||||
const existing = findDailyNote(entries, date)
|
||||
if (existing) selectNote(existing)
|
||||
else persist(resolveDailyNote(date))
|
||||
else persist(resolveDailyNote(date, vaultPath))
|
||||
signalFocusEditor()
|
||||
}
|
||||
|
||||
@@ -366,22 +367,22 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, template))
|
||||
}, [entries, persistNew])
|
||||
persistNew(resolveNewNote(title, type, config.vaultPath, template))
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, template)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Close tab and discard entry+unsaved state if the note was never persisted. */
|
||||
const handleCloseTabWithCleanup = useCallback((path: string) => {
|
||||
@@ -392,17 +393,17 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
|
||||
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
|
||||
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew), [entries, handleSelectNote, persistNew])
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName, config.vaultPath)), [persistNew, config.vaultPath])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
const resolved = resolveNewType(typeName)
|
||||
const resolved = resolveNewType(typeName, config.vaultPath)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
return resolved.entry
|
||||
}, [addEntry])
|
||||
}, [addEntry, config.vaultPath])
|
||||
|
||||
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user