fix: live-reload theme CSS vars on editor save and frontmatter changes
Wire notifyThemeSaved through the frontmatter update chain so CSS variables update immediately when: - The user edits a theme note in raw mode and presses Cmd+S - A frontmatter property is changed via the inspector panel Also expose CodeMirror EditorView on the DOM for Playwright test access, add unit tests for onNotePersisted, and a new Playwright smoke test that verifies the full raw-editor → save → CSS-vars-update flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -159,7 +159,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, 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, replaceEntry: vault.replaceEntry })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, 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, replaceEntry: vault.replaceEntry, onFrontmatterContentChanged: themeManager.notifyThemeSaved })
|
||||
|
||||
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
|
||||
@@ -65,20 +65,24 @@ export interface FrontmatterOpOptions {
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
/** Run a frontmatter update/delete and apply the result to state. */
|
||||
/** Run a frontmatter update/delete and apply the result to state.
|
||||
* Returns the new file content on success, or undefined on failure. */
|
||||
export async function runFrontmatterAndApply(
|
||||
op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined,
|
||||
callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial<VaultEntry>) => void; toast: (m: string | null) => void },
|
||||
options?: FrontmatterOpOptions,
|
||||
): Promise<void> {
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
callbacks.updateTab(path, await executeFrontmatterOp(op, path, key, value))
|
||||
const newContent = await executeFrontmatterOp(op, path, key, value)
|
||||
callbacks.updateTab(path, newContent)
|
||||
const patch = frontmatterToEntryPatch(op, key, value)
|
||||
if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch)
|
||||
if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
|
||||
return newContent
|
||||
} catch (err) {
|
||||
console.error(`Failed to ${op} frontmatter:`, err)
|
||||
if (options?.silent) throw err
|
||||
callbacks.toast(`Failed to ${op} property`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,9 @@ export function useCodeMirror(
|
||||
|
||||
const view = new EditorView({ state, parent })
|
||||
viewRef.current = view
|
||||
// Expose EditorView on the parent DOM for Playwright test access
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(parent as any).__cmView = view
|
||||
|
||||
// When CSS zoom changes on the document, CodeMirror's cached measurements
|
||||
// (scaleX/scaleY, line heights, character widths) become stale because
|
||||
@@ -136,6 +139,8 @@ export function useCodeMirror(
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('laputa-zoom-change', handleZoomChange)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (parent as any).__cmView
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
|
||||
@@ -219,6 +219,43 @@ describe('useEditorSave', () => {
|
||||
expect(updateVaultContent).toHaveBeenCalledWith('/vault/note.md', edited)
|
||||
})
|
||||
|
||||
it('calls onNotePersisted with path and content after saving pending content', async () => {
|
||||
const onNotePersisted = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/vault/theme/default.md', '---\nbackground: "#FFD700"\n---\n')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(onNotePersisted).toHaveBeenCalledWith(
|
||||
'/vault/theme/default.md',
|
||||
'---\nbackground: "#FFD700"\n---\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onNotePersisted for unsaved fallback when no pending content', async () => {
|
||||
const onNotePersisted = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
|
||||
)
|
||||
|
||||
// No handleContentChange — simulate Cmd+S on a newly created unsaved note
|
||||
await act(async () => {
|
||||
await result.current.handleSave({ path: '/vault/theme/default.md', content: '---\nbackground: "#FF0000"\n---\n' })
|
||||
})
|
||||
|
||||
expect(onNotePersisted).toHaveBeenCalledWith(
|
||||
'/vault/theme/default.md',
|
||||
'---\nbackground: "#FF0000"\n---\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('successive edits and saves persist each version correctly', async () => {
|
||||
const { result } = renderSaveHook()
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface NoteActionsConfig {
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
onNewNotePersisted?: () => void
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
|
||||
onFrontmatterContentChanged?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
function isTitleKey(key: string): boolean {
|
||||
@@ -131,7 +133,8 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleCreateType: creation.handleCreateType,
|
||||
createTypeEntrySilent: creation.createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
|
||||
await runFrontmatterOp('update', path, key, value, options)
|
||||
const newContent = await runFrontmatterOp('update', path, key, value, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (shouldRenameOnTitleUpdate(key, value)) {
|
||||
try {
|
||||
await renameAfterTitleChange(path, value, {
|
||||
@@ -142,9 +145,15 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string, options?: FrontmatterOpOptions) => runFrontmatterOp('delete', path, key, undefined, options), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user