Support wiki-style Markdown document links (#1282)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
Colin Chambachan
2026-05-01 13:15:08 -07:00
committed by GitHub
co-authored by Orca Jinwoo-H
parent 19c4888e17
commit d4bbf473c1
29 changed files with 2244 additions and 79 deletions
+150
View File
@@ -96,6 +96,27 @@ const REPO_PATH = path.resolve('/workspace/repo')
const WORKSPACE_DIR = path.resolve('/workspace')
const WORKTREE_FEATURE_PATH = path.resolve('/workspace/repo-feature')
type MockDirEntry = {
name: string
directory?: boolean
file?: boolean
symlink?: boolean
}
function dirEntry({ name, directory, file, symlink }: MockDirEntry): {
name: string
isDirectory: () => boolean
isFile: () => boolean
isSymbolicLink: () => boolean
} {
return {
name,
isDirectory: () => directory ?? false,
isFile: () => file ?? false,
isSymbolicLink: () => symlink ?? false
}
}
describe('registerFilesystemHandlers', () => {
const store = {
getRepos: () => [
@@ -311,6 +332,135 @@ describe('registerFilesystemHandlers', () => {
expect(bulkUnstageFilesMock).not.toHaveBeenCalled()
})
it('lists markdown documents recursively for a registered worktree', async () => {
readdirMock.mockImplementation(async (dirPath: string) => {
if (dirPath === WORKTREE_FEATURE_PATH) {
return [
dirEntry({ name: 'README.md', file: true }),
dirEntry({ name: 'docs', directory: true }),
dirEntry({ name: 'script.ts', file: true })
]
}
if (dirPath === path.join(WORKTREE_FEATURE_PATH, 'docs')) {
return [
dirEntry({ name: 'Guide.MDX', file: true }),
dirEntry({ name: 'notes.markdown', file: true })
]
}
return []
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual([
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'Guide.MDX'),
relativePath: 'docs/Guide.MDX',
basename: 'Guide.MDX',
name: 'Guide'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'notes.markdown'),
relativePath: 'docs/notes.markdown',
basename: 'notes.markdown',
name: 'notes'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'README.md'),
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
}
])
})
it('skips ignored and symlinked directories when listing markdown documents', async () => {
readdirMock.mockImplementation(async (dirPath: string) => {
if (dirPath === WORKTREE_FEATURE_PATH) {
return [
dirEntry({ name: '.git', directory: true }),
dirEntry({ name: '.hidden', directory: true }),
dirEntry({ name: '.github', directory: true }),
dirEntry({ name: 'node_modules', directory: true }),
dirEntry({ name: 'linked-docs', directory: true, symlink: true }),
dirEntry({ name: 'visible.md', file: true })
]
}
if (dirPath === path.join(WORKTREE_FEATURE_PATH, '.github')) {
return [dirEntry({ name: 'CONTRIBUTING.md', file: true })]
}
throw new Error(`Unexpected readdir: ${dirPath}`)
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual([
{
filePath: path.join(WORKTREE_FEATURE_PATH, '.github', 'CONTRIBUTING.md'),
relativePath: '.github/CONTRIBUTING.md',
basename: 'CONTRIBUTING.md',
name: 'CONTRIBUTING'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'visible.md'),
relativePath: 'visible.md',
basename: 'visible.md',
name: 'visible'
}
])
})
it('rejects markdown document listing for authorized but unregistered roots', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: path.resolve('/workspace/unregistered')
})
).rejects.toThrow('Access denied: unknown repository or worktree path')
expect(readdirMock).not.toHaveBeenCalled()
})
it('lists remote markdown documents through the SSH filesystem provider', async () => {
const provider = {
listFiles: vi
.fn()
.mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts'])
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: '/home/user/project',
connectionId: 'ssh-1'
})
).resolves.toEqual([
{
filePath: '/home/user/project/docs/guide.mdx',
relativePath: 'docs/guide.mdx',
basename: 'guide.mdx',
name: 'guide'
},
{
filePath: '/home/user/project/README.md',
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
}
])
})
it('routes branch compare queries through the git compare helper', async () => {
getBranchCompareMock.mockResolvedValue({
summary: {
+22
View File
@@ -12,6 +12,7 @@ import type {
GitConflictOperation,
GitDiffResult,
GitStatusResult,
MarkdownDocument,
SearchOptions,
SearchResult
} from '../../shared/types'
@@ -47,6 +48,7 @@ import {
import { listQuickOpenFiles } from './filesystem-list-files'
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import { searchWithGitGrep } from './filesystem-search-git'
import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents'
import { checkRgAvailable } from './rg-availability'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
@@ -161,6 +163,26 @@ export function registerFilesystemHandlers(store: Store): void {
}
)
ipcMain.handle(
'fs:listMarkdownDocuments',
async (
_event,
args: { rootPath: string; connectionId?: string }
): Promise<MarkdownDocument[]> => {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
if (!provider) {
throw new Error(`No filesystem provider for connection "${args.connectionId}"`)
}
const relativePaths = await provider.listFiles(args.rootPath)
return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths)
}
const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store)
return listMarkdownDocuments(rootPath)
}
)
ipcMain.handle(
'fs:writeFile',
async (
+98
View File
@@ -0,0 +1,98 @@
import { readdir } from 'fs/promises'
import { basename as pathBasename, extname, join, relative } from 'path'
import type { MarkdownDocument } from '../../shared/types'
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
function isMarkdownDocumentName(name: string): boolean {
const extension = extname(name).toLowerCase()
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
}
function basenameFromRelativePath(relativePath: string): string {
const normalizedPath = relativePath.replaceAll('\\', '/')
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
}
function isSafeRelativePath(relativePath: string): boolean {
return !relativePath.split('/').includes('..')
}
function toMarkdownDocument(rootPath: string, filePath: string): MarkdownDocument {
const basename = pathBasename(filePath)
const extension = extname(basename)
return {
filePath,
relativePath: normalizeRelativePath(relative(rootPath, filePath)),
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
}
export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
): MarkdownDocument | null {
const normalizedRelativePath = normalizeRelativePath(relativePath)
// Why: SSH providers should return root-relative paths; reject escape
// segments before building a synthetic absolute path for renderer use.
if (!isSafeRelativePath(normalizedRelativePath)) {
return null
}
const basename = basenameFromRelativePath(normalizedRelativePath)
if (!isMarkdownDocumentName(basename)) {
return null
}
const extension = extname(basename)
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
return {
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
relativePath: normalizedRelativePath,
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
}
export function markdownDocumentsFromRelativePaths(
rootPath: string,
relativePaths: string[]
): MarkdownDocument[] {
return relativePaths
.map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath))
.filter((document): document is MarkdownDocument => document !== null)
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
export async function listMarkdownDocuments(rootPath: string): Promise<MarkdownDocument[]> {
const documents: MarkdownDocument[] = []
async function visitDirectory(dirPath: string): Promise<void> {
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue
}
const entryPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
if (entry.name === '.git' || entry.name === 'node_modules') {
continue
}
if (entry.name.startsWith('.') && entry.name !== '.github') {
continue
}
await visitDirectory(entryPath)
continue
}
if (entry.isFile() && isMarkdownDocumentName(entry.name)) {
documents.push(toMarkdownDocument(rootPath, entryPath))
}
}
}
await visitDirectory(rootPath)
return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
+5
View File
@@ -36,6 +36,7 @@ import type {
LinearLabel,
LinearMember,
LinearTeam,
MarkdownDocument,
GitHubIssueUpdate,
NotificationDispatchRequest,
NotificationDispatchResult,
@@ -630,6 +631,10 @@ export type PreloadApi = {
filePath: string
connectionId?: string
}) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }>
listMarkdownDocuments: (args: {
rootPath: string
connectionId?: string
}) => Promise<MarkdownDocument[]>
writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise<void>
createFile: (args: { filePath: string; connectionId?: string }) => Promise<void>
createDir: (args: { dirPath: string; connectionId?: string }) => Promise<void>
+5
View File
@@ -1100,6 +1100,11 @@ const api = {
connectionId?: string
}): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> =>
ipcRenderer.invoke('fs:readFile', args),
listMarkdownDocuments: (args: {
rootPath: string
connectionId?: string
}): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> =>
ipcRenderer.invoke('fs:listMarkdownDocuments', args),
writeFile: (args: {
filePath: string
content: string
+7
View File
@@ -897,6 +897,13 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--warning, #f59e0b) 52%, transparent);
}
.monaco-editor .monaco-markdown-doc-link {
color: #58a6ff !important;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
/* Why: Monaco's find-widget button tooltips (Close, Find in Selection, etc.)
render inside a `.context-view` wrapper → `.workbench-hover-container` →
`.workbench-hover.compact`, positioned above the target button. The wrapper's
@@ -142,6 +142,16 @@
text-decoration-color: currentColor;
}
.markdown-body .markdown-doc-link {
text-decoration-style: solid;
}
.markdown-body .markdown-doc-link-broken {
color: var(--muted-foreground);
cursor: default !important;
text-decoration-style: dotted;
}
.markdown-light .markdown-body a {
color: #0969da;
}
@@ -150,6 +160,11 @@
color: #58a6ff;
}
.markdown-light .markdown-body .markdown-doc-link-broken,
.markdown-dark .markdown-body .markdown-doc-link-broken {
color: var(--muted-foreground);
}
.markdown-body code {
padding: 0.2em 0.4em;
border-radius: 5px;
@@ -592,6 +592,110 @@
color: var(--muted-foreground);
}
.rich-markdown-doc-link-menu {
position: absolute;
z-index: 30;
display: flex;
width: min(360px, calc(100% - 24px));
max-height: 320px;
flex-direction: column;
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--background) 92%, transparent);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(16px);
}
.rich-markdown-doc-link-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: inherit;
text-align: left;
}
.rich-markdown-doc-link-item:hover,
.rich-markdown-doc-link-item.is-active {
background: var(--accent);
}
.rich-markdown-doc-link-item.is-empty {
color: var(--muted-foreground);
font-size: 13px;
font-style: italic;
cursor: default;
}
.rich-markdown-doc-link-item.is-empty:hover {
background: transparent;
}
.rich-markdown-doc-link-footer {
padding: 6px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
color: var(--muted-foreground);
font-size: 11px;
}
.rich-markdown-doc-link-hint {
padding: 6px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
color: var(--muted-foreground);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
/* ── Doc Links ───────────────────────────────────────── */
.rich-markdown-editor .rich-markdown-doc-link {
color: #0969da;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-doc-link {
color: #58a6ff;
}
.rich-markdown-editor .rich-markdown-doc-link.rich-markdown-doc-link--missing {
color: var(--muted-foreground);
text-decoration-style: dashed;
}
.rich-markdown-mod-held .rich-markdown-editor .rich-markdown-doc-link {
cursor: pointer;
}
.rich-markdown-mod-held
.rich-markdown-editor
.rich-markdown-doc-link.rich-markdown-doc-link--missing {
cursor: default;
}
/* In-progress [[target]] text while cursor is inside. Matches the committed
doc-link styling so the visual doesn't jump when the atom node takes over. */
.rich-markdown-editor .rich-markdown-doc-link-preview {
color: #0969da;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-doc-link-preview {
color: #58a6ff;
}
.rich-markdown-editor .rich-markdown-doc-link-preview.rich-markdown-doc-link-preview--missing {
color: var(--muted-foreground);
text-decoration-style: dashed;
}
/* ── Link Bubble ──────────────────────────────────────── */
.rich-markdown-link-bubble {
@@ -9,6 +9,7 @@ import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
import { useMarkdownDocuments } from './useMarkdownDocuments'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
@@ -84,7 +85,7 @@ export function EditorContent({
const openConflictReview = useAppStore((s) => s.openConflictReview)
const closeFile = useAppStore((s) => s.closeFile)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const md = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
const activeConflictEntry =
worktreeEntries.find((entry) => entry.path === activeFile.relativePath) ?? null
@@ -107,7 +108,7 @@ export function EditorContent({
content={editBuffers[activeFile.id] ?? fc.content}
language={resolvedLanguage}
onContentChange={handleContentChange}
onSave={handleSave}
onSave={isMarkdown ? md.mdSave : handleSave}
revealLine={
pendingEditorReveal?.filePath === activeFile.filePath ? pendingEditorReveal.line : undefined
}
@@ -121,6 +122,7 @@ export function EditorContent({
? pendingEditorReveal.matchLength
: undefined
}
markdownDocuments={isMarkdown ? md.markdownDocuments : undefined}
/>
)
@@ -169,8 +171,8 @@ export function EditorContent({
: handleContentChange
const onSaveWithFm = fm
? (body: string): Promise<void> => handleSave(prependFrontMatter(fm.raw, body))
: handleSave
? (body: string): Promise<void> => md.mdSave(prependFrontMatter(fm.raw, body))
: md.mdSave
return (
<div className="flex h-full min-h-0 flex-col">
@@ -190,6 +192,8 @@ export function EditorContent({
onContentChange={onContentChangeWithFm}
onDirtyStateHint={handleDirtyStateHint}
onSave={onSaveWithFm}
onOpenDocLink={md.onOpenDocLink}
markdownDocuments={md.markdownDocuments}
// Why: render the front-matter banner below the editor toolbar
// (inside the editor shell) so formatting controls remain at
// the top of the pane — the banner is read-only context, not
@@ -221,6 +225,7 @@ export function EditorContent({
content={currentContent}
filePath={activeFile.filePath}
scrollCacheKey={`${editorViewStateKey}:preview`}
{...md.previewProps}
/>
</div>
</div>
@@ -302,6 +307,7 @@ export function EditorContent({
filePath={activeFile.filePath}
scrollCacheKey={markdownPreviewViewStateKey}
initialAnchor={activeFile.markdownPreviewAnchor ?? null}
{...md.previewProps}
/>
</div>
)
@@ -403,6 +409,7 @@ export function EditorContent({
content={modifiedDiffContent}
filePath={activeFile.filePath}
scrollCacheKey={`${diffViewStateKey}:preview`}
{...md.previewProps}
/>
</div>
</div>
@@ -421,7 +428,7 @@ export function EditorContent({
editable={isEditable}
worktreeId={activeFile.worktreeId}
onContentChange={isEditable ? handleContentChange : undefined}
onSave={isEditable ? handleSave : undefined}
onSave={isEditable ? (isMarkdown ? md.mdSave : handleSave) : undefined}
/>
)
}
@@ -23,12 +23,18 @@ import { toast } from 'sonner'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { detectLanguage } from '@/lib/language-detect'
import type { Worktree } from '../../../../shared/types'
import type { MarkdownDocument, Worktree } from '../../../../shared/types'
import {
fileUrlToAbsolutePath,
getMarkdownPreviewLinkTarget,
resolveMarkdownPreviewHref
} from './markdown-preview-links'
import {
createMarkdownDocumentIndex,
parseMarkdownDocLinkHref,
remarkMarkdownDocLinks,
resolveMarkdownDocLink
} from './markdown-doc-links'
import { absolutePathToFileUri, resolveMarkdownLinkTarget } from './markdown-internal-links'
import { useLocalImageSrc } from './useLocalImageSrc'
import CodeBlockCopyButton from './CodeBlockCopyButton'
@@ -47,6 +53,8 @@ type MarkdownPreviewProps = {
filePath: string
scrollCacheKey: string
initialAnchor?: string | null
markdownDocuments?: MarkdownDocument[]
onOpenDocument?: (document: MarkdownDocument) => void | Promise<void>
}
const markdownPreviewSanitizeSchema = {
@@ -144,7 +152,9 @@ export default function MarkdownPreview({
content,
filePath,
scrollCacheKey,
initialAnchor = null
initialAnchor = null,
markdownDocuments = [],
onOpenDocument
}: MarkdownPreviewProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
@@ -172,6 +182,10 @@ export default function MarkdownPreview({
const renderedContent = usePreserveSectionDuringExternalEdit(content, bodyRef)
const frontMatter = useMemo(() => extractFrontMatter(renderedContent), [renderedContent])
const markdownDocumentIndex = useMemo(
() => createMarkdownDocumentIndex(markdownDocuments),
[markdownDocuments]
)
const frontMatterInner = useMemo(() => {
if (!frontMatter) {
return ''
@@ -410,7 +424,36 @@ export default function MarkdownPreview({
sluggerRef.current.reset()
const slugger = sluggerRef.current
return {
a: ({ href, children, ...props }) => {
a: ({ href, children, className, ...props }) => {
const docLinkTarget = parseMarkdownDocLinkHref(href)
if (docLinkTarget !== null) {
const resolution = resolveMarkdownDocLink(docLinkTarget, markdownDocumentIndex)
const resolvedDocument = resolution.status === 'resolved' ? resolution.document : null
const title =
resolution.status === 'ambiguous' ? 'Document link is ambiguous' : 'Document not found'
const handleDocLinkClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
event.preventDefault()
if (resolvedDocument && onOpenDocument) {
void onOpenDocument(resolvedDocument)
}
}
return (
<a
{...props}
href={href}
className={`${className ?? ''} ${
resolvedDocument ? 'markdown-doc-link' : 'markdown-doc-link-broken'
}`.trim()}
title={resolvedDocument ? undefined : title}
onClick={handleDocLinkClick}
>
{children}
</a>
)
}
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
if (!href) {
return
@@ -547,7 +590,13 @@ export default function MarkdownPreview({
}
return (
<a {...props} href={href} onClick={handleClick} style={{ cursor: 'pointer' }}>
<a
{...props}
href={href}
className={className}
onClick={handleClick}
style={{ cursor: 'pointer' }}
>
{children}
</a>
)
@@ -644,6 +693,8 @@ export default function MarkdownPreview({
filePath,
isDark,
isMac,
markdownDocumentIndex,
onOpenDocument,
openFile,
openMarkdownPreview,
scrollToAnchor,
@@ -748,7 +799,13 @@ export default function MarkdownPreview({
)}
<Markdown
components={components}
remarkPlugins={[remarkGfm, remarkBreaks, remarkFrontmatter, remarkMath]}
remarkPlugins={[
remarkGfm,
remarkBreaks,
remarkFrontmatter,
remarkMath,
remarkMarkdownDocLinks
]}
// Why: raw HTML must be sanitized before any trusted renderer expands
// it into richer DOM. Running KaTeX and syntax highlighting after
// sanitize preserves VS Code-style math/code rendering without having
@@ -1,15 +1,8 @@
import React, { useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import { Copy, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache'
import '@/lib/monaco-setup'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
@@ -22,6 +15,16 @@ import {
endProgrammaticContentSync,
shouldIgnoreMonacoContentChange
} from './monaco-programmatic-sync'
import {
clearMarkdownDocCompletionDocuments,
ensureMarkdownDocCompletionProvider,
setMarkdownDocCompletionDocuments
} from './monaco-markdown-doc-completions'
import { MonacoGutterContextMenu } from './MonacoGutterContextMenu'
import {
createMarkdownDocLinkDecorationController,
type MarkdownDocLinkDecorationController
} from './monaco-markdown-doc-link-decorations'
type MonacoEditorProps = {
filePath: string
@@ -34,6 +37,7 @@ type MonacoEditorProps = {
revealLine?: number
revealColumn?: number
revealMatchLength?: number
markdownDocuments?: MarkdownDocument[]
}
export default function MonacoEditor({
@@ -46,9 +50,14 @@ export default function MonacoEditor({
onSave,
revealLine,
revealColumn,
revealMatchLength
revealMatchLength,
markdownDocuments
}: MonacoEditorProps): React.JSX.Element {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const modelKeyRef = useRef<string | null>(null)
const languageRef = useRef(language)
languageRef.current = language
const markdownDocLinkDecorationsRef = useRef<MarkdownDocLinkDecorationController | null>(null)
const revealDecorationRef = useRef<editor.IEditorDecorationsCollection | null>(null)
const revealHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const revealRafRef = useRef<number | null>(null)
@@ -83,6 +92,22 @@ export default function MonacoEditor({
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const updateMarkdownCompletionDocuments = useCallback((): void => {
const modelKey = editorRef.current?.getModel()?.uri.toString() ?? null
if (modelKeyRef.current && modelKeyRef.current !== modelKey) {
clearMarkdownDocCompletionDocuments(modelKeyRef.current)
}
modelKeyRef.current = modelKey
if (!modelKey) {
return
}
if (language === 'markdown' && markdownDocuments) {
setMarkdownDocCompletionDocuments(modelKey, markdownDocuments)
} else {
clearMarkdownDocCompletionDocuments(modelKey)
}
}, [language, markdownDocuments])
const clearTransientRevealHighlight = useCallback(() => {
if (revealHighlightTimerRef.current !== null) {
clearTimeout(revealHighlightTimerRef.current)
@@ -161,6 +186,12 @@ export default function MonacoEditor({
const handleMount: OnMount = useCallback(
(editorInstance, monaco) => {
editorRef.current = editorInstance
markdownDocLinkDecorationsRef.current = createMarkdownDocLinkDecorationController(
editorInstance,
() => languageRef.current
)
ensureMarkdownDocCompletionProvider(monaco)
updateMarkdownCompletionDocuments()
// Why: see comment on contentRef — reconcile the retained model against
// the current prop before any user interaction so external changes that
@@ -261,7 +292,14 @@ export default function MonacoEditor({
}
}
},
[queueReveal, setupCopy, filePath, setEditorCursorLine, viewStateKey]
[
queueReveal,
setupCopy,
filePath,
setEditorCursorLine,
updateMarkdownCompletionDocuments,
viewStateKey
]
)
const handleChange = useCallback(
@@ -347,6 +385,24 @@ export default function MonacoEditor({
})
}, [editorFontSize, settings])
useEffect(() => {
markdownDocLinkDecorationsRef.current?.refresh()
}, [content, language])
useEffect(() => {
updateMarkdownCompletionDocuments()
}, [updateMarkdownCompletionDocuments])
useEffect(() => {
return () => {
if (modelKeyRef.current) {
clearMarkdownDocCompletionDocuments(modelKeyRef.current)
}
markdownDocLinkDecorationsRef.current?.dispose()
markdownDocLinkDecorationsRef.current = null
}
}, [])
useEffect(() => {
const handler = (event: Event): void => {
const detail = (event as CustomEvent).detail as
@@ -422,57 +478,14 @@ export default function MonacoEditor({
/>
{toastNode}
{/* Radix context menu for line number gutter right-click */}
<DropdownMenu open={gutterMenuOpen} onOpenChange={setGutterMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: gutterMenuPoint.x, top: gutterMenuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={0} align="start">
<DropdownMenuItem
onSelect={() => {
window.api.ui.writeClipboardText(`${filePath}#L${gutterMenuLine}`)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.api.ui.writeClipboardText(`${relativePath}#L${gutterMenuLine}`)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Rel. Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
// Derive worktree root from the absolute and relative paths
const worktreePath = filePath.slice(0, -(relativePath.length + 1))
const activeFile = useAppStore
.getState()
.openFiles.find((f) => f.filePath === filePath)
const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined
const url = await window.api.git.remoteFileUrl({
worktreePath,
relativePath,
line: gutterMenuLine,
connectionId
})
if (url) {
window.api.ui.writeClipboardText(url)
}
}}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
Copy Remote URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MonacoGutterContextMenu
open={gutterMenuOpen}
onOpenChange={setGutterMenuOpen}
point={gutterMenuPoint}
line={gutterMenuLine}
filePath={filePath}
relativePath={relativePath}
/>
</div>
)
}
@@ -0,0 +1,80 @@
import React from 'react'
import { Copy, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
type MonacoGutterContextMenuProps = {
open: boolean
onOpenChange: (open: boolean) => void
point: { x: number; y: number }
line: number
filePath: string
relativePath: string
}
export function MonacoGutterContextMenu({
open,
onOpenChange,
point,
line,
filePath,
relativePath
}: MonacoGutterContextMenuProps): React.JSX.Element {
return (
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: point.x, top: point.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={0} align="start">
<DropdownMenuItem onSelect={() => window.api.ui.writeClipboardText(`${filePath}#L${line}`)}>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => window.api.ui.writeClipboardText(`${relativePath}#L${line}`)}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Rel. Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
const state = useAppStore.getState()
const activeFile = state.openFiles.find((f) => f.filePath === filePath)
if (!activeFile) {
return
}
const worktree = findWorktreeById(state.worktreesByRepo, activeFile.worktreeId)
if (!worktree) {
return
}
const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined
const url = await window.api.git.remoteFileUrl({
worktreePath: worktree.path,
relativePath,
line,
connectionId
})
if (url) {
window.api.ui.writeClipboardText(url)
}
}}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
Copy Remote URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,70 @@
import React from 'react'
import type { Editor } from '@tiptap/react'
import { cn } from '@/lib/utils'
import { commitRow } from './rich-markdown-commands'
import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands'
type RichMarkdownDocLinkMenuProps = {
editor: Editor | null
menu: DocLinkMenuState
rows: DocLinkMenuRow[]
totalMatches: number
selectedIndex: number
}
export function RichMarkdownDocLinkMenu({
editor,
menu,
rows,
totalMatches,
selectedIndex
}: RichMarkdownDocLinkMenuProps): React.JSX.Element {
const overflow = totalMatches > rows.length
return (
<div
className="rich-markdown-doc-link-menu"
style={{ left: menu.left, top: menu.top }}
role="listbox"
aria-label="Markdown document links"
>
{rows.length === 0 ? (
<div className="rich-markdown-doc-link-item is-empty">No documents found</div>
) : (
rows.map((row, index) => {
const rowKey = row.kind === 'document' ? row.document.filePath : row.id
return (
<button
key={rowKey}
type="button"
className={cn('rich-markdown-doc-link-item', index === selectedIndex && 'is-active')}
// Why: mousedown inside the editor-mounted popover would otherwise
// blur the editor before click fires, losing the selection we need
// to run the commit transaction against.
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor && commitRow(editor, menu, row)}
>
{row.kind === 'document' ? (
<span className="flex min-w-0 flex-1 flex-col items-start">
<span className="truncate text-sm font-medium">{row.document.name}</span>
<span className="truncate text-xs text-muted-foreground">
{row.document.relativePath}
</span>
</span>
) : (
<span className="truncate text-sm">{row.label}</span>
)}
</button>
)
})
)}
{overflow ? (
<div className="rich-markdown-doc-link-footer">
Showing {rows.length} of {totalMatches}
</div>
) : null}
<div className="rich-markdown-doc-link-hint">
↑↓ navigate&nbsp;&nbsp;↵ select&nbsp;&nbsp;esc dismiss
</div>
</div>
)
}
@@ -2,14 +2,22 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import type { MarkdownDocument } from '../../../../shared/types'
import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu'
import { RichMarkdownDocLinkMenu } from './RichMarkdownDocLinkMenu'
import { useAppStore } from '@/store'
import { RichMarkdownToolbar } from './RichMarkdownToolbar'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { useLocalImagePick } from './useLocalImagePick'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { slashCommands, syncSlashMenu } from './rich-markdown-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-commands'
import { slashCommands, syncDocLinkMenu, syncSlashMenu } from './rich-markdown-commands'
import type {
DocLinkMenuRow,
DocLinkMenuState,
SlashCommand,
SlashMenuState
} from './rich-markdown-commands'
import { getMarkdownDocCompletionDocuments } from './markdown-doc-completions'
import { RichMarkdownSearchBar } from './RichMarkdownSearchBar'
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
import {
@@ -42,6 +50,8 @@ type RichMarkdownEditorProps = {
onContentChange: (content: string) => void
onDirtyStateHint: (dirty: boolean) => void
onSave: (content: string) => void
onOpenDocLink?: (target: string) => void
markdownDocuments?: MarkdownDocument[]
// Why: front-matter is stripped from the rich editor's content but we still
// want it visible to the user. It renders between the toolbar and the editor
// surface so the formatting toolbar stays at the top of the pane.
@@ -61,6 +71,8 @@ export default function RichMarkdownEditor({
onContentChange,
onDirtyStateHint,
onSave,
onOpenDocLink,
markdownDocuments,
headerSlot
}: RichMarkdownEditorProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
@@ -78,14 +90,20 @@ export default function RichMarkdownEditor({
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null)
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0)
const [docLinkMenu, setDocLinkMenu] = useState<DocLinkMenuState | null>(null)
const [selectedDocLinkIndex, setSelectedDocLinkIndex] = useState(0)
const isMac = navigator.userAgent.includes('Mac')
const lastCommittedMarkdownRef = useRef(content)
const slashMenuRef = useRef<SlashMenuState | null>(null)
const filteredSlashCommandsRef = useRef<SlashCommand[]>(slashCommands)
const selectedCommandIndexRef = useRef(0)
const docLinkMenuRef = useRef<DocLinkMenuState | null>(null)
const filteredDocLinkRowsRef = useRef<DocLinkMenuRow[]>([])
const selectedDocLinkIndexRef = useRef(0)
const onContentChangeRef = useRef(onContentChange)
const onDirtyStateHintRef = useRef(onDirtyStateHint)
const onSaveRef = useRef(onSave)
const onOpenDocLinkRef = useRef(onOpenDocLink)
const handleLocalImagePickRef = useRef<() => void>(() => {})
const openSearchRef = useRef<() => void>(() => {})
// Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays
@@ -111,6 +129,7 @@ export default function RichMarkdownEditor({
onContentChangeRef.current = onContentChange
onDirtyStateHintRef.current = onDirtyStateHint
onSaveRef.current = onSave
onOpenDocLinkRef.current = onOpenDocLink
isEditingLinkRef.current = isEditingLink
const flushPendingSerialization = useCallback(() => {
@@ -161,13 +180,18 @@ export default function RichMarkdownEditor({
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
flushPendingSerialization,
openSearchRef,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSlashMenu
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
}),
// Why: Cmd/Ctrl-click activates links via the shared classifier +
// dispatcher, so in-worktree .md links open in an Orca tab instead of the
@@ -183,6 +207,16 @@ export default function RichMarkdownEditor({
if (!ed || !modKey) {
return false
}
// Why: doc links are atom nodes (not marks), so resolve(pos).marks()
// won't find them. Check nodeAt(pos) first for doc link navigation.
const clickedNode = view.state.doc.nodeAt(pos)
if (clickedNode?.type.name === 'markdownDocLink') {
const target = clickedNode.attrs.target as string
if (target && onOpenDocLinkRef.current) {
onOpenDocLinkRef.current(target)
}
return true
}
const linkMark = view.state.doc
.resolve(pos)
.marks()
@@ -250,6 +284,7 @@ export default function RichMarkdownEditor({
},
onUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
// Why: bail out during normalizeSoftBreaks's onCreate transaction so the
// structural housekeeping doesn't mark the file dirty before the user
@@ -281,6 +316,7 @@ export default function RichMarkdownEditor({
},
onSelectionUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
// Sync link bubble: show preview when cursor is on a link, hide otherwise.
// Any selection change in the editor cancels an in-progress link edit.
@@ -341,6 +377,23 @@ export default function RichMarkdownEditor({
}
}, [editor, filePath])
// Why: the doc link NodeView reads the document list from storage to style
// resolved vs. missing links. The no-op transaction with meta flag triggers
// both nodeView `update` callbacks and the decoration plugin rebuild.
useEffect(() => {
if (editor && markdownDocuments) {
isApplyingProgrammaticUpdateRef.current = true
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(editor.storage as any).markdownDocLink.documents = markdownDocuments
const tr = editor.state.tr.setMeta('docLinksUpdated', true)
editor.view.dispatch(tr)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
}
}, [editor, markdownDocuments])
const handleLocalImagePick = useLocalImagePick(editor, filePath)
useEffect(() => {
@@ -410,6 +463,39 @@ export default function RichMarkdownEditor({
)
}, [filteredSlashCommands.length])
// Why: memo key is the `markdownDocuments` prop (stable reference from parent),
// not `editor.storage.markdownDocLink.documents`. The storage mirror is mutated
// in place by the extension so React would not see a new reference and the memo
// would stale-out. The prop is the single source of truth for filtering.
const DOC_LINK_MENU_MAX_ROWS = 20
const { docLinkRows, docLinkTotalMatches } = useMemo(() => {
if (!docLinkMenu || !markdownDocuments) {
return { docLinkRows: [] as DocLinkMenuRow[], docLinkTotalMatches: 0 }
}
const matches = getMarkdownDocCompletionDocuments(markdownDocuments, docLinkMenu.query)
const rows: DocLinkMenuRow[] = matches
.slice(0, DOC_LINK_MENU_MAX_ROWS)
.map((document) => ({ kind: 'document', document }))
return { docLinkRows: rows, docLinkTotalMatches: matches.length }
}, [docLinkMenu, markdownDocuments])
useEffect(() => {
docLinkMenuRef.current = docLinkMenu
}, [docLinkMenu])
useEffect(() => {
filteredDocLinkRowsRef.current = docLinkRows
}, [docLinkRows])
useEffect(() => {
selectedDocLinkIndexRef.current = selectedDocLinkIndex
}, [selectedDocLinkIndex])
useEffect(() => {
if (docLinkRows.length === 0) {
setSelectedDocLinkIndex(0)
return
}
setSelectedDocLinkIndex((currentIndex) => Math.min(currentIndex, docLinkRows.length - 1))
}, [docLinkRows.length])
useEffect(() => {
if (!editor) {
return
@@ -484,6 +570,7 @@ export default function RichMarkdownEditor({
isApplyingProgrammaticUpdateRef.current = false
}
syncSlashMenu(editor, rootRef.current, setSlashMenu)
syncDocLinkMenu(editor, rootRef.current, setDocLinkMenu)
// Why: fileId is part of the dep array so switching between files (where
// content can coincidentally match what was last committed for the prior
// file) still triggers the content-sync path and prevents cross-file
@@ -540,6 +627,15 @@ export default function RichMarkdownEditor({
onImagePick={handleLocalImagePick}
/>
) : null}
{docLinkMenu ? (
<RichMarkdownDocLinkMenu
editor={editor}
menu={docLinkMenu}
rows={docLinkRows}
totalMatches={docLinkTotalMatches}
selectedIndex={selectedDocLinkIndex}
/>
) : null}
</div>
)
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { MarkdownDocument } from '../../../../shared/types'
import {
getMarkdownDocCompletionContext,
getMarkdownDocCompletionDocuments
} from './markdown-doc-completions'
const documents: MarkdownDocument[] = [
{
filePath: '/repo/docs/setup.md',
relativePath: 'docs/setup.md',
basename: 'setup.md',
name: 'setup'
},
{
filePath: '/repo/notes/plan.md',
relativePath: 'notes/plan.md',
basename: 'plan.md',
name: 'plan'
}
]
describe('getMarkdownDocCompletionContext', () => {
it('detects partial doc links', () => {
expect(getMarkdownDocCompletionContext('before [[se')).toEqual({ partial: 'se' })
})
it('supports an empty partial after opening brackets', () => {
expect(getMarkdownDocCompletionContext('[[')).toEqual({ partial: '' })
})
it('rejects closed or malformed contexts', () => {
expect(getMarkdownDocCompletionContext('[[done]]')).toBeNull()
expect(getMarkdownDocCompletionContext('plain text')).toBeNull()
})
})
describe('getMarkdownDocCompletionDocuments', () => {
it('filters by name or relative path', () => {
expect(getMarkdownDocCompletionDocuments(documents, 'do')).toEqual([documents[0]])
expect(getMarkdownDocCompletionDocuments(documents, 'notes/pl')).toEqual([documents[1]])
})
it('normalizes Windows-style partial paths', () => {
expect(getMarkdownDocCompletionDocuments(documents, 'docs\\se')).toEqual([documents[0]])
})
})
@@ -0,0 +1,43 @@
import type { MarkdownDocument } from '../../../../shared/types'
export type MarkdownDocCompletionContext = {
partial: string
}
function normalizeCompletionText(value: string): string {
return value.trim().replaceAll('\\', '/').toLowerCase()
}
export function getMarkdownDocCompletionContext(
linePrefix: string
): MarkdownDocCompletionContext | null {
const start = linePrefix.lastIndexOf('[[')
if (start === -1) {
return null
}
const partial = linePrefix.slice(start + 2)
if (partial.includes('[') || partial.includes(']')) {
return null
}
return { partial }
}
export function getMarkdownDocCompletionDocuments(
documents: MarkdownDocument[],
partial: string
): MarkdownDocument[] {
const normalizedPartial = normalizeCompletionText(partial)
return documents
.filter((document) => {
if (!normalizedPartial) {
return true
}
return (
normalizeCompletionText(document.name).startsWith(normalizedPartial) ||
normalizeCompletionText(document.relativePath).startsWith(normalizedPartial)
)
})
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
@@ -0,0 +1,198 @@
import { describe, expect, it } from 'vitest'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import Markdown from 'react-markdown'
import type { MarkdownDocument } from '../../../../shared/types'
import {
createMarkdownDocumentIndex,
createMarkdownDocLinkHref,
parseMarkdownDocLinkHref,
remarkMarkdownDocLinks,
resolveMarkdownDocLink,
splitMarkdownDocLinkText
} from './markdown-doc-links'
type TestNode = {
type: string
value?: string
url?: string
children?: TestNode[]
}
const documents: MarkdownDocument[] = [
{
filePath: '/repo/docs/setup-guide.md',
relativePath: 'docs/setup-guide.md',
basename: 'setup-guide.md',
name: 'setup-guide'
},
{
filePath: '/repo/notes/README.MDX',
relativePath: 'notes/README.MDX',
basename: 'README.MDX',
name: 'README'
}
]
describe('splitMarkdownDocLinkText', () => {
it('splits one doc link', () => {
expect(splitMarkdownDocLinkText('See [[setup-guide]].')).toEqual([
{ type: 'text', value: 'See ' },
{ type: 'docLink', target: 'setup-guide', label: 'setup-guide' },
{ type: 'text', value: '.' }
])
})
it('splits multiple doc links', () => {
expect(splitMarkdownDocLinkText('[[one]] and [[two]]')).toEqual([
{ type: 'docLink', target: 'one', label: 'one' },
{ type: 'text', value: ' and ' },
{ type: 'docLink', target: 'two', label: 'two' }
])
})
it('leaves unsupported forms as text', () => {
expect(splitMarkdownDocLinkText('[[]] [[doc|Label]] [[bad [target]] [[draft')).toEqual([
{ type: 'text', value: '[[]]' },
{ type: 'text', value: ' [[doc|Label]]' },
{ type: 'text', value: ' [[bad [target]]' },
{ type: 'text', value: ' [[draft' }
])
})
})
describe('resolveMarkdownDocLink', () => {
it('resolves basename links', () => {
const result = resolveMarkdownDocLink('setup-guide', createMarkdownDocumentIndex(documents))
expect(result.status).toBe('resolved')
expect(result.status === 'resolved' ? result.document.relativePath : null).toBe(
'docs/setup-guide.md'
)
})
it('resolves relative paths with or without extensions', () => {
const index = createMarkdownDocumentIndex(documents)
expect(resolveMarkdownDocLink('docs/setup-guide', index)).toMatchObject({
status: 'resolved',
document: { relativePath: 'docs/setup-guide.md' }
})
expect(resolveMarkdownDocLink('notes/README.MDX', index)).toMatchObject({
status: 'resolved',
document: { relativePath: 'notes/README.MDX' }
})
})
it('treats duplicate normalized basenames as ambiguous', () => {
const index = createMarkdownDocumentIndex([
...documents,
{
filePath: '/repo/other/Setup-Guide.md',
relativePath: 'other/Setup-Guide.md',
basename: 'Setup-Guide.md',
name: 'Setup-Guide'
}
])
expect(resolveMarkdownDocLink('setup-guide', index).status).toBe('ambiguous')
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',
createMarkdownDocumentIndex(documents)
)
expect(result.status).toBe('resolved')
})
it('returns missing for unknown links', () => {
expect(resolveMarkdownDocLink('missing-note', createMarkdownDocumentIndex(documents))).toEqual({
status: 'missing'
})
})
})
describe('doc link hrefs', () => {
it('round-trips encoded targets', () => {
const href = createMarkdownDocLinkHref('docs/setup guide')
expect(href).toBe('#orca-doc-link=docs%2Fsetup%20guide')
expect(parseMarkdownDocLinkHref(href)).toBe('docs/setup guide')
})
it('ignores normal hash links', () => {
expect(parseMarkdownDocLinkHref('#overview')).toBeNull()
})
})
describe('remarkMarkdownDocLinks', () => {
it('runs as a react-markdown remark plugin', () => {
const html = renderToStaticMarkup(
React.createElement(
Markdown,
{ remarkPlugins: [remarkMarkdownDocLinks] },
'link to [[other.md]]'
)
)
expect(html).toContain('<a href="#orca-doc-link=other.md">other.md</a>')
expect(html).not.toContain('[[other.md]]')
})
it('transforms text nodes but not code, links, or images', () => {
const tree: TestNode = {
type: 'root',
children: [
{ type: 'text', value: 'See [[setup-guide]]' },
{ type: 'inlineCode', value: '[[code]]' },
{ type: 'link', url: '[[url]]', children: [{ type: 'text', value: '[[label]]' }] },
{ type: 'image', url: '[[image]]' }
]
}
remarkMarkdownDocLinks()(tree)
expect(tree.children?.[0]).toMatchObject({
type: 'text',
value: 'See '
})
expect(tree.children?.[1]).toMatchObject({
type: 'link',
url: '#orca-doc-link=setup-guide'
})
expect(tree.children?.[2]).toEqual({ type: 'inlineCode', value: '[[code]]' })
expect(tree.children?.[3]).toEqual({
type: 'link',
url: '[[url]]',
children: [{ type: 'text', value: '[[label]]' }]
})
expect(tree.children?.[4]).toEqual({ type: 'image', url: '[[image]]' })
})
})
@@ -0,0 +1,220 @@
import type { MarkdownDocument } from '../../../../shared/types'
export const MARKDOWN_DOC_LINK_PREFIX = '#orca-doc-link='
type MarkdownTextNode = {
type: 'text'
value: string
}
type MarkdownLinkNode = {
type: 'link'
url: string
title: null
children: MarkdownTextNode[]
}
type MarkdownNode = {
type: string
value?: string
children?: MarkdownNode[]
}
export type MarkdownDocLinkTextPart =
| { type: 'text'; value: string }
| { type: 'docLink'; target: string; label: string }
export type MarkdownDocumentIndex = {
byName: Map<string, MarkdownDocument[]>
byRelativePath: Map<string, MarkdownDocument[]>
byRelativePathWithoutExtension: Map<string, MarkdownDocument[]>
}
export type MarkdownDocLinkResolution =
| { status: 'resolved'; document: MarkdownDocument }
| { status: 'missing' }
| { status: 'ambiguous'; matches: MarkdownDocument[] }
export function stripMarkdownExtension(value: string): string {
const lower = value.toLowerCase()
for (const extension of ['.markdown', '.mdx', '.md']) {
if (lower.endsWith(extension)) {
return value.slice(0, -extension.length)
}
}
return value
}
function normalizeDocLinkKey(value: string): string {
let normalized = value.trim().replaceAll('\\', '/')
while (normalized.startsWith('./')) {
normalized = normalized.slice(2)
}
return normalized.toLowerCase()
}
function addIndexedDocument(
map: Map<string, MarkdownDocument[]>,
key: string,
document: MarkdownDocument
): void {
const existing = map.get(key)
if (existing) {
existing.push(document)
} else {
map.set(key, [document])
}
}
function resolveMatches(matches: MarkdownDocument[] | undefined): MarkdownDocLinkResolution | null {
if (!matches) {
return null
}
return matches.length === 1
? { status: 'resolved', document: matches[0] }
: { status: 'ambiguous', matches }
}
export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): MarkdownDocumentIndex {
const byName = new Map<string, MarkdownDocument[]>()
const byRelativePath = new Map<string, MarkdownDocument[]>()
const byRelativePathWithoutExtension = new Map<string, MarkdownDocument[]>()
for (const document of documents) {
addIndexedDocument(byName, normalizeDocLinkKey(document.name), document)
addIndexedDocument(byRelativePath, normalizeDocLinkKey(document.relativePath), document)
addIndexedDocument(
byRelativePathWithoutExtension,
normalizeDocLinkKey(stripMarkdownExtension(document.relativePath)),
document
)
}
return { byName, byRelativePath, byRelativePathWithoutExtension }
}
export function resolveMarkdownDocLink(
target: string,
index: MarkdownDocumentIndex
): MarkdownDocLinkResolution {
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)
)
if (relativeWithoutExtension) {
return relativeWithoutExtension
}
if (!normalizedTarget.includes('/')) {
const byName = resolveMatches(index.byName.get(extensionlessTarget))
if (byName) {
return byName
}
}
return { status: 'missing' }
}
export function getMarkdownDocLinkTarget(rawTarget: string): string | null {
const target = rawTarget.trim()
if (!target || /[\r\n[\]|]/.test(target)) {
return null
}
return target
}
export function splitMarkdownDocLinkText(value: string): MarkdownDocLinkTextPart[] {
const parts: MarkdownDocLinkTextPart[] = []
let position = 0
while (position < value.length) {
const start = value.indexOf('[[', position)
if (start === -1) {
parts.push({ type: 'text', value: value.slice(position) })
break
}
const end = value.indexOf(']]', start + 2)
if (end === -1) {
parts.push({ type: 'text', value: value.slice(position) })
break
}
const target = getMarkdownDocLinkTarget(value.slice(start + 2, end))
if (!target) {
parts.push({ type: 'text', value: value.slice(position, end + 2) })
position = end + 2
continue
}
if (start > position) {
parts.push({ type: 'text', value: value.slice(position, start) })
}
parts.push({ type: 'docLink', target, label: target })
position = end + 2
}
return parts.length === 0 ? [{ type: 'text', value }] : parts
}
export function createMarkdownDocLinkHref(target: string): string {
return `${MARKDOWN_DOC_LINK_PREFIX}${encodeURIComponent(target)}`
}
export function parseMarkdownDocLinkHref(href: string | undefined): string | null {
if (!href?.startsWith(MARKDOWN_DOC_LINK_PREFIX)) {
return null
}
try {
return decodeURIComponent(href.slice(MARKDOWN_DOC_LINK_PREFIX.length))
} catch {
return null
}
}
function createDocLinkNode(target: string, label: string): MarkdownLinkNode {
return {
type: 'link',
url: createMarkdownDocLinkHref(target),
title: null,
children: [{ type: 'text', value: label }]
}
}
function transformChildren(node: MarkdownNode): void {
if (!node.children || node.type === 'link' || node.type === 'image') {
return
}
const nextChildren: MarkdownNode[] = []
for (const child of node.children) {
if (child.type === 'text' && child.value !== undefined) {
for (const part of splitMarkdownDocLinkText(child.value)) {
nextChildren.push(
part.type === 'text'
? { type: 'text', value: part.value }
: createDocLinkNode(part.target, part.label)
)
}
} else {
transformChildren(child)
nextChildren.push(child)
}
}
node.children = nextChildren
}
export function remarkMarkdownDocLinks(): (tree: MarkdownNode) => void {
return (tree) => transformChildren(tree)
}
@@ -35,4 +35,31 @@ describe('rich markdown round trip', () => {
it('preserves markdown tables', () => {
expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a')
})
it('preserves doc links', () => {
expect(roundTripMarkdown('See [[setup-guide]] for details\n')).toBe(
'See [[setup-guide]] for details'
)
})
it('preserves adjacent doc links', () => {
expect(roundTripMarkdown('[[one]][[two]]\n')).toBe('[[one]][[two]]')
})
it('preserves doc links with paths', () => {
expect(roundTripMarkdown('Link to [[docs/setup-guide.md]]\n')).toBe(
'Link to [[docs/setup-guide.md]]'
)
})
it('does not encode invalid doc links', () => {
const result = roundTripMarkdown('Empty [[]] and piped [[a|b]]\n')
expect(result).toContain('[[]]')
expect(result).toContain('[[a|b]]')
})
it('preserves doc links inside fenced code blocks as plain text', () => {
const input = '```\n[[not-a-link]]\n```\n'
expect(roundTripMarkdown(input)).toBe('```\n[[not-a-link]]\n```')
})
})
@@ -0,0 +1,70 @@
import type { OnMount } from '@monaco-editor/react'
import type { IDisposable } from 'monaco-editor'
import type { MarkdownDocument } from '../../../../shared/types'
import {
getMarkdownDocCompletionContext,
getMarkdownDocCompletionDocuments
} from './markdown-doc-completions'
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 {
// 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: ['['],
provideCompletionItems(model, position) {
const line = model.getLineContent(position.lineNumber)
const context = getMarkdownDocCompletionContext(line.slice(0, position.column - 1))
if (!context) {
return { suggestions: [] }
}
const documents = documentsByModel.get(model.uri.toString()) ?? []
const suffix = line.slice(position.column - 1)
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column - context.partial.length,
endLineNumber: position.lineNumber,
endColumn: position.column
}
return {
suggestions: getMarkdownDocCompletionDocuments(documents, context.partial).map(
(document) => ({
label: document.name,
kind: monaco.languages.CompletionItemKind.File,
detail: document.relativePath,
insertText: suffix.startsWith(']]') ? document.name : `${document.name}]]`,
range
})
)
}
}
})
}
export function setMarkdownDocCompletionDocuments(
modelKey: string,
documents: MarkdownDocument[]
): void {
documentsByModel.set(modelKey, documents)
}
export function clearMarkdownDocCompletionDocuments(modelKey: string): void {
documentsByModel.delete(modelKey)
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { getMarkdownDocLinkDecorationRanges } from './monaco-markdown-doc-link-decorations'
describe('getMarkdownDocLinkDecorationRanges', () => {
it('returns Monaco ranges for valid doc links', () => {
expect(getMarkdownDocLinkDecorationRanges('link to [[other.md]]')).toEqual([
{
startLineNumber: 1,
startColumn: 9,
endLineNumber: 1,
endColumn: 21
}
])
})
it('ignores unsupported doc link syntax', () => {
expect(getMarkdownDocLinkDecorationRanges('[[doc|Label]] [[bad [target]] [[]]')).toEqual([])
})
it('ignores doc links inside inline and fenced code', () => {
expect(
getMarkdownDocLinkDecorationRanges('`[[inline]]`\n\n```md\n[[fenced]]\n```\n[[real]]')
).toEqual([
{
startLineNumber: 6,
startColumn: 1,
endLineNumber: 6,
endColumn: 9
}
])
})
})
@@ -0,0 +1,107 @@
import type { editor, IDisposable, IRange } from 'monaco-editor'
import { getMarkdownDocLinkTarget } from './markdown-doc-links'
function getInlineCodeSpans(line: string): { start: number; end: number }[] {
const spans: { start: number; end: number }[] = []
let start = -1
for (let index = 0; index < line.length; index += 1) {
if (line[index] !== '`' || (index > 0 && line[index - 1] === '\\')) {
continue
}
if (start === -1) {
start = index
} else {
spans.push({ start, end: index + 1 })
start = -1
}
}
return spans
}
function isInsideSpan(index: number, spans: { start: number; end: number }[]): boolean {
return spans.some((span) => index >= span.start && index < span.end)
}
export function getMarkdownDocLinkDecorationRanges(content: string): IRange[] {
const ranges: IRange[] = []
let insideFence = false
content.split('\n').forEach((line, lineIndex) => {
if (/^\s*(```|~~~)/.test(line)) {
insideFence = !insideFence
return
}
if (insideFence) {
return
}
const inlineCodeSpans = getInlineCodeSpans(line)
let searchFrom = 0
while (searchFrom < line.length) {
const start = line.indexOf('[[', searchFrom)
if (start === -1) {
break
}
const end = line.indexOf(']]', start + 2)
if (end === -1) {
break
}
if (!isInsideSpan(start, inlineCodeSpans)) {
const target = getMarkdownDocLinkTarget(line.slice(start + 2, end))
if (target) {
ranges.push({
startLineNumber: lineIndex + 1,
startColumn: start + 1,
endLineNumber: lineIndex + 1,
endColumn: end + 3
})
}
}
searchFrom = end + 2
}
})
return ranges
}
export type MarkdownDocLinkDecorationController = {
refresh: () => void
dispose: () => void
}
export function createMarkdownDocLinkDecorationController(
editorInstance: editor.IStandaloneCodeEditor,
getLanguage: () => string
): MarkdownDocLinkDecorationController {
const collection = editorInstance.createDecorationsCollection()
const refresh = (): void => {
const model = editorInstance.getModel()
if (!model || getLanguage() !== 'markdown') {
collection.clear()
return
}
collection.set(
getMarkdownDocLinkDecorationRanges(model.getValue()).map((range) => ({
range,
options: {
inlineClassName: 'monaco-markdown-doc-link',
stickiness: 1
}
}))
)
}
const listener: IDisposable = editorInstance.onDidChangeModelContent(refresh)
refresh()
return {
refresh,
dispose: () => {
listener.dispose()
collection.clear()
}
}
}
@@ -1,7 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { getMarkdownDocLinkTarget } from './markdown-doc-links'
const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:'
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
const PLACEHOLDER_SUFFIX = ']]'
const INLINE_HTML_PATTERN = /^<!--[\s\S]*?-->|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
@@ -172,6 +174,28 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
}
// Why: doc link encoding runs inside the same while loop (not a separate
// pre-pass) so that fenced code blocks and backtick code spans are already
// skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding
// sibling placeholders that were already emitted earlier in this pass.
if (
content[index] === '[' &&
content[index + 1] === '[' &&
!content.startsWith('[[ORCA_', index) &&
!isEscaped(content, index)
) {
const closingIndex = content.indexOf(']]', index + 2)
if (closingIndex !== -1) {
const rawTarget = content.slice(index + 2, closingIndex)
const target = getMarkdownDocLinkTarget(rawTarget)
if (target) {
result += `${DOC_LINK_PLACEHOLDER_PREFIX}${target}${PLACEHOLDER_SUFFIX}`
index = closingIndex + 2
continue
}
}
}
const nextChar = content[index]
result += nextChar
isLineStart = nextChar === '\n'
@@ -1,6 +1,8 @@
import React from 'react'
import type { Editor } from '@tiptap/react'
import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
import type { MarkdownDocument } from '../../../../shared/types'
import { stripMarkdownExtension } from './markdown-doc-links'
export type SlashMenuState = {
query: string
@@ -10,6 +12,23 @@ export type SlashMenuState = {
top: number
}
export type DocLinkMenuState = {
query: string
// Why: `from` points at the first `[` of the `[[` trigger (not after the brackets)
// so the commit path's deleteRange({ from, to }) removes `[[query` entirely before
// inserting the atom node.
from: number
to: number
left: number
top: number
}
// Why: discriminated union so v2 can add non-document rows (e.g., "Create <query>")
// without refactoring the commit path. v1 only emits `document` rows.
export type DocLinkMenuRow =
| { kind: 'document'; document: MarkdownDocument }
| { kind: 'action'; id: string; label: string; run: (editor: Editor) => void }
export type SlashCommandId =
| 'text'
| 'heading-1'
@@ -221,3 +240,116 @@ export function syncSlashMenu(
top: coords.bottom - rect.top + 8
})
}
// Why: the leading alternation `(^|[\s(])` is a mid-word guard so typing `foo[[`
// inside a word does not fire the popover — `[[` only triggers at start-of-block,
// after whitespace, or after `(`. `[^[\]|\r\n]*` bans characters that would break
// the trigger contract (nested brackets) or belong to a deferred v1 feature (`|`
// display-text override).
const DOC_LINK_TRIGGER_REGEX = /(^|[\s(])\[\[([^[\]|\r\n]*)$/
/**
* Inserts an atom doc-link node at the trigger range. Directly creating the
* markdownDocLink node (instead of typing `[[target]]` and relying on the
* auto-convert plugin) keeps undo to a single step and avoids a one-tick
* flicker where the inline preview decoration would also run.
*/
export function runDocLinkCommand(
editor: Editor,
menu: { from: number; to: number },
document: MarkdownDocument
): void {
const target = stripMarkdownExtension(document.relativePath)
editor
.chain()
.focus()
.deleteRange({ from: menu.from, to: menu.to })
.insertContentAt(menu.from, { type: 'markdownDocLink', attrs: { target } })
.run()
}
/**
* Single commit entrypoint shared by click and keyboard paths. Dispatches on
* the row kind so v2 action rows plug in without refactoring either caller.
*/
export function commitRow(editor: Editor, menu: DocLinkMenuState, row: DocLinkMenuRow): void {
if (row.kind === 'document') {
runDocLinkCommand(editor, menu, row.document)
return
}
// Why: action rows (v2 "Create <query>") delete the trigger text and hand
// off to their own run(). v1 never emits action rows so this branch is dead
// today, but the shape is in place so the hook point costs nothing later.
editor.chain().focus().deleteRange({ from: menu.from, to: menu.to }).run()
row.run(editor)
}
/**
* Mirror of `syncSlashMenu` for the `[[...]]` doc-link trigger. Every bail
* path MUST call setDocLinkMenu(null) so a previously-open popover closes
* when the cursor leaves the trigger region.
*/
export function syncDocLinkMenu(
editor: Editor,
root: HTMLDivElement | null,
setDocLinkMenu: React.Dispatch<React.SetStateAction<DocLinkMenuState | null>>
): void {
// Guard 1: not editable, IME composing, or non-empty selection.
if (!root || editor.view.composing || !editor.isEditable) {
setDocLinkMenu(null)
return
}
const { state, view } = editor
const { selection } = state
if (!selection.empty) {
setDocLinkMenu(null)
return
}
// Guard 2: must be inside a textblock.
const { $from } = selection
if (!$from.parent.isTextblock) {
setDocLinkMenu(null)
return
}
// Guard 3: skip code contexts (fenced code, inline code mark). Inside code,
// `[[` is literal text and must not open the popover.
if ($from.parent.type.spec.code) {
setDocLinkMenu(null)
return
}
const codeMarkType = state.schema.marks.code
if (codeMarkType && state.doc.rangeHasMark($from.pos, $from.pos, codeMarkType)) {
setDocLinkMenu(null)
return
}
// Guard 4/5: extract block text and match the trigger regex.
const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
const match = blockTextBeforeCursor.match(DOC_LINK_TRIGGER_REGEX)
if (!match) {
setDocLinkMenu(null)
return
}
// Why: use lastIndexOf('[[') for the `from` position because match.index points
// at the boundary char captured by group 1 (whitespace/`(`), not at the first
// `[`. Using match.index directly would make deleteRange({ from, to }) also
// eat the preceding whitespace/paren. `[[` cannot appear inside capture group 2
// (the negated class excludes `[`), so lastIndexOf is unambiguous.
const bracketOffset = blockTextBeforeCursor.lastIndexOf('[[')
const from = selection.from - ($from.parentOffset - bracketOffset)
const coords = view.coordsAtPos(selection.from)
const rect = root.getBoundingClientRect()
setDocLinkMenu({
query: match[2] ?? '',
from,
to: selection.from,
left: coords.left - rect.left,
top: coords.bottom - rect.top + 8
})
}
@@ -0,0 +1,313 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { MarkdownDocument } from '../../../../shared/types'
import type { MarkdownDocumentIndex } from './markdown-doc-links'
import {
createMarkdownDocumentIndex,
getMarkdownDocLinkTarget,
resolveMarkdownDocLink
} from './markdown-doc-links'
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
const DOC_LINK_PLACEHOLDER_SUFFIX = ']]'
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
const DOC_LINK_PATTERN = /\[\[([^[\]\r\n|]+)\]\]/g
const docLinkDissolveKey = new PluginKey('docLinkDissolve')
const docLinkAutoConvertKey = new PluginKey('docLinkAutoConvert')
const docLinkInlinePreviewKey = new PluginKey('docLinkInlinePreview')
type DocLinkStorage = {
documents: MarkdownDocument[]
_cachedDocs: MarkdownDocument[] | null
_cachedIndex: MarkdownDocumentIndex | null
}
function getDocIndex(storage: DocLinkStorage): MarkdownDocumentIndex | null {
if (storage.documents.length === 0) {
// Why: clear the cache so stale MarkdownDocument references aren't retained
// after the document list empties (e.g., when switching worktrees).
storage._cachedDocs = null
storage._cachedIndex = null
return null
}
if (storage._cachedDocs !== storage.documents) {
storage._cachedIndex = createMarkdownDocumentIndex(storage.documents)
storage._cachedDocs = storage.documents
}
return storage._cachedIndex
}
function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): DecorationSet {
const decorations: Decoration[] = []
const index = getDocIndex(storage)
const cursor = state.selection.from
state.doc.descendants((node, pos) => {
if (node.type.name !== 'text' || !node.text) {
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const target = getMarkdownDocLinkTarget(match[1])
if (!target || match.index === undefined) {
continue
}
const from = pos + match.index
const to = from + match[0].length
// Why: only decorate the match the cursor is currently editing. Other
// `[[target]]` matches are auto-converted to atom nodes on the next
// transaction, so decorating them here just causes a one-frame flicker.
if (cursor <= from || cursor > to) {
continue
}
const resolved = resolveAgainstIndex(target, index)
const cls = resolved
? 'rich-markdown-doc-link-preview'
: 'rich-markdown-doc-link-preview rich-markdown-doc-link-preview--missing'
decorations.push(Decoration.inline(from, to, { class: cls }))
}
})
return DecorationSet.create(state.doc, decorations)
}
function resolveAgainstIndex(target: string, index: MarkdownDocumentIndex | null): boolean {
if (!index) {
return false
}
return resolveMarkdownDocLink(target, index).status === 'resolved'
}
export const MarkdownDocLink = Node.create({
name: 'markdownDocLink',
inline: true,
group: 'inline',
atom: true,
selectable: true,
addStorage() {
return {
documents: [] as MarkdownDocument[],
_cachedDocs: null as MarkdownDocument[] | null,
_cachedIndex: null as MarkdownDocumentIndex | null
}
},
addAttributes() {
return {
target: {
default: '',
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
}
}
},
markdownTokenName: 'markdownDocLink',
markdownTokenizer: {
name: 'markdownDocLink',
level: 'inline',
start: DOC_LINK_PLACEHOLDER_PREFIX,
tokenize(src: string) {
if (!src.startsWith(DOC_LINK_PLACEHOLDER_PREFIX)) {
return undefined
}
const endIndex = src.indexOf(DOC_LINK_PLACEHOLDER_SUFFIX, DOC_LINK_PLACEHOLDER_PREFIX.length)
if (endIndex === -1) {
return undefined
}
const placeholder = src.slice(0, endIndex + DOC_LINK_PLACEHOLDER_SUFFIX.length)
const target = src.slice(DOC_LINK_PLACEHOLDER_PREFIX.length, endIndex)
return {
type: 'markdownDocLink',
raw: placeholder,
text: target
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'markdownDocLink') {
return []
}
return helpers.createNode('markdownDocLink', {
target: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) =>
`[[${typeof node.attrs?.target === 'string' ? node.attrs.target : ''}]]`,
addNodeView() {
const storage = this.storage as DocLinkStorage
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
const getTarget = (n: { attrs: Record<string, unknown> }): string =>
typeof n.attrs.target === 'string' ? n.attrs.target : ''
const target = getTarget(node)
const dom = document.createElement('span')
dom.setAttribute('data-doc-link-target', target)
dom.setAttribute('contenteditable', 'false')
dom.textContent = target
const applyResolutionClass = (t: string): void => {
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
dom.className = resolved
? 'rich-markdown-doc-link'
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
}
applyResolutionClass(target)
return {
dom,
// Why: this fires on every transaction, including the no-op dispatched
// when the document list changes in storage. Re-checking resolution
// here keeps the blue/grey styling current without a full re-render.
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
if (updatedNode.type.name !== 'markdownDocLink') {
return false
}
const newTarget = getTarget(updatedNode)
dom.setAttribute('data-doc-link-target', newTarget)
dom.textContent = newTarget
applyResolutionClass(newTarget)
return true
}
}
}
},
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
// any order — brackets first then target, paste, etc. — converts to a doc
// link node. Input rules only fire on sequential append at the cursor.
addProseMirrorPlugins() {
const nodeType = this.type
const storage = this.storage as DocLinkStorage
return [
// Why: when the cursor is adjacent to a doc link atom and the user presses
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
// Without this, atom nodes are un-enterable — the cursor jumps over them.
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
// keys and let Shift+Arrow extend the selection normally.
new Plugin({
key: docLinkDissolveKey,
props: {
handleKeyDown(view, event) {
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
return false
}
let direction: 'left' | 'right'
if (event.key === 'ArrowLeft') {
direction = 'left'
} else if (event.key === 'ArrowRight') {
direction = 'right'
} else {
return false
}
const { state } = view
if (!(state.selection instanceof TextSelection)) {
return false
}
const { $from } = state.selection
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
return false
}
const target = typeof adjacent.attrs.target === 'string' ? adjacent.attrs.target : ''
const text = `[[${target}]]`
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
const nodeEnd = nodeStart + adjacent.nodeSize
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
view.dispatch(tr)
return true
}
}
}),
new Plugin({
key: docLinkAutoConvertKey,
appendTransaction(_transactions, _oldState, newState) {
const { tr } = newState
const cursor = newState.selection.from
let modified = false
newState.doc.descendants((node, pos) => {
if (node.type.name !== 'text' || !node.text) {
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const target = getMarkdownDocLinkTarget(match[1])
if (!target || match.index === undefined) {
continue
}
const from = pos + match.index
const to = from + match[0].length
// Why: skip when the cursor is anywhere from just inside [[
// through the closing ]]. The inline preview decoration gives
// real-time resolution feedback while the user is still editing.
if (cursor > from && cursor <= to) {
continue
}
const docLinkNode = nodeType.create({ target })
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
modified = true
}
})
return modified ? tr : null
}
}),
// Why: while the cursor is inside [[target]], the text hasn't converted
// to an atom node yet. This decoration gives real-time blue/grey feedback
// so the user knows whether the target resolves before moving the cursor out.
new Plugin({
key: docLinkInlinePreviewKey,
state: {
init(_, state) {
return buildPreviewDecorations(state, storage)
},
apply(tr, prev, oldState, newState) {
const selectionMoved = !oldState.selection.eq(newState.selection)
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
return prev
}
return buildPreviewDecorations(newState, storage)
}
},
props: {
decorations(state) {
return docLinkInlinePreviewKey.getState(state)
}
}
})
]
},
parseHTML() {
return [{ tag: 'span[data-doc-link-target]' }]
},
renderHTML({ HTMLAttributes, node }) {
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-doc-link-target': target,
contenteditable: 'false',
class: 'rich-markdown-doc-link'
}),
target
]
}
})
@@ -14,6 +14,7 @@ import { Markdown } from '@tiptap/markdown'
import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
import { MarkdownDocLink } from './rich-markdown-doc-link'
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
import { DragSelectionGuard } from './drag-selection-guard'
@@ -133,6 +134,7 @@ export function createRichMarkdownExtensions({
TableCell,
RawMarkdownHtmlInline,
RawMarkdownHtmlBlock,
MarkdownDocLink,
DragSelectionGuard,
Markdown.configure({
markedOptions: {
@@ -2,7 +2,14 @@ import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
import type { Editor } from '@tiptap/react'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import { runSlashCommand, type SlashCommand, type SlashMenuState } from './rich-markdown-commands'
import {
commitRow,
runSlashCommand,
type DocLinkMenuRow,
type DocLinkMenuState,
type SlashCommand,
type SlashMenuState
} from './rich-markdown-commands'
export type KeyHandlerContext = {
isMac: boolean
@@ -15,13 +22,18 @@ export type KeyHandlerContext = {
slashMenuRef: MutableRefObject<SlashMenuState | null>
filteredSlashCommandsRef: MutableRefObject<SlashCommand[]>
selectedCommandIndexRef: MutableRefObject<number>
docLinkMenuRef: MutableRefObject<DocLinkMenuState | null>
filteredDocLinkRowsRef: MutableRefObject<DocLinkMenuRow[]>
selectedDocLinkIndexRef: MutableRefObject<number>
handleLocalImagePickRef: MutableRefObject<() => void>
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
setIsEditingLink: (editing: boolean) => void
setLinkBubble: (bubble: LinkBubbleState | null) => void
setSelectedCommandIndex: Dispatch<SetStateAction<number>>
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
setSlashMenu: (menu: SlashMenuState | null) => void
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
}
/**
@@ -85,10 +97,10 @@ export function createRichMarkdownKeyHandler(
}
// Tab/Shift-Tab: indent/outdent lists, insert spaces in code blocks,
// and prevent focus from escaping the editor. When the slash menu is
// open, Tab selects a command instead (handled in the slash-menu block
// below).
if (event.key === 'Tab' && !ctx.slashMenuRef.current) {
// and prevent focus from escaping the editor. When the slash menu or
// doc-link menu is open, Tab selects a row instead (handled in the
// menu blocks below).
if (event.key === 'Tab' && !ctx.slashMenuRef.current && !ctx.docLinkMenuRef.current) {
event.preventDefault()
const ed = ctx.editorRef.current
if (!ed) {
@@ -115,6 +127,67 @@ export function createRichMarkdownKeyHandler(
return true
}
// ── Doc-link menu navigation ──────────────────────
// Why: this block MUST be registered before the slash-menu block below.
// The slash-menu block early-returns with `return false` when no slash
// menu is open, which short-circuits every subsequent handler — a
// doc-link block placed after it would be dead code. When THIS menu is
// closed, fall through (no early return) so the slash-menu block below
// still gets a chance.
const currentDocLinkMenu = ctx.docLinkMenuRef.current
if (currentDocLinkMenu) {
const currentFilteredDocLinkRows = ctx.filteredDocLinkRowsRef.current
const activeEditorForDocLink = ctx.editorRef.current
if (event.key === 'ArrowDown') {
if (currentFilteredDocLinkRows.length === 0) {
return false
}
event.preventDefault()
ctx.setSelectedDocLinkIndex(
(currentIndex) => (currentIndex + 1) % currentFilteredDocLinkRows.length
)
return true
}
if (event.key === 'ArrowUp') {
if (currentFilteredDocLinkRows.length === 0) {
return false
}
event.preventDefault()
ctx.setSelectedDocLinkIndex(
(currentIndex) =>
(currentIndex - 1 + currentFilteredDocLinkRows.length) %
currentFilteredDocLinkRows.length
)
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
// Why: with zero rows (empty state), Enter must fall through so it
// behaves as a normal paragraph break instead of silently eating the
// keystroke. syncDocLinkMenu closes the popover on the next tick.
if (currentFilteredDocLinkRows.length === 0 || !activeEditorForDocLink) {
return false
}
event.preventDefault()
const selectedRow =
currentFilteredDocLinkRows[ctx.selectedDocLinkIndexRef.current] ??
currentFilteredDocLinkRows[0]
if (selectedRow) {
commitRow(activeEditorForDocLink, currentDocLinkMenu, selectedRow)
}
return true
}
if (event.key === 'Escape') {
event.preventDefault()
ctx.setDocLinkMenu(null)
return true
}
// Any other key (including ArrowLeft/ArrowRight, Backspace, printable
// characters) falls through. syncDocLinkMenu re-runs on the next
// onUpdate/onSelectionUpdate and closes or refreshes the popover based
// on whether the trigger still matches.
}
// ── Slash menu navigation ─────────────────────────
const currentSlashMenu = ctx.slashMenuRef.current
if (!currentSlashMenu) {
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { getConnectionId } from '@/lib/connection-context'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import { createMarkdownDocumentIndex, resolveMarkdownDocLink } from './markdown-doc-links'
type UseMarkdownDocumentsResult = {
markdownDocuments: MarkdownDocument[]
openMarkdownDocument: (document: MarkdownDocument) => Promise<void>
onOpenDocLink: (target: string) => void
previewProps: {
markdownDocuments: MarkdownDocument[]
onOpenDocument: (document: MarkdownDocument) => Promise<void>
}
mdSave: (content: string) => Promise<void>
}
export function useMarkdownDocuments(
activeFile: OpenFile,
isMarkdown: boolean,
viewMode: MarkdownViewMode,
onSave: (content: string) => Promise<void>
): UseMarkdownDocumentsResult {
const worktreeId = activeFile.worktreeId
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const openFile = useAppStore((s) => s.openFile)
const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState<
Record<string, MarkdownDocument[]>
>({})
const requestRef = useRef(0)
const worktreePath = useMemo(() => {
if (!worktreeId) {
return null
}
return findWorktreeById(worktreesByRepo, worktreeId)?.path ?? null
}, [worktreeId, worktreesByRepo])
const connectionId = getConnectionId(worktreeId)
const refreshMarkdownDocuments = useCallback(async (): Promise<void> => {
if (!worktreeId || !worktreePath) {
return
}
const requestId = requestRef.current + 1
requestRef.current = requestId
try {
const documents = await window.api.fs.listMarkdownDocuments({
rootPath: worktreePath,
connectionId: connectionId ?? undefined
})
if (requestRef.current !== requestId) {
return
}
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: documents
}))
} catch (err) {
console.error('Failed to list markdown documents:', err)
if (requestRef.current === requestId) {
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: []
}))
}
}
}, [connectionId, worktreeId, worktreePath])
const openMarkdownDocument = useCallback(
async (document: MarkdownDocument): Promise<void> => {
if (!worktreeId) {
return
}
try {
const stats = await window.api.fs.stat({
filePath: document.filePath,
connectionId: connectionId ?? undefined
})
if (stats.isDirectory) {
await refreshMarkdownDocuments()
return
}
} catch {
await refreshMarkdownDocuments()
return
}
openFile({
filePath: document.filePath,
relativePath: document.relativePath,
worktreeId,
language: 'markdown',
mode: 'edit'
})
},
[connectionId, openFile, refreshMarkdownDocuments, worktreeId]
)
useEffect(() => {
if (!isMarkdown) {
return
}
void refreshMarkdownDocuments()
}, [activeFile.id, isMarkdown, viewMode, refreshMarkdownDocuments])
const markdownDocuments = useMemo(
() => (worktreeId ? (markdownDocumentsByWorktree[worktreeId] ?? []) : []),
[worktreeId, markdownDocumentsByWorktree]
)
const previewProps = useMemo(
() => ({ markdownDocuments, onOpenDocument: openMarkdownDocument }),
[markdownDocuments, openMarkdownDocument]
)
const mdSave = useCallback(
(content: string) => onSave(content).then(() => refreshMarkdownDocuments()),
[onSave, refreshMarkdownDocuments]
)
const docIndex = useMemo(
() => createMarkdownDocumentIndex(markdownDocuments),
[markdownDocuments]
)
const onOpenDocLink = useCallback(
(target: string) => {
const resolution = resolveMarkdownDocLink(target, docIndex)
if (resolution.status === 'resolved') {
void openMarkdownDocument(resolution.document)
}
},
[docIndex, openMarkdownDocument]
)
return { markdownDocuments, openMarkdownDocument, onOpenDocLink, previewProps, mdSave }
}
+7
View File
@@ -1202,6 +1202,13 @@ export type DirEntry = {
isSymlink: boolean
}
export type MarkdownDocument = {
filePath: string
relativePath: string
basename: string
name: string
}
// ─── Filesystem watcher ─────────────────────────────────────
export type FsChangeEvent = {
kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow'