From d4bbf473c1a4fa8ad53fe51418e5f49125aa2cb7 Mon Sep 17 00:00:00 2001 From: Colin Chambachan Date: Fri, 1 May 2026 16:15:08 -0400 Subject: [PATCH] Support wiki-style Markdown document links (#1282) Co-authored-by: Orca Co-authored-by: Jinwoo-H --- src/main/ipc/filesystem.test.ts | 150 +++++++++ src/main/ipc/filesystem.ts | 22 ++ src/main/ipc/markdown-documents.ts | 98 ++++++ src/preload/api-types.ts | 5 + src/preload/index.ts | 5 + src/renderer/src/assets/main.css | 7 + src/renderer/src/assets/markdown-preview.css | 15 + .../src/assets/rich-markdown-editor.css | 104 ++++++ .../src/components/editor/EditorContent.tsx | 17 +- .../src/components/editor/MarkdownPreview.tsx | 67 +++- .../src/components/editor/MonacoEditor.tsx | 135 ++++---- .../editor/MonacoGutterContextMenu.tsx | 80 +++++ .../editor/RichMarkdownDocLinkMenu.tsx | 70 ++++ .../components/editor/RichMarkdownEditor.tsx | 102 +++++- .../editor/markdown-doc-completions.test.ts | 47 +++ .../editor/markdown-doc-completions.ts | 43 +++ .../editor/markdown-doc-links.test.ts | 198 +++++++++++ .../components/editor/markdown-doc-links.ts | 220 ++++++++++++ .../editor/markdown-round-trip.test.ts | 27 ++ .../editor/monaco-markdown-doc-completions.ts | 70 ++++ ...naco-markdown-doc-link-decorations.test.ts | 32 ++ .../monaco-markdown-doc-link-decorations.ts | 107 ++++++ .../components/editor/raw-markdown-html.ts | 24 ++ .../editor/rich-markdown-commands.tsx | 132 ++++++++ .../editor/rich-markdown-doc-link.ts | 313 ++++++++++++++++++ .../editor/rich-markdown-extensions.ts | 2 + .../editor/rich-markdown-key-handler.ts | 83 ++++- .../components/editor/useMarkdownDocuments.ts | 141 ++++++++ src/shared/types.ts | 7 + 29 files changed, 2244 insertions(+), 79 deletions(-) create mode 100644 src/main/ipc/markdown-documents.ts create mode 100644 src/renderer/src/components/editor/MonacoGutterContextMenu.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx create mode 100644 src/renderer/src/components/editor/markdown-doc-completions.test.ts create mode 100644 src/renderer/src/components/editor/markdown-doc-completions.ts create mode 100644 src/renderer/src/components/editor/markdown-doc-links.test.ts create mode 100644 src/renderer/src/components/editor/markdown-doc-links.ts create mode 100644 src/renderer/src/components/editor/monaco-markdown-doc-completions.ts create mode 100644 src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts create mode 100644 src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-doc-link.ts create mode 100644 src/renderer/src/components/editor/useMarkdownDocuments.ts diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 39b348fa04b..4cc0e88d50c 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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: { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 50bbf5bcf10..2e22c981af5 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 => { + 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 ( diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts new file mode 100644 index 00000000000..cedf854be0b --- /dev/null +++ b/src/main/ipc/markdown-documents.ts @@ -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 { + const documents: MarkdownDocument[] = [] + + async function visitDirectory(dirPath: string): Promise { + 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)) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0c5af49e584..829ea4b023f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise createFile: (args: { filePath: string; connectionId?: string }) => Promise createDir: (args: { dirPath: string; connectionId?: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 1b6b9ae8f75..a78945c1a23 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 69c2b8c4820..fe5ec79226b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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 diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index fb37e366e75..625696c41f1 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -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; diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 59c0231bd14..25bcaacd260 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -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 { diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index c9c69fbe60b..fd1f17d709a 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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 => handleSave(prependFrontMatter(fm.raw, body)) - : handleSave + ? (body: string): Promise => md.mdSave(prependFrontMatter(fm.raw, body)) + : md.mdSave return (
@@ -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} />
@@ -302,6 +307,7 @@ export function EditorContent({ filePath={activeFile.filePath} scrollCacheKey={markdownPreviewViewStateKey} initialAnchor={activeFile.markdownPreviewAnchor ?? null} + {...md.previewProps} /> ) @@ -403,6 +409,7 @@ export function EditorContent({ content={modifiedDiffContent} filePath={activeFile.filePath} scrollCacheKey={`${diffViewStateKey}:preview`} + {...md.previewProps} /> @@ -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} /> ) } diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index ecf96e162d6..a9fd3056483 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -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 } 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(null) const bodyRef = useRef(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): void => { + event.preventDefault() + if (resolvedDocument && onOpenDocument) { + void onOpenDocument(resolvedDocument) + } + } + + return ( + + {children} + + ) + } + const handleClick = (event: React.MouseEvent): void => { if (!href) { return @@ -547,7 +590,13 @@ export default function MarkdownPreview({ } return ( - + {children} ) @@ -644,6 +693,8 @@ export default function MarkdownPreview({ filePath, isDark, isMac, + markdownDocumentIndex, + onOpenDocument, openFile, openMarkdownPreview, scrollToAnchor, @@ -748,7 +799,13 @@ export default function MarkdownPreview({ )} (null) + const modelKeyRef = useRef(null) + const languageRef = useRef(language) + languageRef.current = language + const markdownDocLinkDecorationsRef = useRef(null) const revealDecorationRef = useRef(null) const revealHighlightTimerRef = useRef | null>(null) const revealRafRef = useRef(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 */} - - - + ) + }) + )} + {overflow ? ( +
+ Showing {rows.length} of {totalMatches} +
+ ) : null} +
+ ↑↓ navigate  ↵ select  esc dismiss +
+ + ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 8b2c25bba78..972440fd603 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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(null) @@ -78,14 +90,20 @@ export default function RichMarkdownEditor({ const scrollContainerRef = useRef(null) const [slashMenu, setSlashMenu] = useState(null) const [selectedCommandIndex, setSelectedCommandIndex] = useState(0) + const [docLinkMenu, setDocLinkMenu] = useState(null) + const [selectedDocLinkIndex, setSelectedDocLinkIndex] = useState(0) const isMac = navigator.userAgent.includes('Mac') const lastCommittedMarkdownRef = useRef(content) const slashMenuRef = useRef(null) const filteredSlashCommandsRef = useRef(slashCommands) const selectedCommandIndexRef = useRef(0) + const docLinkMenuRef = useRef(null) + const filteredDocLinkRowsRef = useRef([]) + 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 ? ( + + ) : null} ) } diff --git a/src/renderer/src/components/editor/markdown-doc-completions.test.ts b/src/renderer/src/components/editor/markdown-doc-completions.test.ts new file mode 100644 index 00000000000..c65386aecb4 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-completions.test.ts @@ -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]]) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-doc-completions.ts b/src/renderer/src/components/editor/markdown-doc-completions.ts new file mode 100644 index 00000000000..094920dd968 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-completions.ts @@ -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)) +} diff --git a/src/renderer/src/components/editor/markdown-doc-links.test.ts b/src/renderer/src/components/editor/markdown-doc-links.test.ts new file mode 100644 index 00000000000..01b43fa7a79 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-links.test.ts @@ -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('other.md') + 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]]' }) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-doc-links.ts b/src/renderer/src/components/editor/markdown-doc-links.ts new file mode 100644 index 00000000000..faffeca6b14 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-links.ts @@ -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 + byRelativePath: Map + byRelativePathWithoutExtension: Map +} + +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, + 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() + const byRelativePath = new Map() + const byRelativePathWithoutExtension = new Map() + + 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) +} diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 20e7c8d64ad..c20c5355f88 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -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```') + }) }) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts new file mode 100644 index 00000000000..882b011e9b3 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts @@ -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[1] + +let provider: IDisposable | null = null +let providerMonaco: MonacoApi | null = null +const documentsByModel = new Map() + +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) +} diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts new file mode 100644 index 00000000000..06b23e8a225 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts @@ -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 + } + ]) + }) +}) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts new file mode 100644 index 00000000000..893ae8062a6 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts @@ -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() + } + } +} diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index 3b2184d2653..3f53598aaf4 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -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 = /^|^<\/?[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' diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx index a11664e9007..aee9405ce83 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx @@ -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 ") +// 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 ") 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> +): 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 + }) +} diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts new file mode 100644 index 00000000000..f7df555bceb --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -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 } }) => { + const getTarget = (n: { attrs: Record }): 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 }) => { + 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 + ] + } +}) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 9eb32788ba2..00479bfd89f 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -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: { diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index d4734b86f2f..61fc9982e5b 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -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 filteredSlashCommandsRef: MutableRefObject selectedCommandIndexRef: MutableRefObject + docLinkMenuRef: MutableRefObject + filteredDocLinkRowsRef: MutableRefObject + selectedDocLinkIndexRef: MutableRefObject handleLocalImagePickRef: MutableRefObject<() => void> flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> setIsEditingLink: (editing: boolean) => void setLinkBubble: (bubble: LinkBubbleState | null) => void setSelectedCommandIndex: Dispatch> + setSelectedDocLinkIndex: Dispatch> 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) { diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts new file mode 100644 index 00000000000..f0fdb092c7d --- /dev/null +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -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 + onOpenDocLink: (target: string) => void + previewProps: { + markdownDocuments: MarkdownDocument[] + onOpenDocument: (document: MarkdownDocument) => Promise + } + mdSave: (content: string) => Promise +} + +export function useMarkdownDocuments( + activeFile: OpenFile, + isMarkdown: boolean, + viewMode: MarkdownViewMode, + onSave: (content: string) => Promise +): UseMarkdownDocumentsResult { + const worktreeId = activeFile.worktreeId + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const openFile = useAppStore((s) => s.openFile) + const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState< + Record + >({}) + 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 => { + 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 => { + 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 } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 945b33403a9..302f92e55d3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'