mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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:
co-authored by
Orca
Jinwoo-H
parent
19c4888e17
commit
d4bbf473c1
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ↵ select 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 }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user