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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-01 01:32:28 -04:00
co-authored by Orca
parent e3c837eb84
commit 3308779a1b
5 changed files with 72 additions and 26 deletions
@@ -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<void> =>
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<void> => handleMarkdownSave(prependFrontMatter(fm.raw, body))
: handleMarkdownSave
? (body: string): Promise<void> => md.mdSave(prependFrontMatter(fm.raw, body))
: md.mdSave
return (
<div className="flex h-full min-h-0 flex-col">
@@ -226,7 +223,7 @@ export function EditorContent({
content={currentContent}
filePath={activeFile.filePath}
scrollCacheKey={`${editorViewStateKey}:preview`}
{...markdownDocLinks.previewProps}
{...md.previewProps}
/>
</div>
</div>
@@ -308,7 +305,7 @@ export function EditorContent({
filePath={activeFile.filePath}
scrollCacheKey={markdownPreviewViewStateKey}
initialAnchor={activeFile.markdownPreviewAnchor ?? null}
{...markdownDocLinks.previewProps}
{...md.previewProps}
/>
</div>
)
@@ -410,7 +407,7 @@ export function EditorContent({
content={modifiedDiffContent}
filePath={activeFile.filePath}
scrollCacheKey={`${diffViewStateKey}:preview`}
{...markdownDocLinks.previewProps}
{...md.previewProps}
/>
</div>
</div>
@@ -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}
/>
)
}
@@ -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',
@@ -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) {
@@ -9,12 +9,21 @@ import {
type MonacoApi = Parameters<OnMount>[1]
let provider: IDisposable | null = null
let providerMonaco: MonacoApi | null = null
const documentsByModel = new Map<string, MarkdownDocument[]>()
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: ['['],
@@ -11,13 +11,14 @@ type UseMarkdownDocumentsResult = {
markdownDocuments: MarkdownDocument[]
onOpenDocument: (document: MarkdownDocument) => Promise<void>
}
refreshMarkdownDocuments: () => Promise<void>
mdSave: (content: string) => Promise<void>
}
export function useMarkdownDocuments(
activeFile: OpenFile,
isMarkdown: boolean,
viewMode: MarkdownViewMode
viewMode: MarkdownViewMode,
onSave: (content: string) => Promise<void>
): 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 }
}