From 3a9b6df3b3efef0a4fedb00975da4a052fec5c80 Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Sat, 11 Jul 2026 23:49:05 +0100 Subject: [PATCH 1/3] feat: add wiki-link hover previews --- .../src-tauri/src/fs/asset_protocol.rs | 61 +++- apps/desktop/src/components/note-pane.tsx | 12 + .../src/components/route-content.test.tsx | 23 +- .../wiki-link-hover-preview.test.tsx | 248 +++++++++++++ .../components/wiki-link-hover-preview.tsx | 237 +++++++++++++ apps/desktop/src/editor/markdown-preview.tsx | 14 +- apps/desktop/src/editor/note-editor.test.tsx | 46 ++- apps/desktop/src/editor/note-editor.tsx | 10 + .../editor/use-editor-autocomplete.test.tsx | 21 ++ .../src/editor/use-editor-autocomplete.ts | 6 +- .../editor/use-wiki-link-hover-preview.tsx | 36 ++ .../editor/use-wiki-link-navigation.test.tsx | 76 +++- .../src/editor/use-wiki-link-navigation.ts | 38 +- .../src/lib/read-existing-note-source.test.ts | 55 +++ .../src/lib/read-existing-note-source.ts | 29 ++ .../desktop/src/lib/use-file-changes.test.tsx | 12 +- apps/desktop/src/lib/use-file-changes.ts | 19 +- docs/contributing/editor-architecture.md | 3 + docs/porting/README.md | 2 +- docs/porting/backlink-hover-previews.md | 41 ++- .../core/src/actions/backlink-target.test.ts | 13 + packages/core/src/actions/backlink-target.ts | 6 + packages/core/src/exports/platform.ts | 4 + packages/core/src/graph/create-note.test.ts | 30 +- packages/core/src/graph/create-note.ts | 199 +---------- .../resolve-existing-wiki-target.test.ts | 335 ++++++++++++++++++ .../src/graph/resolve-existing-wiki-target.ts | 240 +++++++++++++ pnpm-lock.yaml | 172 ++++----- pnpm-workspace.yaml | 8 +- 29 files changed, 1673 insertions(+), 323 deletions(-) create mode 100644 apps/desktop/src/components/wiki-link-hover-preview.test.tsx create mode 100644 apps/desktop/src/components/wiki-link-hover-preview.tsx create mode 100644 apps/desktop/src/editor/use-wiki-link-hover-preview.tsx create mode 100644 apps/desktop/src/lib/read-existing-note-source.test.ts create mode 100644 apps/desktop/src/lib/read-existing-note-source.ts create mode 100644 packages/core/src/graph/resolve-existing-wiki-target.test.ts create mode 100644 packages/core/src/graph/resolve-existing-wiki-target.ts diff --git a/apps/desktop/src-tauri/src/fs/asset_protocol.rs b/apps/desktop/src-tauri/src/fs/asset_protocol.rs index 480a33467..fecfc769a 100644 --- a/apps/desktop/src-tauri/src/fs/asset_protocol.rs +++ b/apps/desktop/src-tauri/src/fs/asset_protocol.rs @@ -16,6 +16,9 @@ //! commands — a request racing a graph switch is refused, never resolved //! against the new graph. The path must live under `assets/` and passes the //! shared symlink-aware traversal guard before any IO. +//! Passive previews append `?reflect-preview=raster`; those responses are +//! served only when byte sniffing identifies PNG, JPEG, GIF, or WebP content, +//! so an SVG renamed with a raster extension cannot load subresources there. use std::borrow::Cow; @@ -28,6 +31,7 @@ use super::GraphState; /// The scheme name, shared with the `lib.rs` registration. The frontend and /// the CSP `img-src` grant in `tauri.conf.json` spell it out literally. pub(crate) const SCHEME: &str = "reflect-asset"; +const PREVIEW_RASTER_QUERY: &str = "reflect-preview=raster"; /// Protocol entry point (`register_asynchronous_uri_scheme_protocol`). Runs /// on the webview's calling thread — on WebKit, the app's main thread — so it @@ -42,26 +46,33 @@ pub(crate) fn handle( let request_path = percent_encoding::percent_decode(&request.uri().path().as_bytes()[1..]) .decode_utf8_lossy() .into_owned(); + let preview_raster_only = requests_preview_raster(request.uri().query()); let method_allowed = request.method() == tauri::http::Method::GET; tauri::async_runtime::spawn_blocking(move || { if !method_allowed { responder.respond(status_response(StatusCode::METHOD_NOT_ALLOWED)); return; } - responder.respond(response_for(&app, &request_path)); + responder.respond(response_for(&app, &request_path, preview_raster_only)); }); } fn response_for( app: &AppHandle, request_path: &str, + preview_raster_only: bool, ) -> Response> { match serve(app, request_path) { - Ok((mime, bytes)) => Response::builder() - .header(header::CONTENT_TYPE, mime) - .header(header::CONTENT_LENGTH, bytes.len()) - .body(Cow::Owned(bytes)) - .unwrap_or_else(|_| status_response(StatusCode::INTERNAL_SERVER_ERROR)), + Ok((mime, bytes)) => { + if preview_raster_only && !is_preview_safe_raster_mime(&mime) { + return status_response(StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + Response::builder() + .header(header::CONTENT_TYPE, mime) + .header(header::CONTENT_LENGTH, bytes.len()) + .body(Cow::Owned(bytes)) + .unwrap_or_else(|_| status_response(StatusCode::INTERNAL_SERVER_ERROR)) + } Err(status) => { tracing::warn!(path = request_path, %status, "asset protocol refused a request"); status_response(status) @@ -69,6 +80,21 @@ fn response_for( } } +fn requests_preview_raster(query: Option<&str>) -> bool { + query.is_some_and(|query| { + query + .split('&') + .any(|parameter| parameter == PREVIEW_RASTER_QUERY) + }) +} + +fn is_preview_safe_raster_mime(mime: &str) -> bool { + matches!( + mime, + "image/png" | "image/jpeg" | "image/gif" | "image/webp" + ) +} + fn status_response(status: StatusCode) -> Response> { Response::builder() .status(status) @@ -155,4 +181,27 @@ mod tests { StatusCode::FORBIDDEN, ); } + + #[test] + fn recognizes_only_the_explicit_preview_raster_query() { + assert!(requests_preview_raster(Some("reflect-preview=raster"))); + assert!(requests_preview_raster(Some( + "cache=1&reflect-preview=raster" + ))); + assert!(!requests_preview_raster(None)); + assert!(!requests_preview_raster(Some("reflect-preview=svg"))); + } + + #[test] + fn preview_raster_filter_uses_sniffed_content_not_the_filename() { + let disguised_svg = br#""#; + let svg_mime = MimeType::parse(disguised_svg, "assets/disguised.png"); + assert_ne!(svg_mime, "image/png"); + assert!(!is_preview_safe_raster_mime(&svg_mime)); + + let png_signature = b"\x89PNG\r\n\x1a\n"; + let png_mime = MimeType::parse(png_signature, "assets/image.bin"); + assert_eq!(png_mime, "image/png"); + assert!(is_preview_safe_raster_mime(&png_mime)); + } } diff --git a/apps/desktop/src/components/note-pane.tsx b/apps/desktop/src/components/note-pane.tsx index 59dfde57f..be888054b 100644 --- a/apps/desktop/src/components/note-pane.tsx +++ b/apps/desktop/src/components/note-pane.tsx @@ -23,6 +23,8 @@ import { useNoteDocument } from '@/editor/use-note-document' import { useTagNavigation } from '@/editor/use-tag-navigation' import { useTemplateSlashItems } from '@/editor/use-template-slash-items' import { useWikiLinkNavigation } from '@/editor/use-wiki-link-navigation' +import { useWikiLinkHoverPreview } from '@/editor/use-wiki-link-hover-preview' +import { isTouchEditorSurface } from '@/lib/platform-surface' import { cn } from '@/lib/utils' import { useGraph } from '@/providers/graph-provider' import { useSettings } from '@/providers/settings-provider' @@ -157,6 +159,13 @@ export function NotePaneComponent({ resolveFileInfo, saveError, } = useAssetPersistence(generation, path) + const renderWikilinkHoverCard = useWikiLinkHoverPreview({ + generation, + graphKey: graph?.root ?? null, + dateFormat: settings.dateFormat, + resolveImageUrl, + resolveAssetOpenPath, + }) const onWikiLinkClick = useWikiLinkNavigation(generation) const onTagClick = useTagNavigation() const { onWikilinkSearch, onTagSearch } = useEditorAutocomplete() @@ -340,6 +349,9 @@ export function NotePaneComponent({ resolveFileLink={resolveAssetFileLink} resolveFileInfo={resolveFileInfo} onWikiLinkClick={onWikiLinkClick} + {...(generation !== null && !isTouchEditorSurface() + ? { renderWikilinkHoverCard } + : {})} onTagClick={onTagClick} onWikilinkSearch={onWikilinkSearch} onTagSearch={onTagSearch} diff --git a/apps/desktop/src/components/route-content.test.tsx b/apps/desktop/src/components/route-content.test.tsx index 182a39fcd..294c2dd02 100644 --- a/apps/desktop/src/components/route-content.test.tsx +++ b/apps/desktop/src/components/route-content.test.tsx @@ -1,6 +1,6 @@ import { act, render, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ReactElement } from 'react' import { setBridge } from '@reflect/core' import { PaletteProvider, usePalette } from '@/components/command-palette/palette-provider' @@ -8,6 +8,7 @@ import { flushOpenDocuments } from '@/editor/open-documents' import type { NoteEditorHandle } from '@/editor/note-editor' import { RouterProvider } from '@/routing/router' import type { Route } from '@/routing/route' +import { setPlatformSurface } from '@/lib/platform-surface' import { RouteContent } from './route-content' /** @@ -21,6 +22,7 @@ import { RouteContent } from './route-content' const editorProbe = vi.hoisted(() => ({ onChange: null as ((markdown: string) => void) | null, focusCalls: [] as string[], + hoverRenderer: null as boolean | null, })) vi.mock('@/editor/note-editor', async () => { @@ -30,11 +32,14 @@ vi.mock('@/editor/note-editor', async () => { initialContent, onChange, handleRef, + renderWikilinkHoverCard, }: { initialContent: string onChange: (markdown: string) => void handleRef?: (handle: NoteEditorHandle | null) => void + renderWikilinkHoverCard?: unknown }) => { + editorProbe.hoverRenderer = renderWikilinkHoverCard !== undefined const markdownRef = useRef(initialContent) editorProbe.onChange = (markdown) => { markdownRef.current = markdown @@ -129,6 +134,7 @@ beforeEach(() => { writes = [] editorProbe.onChange = null editorProbe.focusCalls.length = 0 + editorProbe.hoverRenderer = null mockInvoke.mockReset() mockInvoke.mockImplementation(async (command, args) => { if (command === 'note_read') { @@ -151,6 +157,10 @@ beforeEach(() => { }) }) +afterEach(() => { + setPlatformSurface({ touchEditor: false, mobileApp: false }) +}) + function PaletteProbe(): ReactElement { const { open, query } = usePalette() return {JSON.stringify({ open, query })} @@ -190,12 +200,23 @@ describe('RouteContent', () => { await view.findByLabelText('Editing notes/exist.md') expect(view.queryByTestId('daily-stream')).toBeNull() expect(view.getByTestId('fake-editor').textContent).toContain('# Hello') + expect(editorProbe.hoverRenderer).toBe(true) // The navigated-to note takes focus on mount. await waitFor(() => expect(editorProbe.focusCalls).toContain('focus')) view.unmount() }) + it('omits the wiki-link hover renderer on a touch editor surface', async () => { + setPlatformSurface({ touchEditor: true }) + files['notes/exist.md'] = '# Hello\n' + const view = renderRoute({ kind: 'note', path: 'notes/exist.md' }) + + await view.findByLabelText('Editing notes/exist.md') + expect(editorProbe.hoverRenderer).toBe(false) + view.unmount() + }) + it('opens a missing note seeded with an empty focused title, writing nothing', async () => { const view = renderRoute({ kind: 'note', path: 'notes/new.md' }) diff --git a/apps/desktop/src/components/wiki-link-hover-preview.test.tsx b/apps/desktop/src/components/wiki-link-hover-preview.test.tsx new file mode 100644 index 000000000..1b9325711 --- /dev/null +++ b/apps/desktop/src/components/wiki-link-hover-preview.test.tsx @@ -0,0 +1,248 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FileChange } from '@reflect/core' +import { WikiLinkHoverPreview } from './wiki-link-hover-preview' + +const mocks = vi.hoisted(() => ({ + resolveExistingWikiTarget: vi.fn(), + readExistingNoteSource: vi.fn(), + markdownPreview: vi.fn(), +})) + +let fileChangeHandler: ((changes: FileChange[]) => void) | null = null + +vi.mock('@reflect/core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveExistingWikiTarget: mocks.resolveExistingWikiTarget, + } +}) + +vi.mock('@/lib/read-existing-note-source', () => ({ + readExistingNoteSource: mocks.readExistingNoteSource, +})) + +vi.mock('@/lib/use-file-changes', () => ({ + useFileChanges: (handler: ((changes: FileChange[]) => void) | null) => { + fileChangeHandler = handler + return true + }, +})) + +vi.mock('@/editor/markdown-preview', () => ({ + MarkdownPreview: (props: { + content: string + interactive: boolean + renderEmbeds: boolean + resolveImageUrl: (src: string) => string | null + }) => { + mocks.markdownPreview(props) + return
{props.content}
+ }, +})) + +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve: (value: T) => void = () => {} + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +function renderPreview( + target: string, + options: { + dismiss?: () => void + generation?: number | null + graphKey?: string | null + } = {}, +) { + return render( + + source.startsWith('assets/') && !source.includes('..') ? source : null + } + resolveImageUrl={(source) => `reflect-asset://${source}`} + />, + ) +} + +describe('WikiLinkHoverPreview', () => { + afterEach(cleanup) + + beforeEach(() => { + fileChangeHandler = null + mocks.resolveExistingWikiTarget.mockReset() + mocks.readExistingNoteSource.mockReset() + mocks.markdownPreview.mockReset() + }) + + it('stays invisible while loading, strips frontmatter, and renders passively', async () => { + const read = deferred() + mocks.resolveExistingWikiTarget.mockResolvedValue({ + kind: 'resolved', + path: 'notes/alpha.md', + }) + mocks.readExistingNoteSource.mockReturnValue(read.promise) + + renderPreview('Alpha') + expect(screen.queryByTestId('wiki-link-hover-preview')).toBeNull() + + await act(async () => { + read.resolve('---\nprivate: true\n---\n# Alpha\n\nBody') + await read.promise + }) + + expect((await screen.findByTestId('markdown-preview')).textContent).toBe( + '# Alpha\n\nBody', + ) + const props = mocks.markdownPreview.mock.calls.at(-1)?.[0] + expect(props).toMatchObject({ + content: '# Alpha\n\nBody', + interactive: false, + renderEmbeds: false, + }) + expect(props.resolveImageUrl('https://example.com/cat.png')).toBeNull() + expect(props.resolveImageUrl('assets/../secret.png')).toBeNull() + expect(props.resolveImageUrl('assets/vector.svg')).toBeNull() + expect(props.resolveImageUrl('assets/cat.png')).toBe( + 'reflect-asset://assets/cat.png?reflect-preview=raster', + ) + }) + + it('dismisses missing, ambiguous, and unavailable targets without showing a card', async () => { + for (const resolution of [ + { kind: 'missing' }, + { kind: 'ambiguous', paths: ['notes/a.md', 'notes/b.md'] }, + { kind: 'unavailable', paths: ['notes/a.md'] }, + ]) { + const dismiss = vi.fn() + mocks.resolveExistingWikiTarget.mockResolvedValueOnce(resolution) + const view = renderPreview('Target', { dismiss }) + + await waitFor(() => expect(dismiss).toHaveBeenCalledOnce()) + expect(screen.queryByTestId('wiki-link-hover-preview')).toBeNull() + view.unmount() + } + expect(mocks.readExistingNoteSource).not.toHaveBeenCalled() + }) + + it('does not let a late request for A replace a newer preview for B', async () => { + const aResolution = deferred<{ kind: 'resolved'; path: string }>() + mocks.resolveExistingWikiTarget.mockImplementation((target: string) => + target === 'A' + ? aResolution.promise + : Promise.resolve({ kind: 'resolved', path: 'notes/b.md' }), + ) + mocks.readExistingNoteSource.mockResolvedValue('# B') + const dismiss = vi.fn() + const view = renderPreview('A', { dismiss }) + + view.rerender( + null} + resolveImageUrl={() => null} + />, + ) + expect((await screen.findByTestId('markdown-preview')).textContent).toBe('# B') + + await act(async () => { + aResolution.resolve({ kind: 'resolved', path: 'notes/a.md' }) + await aResolution.promise + }) + + expect(screen.getByTestId('markdown-preview').textContent).toBe('# B') + expect(mocks.readExistingNoteSource).toHaveBeenCalledTimes(1) + }) + + it('dismisses a target changed while resolution is still pending', async () => { + const resolution = deferred<{ kind: 'resolved'; path: string }>() + const dismiss = vi.fn() + mocks.resolveExistingWikiTarget.mockReturnValue(resolution.promise) + renderPreview('Alpha', { dismiss }) + + act(() => { + fileChangeHandler?.([{ path: 'notes/alpha.md', kind: 'remove' }]) + }) + await act(async () => { + resolution.resolve({ kind: 'resolved', path: 'notes/alpha.md' }) + await resolution.promise + }) + + await waitFor(() => expect(dismiss).toHaveBeenCalledOnce()) + expect(mocks.readExistingNoteSource).not.toHaveBeenCalled() + expect(screen.queryByTestId('wiki-link-hover-preview')).toBeNull() + }) + + it('does not restart a visible request on an unrelated parent rerender', async () => { + const dismiss = vi.fn() + mocks.resolveExistingWikiTarget.mockResolvedValue({ + kind: 'resolved', + path: 'notes/alpha.md', + }) + mocks.readExistingNoteSource.mockResolvedValue('# Alpha') + const view = renderPreview('Alpha', { dismiss }) + await screen.findByTestId('wiki-link-hover-preview') + + view.rerender( + null} + resolveImageUrl={() => null} + />, + ) + + expect(mocks.resolveExistingWikiTarget).toHaveBeenCalledOnce() + expect(mocks.readExistingNoteSource).toHaveBeenCalledOnce() + }) + + it('dismisses when the resolved target is updated or removed', async () => { + const dismiss = vi.fn() + mocks.resolveExistingWikiTarget.mockResolvedValue({ + kind: 'resolved', + path: 'notes/alpha.md', + }) + mocks.readExistingNoteSource.mockResolvedValue('# Alpha') + renderPreview('Alpha', { dismiss }) + + await screen.findByTestId('wiki-link-hover-preview') + expect(fileChangeHandler).not.toBeNull() + act(() => { + fileChangeHandler?.([{ path: 'notes/alpha.md', kind: 'upsert' }]) + }) + + expect(dismiss).toHaveBeenCalledOnce() + expect(screen.queryByTestId('wiki-link-hover-preview')).toBeNull() + }) + + it('shows a formatted subject and Empty note for an empty daily note', async () => { + mocks.resolveExistingWikiTarget.mockResolvedValue({ + kind: 'resolved', + path: 'daily/2026-06-09.md', + }) + mocks.readExistingNoteSource.mockResolvedValue('---\nid: day\n---\n\n') + renderPreview('2026-06-09') + + expect(await screen.findByText('Tue, June 9th, 2026')).not.toBeNull() + expect(screen.getByText('Empty note')).not.toBeNull() + expect(mocks.markdownPreview).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/wiki-link-hover-preview.tsx b/apps/desktop/src/components/wiki-link-hover-preview.tsx new file mode 100644 index 000000000..0946c39ac --- /dev/null +++ b/apps/desktop/src/components/wiki-link-hover-preview.tsx @@ -0,0 +1,237 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactElement, +} from 'react' +import { + dateFromDailyPath, + resolveExistingWikiTarget, + splitFrontmatter, + type DateFormat, + type FileChange, +} from '@reflect/core' +import { MarkdownPreview } from '@/editor/markdown-preview' +import { formatDayLabel } from '@/lib/dates' +import { readExistingNoteSource } from '@/lib/read-existing-note-source' +import { useFileChanges } from '@/lib/use-file-changes' + +interface WikiLinkHoverPreviewProps { + target: string + dismiss: () => void + generation: number | null + graphKey: string | null + dateFormat: DateFormat + resolveImageUrl: (src: string) => string | null + resolveAssetOpenPath: (src: string) => string | null +} + +interface PreviewScope { + target: string + generation: number + graphKey: string +} + +interface PreviewLoad { + scope: PreviewScope + path: string + body: string | null +} + +function sameScope(load: PreviewLoad, scope: PreviewScope | null): boolean { + return ( + scope !== null && + load.scope.target === scope.target && + load.scope.generation === scope.generation && + load.scope.graphKey === scope.graphKey + ) +} + +function isSvgAsset(path: string): boolean { + return path.toLowerCase().endsWith('.svg') +} + +function previewRasterUrl(url: string): string { + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}reflect-preview=raster` +} + +/** + * Reflect's passive body for Meowdown's wiki-link hover card. + * + * The component remains visually empty until target resolution and the local + * read both succeed. Every request is scoped to the target and graph session, + * so late work from a previous hover can never replace the current preview. + */ +export function WikiLinkHoverPreview({ + target, + dismiss, + generation, + graphKey, + dateFormat, + resolveImageUrl, + resolveAssetOpenPath, +}: WikiLinkHoverPreviewProps): ReactElement | null { + const [load, setLoad] = useState(null) + const requestEpoch = useRef(0) + const previousSession = useRef({ generation, graphKey }) + const changeSequence = useRef(0) + const pathChangeSequence = useRef(new Map()) + const resolvedPath = useRef(null) + const currentScope = + generation === null || graphKey === null ? null : { target, generation, graphKey } + const visibleLoad = load !== null && sameScope(load, currentScope) ? load : null + + const handleFileChanges = useCallback( + (changes: FileChange[]) => { + let resolvedTargetChanged = false + for (const change of changes) { + const sequence = ++changeSequence.current + pathChangeSequence.current.set(change.path, sequence) + if (change.path === resolvedPath.current) { + resolvedTargetChanged = true + } + } + if (!resolvedTargetChanged) { + return + } + requestEpoch.current += 1 + resolvedPath.current = null + setLoad(null) + dismiss() + }, + [dismiss], + ) + const watcherReady = useFileChanges(handleFileChanges) + + useEffect(() => { + const epoch = ++requestEpoch.current + let active = true + resolvedPath.current = null + + if (!watcherReady) { + return () => { + active = false + requestEpoch.current += 1 + resolvedPath.current = null + } + } + + const requestStartSequence = changeSequence.current + const sessionChanged = + previousSession.current.generation !== generation || + previousSession.current.graphKey !== graphKey + previousSession.current = { generation, graphKey } + + if (sessionChanged || generation === null || graphKey === null) { + dismiss() + return () => { + active = false + requestEpoch.current += 1 + } + } + const requestScope: PreviewScope = { target, generation, graphKey } + + void (async () => { + try { + const resolution = await resolveExistingWikiTarget( + requestScope.target, + requestScope.generation, + ) + if (!active || requestEpoch.current !== epoch) { + return + } + if (resolution.kind !== 'resolved') { + dismiss() + return + } + const changedDuringRequest = (): boolean => + (pathChangeSequence.current.get(resolution.path) ?? 0) > requestStartSequence + if (changedDuringRequest()) { + dismiss() + return + } + + resolvedPath.current = resolution.path + setLoad({ scope: requestScope, path: resolution.path, body: null }) + const source = await readExistingNoteSource( + resolution.path, + requestScope.generation, + ) + if (!active || requestEpoch.current !== epoch) { + return + } + if (changedDuringRequest()) { + resolvedPath.current = null + setLoad(null) + dismiss() + return + } + setLoad({ + scope: requestScope, + path: resolution.path, + body: splitFrontmatter(source).body, + }) + } catch { + if (active && requestEpoch.current === epoch) { + dismiss() + } + } + })() + + return () => { + active = false + requestEpoch.current += 1 + resolvedPath.current = null + } + }, [dismiss, generation, graphKey, target, watcherReady]) + + const resolveLocalImageUrl = useCallback( + (source: string): string | null => { + const assetPath = resolveAssetOpenPath(source) + // SVG can contain external subresource references. The filename check + // avoids an unnecessary request; the query also makes the asset protocol + // enforce a sniffed raster MIME allowlist, so renamed SVG bytes cannot + // bypass the passive card's no-network boundary. + if (assetPath === null || isSvgAsset(assetPath)) { + return null + } + const url = resolveImageUrl(assetPath) + return url === null ? null : previewRasterUrl(url) + }, + [resolveAssetOpenPath, resolveImageUrl], + ) + + if (visibleLoad?.body === null || visibleLoad?.body === undefined) { + return null + } + + const dailyDate = dateFromDailyPath(visibleLoad.path) + const empty = visibleLoad.body.trim().length === 0 + + return ( + + ) +} diff --git a/apps/desktop/src/editor/markdown-preview.tsx b/apps/desktop/src/editor/markdown-preview.tsx index 4abc5d55b..0b80ecd4e 100644 --- a/apps/desktop/src/editor/markdown-preview.tsx +++ b/apps/desktop/src/editor/markdown-preview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react' +import { useCallback, useEffect, useRef, type ReactElement } from 'react' import { MarkdownView } from '@meowdown/react' import { useOpenExternalLink } from '@/editor/open-external-link' import { cn } from '@/lib/utils' @@ -25,6 +25,10 @@ interface MarkdownPreviewProps { * originating click so handlers can honor ⌘-click (open in new window). */ onWikiLinkClick?: (target: string, event?: MouseEvent | KeyboardEvent) => void + /** Whether rendered links and controls can be activated (default true). */ + interactive?: boolean + /** Whether rich embeds may create remote media frames (default true). */ + renderEmbeds?: boolean /** Extra classes for the rendered root. */ className?: string } @@ -33,6 +37,8 @@ export function MarkdownPreview({ content, resolveImageUrl, onWikiLinkClick, + interactive = true, + renderEmbeds = true, className, }: MarkdownPreviewProps): ReactElement { const openExternalLink = useOpenExternalLink() @@ -50,7 +56,7 @@ export function MarkdownPreview({ // either always pass the handler (chat) or never do (palette preview). An // inert preview omits the handler so a chip click is a no-op rather than a // dead navigation. - const [navigates] = useState(() => onWikiLinkClick != null) + const navigates = interactive && onWikiLinkClick != null const resolveImageUrlStable = useCallback( (src: string) => resolveRef.current?.(src) ?? undefined, @@ -66,8 +72,10 @@ export function MarkdownPreview({ diff --git a/apps/desktop/src/editor/note-editor.test.tsx b/apps/desktop/src/editor/note-editor.test.tsx index 3e8bb1ed2..f18b9e368 100644 --- a/apps/desktop/src/editor/note-editor.test.tsx +++ b/apps/desktop/src/editor/note-editor.test.tsx @@ -28,7 +28,14 @@ interface CapturedEditorProps { onFileClick?: (payload: { href: string; name: string; event: MouseEvent | KeyboardEvent }) => void } -const captured = vi.hoisted(() => ({ props: null as CapturedEditorProps | null })) +interface HoverRenderContext { + target: string + dismiss: () => void +} + +const captured = vi.hoisted(() => ({ + props: null as CapturedEditorProps | null, +})) /** The stub editor `useEditor` hands `EditorInputTraits` (see the mock below). */ const editorStub = vi.hoisted(() => ({ @@ -55,6 +62,17 @@ vi.mock('@/lib/windows/open-in-new-window', async (importOriginal) => ({ // `useEditor` backs `EditorInputTraits` (mounted inside the editor). vi.mock('@meowdown/react', () => ({ useEditor: () => editorStub, + WikilinkHoverCard: ({ + children, + }: { + children: (context: HoverRenderContext) => ReactNode + }) => { + return ( +
+ {children({ target: 'Alpha', dismiss: vi.fn() })} +
+ ) + }, MeowdownEditor: (props: CapturedEditorProps) => { captured.props = props return ( @@ -164,6 +182,32 @@ describe('NoteEditor markdown syntax mode', () => { }) }) +describe('NoteEditor wiki-link hover card', () => { + it('does not mount the optional card without a host renderer', () => { + render() + expect(screen.queryByTestId('wikilink-hover-card')).toBeNull() + }) + + it('renders the latest host renderer after a prop change', () => { + const view = render( + First {target}} + />, + ) + expect(screen.getByText('First Alpha')).not.toBeNull() + + view.rerender( + Second {target}} + />, + ) + + expect(screen.getByText('Second Alpha')).not.toBeNull() + }) +}) + describe('NoteEditor time format', () => { it('passes the 12-hour clock to meowdown by default', () => { renderEditor() diff --git a/apps/desktop/src/editor/note-editor.tsx b/apps/desktop/src/editor/note-editor.tsx index 421a896e5..739c996b9 100644 --- a/apps/desktop/src/editor/note-editor.tsx +++ b/apps/desktop/src/editor/note-editor.tsx @@ -20,12 +20,14 @@ import { } from '@meowdown/core' import { MeowdownEditor, + WikilinkHoverCard, type EditorHandle, type PendingReplacementResolveHandler, type SelectionMenuSearchHandler, type SlashMenuSearchHandler, type TagSearchHandler, type WikilinkSearchHandler, + type WikilinkHoverCardRenderContext, } from '@meowdown/react' import { EditorInputTraits } from '@/editor/editor-input-traits' import { FormattingToolbarBridge } from '@/editor/formatting-toolbar-bridge' @@ -41,6 +43,8 @@ import { isDeepLinkUrl } from '@/lib/deep-links/parse' import { useFollowDeepLink } from '@/lib/deep-links/use-follow-deep-link' import { cn } from '@/lib/utils' +type WikilinkHoverRenderer = (context: WikilinkHoverCardRenderContext) => ReactNode + /** * Reflect's note editor: a thin wrapper over `@meowdown/react`'s * ``. meowdown owns the editing surface (wiki-link clicks, @@ -152,6 +156,8 @@ interface NoteEditorProps { * modifiers, e.g. ⌘-click opens the target in a new window. */ onWikiLinkClick?: (target: string, event?: MouseEvent | KeyboardEvent) => void + /** Render the passive body of Meowdown's editor-scoped wiki-link hover card. */ + renderWikilinkHoverCard?: WikilinkHoverRenderer /** Click on an inline `#tag`. The tag name arrives without the leading `#`. */ onTagClick?: (tag: string) => void /** Search notes for the `[[` autocomplete menu. */ @@ -207,6 +213,7 @@ export function NoteEditor({ resolveFileLink, resolveFileInfo, onWikiLinkClick, + renderWikilinkHoverCard, onTagClick, onWikilinkSearch, onTagSearch, @@ -422,6 +429,9 @@ export function NoteEditor({ > + {renderWikilinkHoverCard !== undefined ? ( + {renderWikilinkHoverCard} + ) : null} {children} { ) }) + it('reports an unavailable background create distinctly from ambiguity', async () => { + resolveOrCreateNoteWithTitle.mockResolvedValue({ + kind: 'unavailable', + paths: ['notes/business-ideas.md'], + }) + const { result } = renderHook(() => useEditorAutocomplete()) + const items = await result.current.onWikilinkSearch('Business ideas') + + act(() => { + items[0]!.onSelect?.() + }) + + await waitFor(() => + expect(resolveOrCreateNoteWithTitle).toHaveBeenCalledWith('Business ideas', 7), + ) + expect(startOperation).toHaveBeenCalledWith('Creating note') + expect(operationFail).toHaveBeenCalledWith( + 'Couldn’t create “Business ideas” while a potentially matching note is unavailable. Try again when it is available on this device.', + ) + }) + it('surfaces a failed background create instead of silently doing nothing', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) resolveOrCreateNoteWithTitle.mockRejectedValue(new Error('graph changed')) diff --git a/apps/desktop/src/editor/use-editor-autocomplete.ts b/apps/desktop/src/editor/use-editor-autocomplete.ts index 76b960d9a..b3343ce2d 100644 --- a/apps/desktop/src/editor/use-editor-autocomplete.ts +++ b/apps/desktop/src/editor/use-editor-autocomplete.ts @@ -55,13 +55,17 @@ export function useEditorAutocomplete(): EditorAutocomplete { // The `[[` autocomplete's create row: re-resolve and inspect the title's // on-disk slug family before creating. The menu inserts the link text either - // way; an ambiguous or failed create simply leaves it unresolved. + // way; an ambiguous, unavailable, or failed create leaves it unresolved. const resolveOrCreateFromAutocomplete = useCallback( async (title: string) => { if (generation !== null) { const outcome = await resolveOrCreateNoteWithTitle(title, generation) if (outcome.kind === 'ambiguous') { reportAmbiguousNoteTitle('Creating note', title) + } else if (outcome.kind === 'unavailable') { + startOperation('Creating note').fail( + `Couldn’t create “${title}” while a potentially matching note is unavailable. Try again when it is available on this device.`, + ) } } }, diff --git a/apps/desktop/src/editor/use-wiki-link-hover-preview.tsx b/apps/desktop/src/editor/use-wiki-link-hover-preview.tsx new file mode 100644 index 000000000..7738a7602 --- /dev/null +++ b/apps/desktop/src/editor/use-wiki-link-hover-preview.tsx @@ -0,0 +1,36 @@ +import { useCallback, type ReactNode } from 'react' +import type { WikilinkHoverCardRenderContext } from '@meowdown/react' +import type { DateFormat } from '@reflect/core' +import { WikiLinkHoverPreview } from '@/components/wiki-link-hover-preview' + +interface WikiLinkHoverPreviewOptions { + generation: number | null + graphKey: string | null + dateFormat: DateFormat + resolveImageUrl: (src: string) => string | null + resolveAssetOpenPath: (src: string) => string | null +} + +/** Build the target renderer supplied to Meowdown's editor-scoped hover card. */ +export function useWikiLinkHoverPreview({ + generation, + graphKey, + dateFormat, + resolveImageUrl, + resolveAssetOpenPath, +}: WikiLinkHoverPreviewOptions): (context: WikilinkHoverCardRenderContext) => ReactNode { + return useCallback( + ({ target, dismiss }: WikilinkHoverCardRenderContext) => ( + + ), + [dateFormat, generation, graphKey, resolveAssetOpenPath, resolveImageUrl], + ) +} diff --git a/apps/desktop/src/editor/use-wiki-link-navigation.test.tsx b/apps/desktop/src/editor/use-wiki-link-navigation.test.tsx index 047b5c9f6..859ec0fb5 100644 --- a/apps/desktop/src/editor/use-wiki-link-navigation.test.tsx +++ b/apps/desktop/src/editor/use-wiki-link-navigation.test.tsx @@ -5,6 +5,7 @@ import { RouterProvider, useRouter } from '@/routing/router' import { useWikiLinkNavigation } from './use-wiki-link-navigation' const resolveWikiTarget = vi.hoisted(() => vi.fn()) +const resolveExistingWikiTarget = vi.hoisted(() => vi.fn()) const resolveOrCreateNoteWithTitle = vi.hoisted(() => vi.fn()) const openRouteInNewWindow = vi.hoisted(() => vi.fn<() => Promise>()) const operationFail = vi.hoisted(() => vi.fn()) @@ -12,6 +13,7 @@ const startOperation = vi.hoisted(() => vi.fn(() => ({ fail: operationFail }))) vi.mock('@reflect/core', async (importOriginal) => ({ ...(await importOriginal()), resolveWikiTarget, + resolveExistingWikiTarget, resolveOrCreateNoteWithTitle, })) vi.mock('@/lib/windows/open-in-new-window', async (importOriginal) => ({ @@ -53,6 +55,7 @@ function currentRoute(view: ReturnType): string { beforeEach(() => { resolveWikiTarget.mockReset() + resolveExistingWikiTarget.mockReset() resolveOrCreateNoteWithTitle.mockReset() openRouteInNewWindow.mockReset() openRouteInNewWindow.mockResolvedValue(true) @@ -89,18 +92,23 @@ describe('useWikiLinkNavigation', () => { }) it('treats an unresolved ISO date as a daily target, without a focus intent', async () => { - resolveWikiTarget.mockResolvedValue({ kind: 'unresolved', text: '2026-06-09' }) + resolveExistingWikiTarget.mockResolvedValue({ kind: 'missing' }) const view = renderHost() lastHandler?.('2026-06-09') await waitFor(() => expect(currentRoute(view)).toContain('"daily"')) expect(currentRoute(view)).toContain('2026-06-09') expect(view.getByTestId('route').getAttribute('data-focus')).toBe('false') expect(resolveOrCreateNoteWithTitle).not.toHaveBeenCalled() + expect(resolveExistingWikiTarget).toHaveBeenCalledWith('2026-06-09', 1) + expect(resolveWikiTarget).not.toHaveBeenCalled() view.unmount() }) it('preserves an existing regular note titled as an ISO date', async () => { - resolveWikiTarget.mockResolvedValue({ kind: 'resolved', ref: 'notes/2026-06-09.md' }) + resolveExistingWikiTarget.mockResolvedValue({ + kind: 'resolved', + path: 'notes/2026-06-09.md', + }) const view = renderHost() lastHandler?.('2026-06-09') @@ -110,6 +118,51 @@ describe('useWikiLinkNavigation', () => { view.unmount() }) + it('retains read-only index resolution for ISO dates without a graph generation', async () => { + resolveWikiTarget.mockResolvedValue({ kind: 'resolved', ref: 'notes/2026-06-09.md' }) + const view = renderHost(null) + + lastHandler?.('2026-06-09') + + await waitFor(() => expect(currentRoute(view)).toContain('notes/2026-06-09.md')) + expect(resolveWikiTarget).toHaveBeenCalledWith('2026-06-09') + expect(resolveExistingWikiTarget).not.toHaveBeenCalled() + view.unmount() + }) + + it('does not choose between ambiguous ISO-date targets', async () => { + resolveExistingWikiTarget.mockResolvedValue({ + kind: 'ambiguous', + paths: ['daily/2026-06-09.md', 'daily/2026-06-09-2.md'], + }) + const view = renderHost() + + lastHandler?.('2026-06-09') + + await waitFor(() => expect(operationFail).toHaveBeenCalled()) + expect(currentRoute(view)).toContain('"today"') + expect(resolveOrCreateNoteWithTitle).not.toHaveBeenCalled() + view.unmount() + }) + + it('does not turn an unavailable ISO-date target into a lazy daily route', async () => { + resolveExistingWikiTarget.mockResolvedValue({ + kind: 'unavailable', + paths: ['daily/2026-06-09.md'], + }) + const view = renderHost() + + lastHandler?.('2026-06-09') + + await waitFor(() => + expect(operationFail).toHaveBeenCalledWith( + expect.stringContaining('currently unavailable'), + ), + ) + expect(currentRoute(view)).toContain('"today"') + view.unmount() + }) + it('routes a resolved daily alias through the daily view', async () => { resolveOrCreateNoteWithTitle.mockResolvedValue({ kind: 'resolved', @@ -203,6 +256,25 @@ describe('useWikiLinkNavigation', () => { view.unmount() }) + it('does not navigate or create when a matching title is unavailable', async () => { + resolveOrCreateNoteWithTitle.mockResolvedValue({ + kind: 'unavailable', + paths: ['notes/business-ideas.md'], + }) + const view = renderHost(7) + + lastHandler?.('Business ideas') + + await waitFor(() => + expect(operationFail).toHaveBeenCalledWith( + expect.stringContaining('currently unavailable'), + ), + ) + expect(currentRoute(view)).toContain('"today"') + expect(resolveWikiTarget).not.toHaveBeenCalled() + view.unmount() + }) + it('surfaces a resolution failure instead of silently doing nothing', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) resolveOrCreateNoteWithTitle.mockRejectedValue(new Error('index unavailable')) diff --git a/apps/desktop/src/editor/use-wiki-link-navigation.ts b/apps/desktop/src/editor/use-wiki-link-navigation.ts index f462d4c5e..a0416a09a 100644 --- a/apps/desktop/src/editor/use-wiki-link-navigation.ts +++ b/apps/desktop/src/editor/use-wiki-link-navigation.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { errorMessage, normalizeWikiTarget, + resolveExistingWikiTarget, resolveOrCreateNoteWithTitle, resolveWikiTarget, } from '@reflect/core' @@ -11,6 +12,12 @@ import { startOperation } from '@/lib/operations' import { useLinkIntentGuard } from '@/lib/windows/use-link-intent-guard' import { routeForPath, type NoteRoute } from '@/routing/route' +function reportUnavailableNoteTitle(title: string): void { + startOperation('Opening link').fail( + `Couldn’t open “${title}” because a matching note is currently unavailable. Try again when it is available on this device.`, + ) +} + /** * Navigation for a clicked `[[wiki link]]`. Calendar-valid ISO dates preserve * ordinary resolution precedence, then open their lazy daily route on a miss. @@ -55,15 +62,32 @@ export function useWikiLinkNavigation( return } if (normalized.date !== undefined) { - const resolution = await resolveWikiTarget(normalized.raw) + if (generation === null) { + const resolution = await resolveWikiTarget(normalized.raw) + if (isStale()) { + return + } + open( + resolution.kind === 'resolved' + ? routeForPath(resolution.ref) + : { kind: 'daily', date: normalized.date }, + ) + return + } + + const resolution = await resolveExistingWikiTarget(normalized.raw, generation) if (isStale()) { return } - open( - resolution.kind === 'resolved' - ? routeForPath(resolution.ref) - : { kind: 'daily', date: normalized.date }, - ) + if (resolution.kind === 'resolved') { + open(routeForPath(resolution.path)) + } else if (resolution.kind === 'missing') { + open({ kind: 'daily', date: normalized.date }) + } else if (resolution.kind === 'ambiguous') { + reportAmbiguousNoteTitle('Opening link', normalized.raw) + } else { + reportUnavailableNoteTitle(normalized.raw) + } return } if (generation !== null) { @@ -73,6 +97,8 @@ export function useWikiLinkNavigation( } if (outcome.kind === 'ambiguous') { reportAmbiguousNoteTitle('Opening link', normalized.raw) + } else if (outcome.kind === 'unavailable') { + reportUnavailableNoteTitle(normalized.raw) } else { open(routeForPath(outcome.path)) } diff --git a/apps/desktop/src/lib/read-existing-note-source.test.ts b/apps/desktop/src/lib/read-existing-note-source.test.ts new file mode 100644 index 000000000..9cb69dbbc --- /dev/null +++ b/apps/desktop/src/lib/read-existing-note-source.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { readNote } from '@reflect/core' +import { openSession } from '@/editor/open-documents' +import { readExistingNoteSource } from './read-existing-note-source' + +vi.mock('@reflect/core', () => ({ + readNote: vi.fn(), +})) + +vi.mock('@/editor/open-documents', () => ({ + openSession: vi.fn(), +})) + +const readNoteMock = vi.mocked(readNote) +const openSessionMock = vi.mocked(openSession) + +describe('readExistingNoteSource', () => { + beforeEach(() => { + readNoteMock.mockReset() + openSessionMock.mockReset().mockReturnValue(null) + }) + + it('reads the live buffer of an open, ready session', async () => { + openSessionMock.mockReturnValue({ liveContent: () => '# Live' } as never) + readNoteMock.mockResolvedValue('# On disk') + + await expect(readExistingNoteSource('notes/a.md', 7)).resolves.toBe('# Live') + expect(readNoteMock).toHaveBeenCalledWith('notes/a.md', 7) + }) + + it('preserves an authoritative empty live buffer', async () => { + openSessionMock.mockReturnValue({ liveContent: () => '' } as never) + readNoteMock.mockResolvedValue('# On disk') + + await expect(readExistingNoteSource('notes/a.md', 7)).resolves.toBe('') + expect(readNoteMock).toHaveBeenCalledWith('notes/a.md', 7) + }) + + it('refuses a stale live buffer when its generation-pinned file is gone', async () => { + openSessionMock.mockReturnValue({ liveContent: () => '# Stale' } as never) + readNoteMock.mockRejectedValue({ kind: 'notFound', message: 'removed' }) + + await expect(readExistingNoteSource('notes/a.md', 7)).rejects.toMatchObject({ + kind: 'notFound', + }) + }) + + it('falls back to a generation-pinned disk read while the session is loading', async () => { + openSessionMock.mockReturnValue({ liveContent: () => null } as never) + readNoteMock.mockResolvedValue('# On disk') + + await expect(readExistingNoteSource('notes/a.md', 7)).resolves.toBe('# On disk') + expect(readNoteMock).toHaveBeenCalledWith('notes/a.md', 7) + }) +}) diff --git a/apps/desktop/src/lib/read-existing-note-source.ts b/apps/desktop/src/lib/read-existing-note-source.ts new file mode 100644 index 000000000..6826e6e0a --- /dev/null +++ b/apps/desktop/src/lib/read-existing-note-source.ts @@ -0,0 +1,29 @@ +import { readNote } from '@reflect/core' +import { openSession } from '@/editor/open-documents' + +/** + * Read an existing note without disturbing its editor session. + * + * An open session's live buffer is newer than disk and can be read without + * reconciling pending native input (unlike `NoteEditorHandle.getMarkdown`). A + * closed note falls back to a generation-pinned read so a graph switch cannot + * return content from the newly active graph. + */ +export async function readExistingNoteSource( + path: string, + generation: number, +): Promise { + const session = openSession(path) + if (session !== null) { + const liveContent = session.liveContent() + if (liveContent !== null) { + // The index and open-document registry can briefly outlive an external + // delete or iCloud eviction. Prove the generation-pinned file is still + // locally readable before publishing a live buffer; the already-mounted + // watcher guards changes that race this read. + await readNote(path, generation) + return liveContent + } + } + return readNote(path, generation) +} diff --git a/apps/desktop/src/lib/use-file-changes.test.tsx b/apps/desktop/src/lib/use-file-changes.test.tsx index 2215ef12c..3429e94c2 100644 --- a/apps/desktop/src/lib/use-file-changes.test.tsx +++ b/apps/desktop/src/lib/use-file-changes.test.tsx @@ -38,9 +38,9 @@ function stubSubscription(): Subscription { } } -function Host({ handler }: { handler: ((changes: FileChange[]) => void) | null }): null { - useFileChanges(handler) - return null +function Host({ handler }: { handler: ((changes: FileChange[]) => void) | null }) { + const ready = useFileChanges(handler) + return {String(ready)} } const UPSERT: FileChange[] = [{ path: 'notes/a.md', kind: 'upsert' }] @@ -55,7 +55,9 @@ describe('useFileChanges', () => { const subscription = stubSubscription() const handler = vi.fn() const view = render() + expect(view.getByTestId('ready').textContent).toBe('false') await subscription.resolve() + expect(view.getByTestId('ready').textContent).toBe('true') subscription.emit(UPSERT) expect(handler).toHaveBeenCalledWith(UPSERT) view.unmount() @@ -89,7 +91,9 @@ describe('useFileChanges', () => { const second = stubSubscription() const nextHandler = vi.fn() view.rerender() + expect(view.getByTestId('ready').textContent).toBe('false') await second.resolve() + expect(view.getByTestId('ready').textContent).toBe('true') expect(subscribeFileChanges).toHaveBeenCalledTimes(2) expect(first.unlisten).toHaveBeenCalledOnce() @@ -101,11 +105,13 @@ describe('useFileChanges', () => { it('does nothing when disabled or without a bridge', () => { const view = render() expect(subscribeFileChanges).not.toHaveBeenCalled() + expect(view.getByTestId('ready').textContent).toBe('true') view.unmount() hasBridge.mockReturnValue(false) const bridgeless = render() expect(subscribeFileChanges).not.toHaveBeenCalled() + expect(bridgeless.getByTestId('ready').textContent).toBe('true') bridgeless.unmount() }) diff --git a/apps/desktop/src/lib/use-file-changes.ts b/apps/desktop/src/lib/use-file-changes.ts index a40002abd..421a8f888 100644 --- a/apps/desktop/src/lib/use-file-changes.ts +++ b/apps/desktop/src/lib/use-file-changes.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { hasBridge, subscribeFileChanges, type FileChange } from '@reflect/core' /** @@ -14,11 +14,17 @@ import { hasBridge, subscribeFileChanges, type FileChange } from '@reflect/core' * * The subscription follows the handler's identity: memoize the handler over * its real dependencies and the hook resubscribes exactly when they change. - * Pass `null` to disable. + * Pass `null` to disable. The return value becomes true once the current + * handler's native subscription is installed (and is immediately true when + * disabled or running without a bridge), so consumers that cannot tolerate a + * pre-subscription race can wait before starting their work. */ -export function useFileChanges(handler: ((changes: FileChange[]) => void) | null): void { +export function useFileChanges(handler: ((changes: FileChange[]) => void) | null): boolean { + const bridgeAvailable = hasBridge() + const [readyHandler, setReadyHandler] = useState(null) + useEffect(() => { - if (handler === null || !hasBridge()) { + if (handler === null || !bridgeAvailable) { return } let active = true @@ -31,6 +37,7 @@ export function useFileChanges(handler: ((changes: FileChange[]) => void) | null .then((stop) => { if (active) { unlisten = stop + setReadyHandler(() => handler) } else { stop() } @@ -44,5 +51,7 @@ export function useFileChanges(handler: ((changes: FileChange[]) => void) | null active = false unlisten?.() } - }, [handler]) + }, [bridgeAvailable, handler]) + + return handler === null || !bridgeAvailable || readyHandler === handler } diff --git a/docs/contributing/editor-architecture.md b/docs/contributing/editor-architecture.md index f36f47289..b20953a50 100644 --- a/docs/contributing/editor-architecture.md +++ b/docs/contributing/editor-architecture.md @@ -13,6 +13,7 @@ NotePane / DailyStream / MobileNote components — composition only │ └─ createNoteSession() pure document state machine (note-session.ts) │ └─ readNote / writeNote @reflect/core typed commands ├─ useWikiLinkNavigation() [[link]] click → route / create + ├─ useWikiLinkHoverPreview() [[link]] hover → passive local preview ├─ useImagePersistence() paste/drop → assets/ write └─ meowdown + our extensions (note-editor.tsx) ``` @@ -136,6 +137,8 @@ editor work must survive pane teardown. The pieces to understand are: | `wiki-links.ts` | `[[…]]` chips as view decorations over literal text | | `wiki-autocomplete.tsx` / `-entries.ts` | `[[` popover; pure row assembly | | `use-wiki-link-navigation.ts` | chip click → resolve → navigate or create | +| `use-wiki-link-hover-preview.tsx` | desktop chip hover → side-effect-free local preview renderer | +| `read-existing-note-source.ts` | live open-session content, else generation-pinned disk read | | `images.ts` / `use-image-persistence.ts` | image widgets; paste/drop → `assets/` | | `keymap.ts` | central shortcut registry (rejects duplicate bindings) | diff --git a/docs/porting/README.md b/docs/porting/README.md index 6e7efc749..927633406 100644 --- a/docs/porting/README.md +++ b/docs/porting/README.md @@ -41,7 +41,7 @@ designs simply don't survive the move: | Doc | v1 feature | v2 status | | ------------------------------------------------------------------- | ------------------------ | ------------------------------------------------ | | [Note aliases](./note-aliases.md) | `//` title aliases | **Ported** (frontmatter `aliases`) — mapping doc | -| [Backlink hover previews](./backlink-hover-previews.md) | Wiki-link preview | Planned — Meowdown hover UI + local loader | +| [Backlink hover previews](./backlink-hover-previews.md) | Wiki-link preview | **Ported** (passive local desktop card) | | [Audio memos](./audio-memos.md) | Voice notes + transcript | **Ported** (BYOK transcription) — mapping doc | | [AI menu and prompts](./ai-menu-and-prompts.md) | Selection AI + prompts | Planned — needs meowdown + app work | | [Note templates](./note-templates.md) | Per-graph templates | Planned — markdown files in the graph | diff --git a/docs/porting/backlink-hover-previews.md b/docs/porting/backlink-hover-previews.md index 5a3a71a06..576bf1b5a 100644 --- a/docs/porting/backlink-hover-previews.md +++ b/docs/porting/backlink-hover-previews.md @@ -1,9 +1,9 @@ # Porting backlink hover previews -**Status: planned.** Reflect v1 showed a compact preview when the pointer -rested on an inline backlink. V2 already has local note resolution, reads, and -read-only markdown rendering, but Meowdown does not yet expose the wiki-link -hover UI needed to join them. +**Status: ported.** Resting the pointer on a wiki link in a primary desktop +note pane now opens a compact, passive preview of the existing local target. +Meowdown owns the editor hover lifecycle and overlay; Reflect owns +side-effect-free resolution, generation-pinned reads, and local-only content. This is separate from both the `[[` autocomplete menu documented in [Reflect v1: Backlink Menu & Date Generator](../reflect-v1-backlink-menu.md) @@ -86,7 +86,7 @@ being edited. Preserve that value while adopting v2's boundaries: Do not add a mobile long-press as part of this work. A touch preview gesture would need its own interaction design. -## Recommended v2 shape +## Implemented v2 shape The split follows [Editor architecture](../contributing/editor-architecture.md), [Plan 05](../plans/05-markdown-editor.md), and the porting convention that @@ -196,16 +196,21 @@ changing the guarantees in [Privacy](../privacy.md). optimize later with a markdown-aware preview projection if needed, never by cutting raw markdown mid-token. -## UX decisions to make before implementation - -- **Timing:** v1 opened immediately; Meowdown's Markdown-link menu currently - uses a 400ms open delay and 300ms close grace. Choose deliberately and test - rapid pointer travel. -- **Interactivity:** the v1 book markup contained a real external anchor, but - leaving the source link initiated dismissal with no close grace. Decide - whether a later v2 card can be entered or scrolled; keep the first port - passive. -- **Viewport:** v1 clipped at `350 × 200px`. Preserve that compact baseline or - choose a tokenized max size and fade. -- **Unavailable state:** decide whether missing, ambiguous, empty, and failed - targets show nothing or a small explicit state. None may trigger creation. +## Implementation decisions + +- **Timing:** a target-aware 300ms dwell avoids flashes while the pointer + crosses prose. Moving to another wiki link restarts the dwell; leaving closes + immediately with no card-entry grace. +- **Interactivity:** the card is pointer-transparent and inert. Its links, + checkboxes, images, and embeds cannot navigate, mutate content, take focus, + or trigger remote loads. +- **Viewport:** the first port preserves the compact `350 × 200px` clipped + surface and uses an 8px collision margin. It has neither scrolling nor a + fade. +- **Unavailable state:** missing, ambiguous, locally unavailable, deleted, and + failed targets show no card. A successfully read note with an empty body + shows `Empty note`; daily notes keep their separately formatted date heading. +- **Resolution:** the shared existing-target resolver has a distinct + `unavailable` outcome in addition to resolved / ambiguous / missing. This + prevents hover or navigation from treating a placeholder or transient read + failure as permission to create a duplicate note. diff --git a/packages/core/src/actions/backlink-target.test.ts b/packages/core/src/actions/backlink-target.test.ts index 1f69f1107..7c2043f32 100644 --- a/packages/core/src/actions/backlink-target.test.ts +++ b/packages/core/src/actions/backlink-target.test.ts @@ -49,4 +49,17 @@ describe('ensureBacklinkTarget', () => { }) expect(readNoteMock).not.toHaveBeenCalled() }) + + it('refuses an unavailable target instead of treating it as ambiguity', async () => { + resolveOrCreateMock.mockResolvedValue({ + kind: 'unavailable', + paths: ['notes/links.md'], + }) + + await expect(ensureBacklinkTarget('Links', 3)).rejects.toMatchObject({ + kind: 'io', + message: expect.stringContaining('notes are unavailable'), + }) + expect(readNoteMock).not.toHaveBeenCalled() + }) }) diff --git a/packages/core/src/actions/backlink-target.ts b/packages/core/src/actions/backlink-target.ts index c947f3a6f..7365408cd 100644 --- a/packages/core/src/actions/backlink-target.ts +++ b/packages/core/src/actions/backlink-target.ts @@ -19,6 +19,12 @@ export async function ensureBacklinkTarget(title: string, generation: number): P `The [[${title}]] backlink matches multiple notes: ${outcome.paths.join(', ')}`, ) } + if (outcome.kind === 'unavailable') { + throw new ReflectError( + 'io', + `The [[${title}]] backlink cannot be resolved while these notes are unavailable: ${outcome.paths.join(', ')}`, + ) + } const source = await readNote(outcome.path, generation) const currentTitle = parseNote({ path: outcome.path, source }).title return wikiLinkSafe(currentTitle) === currentTitle ? currentTitle : title diff --git a/packages/core/src/exports/platform.ts b/packages/core/src/exports/platform.ts index a21956257..19fba6c4d 100644 --- a/packages/core/src/exports/platform.ts +++ b/packages/core/src/exports/platform.ts @@ -144,6 +144,10 @@ export { resolveOrCreateNoteWithTitle, type ResolveOrCreateNoteResult, } from '../graph/create-note' +export { + resolveExistingWikiTarget, + type ExistingWikiTargetResolution, +} from '../graph/resolve-existing-wiki-target' export { settingsSchema, editorMarkdownSyntaxSchema, diff --git a/packages/core/src/graph/create-note.test.ts b/packages/core/src/graph/create-note.test.ts index 281a45feb..b9198e42a 100644 --- a/packages/core/src/graph/create-note.test.ts +++ b/packages/core/src/graph/create-note.test.ts @@ -188,6 +188,22 @@ describe('resolveOrCreateNoteWithTitle', () => { ).toBe(false) }) + it('reuses an unindexed daily file instead of creating a regular date-titled note', async () => { + const invoke = bindBridge({ + files: { 'daily/2026-06-09.md': 'Daily contents\n' }, + }) + + await expect(resolveOrCreateNoteWithTitle('2026-06-09', 7)).resolves.toEqual({ + kind: 'resolved', + path: 'daily/2026-06-09.md', + }) + expect(invoke).toHaveBeenCalledWith('note_read', { + path: 'daily/2026-06-09.md', + generation: 7, + }) + expect(invoke.mock.calls.some(([command]) => command === 'note_create')).toBe(false) + }) + it('refuses multiple indexed notes claiming the same exact title', async () => { const invoke = bindBridge({ query: (sql, params) => @@ -342,7 +358,7 @@ describe('resolveOrCreateNoteWithTitle', () => { expect(invoke.mock.calls.some(([command]) => command === 'note_create')).toBe(false) }) - it('blocks creation when the fallback is ambiguous or unreadable', async () => { + it('reports an unavailable slug-family member instead of mislabeling it ambiguous', async () => { const invoke = bindBridge({ files: { 'notes/business-ideas.md': '# 🧠 Business ideas\n', @@ -352,12 +368,8 @@ describe('resolveOrCreateNoteWithTitle', () => { }) await expect(resolveOrCreateNoteWithTitle('Business ideas', 7)).resolves.toEqual({ - kind: 'ambiguous', - paths: [ - 'notes/business-ideas-2.md', - 'notes/business-ideas-3.md', - 'notes/business-ideas.md', - ], + kind: 'unavailable', + paths: ['notes/business-ideas-3.md'], }) expect(invoke.mock.calls.some(([command]) => command === 'note_create')).toBe(false) }) @@ -378,8 +390,8 @@ describe('resolveOrCreateNoteWithTitle', () => { }) await expect(resolveOrCreateNoteWithTitle('Business ideas', 7)).resolves.toEqual({ - kind: 'ambiguous', - paths: ['notes/business-ideas-2.md', 'notes/business-ideas.md'], + kind: 'unavailable', + paths: ['notes/business-ideas-2.md'], }) expect(invoke.mock.calls.some(([command]) => command === 'note_create')).toBe(false) }) diff --git a/packages/core/src/graph/create-note.ts b/packages/core/src/graph/create-note.ts index a8aa031f9..9107c68cd 100644 --- a/packages/core/src/graph/create-note.ts +++ b/packages/core/src/graph/create-note.ts @@ -1,12 +1,12 @@ import { ulid } from 'ulidx' -import { findExactWikiTargetMatches } from '../indexing/queries' -import { foldFallbackTitleKey, foldKey } from '../markdown/keys' -import { parseNote } from '../markdown/extract' import { upsertFrontmatter } from '../markdown/frontmatter' import { slugForTitle } from '../markdown/slug' -import { subjectAliases } from '../markdown/subject-aliases' -import { createNoteIfAbsent, listFiles, readNote } from './commands' -import { notePath, NOTES_DIR } from './paths' +import { createNoteIfAbsent } from './commands' +import { notePath } from './paths' +import { + resolveExistingWikiTarget, + type ExistingWikiTargetResolution, +} from './resolve-existing-wiki-target' /** * Note identity at creation (`docs/readable-filenames.md`): regular notes get @@ -89,8 +89,9 @@ export type ResolveOrCreateNoteResult = | { readonly kind: 'resolved'; readonly path: string } | { readonly kind: 'created'; readonly path: string } | { readonly kind: 'ambiguous'; readonly paths: readonly string[] } + | { readonly kind: 'unavailable'; readonly paths: readonly string[] } -type ExistingTitleResolution = Exclude +type ExistingTitleResolution = Exclude /** * Claim the first free path in `slug`'s collision family (`slug.md`, then @@ -130,192 +131,30 @@ async function claimNotePathForSlug( throw new Error(`no available note path for slug "${slug}" after ${MAX_CREATE_ATTEMPTS} attempts`) } -interface DiskTitleMatch { - exactTitlePaths: string[] - exactAliasPaths: string[] - fallbackTitlePaths: string[] - fallbackAliasPaths: string[] - unreadablePaths: string[] -} - -/** - * Does `path` belong to the collision family for `slug` (`slug.md`, - * `slug-2.md`, ...)? Limiting the fallback scan to this family keeps the - * second-chance lookup cheap and avoids turning link navigation into a fuzzy - * graph-wide title search. - */ -function isSlugFamilyPath(path: string, slug: string): boolean { - // Derived from the same contract `notePath` builds with, so a directory - // rename can't silently turn the disk guard into an always-empty scan. - const prefix = `${NOTES_DIR}/` - const suffix = '.md' - if (!path.startsWith(prefix) || !path.endsWith(suffix)) { - return false - } - const stem = path.slice(prefix.length, -suffix.length) - if (stem === slug) { - return true - } - if (!stem.startsWith(`${slug}-`)) { - return false - } - return /^\d+$/.test(stem.slice(slug.length + 1)) -} - -/** - * Inspect the title-derived filename family directly on disk. The index can - * briefly lag a sync checkout; disk is therefore the final authority before a - * missing-link click is allowed to mint a suffixed note. - */ -async function matchTitleOnDisk(title: string, generation: number): Promise { - const slug = slugForTitle(title) - const candidates = (await listFiles(generation)) - .filter((file) => isSlugFamilyPath(file.path, slug)) - .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) - const targetKey = foldKey(title) - const fallbackKey = foldFallbackTitleKey(title) - const exactTitlePaths: string[] = [] - const exactAliasPaths: string[] = [] - const fallbackTitlePaths: string[] = [] - const fallbackAliasPaths: string[] = [] - const unreadablePaths: string[] = [] - - for (const candidate of candidates) { - if (candidate.placeholder === true) { - unreadablePaths.push(candidate.path) - continue - } - let source: string - try { - source = await readNote(candidate.path, generation) - } catch { - // A disappearing or temporarily unreadable collision cannot be proven - // distinct. Blocking creation is safer than silently minting `-2`. - unreadablePaths.push(candidate.path) - continue - } - const parsed = parseNote({ path: candidate.path, source }) - const aliases = [...parsed.frontmatter.aliases, ...subjectAliases(parsed.title)] - if (foldKey(parsed.title) === targetKey) { - exactTitlePaths.push(candidate.path) - continue - } - if (aliases.some((alias) => foldKey(alias) === targetKey)) { - exactAliasPaths.push(candidate.path) - continue - } - if (fallbackKey !== '' && foldFallbackTitleKey(parsed.title) === fallbackKey) { - fallbackTitlePaths.push(candidate.path) - continue - } - if ( - fallbackKey !== '' && - aliases.some((alias) => foldFallbackTitleKey(alias) === fallbackKey) - ) { - fallbackAliasPaths.push(candidate.path) - } - } - - return { - exactTitlePaths, - exactAliasPaths, - fallbackTitlePaths, - fallbackAliasPaths, - unreadablePaths, - } -} - -function resolutionForPaths(paths: readonly string[]): ExistingTitleResolution | null { - if (paths.length === 1) { - return { kind: 'resolved', path: paths[0]! } - } - if (paths.length > 1) { - return { kind: 'ambiguous', paths: [...paths].sort() } - } - return null -} - -async function indexedTargetResolution( - title: string, -): Promise { - const match = await findExactWikiTargetMatches(title) - return resolutionForPaths(match.paths) -} - -function diskTitleResolution(disk: DiskTitleMatch): ExistingTitleResolution | null { - // An unreadable candidate could claim any higher-precedence spelling. Do - // not choose a readable sibling until the whole collision family is known. - if (disk.unreadablePaths.length > 0) { - return { - kind: 'ambiguous', - paths: [ - ...new Set([ - ...disk.exactTitlePaths, - ...disk.exactAliasPaths, - ...disk.fallbackTitlePaths, - ...disk.fallbackAliasPaths, - ...disk.unreadablePaths, - ]), - ].sort(), - } - } - - // Mirror indexed wiki resolution: an exact title outranks an exact alias. - // Only after both exact tiers miss do the conservative fallback tiers run, - // again preferring a title to an alias. - for (const paths of [ - disk.exactTitlePaths, - disk.exactAliasPaths, - disk.fallbackTitlePaths, - disk.fallbackAliasPaths, - ]) { - const resolution = resolutionForPaths(paths) - if (resolution !== null) { - return resolution - } - } - return null -} - /** * Resolve a wiki-link title while guarding its title-derived creation path * against a stale per-device index. * - * A unique exact index match wins; multiple indexed claims are ambiguous. On - * a miss, the title's on-disk slug family is parsed with the same precedence - * (title before alias), then the conservative leading-emoji fallback. A tier is - * accepted only when exactly one file claims it; multiple or unreadable - * candidates are ambiguous and no file is written. The index is queried once - * more immediately before creation. The native path claim is atomic and - * no-clobber; if it loses to a concurrent sync checkout or creator, the winner - * is resolved before trying a suffix. + * Delegates the read-only decision to {@link resolveExistingWikiTarget}, so + * date/title/alias precedence, ambiguity, unavailable files, the bounded disk + * fallback, and the final index race check have one implementation. Only a + * genuine `missing` result reaches the atomic no-clobber claim. If that claim + * loses to a concurrent sync checkout or creator, the winner is resolved + * before any suffix is tried. */ export async function resolveOrCreateNoteWithTitle( title: string, generation: number, ): Promise { - const indexed = await indexedTargetResolution(title) - if (indexed !== null) { - return indexed - } - - const diskResolution = diskTitleResolution(await matchTitleOnDisk(title, generation)) - if (diskResolution !== null) { - return diskResolution - } - - const reResolved = await indexedTargetResolution(title) - if (reResolved !== null) { - return reResolved + const existing = await resolveExistingWikiTarget(title, generation) + if (existing.kind !== 'missing') { + return existing } // On a lost claim, re-resolve both projections before considering a // suffix: the winner may be the note this link meant. return claimNotePathForSlug(slugForTitle(title), newNoteSource(title), generation, async () => { - const collisionIndex = await indexedTargetResolution(title) - if (collisionIndex !== null) { - return collisionIndex - } - return diskTitleResolution(await matchTitleOnDisk(title, generation)) + const collisionResolution = await resolveExistingWikiTarget(title, generation) + return collisionResolution.kind === 'missing' ? null : collisionResolution }) } diff --git a/packages/core/src/graph/resolve-existing-wiki-target.test.ts b/packages/core/src/graph/resolve-existing-wiki-target.test.ts new file mode 100644 index 000000000..e0baa9fa7 --- /dev/null +++ b/packages/core/src/graph/resolve-existing-wiki-target.test.ts @@ -0,0 +1,335 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setBridge } from '../ipc/bridge' +import { resolveExistingWikiTarget } from './resolve-existing-wiki-target' + +interface BridgeBehavior { + readonly files?: Record + readonly placeholders?: readonly string[] + readonly readErrors?: readonly string[] + readonly query?: (sql: string, params: readonly unknown[]) => Array> + readonly read?: (path: string) => Promise +} + +function bindBridge({ + files = {}, + placeholders = [], + readErrors = [], + query, + read, +}: BridgeBehavior = {}): ReturnType { + const invoke = vi.fn(async (command: string, args?: Record) => { + if (command === 'db_query') { + return query?.( + String(args?.['sql'] ?? ''), + ((args?.['params'] as unknown[]) ?? []), + ) ?? [] + } + if (command === 'list_files') { + return [ + ...Object.entries(files).map(([path, source]) => ({ + path, + size: source.length, + modifiedMs: 1, + })), + ...placeholders.map((path) => ({ + path, + size: 0, + modifiedMs: 1, + placeholder: true, + })), + ...readErrors.map((path) => ({ path, size: 1, modifiedMs: 1 })), + ] + } + if (command === 'note_read') { + const path = String(args?.['path']) + if (read !== undefined) { + return await read(path) + } + if (readErrors.includes(path)) { + throw { kind: 'io', message: `${path} is unavailable` } + } + const source = files[path] + if (source === undefined) { + throw { kind: 'notFound', message: `${path} not found` } + } + return source + } + return null + }) + setBridge({ invoke, listen: async () => () => {} }) + return invoke +} + +function expectNoWrites(invoke: ReturnType): void { + expect( + invoke.mock.calls.some(([command]) => + ['note_create', 'note_write', 'note_delete', 'index_apply_batch'].includes(String(command)), + ), + ).toBe(false) +} + +afterEach(() => { + setBridge(null) +}) + +describe('resolveExistingWikiTarget', () => { + it('returns missing for a blank target without touching the graph', async () => { + const invoke = bindBridge() + + await expect(resolveExistingWikiTarget(' ', 7)).resolves.toEqual({ kind: 'missing' }) + expect(invoke).not.toHaveBeenCalled() + }) + + it('preserves ambiguity in the winning indexed tier', async () => { + const invoke = bindBridge({ + query: (sql) => + sql.includes('from "aliases"') + ? [ + { note_path: 'notes/second.md' }, + { note_path: 'notes/first.md' }, + ] + : [], + }) + + await expect(resolveExistingWikiTarget('Project', 7)).resolves.toEqual({ + kind: 'ambiguous', + paths: ['notes/first.md', 'notes/second.md'], + }) + expectNoWrites(invoke) + }) + + it('resolves one indexed title without probing disk', async () => { + const invoke = bindBridge({ + query: (sql) => + sql.includes('"title_key" = ?') ? [{ path: 'notes/project.md' }] : [], + }) + + await expect(resolveExistingWikiTarget('Project', 7)).resolves.toEqual({ + kind: 'resolved', + path: 'notes/project.md', + }) + expect(invoke.mock.calls.some(([command]) => command === 'note_read')).toBe(false) + expect(invoke.mock.calls.some(([command]) => command === 'list_files')).toBe(false) + expectNoWrites(invoke) + }) + + it('resolves one indexed alias after the title tier misses', async () => { + const invoke = bindBridge({ + query: (sql) => + sql.includes('from "aliases"') ? [{ note_path: 'notes/project.md' }] : [], + }) + + await expect(resolveExistingWikiTarget('Initiative', 7)).resolves.toEqual({ + kind: 'resolved', + path: 'notes/project.md', + }) + expect(invoke.mock.calls.some(([command]) => command === 'note_read')).toBe(false) + expect(invoke.mock.calls.some(([command]) => command === 'list_files')).toBe(false) + expectNoWrites(invoke) + }) + + it('accepts an indexed daily before probing disk or lower index tiers', async () => { + const invoke = bindBridge({ + query: (sql) => + sql.includes('"daily_date" = ?') ? [{ path: 'daily/2026-06-09.md' }] : [], + }) + + await expect(resolveExistingWikiTarget('2026-06-09', 7)).resolves.toEqual({ + kind: 'resolved', + path: 'daily/2026-06-09.md', + }) + expect(invoke.mock.calls.some(([command]) => command === 'note_read')).toBe(false) + expect(invoke.mock.calls.some(([command]) => command === 'list_files')).toBe(false) + expectNoWrites(invoke) + }) + + it('lets an index-lagging daily file outrank an indexed regular date title', async () => { + const invoke = bindBridge({ + files: { 'daily/2026-06-09.md': 'Daily contents\n' }, + query: (sql) => + sql.includes('"title_key" = ?') ? [{ path: 'notes/date-title.md' }] : [], + }) + + await expect(resolveExistingWikiTarget('2026-06-09', 17)).resolves.toEqual({ + kind: 'resolved', + path: 'daily/2026-06-09.md', + }) + expect(invoke).toHaveBeenCalledWith('note_read', { + path: 'daily/2026-06-09.md', + generation: 17, + }) + expectNoWrites(invoke) + }) + + it('accepts an indexed regular date title only after the daily path is missing', async () => { + const invoke = bindBridge({ + query: (sql) => + sql.includes('"title_key" = ?') ? [{ path: 'notes/date-title.md' }] : [], + }) + + await expect(resolveExistingWikiTarget('2026-06-09', 7)).resolves.toEqual({ + kind: 'resolved', + path: 'notes/date-title.md', + }) + expect(invoke).toHaveBeenCalledWith('note_read', { + path: 'daily/2026-06-09.md', + generation: 7, + }) + expectNoWrites(invoke) + }) + + it('reports an unreadable daily file as unavailable instead of accepting a lower tier', async () => { + const invoke = bindBridge({ + query: (sql) => + sql.includes('"title_key" = ?') ? [{ path: 'notes/date-title.md' }] : [], + read: async () => { + throw { kind: 'io', message: 'evicted' } + }, + }) + + await expect(resolveExistingWikiTarget('2026-06-09', 7)).resolves.toEqual({ + kind: 'unavailable', + paths: ['daily/2026-06-09.md'], + }) + expectNoWrites(invoke) + }) + + it('reports an evicted daily placeholder as unavailable instead of missing', async () => { + const invoke = bindBridge({ + placeholders: ['daily/2026-06-09.md'], + query: (sql) => + sql.includes('"title_key" = ?') ? [{ path: 'notes/date-title.md' }] : [], + }) + + await expect(resolveExistingWikiTarget('2026-06-09', 7)).resolves.toEqual({ + kind: 'unavailable', + paths: ['daily/2026-06-09.md'], + }) + expect(invoke).toHaveBeenCalledWith('list_files', { generation: 7 }) + expectNoWrites(invoke) + }) + + it('resolves an index-lagging note from the bounded slug-family scan', async () => { + const invoke = bindBridge({ + files: { + 'notes/business-ideas.md': '# Business ideas\n', + 'notes/unrelated.md': '# Unrelated\n', + }, + }) + + await expect(resolveExistingWikiTarget('Business ideas', 23)).resolves.toEqual({ + kind: 'resolved', + path: 'notes/business-ideas.md', + }) + expect(invoke).toHaveBeenCalledWith('list_files', { generation: 23 }) + expect(invoke).toHaveBeenCalledWith('note_read', { + path: 'notes/business-ideas.md', + generation: 23, + }) + expect(invoke).not.toHaveBeenCalledWith('note_read', { + path: 'notes/unrelated.md', + generation: 23, + }) + expectNoWrites(invoke) + }) + + it.each([ + { + label: 'iCloud placeholder', + behavior: { placeholders: ['notes/business-ideas.md'] }, + }, + { + label: 'read failure', + behavior: { readErrors: ['notes/business-ideas.md'] }, + }, + ])('reports a slug-family $label as unavailable', async ({ behavior }) => { + const invoke = bindBridge(behavior) + + await expect(resolveExistingWikiTarget('Business ideas', 7)).resolves.toEqual({ + kind: 'unavailable', + paths: ['notes/business-ideas.md'], + }) + expectNoWrites(invoke) + }) + + it('keeps a listed-then-deleted slug-family candidate unavailable', async () => { + const invoke = bindBridge({ + readErrors: ['notes/business-ideas.md'], + read: async () => { + throw { kind: 'notFound', message: 'vanished after listing' } + }, + }) + + await expect(resolveExistingWikiTarget('Business ideas', 7)).resolves.toEqual({ + kind: 'unavailable', + paths: ['notes/business-ideas.md'], + }) + expectNoWrites(invoke) + }) + + it('does not globally scan for an unindexed alias outside the target slug family', async () => { + const invoke = bindBridge({ + files: { + 'notes/incubator.md': '---\naliases: [Business ideas]\n---\n# Incubator\n', + }, + }) + + await expect(resolveExistingWikiTarget('Business ideas', 7)).resolves.toEqual({ + kind: 'missing', + }) + expect(invoke.mock.calls.some(([command]) => command === 'note_read')).toBe(false) + expectNoWrites(invoke) + }) + + it('rechecks the index after a disk miss to close the indexing race', async () => { + let titleLookups = 0 + const invoke = bindBridge({ + query: (sql) => { + if (!sql.includes('"title_key" = ?')) { + return [] + } + titleLookups += 1 + return titleLookups === 2 ? [{ path: 'notes/newly-indexed.md' }] : [] + }, + }) + + await expect(resolveExistingWikiTarget('Newly indexed', 7)).resolves.toEqual({ + kind: 'resolved', + path: 'notes/newly-indexed.md', + }) + expect(titleLookups).toBe(2) + expectNoWrites(invoke) + }) + + it('repeats the daily-path probe after a disk miss', async () => { + let dailyReads = 0 + const invoke = bindBridge({ + read: async (path) => { + if (path !== 'daily/2026-06-09.md') { + throw { kind: 'notFound', message: 'missing' } + } + dailyReads += 1 + if (dailyReads === 1) { + throw { kind: 'notFound', message: 'not synced yet' } + } + return 'Arrived during resolution\n' + }, + }) + + await expect(resolveExistingWikiTarget('2026-06-09', 7)).resolves.toEqual({ + kind: 'resolved', + path: 'daily/2026-06-09.md', + }) + expect(dailyReads).toBe(2) + expectNoWrites(invoke) + }) + + it('returns missing after both index checks and the disk fallback miss without writing', async () => { + const invoke = bindBridge() + + await expect(resolveExistingWikiTarget('Absent', 7)).resolves.toEqual({ kind: 'missing' }) + expect(invoke.mock.calls.filter(([command]) => command === 'db_query').length).toBeGreaterThan(1) + expect(invoke).toHaveBeenCalledWith('list_files', { generation: 7 }) + expectNoWrites(invoke) + }) +}) diff --git a/packages/core/src/graph/resolve-existing-wiki-target.ts b/packages/core/src/graph/resolve-existing-wiki-target.ts new file mode 100644 index 000000000..6b67d5eb3 --- /dev/null +++ b/packages/core/src/graph/resolve-existing-wiki-target.ts @@ -0,0 +1,240 @@ +import { isAppError } from '../errors' +import { + findExactWikiTargetMatches, + type ExactWikiTargetMatch, +} from '../indexing/queries' +import { foldFallbackTitleKey, foldKey } from '../markdown/keys' +import { parseNote } from '../markdown/extract' +import { normalizeWikiTarget } from '../markdown/resolve' +import { slugForTitle } from '../markdown/slug' +import { subjectAliases } from '../markdown/subject-aliases' +import { listFiles, readNote } from './commands' +import { dailyPath, NOTES_DIR } from './paths' + +/** The side-effect-free outcome of resolving one existing wiki-link target. */ +export type ExistingWikiTargetResolution = + | { readonly kind: 'resolved'; readonly path: string } + | { readonly kind: 'ambiguous'; readonly paths: readonly string[] } + | { readonly kind: 'unavailable'; readonly paths: readonly string[] } + | { readonly kind: 'missing' } + +type ExistingMatchResolution = Exclude + +interface DiskTitleMatch { + readonly exactTitlePaths: readonly string[] + readonly exactAliasPaths: readonly string[] + readonly fallbackTitlePaths: readonly string[] + readonly fallbackAliasPaths: readonly string[] + readonly unavailablePaths: readonly string[] +} + +type ListNoteFiles = () => ReturnType + +function resolutionForPaths(paths: readonly string[]): ExistingMatchResolution | null { + if (paths.length === 1) { + return { kind: 'resolved', path: paths[0]! } + } + if (paths.length > 1) { + return { kind: 'ambiguous', paths: [...paths].sort() } + } + return null +} + +/** Does `path` belong to `slug.md`, `slug-2.md`, ... under `notes/`? */ +function isSlugFamilyPath(path: string, slug: string): boolean { + const prefix = `${NOTES_DIR}/` + const suffix = '.md' + if (!path.startsWith(prefix) || !path.endsWith(suffix)) { + return false + } + const stem = path.slice(prefix.length, -suffix.length) + if (stem === slug) { + return true + } + if (!stem.startsWith(`${slug}-`)) { + return false + } + return /^\d+$/.test(stem.slice(slug.length + 1)) +} + +/** + * Inspect only the target's title-derived filename family. This deliberately + * is not a graph-wide alias scan: link resolution must remain bounded even + * when the index is rebuilding. + */ +async function matchTitleOnDisk( + title: string, + generation: number, + listNoteFiles: ListNoteFiles, +): Promise { + const slug = slugForTitle(title) + const candidates = (await listNoteFiles()) + .filter((file) => isSlugFamilyPath(file.path, slug)) + .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) + const targetKey = foldKey(title) + const fallbackKey = foldFallbackTitleKey(title) + const exactTitlePaths: string[] = [] + const exactAliasPaths: string[] = [] + const fallbackTitlePaths: string[] = [] + const fallbackAliasPaths: string[] = [] + const unavailablePaths: string[] = [] + + for (const candidate of candidates) { + if (candidate.placeholder === true) { + unavailablePaths.push(candidate.path) + continue + } + let source: string + try { + source = await readNote(candidate.path, generation) + } catch { + // A candidate that vanishes after listing is not proven absent: sync + // may restore it before an atomic claim. Preserve the creation guard. + unavailablePaths.push(candidate.path) + continue + } + const parsed = parseNote({ path: candidate.path, source }) + const aliases = [...parsed.frontmatter.aliases, ...subjectAliases(parsed.title)] + if (foldKey(parsed.title) === targetKey) { + exactTitlePaths.push(candidate.path) + continue + } + if (aliases.some((alias) => foldKey(alias) === targetKey)) { + exactAliasPaths.push(candidate.path) + continue + } + if (fallbackKey !== '' && foldFallbackTitleKey(parsed.title) === fallbackKey) { + fallbackTitlePaths.push(candidate.path) + continue + } + if ( + fallbackKey !== '' && + aliases.some((alias) => foldFallbackTitleKey(alias) === fallbackKey) + ) { + fallbackAliasPaths.push(candidate.path) + } + } + + return { + exactTitlePaths, + exactAliasPaths, + fallbackTitlePaths, + fallbackAliasPaths, + unavailablePaths, + } +} + +function diskTitleResolution(disk: DiskTitleMatch): ExistingMatchResolution | null { + // An unavailable family member might claim any precedence tier, so no + // readable sibling is safe to choose until every candidate can be read. + if (disk.unavailablePaths.length > 0) { + return { kind: 'unavailable', paths: [...disk.unavailablePaths].sort() } + } + + for (const paths of [ + disk.exactTitlePaths, + disk.exactAliasPaths, + disk.fallbackTitlePaths, + disk.fallbackAliasPaths, + ]) { + const resolution = resolutionForPaths(paths) + if (resolution !== null) { + return resolution + } + } + return null +} + +async function dailyFileResolution( + date: string, + generation: number, + listNoteFiles: ListNoteFiles, +): Promise { + const path = dailyPath(date) + try { + await readNote(path, generation) + return { kind: 'resolved', path } + } catch (cause) { + if (isAppError(cause) && cause.kind === 'notFound') { + // An iCloud-evicted daily exists only as `.date.md.icloud`, so reading + // its logical path reports notFound. The generation-pinned listing maps + // that stub back to `path` with `placeholder: true`; it is unavailable, + // not permission to enter the lazy-create path. + const listed = (await listNoteFiles()).some((file) => file.path === path) + return listed ? { kind: 'unavailable', paths: [path] } : null + } + return { kind: 'unavailable', paths: [path] } + } +} + +/** + * Apply index precedence while letting an index-lagging daily file outrank an + * indexed regular title or alias with the same ISO date spelling. + */ +async function indexedResolution( + match: ExactWikiTargetMatch, + date: string | undefined, + generation: number, + listNoteFiles: ListNoteFiles, +): Promise { + if (match.kind === 'date') { + return resolutionForPaths(match.paths) + } + if (date !== undefined) { + const daily = await dailyFileResolution(date, generation, listNoteFiles) + if (daily !== null) { + return daily + } + } + return match.kind === 'missing' ? null : resolutionForPaths(match.paths) +} + +/** + * Resolve an existing wiki target without creating or modifying anything. + * + * The index supplies date/title/alias precedence and preserves ambiguity. A + * generation-pinned disk probe fills two intentional index-lag gaps: a daily + * file is checked before a lower indexed tier, and an index miss scans only + * the regular note's slug family. A final index lookup closes the common race + * where indexing completes during the disk scan. An alias stored outside its + * title's slug family therefore remains missing until the index sees it. + */ +export async function resolveExistingWikiTarget( + target: string, + generation: number, +): Promise { + const normalized = normalizeWikiTarget(target) + if (normalized.key === '') { + return { kind: 'missing' } + } + let listedFiles: ReturnType | null = null + function listNoteFiles(): ReturnType { + listedFiles ??= listFiles(generation) + return listedFiles + } + + const indexed = await indexedResolution( + await findExactWikiTargetMatches(normalized.raw), + normalized.date, + generation, + listNoteFiles, + ) + if (indexed !== null) { + return indexed + } + + const disk = diskTitleResolution( + await matchTitleOnDisk(normalized.raw, generation, listNoteFiles), + ) + if (disk !== null) { + return disk + } + + const reResolved = await indexedResolution( + await findExactWikiTargetMatches(normalized.raw), + normalized.date, + generation, + listNoteFiles, + ) + return reResolved ?? { kind: 'missing' } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e001ef61f..62025f5c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,10 @@ settings: dedupePeers: true excludeLinksFromLockfile: false +overrides: + '@meowdown/core': https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053 + '@meowdown/react': https://pkg.pr.new/prosekit/meowdown/@meowdown/react@834b053 + importers: .: @@ -34,11 +38,11 @@ importers: specifier: ^3.2.2 version: 3.2.2(react@19.2.7) '@meowdown/core': - specifier: ^0.44.1 - version: 0.44.1(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + specifier: https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053 + version: https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) '@meowdown/react': - specifier: ^0.44.1 - version: 0.44.1(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(@types/react@19.2.17)(date-fns@4.4.0)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(react-dom@19.2.7)(react@19.2.7) + specifier: https://pkg.pr.new/prosekit/meowdown/@meowdown/react@834b053 + version: https://pkg.pr.new/prosekit/meowdown/@meowdown/react@834b053(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(@types/react@19.2.17)(date-fns@4.4.0)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1)(react-dom@19.2.7)(react@19.2.7) '@reflect/core': specifier: workspace:* version: link:../../packages/core @@ -653,8 +657,8 @@ packages: '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} - '@codemirror/view@6.43.5': - resolution: {integrity: sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==} + '@codemirror/view@6.43.6': + resolution: {integrity: sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==} '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} @@ -1055,11 +1059,13 @@ packages: '@marijn/find-cluster-break@1.0.3': resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} - '@meowdown/core@0.44.1': - resolution: {integrity: sha512-78kFwyhYRpDLdpk/Asd28+gcp3yVm5sJNH211hR4a2fD9GffPjs0wT4oKbDezoXxwbwDRf9ZNC/flAHumY8jHQ==} + '@meowdown/core@https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053': + resolution: {integrity: sha512-GQEAQTsWBEL0jdtyGVWTydQVa1gFz6TwFHMvWgDs7nlHfFe55uX09gWgyFUEK4miuNFu+aXeRaC2svHHtanoyQ==, tarball: https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053} + version: 0.44.1 - '@meowdown/react@0.44.1': - resolution: {integrity: sha512-8wC+8PAsaK1t5hP9bkHoci+xXF61MvprAG0ZoHKxRtwu/PWzEobmT+Z9mml7m780ok7zTy9We3hFeF7dyjUywg==} + '@meowdown/react@https://pkg.pr.new/prosekit/meowdown/@meowdown/react@834b053': + resolution: {integrity: sha512-HV45fyHCX1ZJM7Drl/lKkEWKQW06EK9CByJQ37DHahU8FjrEcvAeLexA1xi9djAWpP3JQ6v7V0QU87LHxKv7SA==, tarball: https://pkg.pr.new/prosekit/meowdown/@meowdown/react@834b053} + version: 0.44.1 peerDependencies: react: ^19.0.0 react-dom: ^19.0.0 @@ -2690,8 +2696,8 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2784,8 +2790,8 @@ packages: resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.2': - resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} @@ -5126,8 +5132,8 @@ packages: prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.42.0: - resolution: {integrity: sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==} + prosemirror-view@1.42.1: + resolution: {integrity: sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==} proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -6575,7 +6581,7 @@ snapshots: dependencies: '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@codemirror/lang-angular@0.1.4': @@ -6615,7 +6621,7 @@ snapshots: '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@lezer/css': 1.3.4 '@lezer/html': 1.3.13 @@ -6631,7 +6637,7 @@ snapshots: '@codemirror/language': 6.12.4 '@codemirror/lint': 6.9.7 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@lezer/javascript': 1.5.4 @@ -6641,7 +6647,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -6665,7 +6671,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -6676,7 +6682,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@lezer/markdown': 1.6.4 @@ -6739,7 +6745,7 @@ snapshots: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@lezer/xml': 1.0.6 @@ -6782,7 +6788,7 @@ snapshots: '@codemirror/language@6.12.4': dependencies: '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -6795,14 +6801,14 @@ snapshots: '@codemirror/lint@6.9.7': dependencies: '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.5 + '@codemirror/view': 6.43.6 crelt: 1.0.7 '@codemirror/state@6.7.1': dependencies: '@marijn/find-cluster-break': 1.0.3 - '@codemirror/view@6.43.5': + '@codemirror/view@6.43.6': dependencies: '@codemirror/state': 6.7.1 crelt: 1.0.7 @@ -7173,7 +7179,7 @@ snapshots: '@marijn/find-cluster-break@1.0.3': {} - '@meowdown/core@0.44.1(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)': + '@meowdown/core@https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1)': dependencies: '@codemirror/language': 6.12.4 '@codemirror/language-data': 6.5.2 @@ -7183,12 +7189,12 @@ snapshots: '@lezer/markdown': 1.6.4 '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.5(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) - '@prosekit/extensions': 0.18.0-beta.15(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + '@prosekit/extensions': 0.18.0-beta.15(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) '@prosekit/pm': 0.1.19-beta.2 - '@prosekit/web': 0.9.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + '@prosekit/web': 0.9.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) katex: 0.17.0 prosemirror-flat-list: 0.7.1 - prosemirror-highlight: 0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + prosemirror-highlight: 0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) rehype-parse: 9.0.1 rehype-remark: 10.0.1 remark-gfm: 4.0.1 @@ -7211,14 +7217,14 @@ snapshots: - y-prosemirror - yjs - '@meowdown/react@0.44.1(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(@types/react@19.2.17)(date-fns@4.4.0)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(react-dom@19.2.7)(react@19.2.7)': + '@meowdown/react@https://pkg.pr.new/prosekit/meowdown/@meowdown/react@834b053(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(@types/react@19.2.17)(date-fns@4.4.0)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1)(react-dom@19.2.7)(react@19.2.7)': dependencies: '@base-ui/react': 1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7)(react@19.2.7) - '@meowdown/core': 0.44.1(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + '@meowdown/core': https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.5(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) '@prosekit/pm': 0.1.19-beta.2 - '@prosekit/react': 0.8.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(react-dom@19.2.7)(react@19.2.7) + '@prosekit/react': 0.8.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1)(react-dom@19.2.7)(react@19.2.7) clsx: 2.1.1 lucide-react: 1.23.0(react@19.2.7) react-property: 2.0.2 @@ -7386,7 +7392,7 @@ snapshots: - prosemirror-state - prosemirror-transform - '@prosekit/extensions@0.18.0-beta.15(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)': + '@prosekit/extensions@0.18.0-beta.15(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1)': dependencies: '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.5(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) @@ -7397,7 +7403,7 @@ snapshots: prosemirror-enter-rules: 0.1.6 prosemirror-flat-list: 0.7.1 prosemirror-gapcursor: 1.4.1 - prosemirror-highlight: 0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + prosemirror-highlight: 0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) prosemirror-math: 0.2.2 prosemirror-search: 1.1.1 prosemirror-tables: 1.8.5 @@ -7426,13 +7432,13 @@ snapshots: prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 - '@prosekit/react@0.8.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(react-dom@19.2.7)(react@19.2.7)': + '@prosekit/react@0.8.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1)(react-dom@19.2.7)(react@19.2.7)': dependencies: '@prosekit/core': 0.13.0-beta.5(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) '@prosekit/pm': 0.1.19-beta.2 - '@prosekit/web': 0.9.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + '@prosekit/web': 0.9.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) '@prosemirror-adapter/core': 0.5.3 '@prosemirror-adapter/react': 0.5.3(react-dom@19.2.7)(react@19.2.7) optionalDependencies: @@ -7456,7 +7462,7 @@ snapshots: - y-prosemirror - yjs - '@prosekit/web@0.9.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)': + '@prosekit/web@0.9.0-beta.20(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1)': dependencies: '@aria-ui/core': 0.2.1 '@aria-ui/elements': 0.1.12 @@ -7464,7 +7470,7 @@ snapshots: '@floating-ui/dom': 1.7.6 '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.5(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) - '@prosekit/extensions': 0.18.0-beta.15(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0) + '@prosekit/extensions': 0.18.0-beta.15(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1) '@prosekit/pm': 0.1.19-beta.2 prosemirror-tables: 1.8.5 transitivePeerDependencies: @@ -7490,14 +7496,14 @@ snapshots: '@ocavue/utils': 1.7.0 prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 '@prosemirror-adapter/react@0.5.3(react-dom@19.2.7)(react@19.2.7)': dependencies: '@prosemirror-adapter/core': 0.5.3 prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 optionalDependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8500,7 +8506,7 @@ snapshots: '@shikijs/primitive': 4.3.1 '@shikijs/types': 4.3.1 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/engine-javascript@4.3.1': @@ -8522,7 +8528,7 @@ snapshots: dependencies: '@shikijs/types': 4.3.1 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/themes@4.3.1': dependencies: @@ -8531,7 +8537,7 @@ snapshots: '@shikijs/types@4.3.1': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} @@ -8781,7 +8787,7 @@ snapshots: '@types/har-format@1.2.16': {} - '@types/hast@3.0.4': + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -8904,7 +8910,7 @@ snapshots: '@typescript-eslint/types': 8.62.0 eslint-visitor-keys: 5.0.1 - '@ungap/structured-clone@1.3.2': {} + '@ungap/structured-clone@1.3.3': {} '@vercel/oidc@3.2.0': {} @@ -10034,12 +10040,12 @@ snapshots: hast-util-embedded@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element: 3.0.0 hast-util-from-html@2.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 devlop: 1.1.0 hast-util-from-parse5: 8.0.3 parse5: 7.3.0 @@ -10048,7 +10054,7 @@ snapshots: hast-util-from-parse5@8.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 @@ -10059,19 +10065,19 @@ snapshots: hast-util-has-property@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-body-ok-link@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-minify-whitespace@1.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-is-element: 3.0.0 hast-util-whitespace: 3.0.0 @@ -10079,11 +10085,11 @@ snapshots: hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-phrasing@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-has-property: 3.0.0 hast-util-is-body-ok-link: 3.0.1 @@ -10091,7 +10097,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -10105,9 +10111,9 @@ snapshots: hast-util-to-mdast@10.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.2 + '@ungap/structured-clone': 1.3.3 hast-util-phrasing: 3.0.1 hast-util-to-html: 9.0.5 hast-util-to-text: 4.0.2 @@ -10122,18 +10128,18 @@ snapshots: hast-util-to-text@4.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 property-information: 7.2.0 @@ -10699,9 +10705,9 @@ snapshots: mdast-util-to-hast@13.2.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.2 + '@ungap/structured-clone': 1.3.3 devlop: 1.1.0 micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 @@ -11326,20 +11332,20 @@ snapshots: '@ocavue/utils': 1.7.0 prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-dropcursor@1.8.3: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-enter-rules@0.1.6: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-flat-list@0.7.1: dependencies: @@ -11350,31 +11356,31 @@ snapshots: prosemirror-splittable: 1.1.0(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-gapcursor@1.4.1: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 - prosemirror-highlight@0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0): + prosemirror-highlight@0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.5)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.1): optionalDependencies: '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@shikijs/types': 4.3.1 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: @@ -11394,7 +11400,7 @@ snapshots: prosemirror-inputrules: 1.5.1 prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-model@1.25.10: dependencies: @@ -11403,13 +11409,13 @@ snapshots: prosemirror-safari-ime-span@1.0.2: dependencies: prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-search@1.1.1: dependencies: prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-splittable@1.1.0(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0): optionalDependencies: @@ -11421,7 +11427,7 @@ snapshots: dependencies: prosemirror-model: 1.25.10 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-tables@1.8.5: dependencies: @@ -11429,13 +11435,13 @@ snapshots: prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.42.0 + prosemirror-view: 1.42.1 prosemirror-transform@1.12.0: dependencies: prosemirror-model: 1.25.10 - prosemirror-view@1.42.0: + prosemirror-view@1.42.1: dependencies: prosemirror-model: 1.25.10 prosemirror-state: 1.4.4 @@ -11662,18 +11668,18 @@ snapshots: rehype-minify-whitespace@6.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-minify-whitespace: 1.0.1 rehype-parse@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-from-html: 2.0.3 unified: 11.0.5 rehype-remark@10.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 hast-util-to-mdast: 10.1.2 unified: 11.0.5 @@ -11912,7 +11918,7 @@ snapshots: '@shikijs/themes': 4.3.1 '@shikijs/types': 4.3.1 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 side-channel-list@1.0.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 669063a49..166b13a50 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,7 +20,7 @@ minimumReleaseAgeExclude: - prosemirror-flat-list - prosemirror-view -# overrides: -# '@meowdown/core': https://pkg.pr.new/prosekit/meowdown/@meowdown/core@623bc43 -# '@meowdown/react': https://pkg.pr.new/prosekit/meowdown/@meowdown/react@623bc43 -# blockExoticSubdeps: false +overrides: + '@meowdown/core': https://pkg.pr.new/prosekit/meowdown/@meowdown/core@834b053 + '@meowdown/react': https://pkg.pr.new/prosekit/meowdown/@meowdown/react@834b053 +blockExoticSubdeps: false From 53932c63031c1069ae31fc3073db19e1974f1c19 Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Sat, 11 Jul 2026 23:58:58 +0100 Subject: [PATCH 2/3] fix: bind watcher readiness to subscriptions --- .../desktop/src/lib/use-file-changes.test.tsx | 23 +++++++++++++++++++ apps/desktop/src/lib/use-file-changes.ts | 19 +++++++++------ .../resolve-existing-wiki-target.test.ts | 3 ++- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/lib/use-file-changes.test.tsx b/apps/desktop/src/lib/use-file-changes.test.tsx index 3429e94c2..61a4aa869 100644 --- a/apps/desktop/src/lib/use-file-changes.test.tsx +++ b/apps/desktop/src/lib/use-file-changes.test.tsx @@ -102,6 +102,29 @@ describe('useFileChanges', () => { view.unmount() }) + it('waits for a new subscription when the same handler is re-enabled', async () => { + const first = stubSubscription() + const handler = vi.fn() + const view = render() + await first.resolve() + expect(view.getByTestId('ready').textContent).toBe('true') + + view.rerender() + expect(view.getByTestId('ready').textContent).toBe('true') + expect(first.unlisten).toHaveBeenCalledOnce() + + const second = stubSubscription() + view.rerender() + expect(view.getByTestId('ready').textContent).toBe('false') + await second.resolve() + expect(view.getByTestId('ready').textContent).toBe('true') + expect(subscribeFileChanges).toHaveBeenCalledTimes(2) + + second.emit(UPSERT) + expect(handler).toHaveBeenCalledWith(UPSERT) + view.unmount() + }) + it('does nothing when disabled or without a bridge', () => { const view = render() expect(subscribeFileChanges).not.toHaveBeenCalled() diff --git a/apps/desktop/src/lib/use-file-changes.ts b/apps/desktop/src/lib/use-file-changes.ts index 421a8f888..6a6bb35c8 100644 --- a/apps/desktop/src/lib/use-file-changes.ts +++ b/apps/desktop/src/lib/use-file-changes.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { hasBridge, subscribeFileChanges, type FileChange } from '@reflect/core' /** @@ -21,23 +21,28 @@ import { hasBridge, subscribeFileChanges, type FileChange } from '@reflect/core' */ export function useFileChanges(handler: ((changes: FileChange[]) => void) | null): boolean { const bridgeAvailable = hasBridge() - const [readyHandler, setReadyHandler] = useState(null) + const subscription = useMemo( + () => ({ bridgeAvailable, handler }), + [bridgeAvailable, handler], + ) + const [readySubscription, setReadySubscription] = useState(null) useEffect(() => { - if (handler === null || !bridgeAvailable) { + const currentHandler = subscription.handler + if (currentHandler === null || !subscription.bridgeAvailable) { return } let active = true let unlisten: (() => void) | null = null void subscribeFileChanges((changes) => { if (active) { - handler(changes) + currentHandler(changes) } }) .then((stop) => { if (active) { unlisten = stop - setReadyHandler(() => handler) + setReadySubscription(subscription) } else { stop() } @@ -51,7 +56,7 @@ export function useFileChanges(handler: ((changes: FileChange[]) => void) | null active = false unlisten?.() } - }, [bridgeAvailable, handler]) + }, [subscription]) - return handler === null || !bridgeAvailable || readyHandler === handler + return handler === null || !bridgeAvailable || readySubscription === subscription } diff --git a/packages/core/src/graph/resolve-existing-wiki-target.test.ts b/packages/core/src/graph/resolve-existing-wiki-target.test.ts index e0baa9fa7..cf8795fcb 100644 --- a/packages/core/src/graph/resolve-existing-wiki-target.test.ts +++ b/packages/core/src/graph/resolve-existing-wiki-target.test.ts @@ -19,9 +19,10 @@ function bindBridge({ }: BridgeBehavior = {}): ReturnType { const invoke = vi.fn(async (command: string, args?: Record) => { if (command === 'db_query') { + const params = args?.['params'] return query?.( String(args?.['sql'] ?? ''), - ((args?.['params'] as unknown[]) ?? []), + Array.isArray(params) ? params : [], ) ?? [] } if (command === 'list_files') { From 5de03f4f96f13ba980031ca09181ba7e529113ad Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Sun, 12 Jul 2026 00:04:56 +0100 Subject: [PATCH 3/3] fix: handle watcher subscription gaps --- .../wiki-link-hover-preview.test.tsx | 56 ++++++++++++++++++- .../components/wiki-link-hover-preview.tsx | 23 ++++++-- .../desktop/src/lib/use-file-changes.test.tsx | 8 ++- apps/desktop/src/lib/use-file-changes.ts | 28 ++++++++-- 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/wiki-link-hover-preview.test.tsx b/apps/desktop/src/components/wiki-link-hover-preview.test.tsx index 1b9325711..31fb01609 100644 --- a/apps/desktop/src/components/wiki-link-hover-preview.test.tsx +++ b/apps/desktop/src/components/wiki-link-hover-preview.test.tsx @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ })) let fileChangeHandler: ((changes: FileChange[]) => void) | null = null +let fileChangesReady = true +let fileChangeCycle: object = {} vi.mock('@reflect/core', async (importOriginal) => { const actual = await importOriginal() @@ -26,7 +28,7 @@ vi.mock('@/lib/read-existing-note-source', () => ({ vi.mock('@/lib/use-file-changes', () => ({ useFileChanges: (handler: ((changes: FileChange[]) => void) | null) => { fileChangeHandler = handler - return true + return { cycle: fileChangeCycle, settled: fileChangesReady } }, })) @@ -81,6 +83,8 @@ describe('WikiLinkHoverPreview', () => { beforeEach(() => { fileChangeHandler = null + fileChangesReady = true + fileChangeCycle = {} mocks.resolveExistingWikiTarget.mockReset() mocks.readExistingNoteSource.mockReset() mocks.markdownPreview.mockReset() @@ -214,6 +218,56 @@ describe('WikiLinkHoverPreview', () => { expect(mocks.readExistingNoteSource).toHaveBeenCalledOnce() }) + it('hides the old body until a resubscribed watcher reloads it', async () => { + const dismiss = vi.fn() + mocks.resolveExistingWikiTarget.mockResolvedValue({ + kind: 'resolved', + path: 'notes/alpha.md', + }) + mocks.readExistingNoteSource.mockResolvedValue('# Original') + const view = renderPreview('Alpha', { dismiss }) + expect((await screen.findByTestId('markdown-preview')).textContent).toBe('# Original') + + fileChangesReady = false + fileChangeCycle = {} + view.rerender( + null} + resolveImageUrl={() => null} + />, + ) + expect(screen.queryByTestId('wiki-link-hover-preview')).toBeNull() + + const resolution = deferred<{ kind: 'resolved'; path: string }>() + mocks.resolveExistingWikiTarget.mockReturnValue(resolution.promise) + fileChangesReady = true + view.rerender( + null} + resolveImageUrl={() => null} + />, + ) + expect(screen.queryByTestId('wiki-link-hover-preview')).toBeNull() + + await act(async () => { + resolution.resolve({ kind: 'resolved', path: 'notes/alpha.md' }) + await resolution.promise + }) + expect((await screen.findByTestId('markdown-preview')).textContent).toBe('# Original') + expect(mocks.resolveExistingWikiTarget).toHaveBeenCalledTimes(2) + expect(mocks.readExistingNoteSource).toHaveBeenCalledTimes(2) + }) + it('dismisses when the resolved target is updated or removed', async () => { const dismiss = vi.fn() mocks.resolveExistingWikiTarget.mockResolvedValue({ diff --git a/apps/desktop/src/components/wiki-link-hover-preview.tsx b/apps/desktop/src/components/wiki-link-hover-preview.tsx index 0946c39ac..b7b34b982 100644 --- a/apps/desktop/src/components/wiki-link-hover-preview.tsx +++ b/apps/desktop/src/components/wiki-link-hover-preview.tsx @@ -35,6 +35,7 @@ interface PreviewScope { interface PreviewLoad { scope: PreviewScope + watcherCycle: object path: string body: string | null } @@ -81,7 +82,6 @@ export function WikiLinkHoverPreview({ const resolvedPath = useRef(null) const currentScope = generation === null || graphKey === null ? null : { target, generation, graphKey } - const visibleLoad = load !== null && sameScope(load, currentScope) ? load : null const handleFileChanges = useCallback( (changes: FileChange[]) => { @@ -103,14 +103,21 @@ export function WikiLinkHoverPreview({ }, [dismiss], ) - const watcherReady = useFileChanges(handleFileChanges) + const { cycle: watcherCycle, settled: watcherSettled } = useFileChanges(handleFileChanges) + const visibleLoad = + watcherSettled && + load !== null && + load.watcherCycle === watcherCycle && + sameScope(load, currentScope) + ? load + : null useEffect(() => { const epoch = ++requestEpoch.current let active = true resolvedPath.current = null - if (!watcherReady) { + if (!watcherSettled) { return () => { active = false requestEpoch.current += 1 @@ -154,7 +161,12 @@ export function WikiLinkHoverPreview({ } resolvedPath.current = resolution.path - setLoad({ scope: requestScope, path: resolution.path, body: null }) + setLoad({ + scope: requestScope, + watcherCycle, + path: resolution.path, + body: null, + }) const source = await readExistingNoteSource( resolution.path, requestScope.generation, @@ -170,6 +182,7 @@ export function WikiLinkHoverPreview({ } setLoad({ scope: requestScope, + watcherCycle, path: resolution.path, body: splitFrontmatter(source).body, }) @@ -185,7 +198,7 @@ export function WikiLinkHoverPreview({ requestEpoch.current += 1 resolvedPath.current = null } - }, [dismiss, generation, graphKey, target, watcherReady]) + }, [dismiss, generation, graphKey, target, watcherCycle, watcherSettled]) const resolveLocalImageUrl = useCallback( (source: string): string | null => { diff --git a/apps/desktop/src/lib/use-file-changes.test.tsx b/apps/desktop/src/lib/use-file-changes.test.tsx index 61a4aa869..242d5f4f8 100644 --- a/apps/desktop/src/lib/use-file-changes.test.tsx +++ b/apps/desktop/src/lib/use-file-changes.test.tsx @@ -39,8 +39,8 @@ function stubSubscription(): Subscription { } function Host({ handler }: { handler: ((changes: FileChange[]) => void) | null }) { - const ready = useFileChanges(handler) - return {String(ready)} + const subscription = useFileChanges(handler) + return {String(subscription.settled)} } const UPSERT: FileChange[] = [{ path: 'notes/a.md', kind: 'upsert' }] @@ -140,17 +140,19 @@ describe('useFileChanges', () => { // The hook doesn't catch handler throws (the contract only covers the // subscription lifecycle), so only the rejected-subscription path is tested. - it('logs and stays inert when the subscription itself fails', async () => { + it('logs and settles into degraded mode when the subscription fails', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) subscribeFileChanges.mockRejectedValue(new Error('bridge gone')) const handler = vi.fn() const view = render() + expect(view.getByTestId('ready').textContent).toBe('false') await act(async () => {}) expect(consoleError).toHaveBeenCalledWith( 'file-change subscription failed:', expect.any(Error), ) + expect(view.getByTestId('ready').textContent).toBe('true') expect(handler).not.toHaveBeenCalled() expect(() => view.unmount()).not.toThrow() consoleError.mockRestore() diff --git a/apps/desktop/src/lib/use-file-changes.ts b/apps/desktop/src/lib/use-file-changes.ts index 6a6bb35c8..dadd4d6b3 100644 --- a/apps/desktop/src/lib/use-file-changes.ts +++ b/apps/desktop/src/lib/use-file-changes.ts @@ -1,6 +1,14 @@ import { useEffect, useMemo, useState } from 'react' import { hasBridge, subscribeFileChanges, type FileChange } from '@reflect/core' +/** State of the current native file-change subscription attempt. */ +export interface FileChangeSubscriptionState { + /** Opaque identity that changes whenever a new subscription is required. */ + readonly cycle: object + /** Whether the current attempt installed a listener or settled into degraded mode. */ + readonly settled: boolean +} + /** * Subscribe to the watcher's file-change events (Plan 04b) for the lifetime of * the component. Owns the fiddly parts of the subscription lifecycle so call @@ -14,12 +22,16 @@ import { hasBridge, subscribeFileChanges, type FileChange } from '@reflect/core' * * The subscription follows the handler's identity: memoize the handler over * its real dependencies and the hook resubscribes exactly when they change. - * Pass `null` to disable. The return value becomes true once the current - * handler's native subscription is installed (and is immediately true when + * Pass `null` to disable. The result becomes settled once the current handler's + * native subscription attempt finishes (and is immediately settled when * disabled or running without a bridge), so consumers that cannot tolerate a - * pre-subscription race can wait before starting their work. + * pre-subscription race can wait before starting their work. A failed attempt + * is logged and settles into a degraded mode without live updates. The opaque + * cycle lets consumers invalidate work from an earlier subscription attempt. */ -export function useFileChanges(handler: ((changes: FileChange[]) => void) | null): boolean { +export function useFileChanges( + handler: ((changes: FileChange[]) => void) | null, +): FileChangeSubscriptionState { const bridgeAvailable = hasBridge() const subscription = useMemo( () => ({ bridgeAvailable, handler }), @@ -51,6 +63,9 @@ export function useFileChanges(handler: ((changes: FileChange[]) => void) | null // A failed subscription degrades to no live updates for this mount; // surfaced for diagnosis rather than left as an unhandled rejection. console.error('file-change subscription failed:', cause) + if (active) { + setReadySubscription(subscription) + } }) return () => { active = false @@ -58,5 +73,8 @@ export function useFileChanges(handler: ((changes: FileChange[]) => void) | null } }, [subscription]) - return handler === null || !bridgeAvailable || readySubscription === subscription + return { + cycle: subscription, + settled: handler === null || !bridgeAvailable || readySubscription === subscription, + } }