From 3308779a1b5cc56e92a2136fa16016db2c2ed1cd Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 30 Apr 2026 14:55:19 -0400 Subject: [PATCH] fix: correct doc link resolution order, memoize render props, handle stale Monaco provider - Check exact relative path before extensionless match so [[docs/guide.md]] resolves uniquely when docs/guide.mdx also exists - Memoize markdownDocuments, previewProps, and handleSave to avoid unnecessary re-renders of MarkdownPreview and MonacoEditor - Re-register Monaco completion provider when the instance changes and clear stale model entries Co-authored-by: Orca --- .../src/components/editor/EditorContent.tsx | 21 +++++++-------- .../editor/markdown-doc-links.test.ts | 27 +++++++++++++++++++ .../components/editor/markdown-doc-links.ts | 13 +++++---- .../editor/monaco-markdown-doc-completions.ts | 11 +++++++- .../components/editor/useMarkdownDocuments.ts | 26 ++++++++++++------ 5 files changed, 72 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index b75db27c551..666874993ab 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -85,10 +85,7 @@ export function EditorContent({ const openConflictReview = useAppStore((s) => s.openConflictReview) const closeFile = useAppStore((s) => s.closeFile) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) - const markdownDocLinks = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode) - const handleMarkdownSave = (content: string): Promise => - handleSave(content).then(() => markdownDocLinks.refreshMarkdownDocuments()) - + const md = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave) const activeConflictEntry = worktreeEntries.find((entry) => entry.path === activeFile.relativePath) ?? null @@ -111,7 +108,7 @@ export function EditorContent({ content={editBuffers[activeFile.id] ?? fc.content} language={resolvedLanguage} onContentChange={handleContentChange} - onSave={isMarkdown ? handleMarkdownSave : handleSave} + onSave={isMarkdown ? md.mdSave : handleSave} revealLine={ pendingEditorReveal?.filePath === activeFile.filePath ? pendingEditorReveal.line : undefined } @@ -125,7 +122,7 @@ export function EditorContent({ ? pendingEditorReveal.matchLength : undefined } - markdownDocuments={isMarkdown ? markdownDocLinks.markdownDocuments : undefined} + markdownDocuments={isMarkdown ? md.markdownDocuments : undefined} /> ) @@ -174,8 +171,8 @@ export function EditorContent({ : handleContentChange const onSaveWithFm = fm - ? (body: string): Promise => handleMarkdownSave(prependFrontMatter(fm.raw, body)) - : handleMarkdownSave + ? (body: string): Promise => md.mdSave(prependFrontMatter(fm.raw, body)) + : md.mdSave return (
@@ -226,7 +223,7 @@ export function EditorContent({ content={currentContent} filePath={activeFile.filePath} scrollCacheKey={`${editorViewStateKey}:preview`} - {...markdownDocLinks.previewProps} + {...md.previewProps} />
@@ -308,7 +305,7 @@ export function EditorContent({ filePath={activeFile.filePath} scrollCacheKey={markdownPreviewViewStateKey} initialAnchor={activeFile.markdownPreviewAnchor ?? null} - {...markdownDocLinks.previewProps} + {...md.previewProps} /> ) @@ -410,7 +407,7 @@ export function EditorContent({ content={modifiedDiffContent} filePath={activeFile.filePath} scrollCacheKey={`${diffViewStateKey}:preview`} - {...markdownDocLinks.previewProps} + {...md.previewProps} /> @@ -429,7 +426,7 @@ export function EditorContent({ editable={isEditable} worktreeId={activeFile.worktreeId} onContentChange={isEditable ? handleContentChange : undefined} - onSave={isEditable ? (isMarkdown ? handleMarkdownSave : handleSave) : undefined} + onSave={isEditable ? (isMarkdown ? md.mdSave : handleSave) : undefined} /> ) } diff --git a/src/renderer/src/components/editor/markdown-doc-links.test.ts b/src/renderer/src/components/editor/markdown-doc-links.test.ts index de113b07899..01b43fa7a79 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.test.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.test.ts @@ -98,6 +98,33 @@ describe('resolveMarkdownDocLink', () => { expect(resolveMarkdownDocLink('docs/setup-guide', index).status).toBe('resolved') }) + it('prefers exact relative path over ambiguous extensionless match', () => { + const index = createMarkdownDocumentIndex([ + { + filePath: '/repo/docs/guide.md', + relativePath: 'docs/guide.md', + basename: 'guide.md', + name: 'guide' + }, + { + filePath: '/repo/docs/guide.mdx', + relativePath: 'docs/guide.mdx', + basename: 'guide.mdx', + name: 'guide' + } + ]) + + expect(resolveMarkdownDocLink('docs/guide.md', index)).toMatchObject({ + status: 'resolved', + document: { relativePath: 'docs/guide.md' } + }) + expect(resolveMarkdownDocLink('docs/guide.mdx', index)).toMatchObject({ + status: 'resolved', + document: { relativePath: 'docs/guide.mdx' } + }) + expect(resolveMarkdownDocLink('docs/guide', index).status).toBe('ambiguous') + }) + it('normalizes Windows-style targets', () => { const result = resolveMarkdownDocLink( 'docs\\setup-guide.md', diff --git a/src/renderer/src/components/editor/markdown-doc-links.ts b/src/renderer/src/components/editor/markdown-doc-links.ts index 8be0306ffb2..6e8b25a5a98 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.ts @@ -100,6 +100,14 @@ export function resolveMarkdownDocLink( const normalizedTarget = normalizeDocLinkKey(target) const extensionlessTarget = stripMarkdownExtension(normalizedTarget) + // Why: exact relative path must be checked before the extensionless lookup + // so that [[docs/guide.md]] resolves uniquely even when docs/guide.mdx also + // exists (both share the extensionless key "docs/guide"). + const relativeWithExtension = resolveMatches(index.byRelativePath.get(normalizedTarget)) + if (relativeWithExtension) { + return relativeWithExtension + } + const relativeWithoutExtension = resolveMatches( index.byRelativePathWithoutExtension.get(extensionlessTarget) ) @@ -107,11 +115,6 @@ export function resolveMarkdownDocLink( return relativeWithoutExtension } - const relativeWithExtension = resolveMatches(index.byRelativePath.get(normalizedTarget)) - if (relativeWithExtension) { - return relativeWithExtension - } - if (!normalizedTarget.includes('/')) { const byName = resolveMatches(index.byName.get(extensionlessTarget)) if (byName) { diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts index 72bdc21ea89..882b011e9b3 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts @@ -9,12 +9,21 @@ import { type MonacoApi = Parameters[1] let provider: IDisposable | null = null +let providerMonaco: MonacoApi | null = null const documentsByModel = new Map() export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { - if (provider) { + // Why: if Monaco was torn down and re-created (e.g. window reload), the old + // provider reference is stale. Detect this by checking whether the Monaco + // instance changed and re-register. + if (provider && providerMonaco === monaco) { return } + if (provider) { + provider.dispose() + documentsByModel.clear() + } + providerMonaco = monaco provider = monaco.languages.registerCompletionItemProvider('markdown', { triggerCharacters: ['['], diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 3dd59dfdce0..659e7a707e0 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -11,13 +11,14 @@ type UseMarkdownDocumentsResult = { markdownDocuments: MarkdownDocument[] onOpenDocument: (document: MarkdownDocument) => Promise } - refreshMarkdownDocuments: () => Promise + mdSave: (content: string) => Promise } export function useMarkdownDocuments( activeFile: OpenFile, isMarkdown: boolean, - viewMode: MarkdownViewMode + viewMode: MarkdownViewMode, + onSave: (content: string) => Promise ): UseMarkdownDocumentsResult { const worktreeId = activeFile.worktreeId const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) @@ -103,11 +104,20 @@ export function useMarkdownDocuments( void refreshMarkdownDocuments() }, [activeFile.id, isMarkdown, viewMode, refreshMarkdownDocuments]) - const markdownDocuments = worktreeId ? (markdownDocumentsByWorktree[worktreeId] ?? []) : [] + const markdownDocuments = useMemo( + () => (worktreeId ? (markdownDocumentsByWorktree[worktreeId] ?? []) : []), + [worktreeId, markdownDocumentsByWorktree] + ) - return { - markdownDocuments, - previewProps: { markdownDocuments, onOpenDocument: openMarkdownDocument }, - refreshMarkdownDocuments - } + const previewProps = useMemo( + () => ({ markdownDocuments, onOpenDocument: openMarkdownDocument }), + [markdownDocuments, openMarkdownDocument] + ) + + const mdSave = useCallback( + (content: string) => onSave(content).then(() => refreshMarkdownDocuments()), + [onSave, refreshMarkdownDocuments] + ) + + return { markdownDocuments, previewProps, mdSave } }