feat(markdown): support HTML superscript links (#8307)

* feat(markdown): support HTML superscript links

* Add HTML superscript citation links to the rich markdown editor

- Introduce marked-based tokenizer support for `<sup><a>` citation
  links, with a shared editor context so key/link handlers can resolve
  selection status and open an action bubble for citation atoms
  (which markdown setLink/unsetLink can't edit).
- Extract clipboard-write logic shared by cut and cut-visual-line into
  rich-markdown-clipboard-write.ts, and surface a cut-limit error when
  clipboard readback fails.
- Fix MarkdownPreview same-file anchor scrolling to run before the
  unknown-ownership guard so ambiguous folder-workspace ownership
  still scrolls within the open document.
- Keep search's replace-disabled state derived from live matches
  instead of a stale snapshot.

* Update lockfile to dedupe stale package versions after dependency resolu

- Removes duplicate/superseded resolutions (older @babel/* versions, fs-extra 11.3.4/11.3.5, plist 3.1.0, semver 7.7.4) left over from a prior install
- Aligns transitive deps to the single resolved versions already in use elsewhere in the tree
This commit is contained in:
Jinjing
2026-07-11 14:51:52 -07:00
committed by GitHub
parent 6be8687c34
commit ac8879c789
82 changed files with 4301 additions and 1048 deletions
@@ -1026,6 +1026,27 @@
color: #58a6ff;
}
.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] {
line-height: 0;
}
.rich-markdown-editor .rich-markdown-html-superscript-link {
color: #0969da;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-html-superscript-link {
color: #58a6ff;
}
.rich-markdown-mod-held
.rich-markdown-editor
.rich-markdown-html-superscript-link[data-actionable] {
cursor: pointer;
}
.rich-markdown-editor img {
max-width: 100%;
border-radius: 8px;
@@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react'
import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
import {
createRichMarkdownEditorCodec,
type RichMarkdownEditorCodec
} from '@/components/editor/rich-markdown-source-transport'
import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { cn } from '@/lib/utils'
@@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = {
submitShortcutLabel: string
}
function createLinearIssueMarkdownExtensions() {
const extensions = createRichMarkdownExtensions()
function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) {
const extensions = createRichMarkdownExtensions({ codec })
return [
...extensions,
Placeholder.configure({
@@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({
const richMarkdownSpellcheckEnabled = useAppStore(
(s) => s.settings?.richMarkdownSpellcheckEnabled ?? true
)
// Why: changing language recreates Tiptap and re-registers tokenizers, so it
// must also receive a fresh private Marked registry instead of growing one.
const codec = useMemo(() => {
void language
return createRichMarkdownEditorCodec()
}, [language])
const linearIssueMarkdownExtensions = useMemo(() => {
// Why: Tiptap freezes extension options when the editor is created; the
// language value is the recreation key for translated extension options.
void language
return createLinearIssueMarkdownExtensions()
}, [language])
return createLinearIssueMarkdownExtensions(codec)
}, [codec, language])
const editor = useEditor(
{
immediatelyRender: false,
extensions: linearIssueMarkdownExtensions,
content: encodeRawMarkdownHtmlForRichEditor(value),
content: encodeRawMarkdownHtmlForRichEditor(value, codec),
contentType: 'markdown',
editable: !disabled,
editorProps: {
@@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({
onChange(nextValue)
}
},
[language]
[codec, language]
)
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
@@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({
// Why: Linear remains the source of truth when the selected issue changes
// or an optimistic save is reverted; keep the rich view aligned with it.
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
contentType: 'markdown',
emitUpdate: false
})
lastEditorMarkdownRef.current = value
}, [editor, value])
}, [codec, editor, value])
return (
<div
@@ -15,6 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const createBrowserTabMock = vi.fn()
const setActiveWorktreeMock = vi.fn()
const openUrlMock = vi.fn()
const openFileUriMock = vi.fn()
const pathExistsMock = vi.fn(async () => true)
const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined }))
const targetConnectionOwners = vi.hoisted(() => new Map<string, string | null | undefined>())
const worktreeLookup = vi.hoisted(() => ({
value: [] as { id: string; path: string; diffComments: never[] }[]
}))
const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false })))
// Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful
// of action functions. None of the actions fire on the http path under test.
@@ -31,6 +39,9 @@ const storeState = {
clearDeliveredDiffComments: vi.fn(),
keybindings: {},
worktreesByRepo: {},
repos: [],
folderWorkspaces: [],
projectGroups: [],
openFiles: [],
activeFileIdByWorktree: {},
settings: { openLinksInApp: true },
@@ -44,14 +55,22 @@ vi.mock('@/store', () => {
)
return { useAppStore }
})
vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null }))
vi.mock('@/store/slices/worktree-helpers', () => ({
findWorktreeById: (_worktrees: unknown, id: string) =>
worktreeLookup.value.find((worktree) => worktree.id === id) ?? null
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
settingsForRuntimeOwner: (settings: unknown) => settings
}))
vi.mock('@/runtime/runtime-file-client', () => ({
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
statRuntimePath: statRuntimePathMock
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId)
}))
vi.mock('@/lib/connection-owner-resolution', () => ({
createConnectionIdForFileSelector: () => () => connectionOwner.value
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
vi.mock('./MermaidBlock', () => ({ default: () => null }))
@@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
;(window as unknown as { api: unknown }).api = {
shell: {
openUrl: openUrlMock,
openFileUri: vi.fn(),
pathExists: vi.fn(async () => true)
openFileUri: openFileUriMock,
pathExists: pathExistsMock
},
ui: { writeClipboardText: vi.fn(async () => true) }
}
@@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
createBrowserTabMock.mockClear()
setActiveWorktreeMock.mockClear()
openUrlMock.mockClear()
openFileUriMock.mockClear()
pathExistsMock.mockClear()
connectionOwner.value = null
targetConnectionOwners.clear()
worktreeLookup.value = []
storeState.worktreesByRepo = {}
storeState.openMarkdownPreview.mockClear()
statRuntimePathMock.mockClear()
})
afterEach(() => {
@@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
container.remove()
})
function render(): HTMLAnchorElement {
function render(
content = '[example](https://example.com)',
expectedHref = 'https://example.com',
sourceWorktreeId = 'wt-1',
filePath = '/repo/docs/README.md'
): HTMLAnchorElement {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root.render(
<MarkdownPreview
content="[example](https://example.com)"
filePath="/repo/docs/README.md"
sourceWorktreeId="wt-1"
content={content}
filePath={filePath}
sourceWorktreeId={sourceWorktreeId}
scrollCacheKey="test-key"
/>
)
})
const anchor = container.querySelector<HTMLAnchorElement>('a[href="https://example.com"]')
const anchor = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find(
(candidate) => candidate.getAttribute('href') === expectedHref
)
if (!anchor) {
throw new Error('expected a rendered http anchor')
}
@@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
it('keeps system-browser HTTP and file links inert while ownership is unknown', () => {
connectionOwner.value = undefined
const httpAnchor = render()
click(httpAnchor, { metaKey: true, shiftKey: true })
expect(openUrlMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).not.toHaveBeenCalled()
act(() => {
root.unmount()
})
container.remove()
const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md')
click(fileAnchor, { metaKey: true, shiftKey: true })
expect(pathExistsMock).not.toHaveBeenCalled()
expect(openFileUriMock).not.toHaveBeenCalled()
})
it('keeps same-path file links on the explicit SSH source worktree', async () => {
const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] }
const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] }
worktreeLookup.value = [localWorktree, sshWorktree]
storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] }
connectionOwner.value = 'ssh-1'
targetConnectionOwners.set('local-wt', null)
targetConnectionOwners.set('ssh-wt', 'ssh-1')
const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md')
await act(async () => {
anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }))
})
expect(statRuntimePathMock).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }),
'/srv/repo/child.md'
)
expect(storeState.openMarkdownPreview).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }),
{ anchor: null }
)
})
it('renders a raw HTML superscript citation and routes it like a Markdown link', () => {
const source = '<sup><a href="https://example.com">[12]</a></sup>'
const anchor = render(source)
expect(anchor.parentElement?.tagName).toBe('SUP')
expect(anchor.textContent).toBe('[12]')
expect(container.textContent).not.toContain('<sup>')
click(anchor, { metaKey: true })
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
activate: true
})
expect(openUrlMock).not.toHaveBeenCalled()
})
})
@@ -28,6 +28,9 @@ const storeState = {
clearDeliveredDiffComments: vi.fn(),
keybindings: {},
worktreesByRepo: {},
repos: [],
folderWorkspaces: [],
projectGroups: [],
openFiles: [],
activeFileIdByWorktree: {},
settings: { openLinksInApp: true },
@@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
vi.mock('@/runtime/runtime-file-client', () => ({
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null }))
vi.mock('@/lib/connection-owner-resolution', () => ({
createConnectionIdForFileSelector: () => () => null
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
vi.mock('./MermaidBlock', () => ({ default: () => null }))
@@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input'
import { useAppStore } from '@/store'
import { toast } from 'sonner'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { getConnectionId } from '@/lib/connection-context'
import { getConnectionIdForFile } from '@/lib/connection-context'
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { detectLanguage } from '@/lib/language-detect'
import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types'
@@ -68,7 +69,7 @@ import {
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
import { openHttpLink } from '@/lib/http-link-routing'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { markdownPreviewUrlTransform } from './markdown-preview-url-transform'
@@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot(
function findWorktreeForMarkdownPreviewPath(
worktreesByRepo: Record<string, Worktree[]>,
absolutePath: string
absolutePath: string,
acceptsWorktree: (worktree: Worktree) => boolean = () => true
): Worktree | null {
let bestMatch: Worktree | null = null
let bestMatchLength = -1
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
if (relativePathInsideRoot(worktree.path, absolutePath) !== null) {
if (
acceptsWorktree(worktree) &&
relativePathInsideRoot(worktree.path, absolutePath) !== null
) {
const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath(
worktree.path
).length
@@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath(
return bestMatch
}
function findMarkdownPreviewTargetWorktree(
worktreesByRepo: Record<string, Worktree[]>,
absolutePath: string,
sourceWorktree: Worktree | null,
sourceOwner: HttpLinkSourceOwner
): Worktree | null {
if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) {
return sourceWorktree
}
return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => {
const connectionId = getConnectionIdForFile(worktree.id, absolutePath)
if (sourceOwner.kind === 'local') {
return connectionId === null
}
if (sourceOwner.kind === 'ssh') {
return connectionId === sourceOwner.connectionId
}
return false
})
}
export function resolveMarkdownPreviewSourceWorktree(
worktreesByRepo: Record<string, Worktree[]>,
sourceWorktreeId: string | null | undefined,
@@ -498,9 +524,26 @@ export default function MarkdownPreview({
)
const allDiffComments = sourceWorktree?.diffComments
const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId
const sourceConnectionId = sourceRoutingWorktreeId
? (getConnectionId(sourceRoutingWorktreeId) ?? null)
: null
const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim()
const sourceConnectionIdSelector = useMemo(
() =>
createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, {
skip: Boolean(runtimeOwnerId)
}),
[filePath, runtimeOwnerId, sourceRoutingWorktreeId]
)
const sourceConnectionId = useAppStore(sourceConnectionIdSelector)
const sourceOwner = useMemo<HttpLinkSourceOwner>(
() =>
runtimeOwnerId
? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
: sourceConnectionId === undefined
? { kind: 'unknown' }
: sourceConnectionId === null
? { kind: 'local' }
: { kind: 'ssh', connectionId: sourceConnectionId },
[runtimeOwnerId, sourceConnectionId]
)
const worktreeRoot =
sourceWorktree?.path ??
(sourceRoutingWorktreeId
@@ -1254,6 +1297,9 @@ export default function MarkdownPreview({
// dangling in-worktree .md, pre-check existence so the user sees a
// toast instead of the silent no-op from shell.openFileUri.
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
if (sourceOwner.kind === 'unknown') {
return
}
const osTarget = getMarkdownPreviewLinkTarget(href, filePath)
if (!osTarget) {
return
@@ -1267,7 +1313,12 @@ export default function MarkdownPreview({
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
openHttpLink(
parsed.toString(),
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
resolveMarkdownPreviewHttpOpenOptions(
event,
isMac,
sourceRoutingWorktreeId,
sourceOwner
)
)
return
}
@@ -1326,7 +1377,12 @@ export default function MarkdownPreview({
// handled above; this path only sees non-escape-hatch clicks.)
openHttpLink(
target.toString(),
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
resolveMarkdownPreviewHttpOpenOptions(
event,
isMac,
sourceRoutingWorktreeId,
sourceOwner
)
)
return
}
@@ -1347,12 +1403,25 @@ export default function MarkdownPreview({
? { line: classifiedFileTarget.line, column: classifiedFileTarget.column }
: parseLineTarget(target.hash)
// Why: same-file anchors need no ownership/filesystem resolution (e.g.
// `./README.md#heading` when this file is README.md). Run before the
// unknown-ownership guard so ambiguous folder-workspace ownership still
// scrolls within the open document.
if (absolutePath === filePath && target.hash && !lineTarget) {
void scrollToAnchor(target.hash.slice(1))
return
}
const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath)
if (sourceOwner.kind === 'unknown') {
return
}
const targetWorktree = findMarkdownPreviewTargetWorktree(
worktreesByRepo,
absolutePath,
sourceWorktree,
sourceOwner
)
if (!targetWorktree) {
if (sourceRoutingWorktreeId && worktreeRoot) {
// Why: floating markdown files are owned by a synthetic workspace,
@@ -1362,7 +1431,8 @@ export default function MarkdownPreview({
sourceFilePath: filePath,
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
sourceOwner
})
return
}
@@ -1384,8 +1454,15 @@ export default function MarkdownPreview({
return
}
const relativePath = absolutePath.slice(targetWorktree.path.length + 1)
const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath)
if (relativePath === null) {
return
}
const language = detectLanguage(absolutePath)
const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath)
if (targetConnectionId === undefined) {
return
}
try {
const stats = await statRuntimePath(
{
@@ -1395,7 +1472,7 @@ export default function MarkdownPreview({
),
worktreeId: targetWorktree.id,
worktreePath: targetWorktree.path,
connectionId: getConnectionId(targetWorktree.id) ?? undefined
connectionId: targetConnectionId ?? undefined
},
absolutePath
)
@@ -1512,7 +1589,8 @@ export default function MarkdownPreview({
sourceFilePath: filePath,
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
sourceOwner
})
}
@@ -1673,6 +1751,8 @@ export default function MarkdownPreview({
setMarkdownViewMode,
setPendingEditorReveal,
sourceConnectionId,
sourceOwner,
sourceWorktree,
resolvedSourceRuntimeEnvironmentId,
sourceRoutingWorktreeId,
worktreeRoot,
@@ -1,9 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Editor } from '@tiptap/react'
import { useEditorState, type Editor } from '@tiptap/react'
import type { DiffComment, MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
import { useLocalImagePick } from './useLocalImagePick'
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
@@ -25,6 +24,11 @@ import {
runRichMarkdownContextCommand
} from './rich-markdown-context-command-routing'
import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup'
import {
formatSelectedHtmlSuperscriptLinkStatus,
getSelectedHtmlSuperscriptLinkStatus
} from './rich-markdown-selected-link-actions'
type RichMarkdownEditorProps = {
fileId: string
@@ -86,9 +90,11 @@ export default function RichMarkdownEditor({
const allDiffComments = useAppStore((s): DiffComment[] | undefined =>
selectWorktreeDiffComments(s, worktreeId)
)
const worktreeRoot = useAppStore(
(s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null
)
const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({
filePath,
runtimeEnvironmentId,
worktreeId
})
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const menu = useRichMarkdownMenuController({ markdownDocuments })
const isMac = navigator.userAgent.includes('Mac')
@@ -196,6 +202,8 @@ export default function RichMarkdownEditor({
)
const editor = useRichMarkdownEditorInstance({
codec,
htmlSuperscriptLinkContext,
content,
filePath,
worktreeId,
@@ -240,6 +248,13 @@ export default function RichMarkdownEditor({
setSlashMenu: menu.setSlashMenu,
setDocLinkMenu: menu.setDocLinkMenu
})
// Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only
// citation NodeSelections would leave aria status stale without useEditorState.
const selectedCitationStatus = useEditorState({
editor,
selector: (snapshot) =>
getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext)
})
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
// Why: use useLayoutEffect (synchronous cleanup) so the pending serialization
@@ -265,6 +280,7 @@ export default function RichMarkdownEditor({
})
useRichMarkdownProgrammaticSync({
codec,
content,
docLinkMenuSetter: menu.setDocLinkMenu,
editor,
@@ -289,12 +305,14 @@ export default function RichMarkdownEditor({
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
handleLinkCopy,
toggleLinkFromToolbar
} = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, {
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
runtimeEnvironmentId,
htmlSuperscriptLinkContext
})
useEffect(() => {
@@ -370,6 +388,12 @@ export default function RichMarkdownEditor({
showTableOfContents={showTableOfContents}
searchState={searchState}
searchActions={searchActions}
citationStatus={
selectedCitationStatus
? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus)
: ''
}
linkBubbleOwnerId={codec.transport.key}
linkBubbleActions={{
dismissLinkBubble: () => {
setLinkBubble(null)
@@ -379,6 +403,7 @@ export default function RichMarkdownEditor({
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
handleLinkCopy,
setIsEditingLink
}}
onToggleLink={toggleLinkFromToolbar}
@@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = {
matchCase: boolean
matchCount: number
replaceQuery: string
replaceDisabled: boolean
searchQuery: string
searchInputRef: React.RefObject<HTMLInputElement | null>
wholeWord: boolean
@@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = {
toggleReplaceMode: () => void
toggleWholeWord: () => void
}
citationStatus: string
linkBubbleOwnerId: string
linkBubbleActions: {
dismissLinkBubble: () => void
handleLinkSave: (href: string) => void
handleLinkRemove: () => void
handleLinkEditCancel: () => void
handleLinkOpen: () => void
handleLinkCopy: () => void
setIsEditingLink: (editing: boolean) => void
}
onToggleLink: () => void
@@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({
showTableOfContents,
searchState,
searchActions,
citationStatus,
linkBubbleOwnerId,
linkBubbleActions,
onToggleLink,
onImagePick,
@@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({
matchCount={searchState.matchCount}
query={searchState.searchQuery}
replaceQuery={searchState.replaceQuery}
replaceDisabled={searchState.replaceDisabled}
searchInputRef={searchState.searchInputRef}
wholeWord={searchState.wholeWord}
onClose={searchActions.closeSearch}
@@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}
onEditCancel={linkBubbleActions.handleLinkEditCancel}
onOpen={linkBubbleActions.handleLinkOpen}
onCopy={linkBubbleActions.handleLinkCopy}
ownerId={linkBubbleOwnerId}
/>
) : null}
<span className="sr-only" role="status" aria-live="polite">
{citationStatus}
</span>
{slashMenu ? (
<RichMarkdownSlashMenu
editor={editor}
@@ -1,13 +1,19 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Editor } from '@tiptap/react'
import { ExternalLink, Pencil, Unlink } from 'lucide-react'
import { Copy, ExternalLink, Pencil, Unlink } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export type LinkBubbleState = {
kind: 'markdown' | 'html-superscript'
href: string
left: number
top: number
openEnabled: boolean
copyEnabled: boolean
label?: string
}
const LINK_BUBBLE_VIEWPORT_MARGIN = 8
@@ -160,6 +166,8 @@ type RichMarkdownLinkBubbleProps = {
onEditStart: () => void
onEditCancel: () => void
onOpen: () => void
onCopy: () => void
ownerId?: string
portalToDocument?: boolean
}
@@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({
onEditStart,
onEditCancel,
onOpen,
onCopy,
ownerId,
portalToDocument = false
}: RichMarkdownLinkBubbleProps): React.JSX.Element {
const bubbleRef = useRef<HTMLDivElement | null>(null)
@@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
<div
ref={bubbleRef}
className="rich-markdown-link-bubble"
data-rich-markdown-link-bubble=""
data-rich-markdown-link-bubble-owner={ownerId}
style={positionStyle}
onMouseDown={(e) => {
// Prevent editor blur when clicking bubble buttons, but let inputs
@@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({
e.preventDefault()
}
}}
onKeyDown={(e) => e.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
onDismiss()
anchorElement?.querySelector<HTMLElement>('[contenteditable="true"]')?.focus()
return
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return
}
const buttons = Array.from(
bubbleRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') ?? []
)
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (currentIndex === -1 || buttons.length === 0) {
return
}
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus()
}}
>
{isEditing ? (
<LinkEditInput initialHref={linkBubble.href} onSave={onSave} onCancel={onEditCancel} />
@@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({
<span className="rich-markdown-link-url" title={linkBubble.href}>
{linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}` : linkBubble.href}
</span>
<button
type="button"
className="rich-markdown-link-button"
onClick={onOpen}
title={translate(
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.bfc813e909',
'Open link'
)}
disabled={!linkBubble.openEnabled}
onClick={onOpen}
>
<ExternalLink size={14} />
</button>
<button
type="button"
className="rich-markdown-link-button"
onClick={onEditStart}
title={translate(
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
'Edit link'
)}
</LinkBubbleAction>
<LinkBubbleAction
label={translate('auto.components.editor.RichMarkdownLinkBubble.copyLink', 'Copy link')}
disabled={!linkBubble.copyEnabled}
onClick={onCopy}
>
<Pencil size={14} />
</button>
<button
type="button"
className="rich-markdown-link-button"
onClick={onRemove}
title={translate(
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
'Remove link'
)}
>
<Unlink size={14} />
</button>
<Copy size={14} />
</LinkBubbleAction>
{linkBubble.kind === 'markdown' ? (
<>
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
'Edit link'
)}
onClick={onEditStart}
>
<Pencil size={14} />
</LinkBubbleAction>
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
'Remove link'
)}
onClick={onRemove}
>
<Unlink size={14} />
</LinkBubbleAction>
</>
) : null}
</>
)}
</div>
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({
// actions must portal to the app layer to remain above the right sidebar.
return portalToDocument ? createPortal(bubble, document.body) : bubble
}
function LinkBubbleAction({
children,
disabled = false,
label,
onClick
}: {
children: React.ReactNode
disabled?: boolean
label: string
onClick: () => void
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="rich-markdown-link-button"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = {
matchCount: number
query: string
replaceQuery: string
replaceDisabled: boolean
searchInputRef: React.RefObject<HTMLInputElement | null>
wholeWord: boolean
onClose: () => void
@@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({
matchCount,
query,
replaceQuery,
replaceDisabled,
searchInputRef,
wholeWord,
onClose,
@@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({
// Why: surface the same replace shortcut the source editor uses so the toggle
// is discoverable; reads the user's effective binding, formatted per platform.
const replaceShortcut = useOptionalShortcutLabel('editor.replace')
const readOnlyExplanationId = React.useId()
if (!isOpen) {
return null
@@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({
}
const noMatches = matchCount === 0
const readOnlyReplaceExplanation = translate(
'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly',
'Preserved rich content is read-only in rich mode.'
)
const toggleReplaceLabel = isReplaceMode
? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace')
: translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace')
@@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({
'auto.components.editor.RichMarkdownSearchBar.44682b4159',
'Replace in rich markdown editor'
)}
aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined}
/>
</div>
<Button
@@ -267,11 +275,12 @@ export function RichMarkdownSearchBar({
size="icon-xs"
onMouseDown={keepSearchFocus}
onClick={onReplaceCurrent}
disabled={noMatches}
title={translate(
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
'Replace'
)}
disabled={noMatches || replaceDisabled}
title={
replaceDisabled
? readOnlyReplaceExplanation
: translate('auto.components.editor.RichMarkdownSearchBar.fd97c7e585', 'Replace')
}
aria-label={translate(
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
'Replace'
@@ -286,11 +295,15 @@ export function RichMarkdownSearchBar({
size="icon-xs"
onMouseDown={keepSearchFocus}
onClick={onReplaceAll}
disabled={noMatches}
title={translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
)}
disabled={noMatches || replaceDisabled}
title={
replaceDisabled
? readOnlyReplaceExplanation
: translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
)
}
aria-label={translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
@@ -299,6 +312,11 @@ export function RichMarkdownSearchBar({
>
<ReplaceAll size={14} />
</Button>
{replaceDisabled ? (
<span id={readOnlyExplanationId} className="sr-only" role="status">
{readOnlyReplaceExplanation}
</span>
) : null}
</div>
) : null}
</div>
@@ -0,0 +1,22 @@
export type HtmlAttributeQuote = '"' | "'" | null
export function decodeHtmlTextCharacterReferences(value: string): string {
// Why: a <template> parses markup inertly (no script/resource execution), so
// reading textContent safely decodes character references without XSS risk.
const template = document.createElement('template')
template.innerHTML = value
return template.content.textContent ?? ''
}
export function decodeHtmlAttributeCharacterReferences(
value: string,
quote: HtmlAttributeQuote
): string {
// Why: inert <template> decoding; attribute write uses the caller-supplied
// quote and parser-constrained value, so this is not an injection sink.
const template = document.createElement('template')
const delimiter = quote ?? ''
template.innerHTML = `<span data-orca-value=${delimiter}${value}${delimiter}></span>`
const element = template.content.firstElementChild
return element?.getAttribute('data-orca-value') ?? ''
}
@@ -0,0 +1,9 @@
import { Markdown } from '@tiptap/markdown'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
export function createIsolatedMarkdownExtensionForTests() {
return Markdown.configure({
marked: createRichMarkdownEditorCodec().marked,
markedOptions: { gfm: true }
})
}
@@ -7,7 +7,7 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { normalizeEmptyListItems } from './rich-markdown-normalize'
const testExtensions = [
@@ -18,7 +18,7 @@ const testExtensions = [
TableRow,
TableHeader,
TableCell,
Markdown.configure({ markedOptions: { gfm: true } })
createIsolatedMarkdownExtensionForTests()
]
function createEditor(markdown: string): Editor {
@@ -3,7 +3,10 @@ import {
filesystemPathToFileUri,
fileUriToFilesystemPath
} from '../../../../shared/file-uri-path'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import {
isWindowsAbsolutePathLike,
relativePathInsideRoot
} from '../../../../shared/cross-platform-path'
// Pure classifier for markdown link targets. Called by the link-activation
// dispatcher (activateMarkdownLink slice action) from three call sites —
@@ -31,13 +34,6 @@ export type MarkdownLinkTarget =
column?: number
}
// Why: renderer runs with sandbox + contextIsolation, so process.platform is
// unavailable. navigator.userAgent is the portable fallback (AGENTS.md).
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent
const isMacLike = ua.includes('Mac')
const isWindowsLike = ua.includes('Windows')
const caseInsensitiveFs = isMacLike || isWindowsLike
const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx', '.markdown'])
export function absolutePathToFileUri(filePath: string): string {
@@ -60,17 +56,6 @@ function normalizePathForCompare(p: string): string {
return np
}
function isDescendantOf(childAbs: string, parentAbs: string): boolean {
const child = normalizePathForCompare(childAbs)
const parent = normalizePathForCompare(parentAbs)
if (caseInsensitiveFs) {
const lc = child.toLowerCase()
const lp = parent.toLowerCase()
return lc === lp || lc.startsWith(`${lp}/`)
}
return child === parent || child.startsWith(`${parent}/`)
}
function hasMarkdownExtension(p: string): boolean {
const lastDot = p.lastIndexOf('.')
if (lastDot === -1) {
@@ -125,25 +110,7 @@ function resolveRelativeToSource(rawHref: string, sourceFilePath: string): URL |
}
function computeRelativePath(absolutePath: string, worktreeRoot: string): string {
const parent = normalizePathForCompare(worktreeRoot)
const child = normalizePathForCompare(absolutePath)
const prefix = `${parent}/`
if (caseInsensitiveFs) {
if (child.toLowerCase() === parent.toLowerCase()) {
return ''
}
if (child.toLowerCase().startsWith(prefix.toLowerCase())) {
return child.slice(prefix.length)
}
} else {
if (child === parent) {
return ''
}
if (child.startsWith(prefix)) {
return child.slice(prefix.length)
}
}
return child
return relativePathInsideRoot(worktreeRoot, absolutePath) ?? normalizePathForCompare(absolutePath)
}
export function resolveMarkdownLinkTarget(
@@ -195,7 +162,7 @@ export function resolveMarkdownLinkTarget(
if (
worktreeRoot !== null &&
hasMarkdownExtension(pathForClassification) &&
isDescendantOf(pathForClassification, worktreeRoot)
relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
) {
const relativePath = computeRelativePath(pathForClassification, worktreeRoot)
return {
@@ -208,7 +175,7 @@ export function resolveMarkdownLinkTarget(
}
const relativePath =
worktreeRoot !== null && isDescendantOf(pathForClassification, worktreeRoot)
worktreeRoot !== null && relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
? computeRelativePath(pathForClassification, worktreeRoot)
: undefined
@@ -226,3 +193,52 @@ export function resolveMarkdownLinkTarget(
column
}
}
const HTML_ATTRIBUTE_WHITESPACE = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g
export function projectMarkdownHrefForClipboard(href: string): string | null {
const projected = href.replace(HTML_ATTRIBUTE_WHITESPACE, '')
if (!projected || containsAsciiControl(projected)) {
return null
}
if (
isWindowsAbsolutePathLike(projected) ||
projected.startsWith('\\\\') ||
projected.startsWith('//')
) {
return null
}
if (projected.startsWith('#')) {
return projected
}
const scheme = /^([A-Za-z][A-Za-z\d+.-]*):/.exec(projected)?.[1]?.toLowerCase()
if (!scheme) {
return projected
}
if (scheme !== 'http' && scheme !== 'https' && scheme !== 'file') {
return null
}
try {
const parsed = new URL(projected)
if (parsed.protocol !== `${scheme}:`) {
return null
}
if (scheme === 'file' && !parsed.pathname) {
return null
}
return projected
} catch {
return null
}
}
function containsAsciiControl(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 31 || code === 127) {
return true
}
}
return false
}
@@ -149,9 +149,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: true, ctrlKey: false, shiftKey: true },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ forceSystemBrowser: true })
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
})
it('forces the system browser on Ctrl+Shift-click on Linux/Windows', () => {
@@ -159,9 +160,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: true, shiftKey: true },
false,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ forceSystemBrowser: true })
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
})
it('routes a plain Cmd-click through the worktree so it can open in Orca', () => {
@@ -169,9 +171,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: true, ctrlKey: false, shiftKey: false },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('routes a plain click (no modifier) through the worktree', () => {
@@ -179,9 +182,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: false },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('does not force the system browser for Shift without the platform mod key', () => {
@@ -190,9 +194,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: true },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('does not treat Mac Ctrl+Shift-click as the escape hatch', () => {
@@ -201,9 +206,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: true, shiftKey: true },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('passes through a null worktree (openHttpLink then falls back to system browser)', () => {
@@ -211,9 +217,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: false },
true,
null
null,
{ kind: 'local' }
)
).toEqual({ worktreeId: null })
).toEqual({ worktreeId: null, sourceOwner: { kind: 'local' } })
})
})
@@ -4,7 +4,7 @@ import {
fileUriToFilesystemPath
} from '../../../../shared/file-uri-path'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import type { OpenHttpLinkOptions } from '@/lib/http-link-routing'
import type { HttpLinkSourceOwner, OpenHttpLinkOptions } from '@/lib/http-link-routing'
function toFileUrl(filePath: string): string {
return filesystemPathToFileUri(filePath)
@@ -120,12 +120,13 @@ export function isMarkdownPreviewSystemBrowserModifier(
export function resolveMarkdownPreviewHttpOpenOptions(
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
isMac: boolean,
worktreeId: string | null
worktreeId: string | null,
sourceOwner: HttpLinkSourceOwner
): OpenHttpLinkOptions {
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
return { forceSystemBrowser: true }
return { forceSystemBrowser: true, sourceOwner }
}
return { worktreeId }
return { worktreeId, sourceOwner }
}
/**
@@ -2,14 +2,16 @@ import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import type { SlashCommandId } from './rich-markdown-slash-commands'
import { slashCommands } from './rich-markdown-slash-commands'
function roundTripMarkdown(content: string): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
content: encodeRawMarkdownHtmlForRichEditor(content),
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
contentType: 'markdown'
})
@@ -21,10 +23,11 @@ function roundTripMarkdown(content: string): string {
}
function markdownAfterTextReplace(content: string, search: string, replacement: string): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
content: encodeRawMarkdownHtmlForRichEditor(content),
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
contentType: 'markdown'
})
@@ -50,9 +53,10 @@ function markdownAfterTextReplace(content: string, search: string, replacement:
}
function slashCommandMarkdown(commandId: SlashCommandId): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec }),
content: '',
contentType: 'markdown'
})
@@ -71,9 +75,10 @@ function slashCommandMarkdown(commandId: SlashCommandId): string {
}
function slashCommandSelectionParent(commandId: SlashCommandId): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec }),
content: '',
contentType: 'markdown'
})
@@ -1,11 +1,9 @@
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
// Why: extensions are lazily created on first use to avoid eager instantiation
// at import time. A single shared instance is safe here because only one
// round-trip Editor is alive at a time (created and destroyed synchronously).
let roundTripExtensions: ReturnType<typeof createRichMarkdownExtensions> | null = null
const roundTripCache = new Map<string, string | null>()
const MAX_CACHE_ENTRIES = 20
@@ -23,13 +21,23 @@ export function getRichMarkdownRoundTripOutput(content: string): string | null {
let output: string | null = null
try {
if (!roundTripExtensions) {
roundTripExtensions = createRichMarkdownExtensions()
}
const codec = createRichMarkdownEditorCodec()
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '',
worktreeId: '',
worktreeRoot: null,
sourceOwner: { kind: 'unknown' }
})
const editor = new Editor({
element: null,
extensions: roundTripExtensions,
content: encodeRawMarkdownHtmlForRichEditor(content),
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
try {
@@ -2,53 +2,16 @@ import { Node, mergeAttributes } from '@tiptap/core'
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links'
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
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 = ']]'
import type {
RichMarkdownEditorCodec,
RichMarkdownSourceKind,
RichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport'
import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source'
const INLINE_HTML_PATTERN = /^<!--[\s\S]*?-->|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
function encodeHtmlPayload(raw: string): string {
return encodeURIComponent(raw)
}
function decodeHtmlPayload(payload: string): string {
try {
return decodeURIComponent(payload)
} catch {
return ''
}
}
function createPlaceholder(kind: 'inline' | 'block', raw: string): string {
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
return `${prefix}${encodeHtmlPayload(raw)}${PLACEHOLDER_SUFFIX}`
}
function matchPlaceholder(
src: string,
kind: 'inline' | 'block'
): { placeholder: string; value: string } | null {
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
if (!src.startsWith(prefix)) {
return null
}
const endIndex = src.indexOf(PLACEHOLDER_SUFFIX, prefix.length)
if (endIndex === -1) {
return null
}
const placeholder = src.slice(0, endIndex + PLACEHOLDER_SUFFIX.length)
const payload = src.slice(prefix.length, endIndex)
return {
placeholder,
value: decodeHtmlPayload(payload)
}
}
function matchInlineHtml(src: string): string | null {
const match = src.match(INLINE_HTML_PATTERN)
return match?.[0] ?? null
@@ -90,8 +53,13 @@ function matchBlockHtml(content: string, start: number): string | null {
return line
}
export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
export function encodeRawMarkdownHtmlForRichEditor(
content: string,
codec: RichMarkdownEditorCodec,
{ htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {}
): string {
const normalizedContent = normalizeMarkdownReferenceLinks(content)
const { transport } = codec
let index = 0
let isLineStart = true
let activeFence: '`' | '~' | null = null
@@ -170,47 +138,65 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
if (detailsHtml) {
result += createPlaceholder('block', detailsHtml.raw)
result += transport.create('block-html', detailsHtml.raw)
index += detailsHtml.raw.length
continue
}
const blockHtml = matchBlockHtml(normalizedContent, index)
if (blockHtml) {
result += createPlaceholder('block', blockHtml)
result += transport.create('block-html', blockHtml)
index += blockHtml.length
continue
}
}
// Why: authored text that happens to contain this editor's random envelope
// prefix must remain literal even in HTML-free documents and after edits.
if (normalizedContent.startsWith(transport.authoredPrefix, index)) {
const authoredEnd = normalizedContent.indexOf(']]', index + transport.authoredPrefix.length)
const authoredOccurrence =
authoredEnd === -1
? transport.authoredPrefix
: normalizedContent.slice(index, authoredEnd + 2)
result += transport.create('literal', authoredOccurrence)
index += authoredOccurrence.length
continue
}
if (normalizedContent[index] === '<' && !isEscaped(normalizedContent, index)) {
if (htmlSuperscriptLinks) {
const superscriptLink = matchHtmlSuperscriptLinkSource(normalizedContent, index)
if (superscriptLink) {
result += transport.create('html-superscript-link', JSON.stringify(superscriptLink.value))
index = superscriptLink.end
continue
}
}
const inlineHtml = matchInlineHtml(normalizedContent.slice(index))
if (inlineHtml) {
result += createPlaceholder('inline', inlineHtml)
result += transport.create('inline-html', inlineHtml)
index += inlineHtml.length
continue
}
}
// 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.
// Why: doc link encoding runs inside this loop so fenced code and backtick
// spans have already been excluded from semantic preprocessing.
if (
normalizedContent[index] === '[' &&
normalizedContent[index + 1] === '[' &&
!normalizedContent.startsWith('[[ORCA_', index) &&
!isEscaped(normalizedContent, index)
) {
const closingIndex = normalizedContent.indexOf(']]', index + 2)
if (closingIndex !== -1) {
const rawTarget = normalizedContent.slice(index + 2, closingIndex)
const link = parseMarkdownDocLink(rawTarget)
if (link) {
result += `${DOC_LINK_PLACEHOLDER_PREFIX}${formatMarkdownDocLinkBody(
link.target,
link.alias
)}${PLACEHOLDER_SUFFIX}`
if (link && !isReservedRichMarkdownTransportBody(rawTarget)) {
result += transport.create(
'document-link',
formatMarkdownDocLinkBody(link.target, link.alias)
)
index = closingIndex + 2
continue
}
@@ -226,129 +212,123 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
return result
}
export const RawMarkdownHtmlInline = Node.create({
name: 'rawMarkdownHtmlInline',
inline: true,
group: 'inline',
atom: true,
selectable: true,
export function createRichMarkdownLiteral(transport: RichMarkdownSourceTransport) {
return createRawSourceNode({
name: 'richMarkdownLiteral',
kind: 'literal',
inline: true,
transport,
marker: 'data-rich-markdown-literal'
})
}
addAttributes() {
return {
value: {
default: ''
}
}
},
// Why: converting embedded HTML tags into placeholder tokens before the
// markdown parser runs keeps marked's built-in paragraph tokenization intact
// while still letting Orca round-trip the raw markup verbatim.
markdownTokenName: 'rawMarkdownHtmlInline',
markdownTokenizer: {
export function createRawMarkdownHtmlInline(transport: RichMarkdownSourceTransport) {
return createRawSourceNode({
name: 'rawMarkdownHtmlInline',
level: 'inline',
start: INLINE_PLACEHOLDER_PREFIX,
tokenize(src) {
const matched = matchPlaceholder(src, 'inline')
if (!matched) {
return undefined
}
kind: 'inline-html',
inline: true,
transport,
marker: 'data-raw-markdown-html-inline',
className: 'raw-markdown-html-inline'
})
}
function createRawSourceNode({
name,
kind,
inline,
transport,
marker,
className
}: {
name: string
kind: RichMarkdownSourceKind
inline: boolean
transport: RichMarkdownSourceTransport
marker: string
className?: string
}) {
return Node.create({
name,
inline,
group: inline ? 'inline' : 'block',
atom: true,
selectable: true,
addAttributes() {
return {
type: 'rawMarkdownHtmlInline',
raw: matched.placeholder,
text: matched.value
value: {
default: '',
rendered: false
}
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'rawMarkdownHtmlInline') {
return []
}
},
return helpers.createNode('rawMarkdownHtmlInline', {
value: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
// Why: converting embedded HTML tags into placeholder tokens before the
// markdown parser runs keeps marked's built-in paragraph tokenization intact
// while still letting Orca round-trip the raw markup verbatim.
markdownTokenName: name,
markdownTokenizer: {
name,
level: inline ? 'inline' : 'block',
start: transport.startFor(kind),
tokenize(src) {
const matched = transport.match(src, kind)
if (!matched) {
return undefined
}
parseHTML() {
return [{ tag: 'span[data-raw-markdown-html-inline]' }]
},
renderHTML({ HTMLAttributes, node }) {
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-raw-markdown-html-inline': '',
contenteditable: 'false',
class: 'raw-markdown-html-inline'
}),
value
]
}
})
export const RawMarkdownHtmlBlock = Node.create({
name: 'rawMarkdownHtmlBlock',
group: 'block',
atom: true,
selectable: true,
addAttributes() {
return {
value: {
default: ''
return {
type: name,
raw: matched.raw,
text: matched.value,
block: !inline
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== name) {
return []
}
}
},
markdownTokenName: 'rawMarkdownHtmlBlock',
markdownTokenizer: {
return helpers.createNode(name, {
value: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
renderText: ({ node }) => (typeof node.attrs.value === 'string' ? node.attrs.value : ''),
parseHTML() {
return [
{
tag: `${inline ? 'span' : 'div'}[${marker}]`,
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
}
]
},
renderHTML({ HTMLAttributes, node }) {
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
return [
inline ? 'span' : 'div',
mergeAttributes(HTMLAttributes, {
[marker]: '',
contenteditable: 'false',
class: className
}),
inline ? value : ['pre', value]
]
}
})
}
export function createRawMarkdownHtmlBlock(transport: RichMarkdownSourceTransport) {
return createRawSourceNode({
name: 'rawMarkdownHtmlBlock',
level: 'block',
start: BLOCK_PLACEHOLDER_PREFIX,
tokenize(src) {
const matched = matchPlaceholder(src, 'block')
if (!matched) {
return undefined
}
return {
type: 'rawMarkdownHtmlBlock',
raw: matched.placeholder,
text: matched.value,
block: true
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'rawMarkdownHtmlBlock') {
return []
}
return helpers.createNode('rawMarkdownHtmlBlock', {
value: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
parseHTML() {
return [{ tag: 'div[data-raw-markdown-html-block]' }]
},
renderHTML({ HTMLAttributes, node }) {
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-raw-markdown-html-block': '',
contenteditable: 'false',
class: 'raw-markdown-html-block'
}),
['pre', value]
]
}
})
kind: 'block-html',
inline: false,
transport,
marker: 'data-raw-markdown-html-block',
className: 'raw-markdown-html-block'
})
}
@@ -0,0 +1,40 @@
import type { Editor } from '@tiptap/react'
import { NodeSelection } from '@tiptap/pm/state'
export function handleRichMarkdownCitationKey({
editor,
event,
linkBubbleOwnerId,
onOpen
}: {
editor: Editor | null
event: KeyboardEvent
linkBubbleOwnerId: string
onOpen?: () => boolean
}): boolean {
const selection = editor?.state?.selection
if (
!(selection instanceof NodeSelection) ||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink' ||
event.isComposing ||
editor?.view.composing === true
) {
return false
}
if (event.key === 'Enter') {
event.preventDefault()
return onOpen?.() ?? true
}
if (event.key !== 'Tab' || event.shiftKey) {
return false
}
const firstAction = document.querySelector<HTMLButtonElement>(
`[data-rich-markdown-link-bubble-owner="${linkBubbleOwnerId}"] button:not([disabled])`
)
if (!firstAction) {
return false
}
event.preventDefault()
firstAction.focus()
return true
}
@@ -0,0 +1,17 @@
import { DOMSerializer, type Slice } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
export function serializeRichMarkdownSliceForClipboard(
view: EditorView,
slice: Slice
): { html: string } {
if (typeof view.serializeForClipboard === 'function') {
return { html: view.serializeForClipboard(slice).dom.innerHTML }
}
// Why: lightweight unit-test views predate ProseMirror's public clipboard
// serializer; production always takes the metadata-preserving branch above.
const fragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment(slice.content)
const container = document.createElement('div')
container.appendChild(fragment)
return { html: container.innerHTML }
}
@@ -0,0 +1,37 @@
import type { Slice } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import { serializeRichMarkdownSliceForClipboard } from './rich-markdown-clipboard-serialization'
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
/**
* Writes a rich-markdown slice to the clipboard (HTML + plain), verifying
* readback. Returns false when the write must not be followed by a delete
* (source-owning limit or clipboard rejection).
*/
export function writeRichMarkdownSliceToClipboard(
clipboardData: DataTransfer,
view: EditorView,
slice: Slice,
visibleText: string
): boolean {
const status = inspectRichMarkdownSourceOwningSlice(slice)
if (status.containsSourceOwningNode && !status.canPreserve) {
showRichMarkdownSourceOwningCutLimitError()
return false
}
const serialized = serializeRichMarkdownSliceForClipboard(view, slice)
clipboardData.setData('text/html', serialized.html)
clipboardData.setData('text/plain', visibleText)
// Why: if the clipboard rejected the write we must not delete, and we must
// surface the same cut-limit feedback so the no-op is not silent.
if (
typeof clipboardData.getData === 'function' &&
(clipboardData.getData('text/html') !== serialized.html ||
clipboardData.getData('text/plain') !== visibleText)
) {
showRichMarkdownSourceOwningCutLimitError()
return false
}
return true
}
@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-slash-commands'
function createEditor(content = '/'): Editor {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
contentType: 'markdown'
})
@@ -1,7 +1,10 @@
import { DOMSerializer } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
import { cutVisualLine, getVisualLineRange } from './rich-markdown-visual-line'
import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: number): void {
let tr = view.state.tr.delete(from, to)
@@ -24,6 +27,12 @@ function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: numb
export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent): boolean {
const { selection } = view.state
if (!selection.empty) {
const status = inspectRichMarkdownSourceOwningSlice(selection.content())
if (status.containsSourceOwningNode && !status.canPreserve) {
event.preventDefault()
showRichMarkdownSourceOwningCutLimitError()
return true
}
return false
}
@@ -54,7 +63,9 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
}
const cutNode = $from.node(cutDepth)
const text = cutNode.textContent
const contentFrom = $from.start(cutDepth)
const contentTo = $from.end(cutDepth)
const text = createRichMarkdownVisibleTextMap(view.state.doc, contentFrom, contentTo).text
// Why: for paragraphs that word-wrap across multiple visual lines, cut
// only the visual line the cursor is on rather than the entire paragraph.
@@ -85,15 +96,10 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
}
event.preventDefault()
// Why: writing both text/html and text/plain preserves inline formatting
// (bold, italic, links) on round-trip cut-then-paste, while still giving
// a plain-text fallback for external targets.
const serializer = DOMSerializer.fromSchema(view.state.schema)
const fragment = serializer.serializeFragment(cutNode.content)
const div = document.createElement('div')
div.appendChild(fragment)
event.clipboardData.setData('text/html', div.innerHTML)
event.clipboardData.setData('text/plain', text)
const slice = view.state.doc.slice($from.before(cutDepth), $from.after(cutDepth))
if (!writeRichMarkdownSliceToClipboard(event.clipboardData, view, slice, text)) {
return true
}
deleteBlockAndRestoreSelection(view, $from.before(cutDepth), $from.after(cutDepth))
@@ -10,10 +10,16 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
const showCutLimitErrorMock = vi.hoisted(() => vi.fn())
vi.mock('./rich-markdown-source-owning-cut-feedback', () => ({
showRichMarkdownSourceOwningCutLimitError: showCutLimitErrorMock
}))
/**
* Minimal extensions matching the rich editor schema without UI dependencies.
*/
@@ -25,7 +31,7 @@ const testExtensions = [
TableRow,
TableHeader,
TableCell,
Markdown.configure({ markedOptions: { gfm: true } })
createIsolatedMarkdownExtensionForTests()
]
function createEditor(markdown: string): Editor {
@@ -38,6 +44,7 @@ function createEditor(markdown: string): Editor {
}
afterEach(() => {
showCutLimitErrorMock.mockReset()
vi.restoreAllMocks()
})
@@ -109,7 +116,7 @@ function countParagraphs(editor: Editor): number {
return count
}
function createClipboardEventMock(): {
function createClipboardEventMock(options?: { failReadback?: boolean }): {
data: Map<string, string>
event: ClipboardEvent
preventDefault: ReturnType<typeof vi.fn>
@@ -120,7 +127,10 @@ function createClipboardEventMock(): {
clipboardData: {
setData: vi.fn((type: string, value: string) => {
data.set(type, value)
})
}),
getData: options?.failReadback
? vi.fn(() => '')
: vi.fn((type: string) => data.get(type) ?? '')
},
preventDefault
} as unknown as ClipboardEvent
@@ -243,6 +253,38 @@ describe('rich markdown cut handler behavior', () => {
}
})
it('surfaces cut-limit feedback when clipboard readback fails', () => {
const editor = createEditor('Body text to cut.\n')
try {
const pos = 1
let viewState = editor.state.apply(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
)
const view = {
get state() {
return viewState
},
dispatch: vi.fn((tr) => {
viewState = viewState.apply(tr)
}),
domAtPos: vi.fn(() => ({ node: document.createElement('p'), offset: 0 })),
coordsAtPos: vi.fn(() => ({ top: 0, bottom: 20, left: 0, right: 20 })),
posAtCoords: vi.fn(() => null)
} as unknown as EditorView
const clipboard = createClipboardEventMock({ failReadback: true })
const handled = handleRichMarkdownCut(view, clipboard.event)
expect(handled).toBe(true)
expect(clipboard.preventDefault).toHaveBeenCalled()
expect(showCutLimitErrorMock).toHaveBeenCalledTimes(1)
expect(view.dispatch).not.toHaveBeenCalled()
expect(view.state.doc.textContent).toBe('Body text to cut.')
} finally {
editor.destroy()
}
})
it('paragraphs separated by blank lines are separate blocks', () => {
const editor = createEditor('First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n')
@@ -6,11 +6,12 @@ import {
moveFromEmptyDetailsBodyToSummary
} from './rich-markdown-details-extension'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
function createEditor(content: string | JSONContent) {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
contentType: 'markdown'
})
@@ -0,0 +1,20 @@
import { mergeAttributes } from '@tiptap/core'
import type { DOMOutputSpec } from '@tiptap/pm/model'
export function renderRichMarkdownDocLinkHtml(
node: { attrs: Record<string, unknown> },
htmlAttributes: Record<string, unknown>
): DOMOutputSpec {
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
return [
'span',
mergeAttributes(htmlAttributes, {
'data-doc-link-target': target,
...(label ? { 'data-doc-link-label': label } : {}),
contenteditable: 'false',
class: 'rich-markdown-doc-link'
}),
label ?? target
]
}
@@ -1,4 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { Node } 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'
@@ -10,9 +10,11 @@ import {
resolveMarkdownDocLink
} from './markdown-doc-links'
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
const DOC_LINK_PLACEHOLDER_SUFFIX = ']]'
import {
isReservedRichMarkdownTransportBody,
type RichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
@@ -55,7 +57,9 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const link = parseMarkdownDocLink(match[1])
const link = isReservedRichMarkdownTransportBody(match[1])
? null
: parseMarkdownDocLink(match[1])
if (!link || match.index === undefined) {
continue
}
@@ -96,262 +100,252 @@ function getDocLinkDisplayText(node: { attrs: Record<string, unknown> }): string
return getDocLinkAlias(node) ?? getDocLinkTarget(node)
}
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') ?? ''
},
label: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
}
}
},
markdownTokenName: 'markdownDocLink',
markdownTokenizer: {
export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
return Node.create({
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 link = parseMarkdownDocLink(src.slice(DOC_LINK_PLACEHOLDER_PREFIX.length, endIndex))
if (!link) {
return undefined
}
inline: true,
group: 'inline',
atom: true,
selectable: true,
addStorage() {
return {
type: 'markdownDocLink',
raw: placeholder,
text: link.target,
label: link.alias ?? undefined
documents: [] as MarkdownDocument[],
_cachedDocs: null as MarkdownDocument[] | null,
_cachedIndex: null as MarkdownDocumentIndex | null
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'markdownDocLink') {
return []
}
return helpers.createNode('markdownDocLink', {
target: typeof token.text === 'string' ? token.text : '',
label:
typeof (token as { label?: unknown }).label === 'string'
? (token as { label: string }).label
: null
})
},
renderMarkdown: (node) =>
formatMarkdownDocLink(
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
typeof node.attrs?.label === 'string' ? node.attrs.label : null
),
addNodeView() {
const storage = this.storage as DocLinkStorage
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
const target = getDocLinkTarget(node)
const dom = document.createElement('span')
dom.setAttribute('data-doc-link-target', target)
const alias = getDocLinkAlias(node)
if (alias) {
dom.setAttribute('data-doc-link-label', alias)
}
dom.setAttribute('contenteditable', 'false')
dom.textContent = getDocLinkDisplayText(node)
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)
},
addAttributes() {
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 = getDocLinkTarget(updatedNode)
const newAlias = getDocLinkAlias(updatedNode)
dom.setAttribute('data-doc-link-target', newTarget)
if (newAlias) {
dom.setAttribute('data-doc-link-label', newAlias)
} else {
dom.removeAttribute('data-doc-link-label')
}
dom.textContent = getDocLinkDisplayText(updatedNode)
applyResolutionClass(newTarget)
return true
target: {
default: '',
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
},
label: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
}
}
}
},
},
// 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) {
markdownTokenName: 'markdownDocLink',
markdownTokenizer: {
name: 'markdownDocLink',
level: 'inline',
start: transport.startFor('document-link'),
tokenize(src: string) {
const matched = transport.match(src, 'document-link')
if (!matched) {
return undefined
}
const link = isReservedRichMarkdownTransportBody(matched.value)
? null
: parseMarkdownDocLink(matched.value)
if (!link) {
return undefined
}
return {
type: 'markdownDocLink',
raw: matched.raw,
text: link.target,
label: link.alias ?? undefined
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'markdownDocLink') {
return []
}
return helpers.createNode('markdownDocLink', {
target: typeof token.text === 'string' ? token.text : '',
label:
typeof (token as { label?: unknown }).label === 'string'
? (token as { label: string }).label
: null
})
},
renderMarkdown: (node) =>
formatMarkdownDocLink(
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
typeof node.attrs?.label === 'string' ? node.attrs.label : null
),
renderText: ({ node }) => getDocLinkDisplayText(node),
addNodeView() {
const storage = this.storage as DocLinkStorage
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
const target = getDocLinkTarget(node)
const dom = document.createElement('span')
dom.setAttribute('data-doc-link-target', target)
const alias = getDocLinkAlias(node)
if (alias) {
dom.setAttribute('data-doc-link-label', alias)
}
dom.setAttribute('contenteditable', 'false')
dom.textContent = getDocLinkDisplayText(node)
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
}
let direction: 'left' | 'right'
if (event.key === 'ArrowLeft') {
direction = 'left'
} else if (event.key === 'ArrowRight') {
direction = 'right'
const newTarget = getDocLinkTarget(updatedNode)
const newAlias = getDocLinkAlias(updatedNode)
dom.setAttribute('data-doc-link-target', newTarget)
if (newAlias) {
dom.setAttribute('data-doc-link-label', newAlias)
} else {
return false
dom.removeAttribute('data-doc-link-label')
}
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 = getDocLinkTarget(adjacent)
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
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)
dom.textContent = getDocLinkDisplayText(updatedNode)
applyResolutionClass(newTarget)
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, parent) => {
if (node.type.name !== 'text' || !node.text) {
return
}
if (isDocLinkLiteralCodeTextNode(node, parent)) {
return
// 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 = getDocLinkTarget(adjacent)
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
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
}
}
}),
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const link = parseMarkdownDocLink(match[1])
if (!link || match.index === undefined) {
continue
new Plugin({
key: docLinkAutoConvertKey,
appendTransaction(_transactions, _oldState, newState) {
const { tr } = newState
const cursor = newState.selection.from
let modified = false
newState.doc.descendants((node, pos, parent) => {
if (node.type.name !== 'text' || !node.text) {
return
}
if (isDocLinkLiteralCodeTextNode(node, parent)) {
return
}
const from = pos + match.index
const to = from + match[0].length
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const link = isReservedRichMarkdownTransportBody(match[1])
? null
: parseMarkdownDocLink(match[1])
if (!link || match.index === undefined) {
continue
}
// 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 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: link.target, label: link.alias })
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
modified = true
}
})
const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
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)
}
})
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
props: {
decorations(state) {
return docLinkInlinePreviewKey.getState(state)
}
return buildPreviewDecorations(newState, storage)
}
},
props: {
decorations(state) {
return docLinkInlinePreviewKey.getState(state)
}
}
})
]
},
})
]
},
parseHTML() {
return [{ tag: 'span[data-doc-link-target]' }]
},
parseHTML() {
return [{ tag: 'span[data-doc-link-target]' }]
},
renderHTML({ HTMLAttributes, node }) {
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-doc-link-target': target,
...(label ? { 'data-doc-link-label': label } : {}),
contenteditable: 'false',
class: 'rich-markdown-doc-link'
}),
label ?? target
]
}
})
renderHTML({ HTMLAttributes, node }) {
return renderRichMarkdownDocLinkHtml(node, HTMLAttributes)
}
})
}
@@ -2,8 +2,7 @@ import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import type { EditorView } from '@tiptap/pm/view'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { openHttpLink } from '@/lib/http-link-routing'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import {
@@ -14,6 +13,10 @@ import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations'
import type { DiffComment } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
classifyHtmlSuperscriptLinkAction,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
export type ActivateMarkdownLink = (
href: string,
@@ -22,6 +25,7 @@ export type ActivateMarkdownLink = (
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
sourceOwner?: HttpLinkSourceOwner
}
) => void | Promise<unknown>
@@ -33,6 +37,7 @@ type RichMarkdownEditorClickRoutingOptions = {
event: MouseEvent
filePath: string
isMac: boolean
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
markdownCommentsRef: MutableRefObject<DiffComment[]>
markdownSourceLineOffsetRef: MutableRefObject<number>
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
@@ -52,6 +57,7 @@ export function handleRichMarkdownEditorClick({
event,
filePath,
isMac,
htmlSuperscriptLinkContext,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onOpenDocLinkRef,
@@ -65,6 +71,8 @@ export function handleRichMarkdownEditorClick({
worktreeRoot
}: RichMarkdownEditorClickRoutingOptions): boolean {
const editor = editorRef.current
const sourceSnapshot = htmlSuperscriptLinkContext.getSnapshot()
const sourceOwner = sourceSnapshot.sourceOwner
const modKey = isMac ? event.metaKey : event.ctrlKey
if (!editor) {
return false
@@ -88,6 +96,7 @@ export function handleRichMarkdownEditorClick({
filePath,
runtimeEnvironmentId,
src: (clickedNode.attrs.src as string | undefined) ?? '',
sourceOwner,
worktreeId,
worktreeRoot
})
@@ -96,7 +105,16 @@ export function handleRichMarkdownEditorClick({
onOpenDocLinkRef.current?.(clickedNode.attrs.target as string)
return true
}
const href = getClickedLinkHref(view, pos)
const href =
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink'
? String(clickedNode.attrs.href ?? '')
: getClickedLinkHref(view, pos)
if (
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink' &&
!classifyHtmlSuperscriptLinkAction(href, sourceSnapshot)
) {
return true
}
if (!href) {
return false
}
@@ -109,8 +127,8 @@ export function handleRichMarkdownEditorClick({
href,
filePath,
runtimeEnvironmentId,
sourceOwner,
settings,
worktreeId,
worktreeRoot
})
return true
@@ -119,7 +137,8 @@ export function handleRichMarkdownEditorClick({
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
runtimeEnvironmentId,
sourceOwner
})
return true
}
@@ -128,6 +147,7 @@ function activateMarkdownImageClick({
activateMarkdownLink,
filePath,
runtimeEnvironmentId,
sourceOwner,
src,
worktreeId,
worktreeRoot
@@ -135,6 +155,7 @@ function activateMarkdownImageClick({
activateMarkdownLink: ActivateMarkdownLink
filePath: string
runtimeEnvironmentId?: string | null
sourceOwner?: HttpLinkSourceOwner
src: string
worktreeId: string
worktreeRoot: string | null
@@ -146,7 +167,8 @@ function activateMarkdownImageClick({
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
runtimeEnvironmentId,
sourceOwner
})
return true
}
@@ -162,24 +184,27 @@ function getClickedLinkHref(view: EditorView, pos: number): string {
function openMarkdownLinkInClientOs({
href,
filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId,
sourceOwner,
settings
}: {
href: string
filePath: string
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
sourceOwner: HttpLinkSourceOwner
settings: RichMarkdownRuntimeSettings
}): void {
if (sourceOwner.kind === 'unknown') {
return
}
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
if (!classified) {
return
}
if (classified.kind === 'external') {
openHttpLink(classified.url, { forceSystemBrowser: true })
openHttpLink(classified.url, { forceSystemBrowser: true, sourceOwner })
return
}
if (classified.kind === 'anchor') {
@@ -187,7 +212,7 @@ function openMarkdownLinkInClientOs({
}
if (
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, runtimeEnvironmentId), {
connectionId: getConnectionId(worktreeId)
connectionId: sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
})
) {
// Why: Shift-click opens through the client OS, which cannot safely resolve
@@ -8,6 +8,8 @@ import {
type EditorConfigParams
} from './rich-markdown-editor-config'
import type { SlashMenuState } from './rich-markdown-slash-commands'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
function ref<T>(current: T): MutableRefObject<T> {
return { current }
@@ -25,7 +27,15 @@ function getSpellcheckAttribute(config: ReturnType<typeof createRichMarkdownEdit
}
function createConfigParams(overrides: Partial<EditorConfigParams> = {}): EditorConfigParams {
const codec = createRichMarkdownEditorCodec()
return {
codec,
htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
}),
content: '',
filePath: '/repo/README.md',
worktreeId: 'worktree-1',
@@ -15,7 +15,7 @@ import {
type DocLinkMenuState
} from './rich-markdown-commands'
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import {
handleRichMarkdownEditorClick,
type ActivateMarkdownLink,
@@ -29,8 +29,16 @@ import {
import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
import type { DiffComment } from '../../../../shared/types'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import {
getRichMarkdownSelectionLinkBubble,
openSelectedHtmlSuperscriptLink
} from './rich-markdown-selected-link-actions'
export type EditorConfigParams = {
codec: RichMarkdownEditorCodec
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
content: string
filePath: string
worktreeId: string
@@ -79,6 +87,8 @@ export type EditorConfigParams = {
export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseEditorOptions {
const {
content,
codec,
htmlSuperscriptLinkContext,
filePath,
worktreeId,
worktreeRoot,
@@ -125,7 +135,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
return {
immediatelyRender: false,
content: encodeRawMarkdownHtmlForRichEditor(content),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
contentType: 'markdown' as const,
editorProps: {
attributes: {
@@ -135,13 +145,15 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
handleDOMEvents: {
cut: handleRichMarkdownCut
},
handlePaste: (_view, event) =>
handlePaste: (view, event, slice) =>
handleRichMarkdownPaste({
editor: editorRef.current,
event,
filePath,
worktreeId,
runtimeEnvironmentId
runtimeEnvironmentId,
slice,
view
}),
handleTextInput: (view, from, to, text) => {
typedEmptyOrderedListMarkerRef.current = false
@@ -172,12 +184,22 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
typedEmptyOrderedListMarkerRef,
flushPendingSerialization,
openSearchRef,
linkBubbleOwnerId: codec.transport.key,
htmlSuperscriptLinkContext,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
setDocLinkMenu,
openSelectedHtmlSuperscriptLink: () =>
openSelectedHtmlSuperscriptLink({
activateMarkdownLink,
context: htmlSuperscriptLinkContext,
editor: editorRef.current,
root: rootRef.current,
runtimeEnvironmentId
})
}),
handleClick: (view, pos, event) => {
return handleRichMarkdownEditorClick({
@@ -185,6 +207,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
editorRef,
event,
filePath,
htmlSuperscriptLinkContext,
isMac,
markdownCommentsRef,
markdownSourceLineOffsetRef,
@@ -259,13 +282,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
syncAnnotationTarget(nextEditor)
setIsEditingLink(false)
if (nextEditor.isActive('link')) {
const attrs = nextEditor.getAttributes('link')
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
} else {
setLinkBubble(null)
}
setLinkBubble(
getRichMarkdownSelectionLinkBubble(nextEditor, rootRef.current, htmlSuperscriptLinkContext)
)
}
}
}
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
const hardWrappedMarkdown =
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
@@ -16,16 +16,23 @@ import { Markdown } from '@tiptap/markdown'
import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
import {
createRawMarkdownHtmlBlock,
createRawMarkdownHtmlInline,
createRichMarkdownLiteral
} from './raw-markdown-html'
import {
createOrcaDetailsExtensions,
getRichMarkdownPlaceholder
} from './rich-markdown-details-extension'
import { MarkdownDocLink } from './rich-markdown-doc-link'
import { createMarkdownDocLink } from './rich-markdown-doc-link'
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
import { DragSelectionGuard } from './drag-selection-guard'
import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown-annotation-highlight'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
const lowlight = createLowlight(common)
@@ -42,10 +49,19 @@ const RichMarkdownCode = Code.extend({
})
export function createRichMarkdownExtensions({
includePlaceholder = false
codec,
includePlaceholder = false,
htmlSuperscriptLinks = false,
htmlSuperscriptLinkContext
}: {
codec: RichMarkdownEditorCodec
includePlaceholder?: boolean
} = {}): AnyExtension[] {
htmlSuperscriptLinks?: boolean
htmlSuperscriptLinkContext?: RichMarkdownHtmlSuperscriptLinkContext
}): AnyExtension[] {
if (htmlSuperscriptLinks && !htmlSuperscriptLinkContext) {
throw new Error('HTML superscript links require a document interaction context')
}
const extensions: AnyExtension[] = [
// Why: rich-mode detection must use the exact same markdown extension set as
// the live editor. If these drift, Orca can claim a document is editable in
@@ -196,11 +212,16 @@ export function createRichMarkdownExtensions({
throwOnError: false
}
}),
RawMarkdownHtmlInline,
RawMarkdownHtmlBlock,
MarkdownDocLink,
createRichMarkdownLiteral(codec.transport),
...(htmlSuperscriptLinks
? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)]
: []),
createRawMarkdownHtmlInline(codec.transport),
createRawMarkdownHtmlBlock(codec.transport),
createMarkdownDocLink(codec.transport),
DragSelectionGuard,
Markdown.configure({
marked: codec.marked,
markedOptions: {
gfm: true
}
@@ -0,0 +1,73 @@
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { resolveMarkdownLinkTarget } from './markdown-internal-links'
export type RichMarkdownHtmlSuperscriptLinkContextSnapshot = {
version: number
sourceFilePath: string
worktreeId: string
worktreeRoot: string | null
sourceOwner: HttpLinkSourceOwner
}
export type RichMarkdownHtmlSuperscriptLinkContext = {
getSnapshot: () => RichMarkdownHtmlSuperscriptLinkContextSnapshot
subscribe: (listener: () => void) => () => void
update: (snapshot: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>) => void
}
export function createRichMarkdownHtmlSuperscriptLinkContext(
initial: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>
): RichMarkdownHtmlSuperscriptLinkContext {
let snapshot = { ...initial, version: 0 }
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
update: (next) => {
if (
next.sourceFilePath === snapshot.sourceFilePath &&
next.worktreeId === snapshot.worktreeId &&
next.worktreeRoot === snapshot.worktreeRoot &&
sameOwner(next.sourceOwner, snapshot.sourceOwner)
) {
return
}
snapshot = { ...next, version: snapshot.version + 1 }
listeners.forEach((listener) => listener())
}
}
}
export function classifyHtmlSuperscriptLinkAction(
href: string,
snapshot: RichMarkdownHtmlSuperscriptLinkContextSnapshot
): boolean {
if (snapshot.sourceOwner.kind === 'unknown' || /^[\t\n\f\r ]*$/.test(href)) {
return false
}
const target = resolveMarkdownLinkTarget(href, snapshot.sourceFilePath, snapshot.worktreeRoot)
if (!target) {
return false
}
return !(
target.kind === 'file' &&
target.relativePath === undefined &&
(snapshot.sourceOwner.kind === 'runtime' || snapshot.sourceOwner.kind === 'ssh')
)
}
function sameOwner(left: HttpLinkSourceOwner, right: HttpLinkSourceOwner): boolean {
if (left.kind !== right.kind) {
return false
}
if (left.kind === 'runtime' && right.kind === 'runtime') {
return left.runtimeEnvironmentId === right.runtimeEnvironmentId
}
if (left.kind === 'ssh' && right.kind === 'ssh') {
return left.connectionId === right.connectionId
}
return true
}
@@ -0,0 +1,293 @@
import {
decodeHtmlAttributeCharacterReferences,
decodeHtmlTextCharacterReferences,
type HtmlAttributeQuote
} from './html-character-reference-decoder'
export const HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT = 16 * 1024
export const HTML_SUPERSCRIPT_LINK_HREF_LIMIT = 8 * 1024
export const HTML_SUPERSCRIPT_LINK_TEXT_LIMIT = 2 * 1024
export type HtmlSuperscriptLinkSource = {
source: string
href: string
label: string
title: string | null
}
export type HtmlSuperscriptLinkMatch = {
end: number
value: HtmlSuperscriptLinkSource
}
export type HtmlSuperscriptLinkParseStats = {
transitions: number
}
type ParsedAttribute = {
name: 'href' | 'title'
rawValue: string
quote: HtmlAttributeQuote
}
const encoder = new TextEncoder()
export function matchHtmlSuperscriptLinkSource(
input: string,
start = 0,
stats?: HtmlSuperscriptLinkParseStats
): HtmlSuperscriptLinkMatch | null {
const limit = Math.min(input.length, start + HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
let index = matchSimpleTag(input, start, 'sup', false, limit, stats)
if (index === null) {
return null
}
const anchor = matchAnchorStart(input, index, limit, stats)
if (!anchor) {
return null
}
index = anchor.end
const labelStart = index
while (index < limit) {
step(stats)
const code = input.charCodeAt(index)
if (code === 60 || code === 10 || code === 13) {
break
}
index += 1
}
if (index === labelStart || index >= limit || input.charCodeAt(index) !== 60) {
return null
}
const rawLabel = input.slice(labelStart, index)
index = matchSimpleTag(input, index, 'a', true, limit, stats) ?? -1
if (index < 0) {
return null
}
index = matchSimpleTag(input, index, 'sup', true, limit, stats) ?? -1
if (index < 0 || index - start > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT) {
return null
}
const hrefAttribute = anchor.attributes.find((attribute) => attribute.name === 'href')
const titleAttribute = anchor.attributes.find((attribute) => attribute.name === 'title')
if (!hrefAttribute) {
return null
}
const href = decodeHtmlAttributeCharacterReferences(hrefAttribute.rawValue, hrefAttribute.quote)
const label = decodeHtmlTextCharacterReferences(rawLabel)
const title = titleAttribute
? decodeHtmlAttributeCharacterReferences(titleAttribute.rawValue, titleAttribute.quote)
: null
const source = input.slice(start, index)
if (
byteLength(source) > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
byteLength(href) > HTML_SUPERSCRIPT_LINK_HREF_LIMIT ||
byteLength(label) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT ||
(title !== null && byteLength(title) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT)
) {
return null
}
return { end: index, value: { source, href, label, title } }
}
export function parseHtmlSuperscriptLinkSource(source: string): HtmlSuperscriptLinkSource | null {
const match = matchHtmlSuperscriptLinkSource(source)
return match?.end === source.length ? match.value : null
}
function matchAnchorStart(
input: string,
start: number,
limit: number,
stats?: HtmlSuperscriptLinkParseStats
): { end: number; attributes: ParsedAttribute[] } | null {
let index = start
if (input.charCodeAt(index) !== 60 || lowerCode(input.charCodeAt(index + 1)) !== 97) {
return null
}
step(stats, 2)
index += 2
if (!isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
return null
}
const attributes: ParsedAttribute[] = []
while (index < limit) {
const whitespaceStart = index
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (input.charCodeAt(index) === 62) {
step(stats)
return attributes.some((attribute) => attribute.name === 'href')
? { end: index + 1, attributes }
: null
}
if (index === whitespaceStart) {
return null
}
const nameStart = index
while (isAttributeNameCode(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (index === nameStart) {
return null
}
const normalizedName = input.slice(nameStart, index).toLowerCase()
if (normalizedName !== 'href' && normalizedName !== 'title') {
return null
}
if (attributes.some((attribute) => attribute.name === normalizedName)) {
return null
}
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (input.charCodeAt(index) !== 61) {
return null
}
step(stats)
index += 1
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
const parsedValue = parseAttributeValue(input, index, limit, stats)
if (!parsedValue) {
return null
}
attributes.push({
name: normalizedName,
rawValue: parsedValue.rawValue,
quote: parsedValue.quote
})
index = parsedValue.end
}
return null
}
function parseAttributeValue(
input: string,
start: number,
limit: number,
stats?: HtmlSuperscriptLinkParseStats
): { end: number; rawValue: string; quote: HtmlAttributeQuote } | null {
const first = input[start]
if (first === '"' || first === "'") {
let index = start + 1
while (index < limit && input[index] !== first) {
step(stats)
const code = input.charCodeAt(index)
if (code === 10 || code === 13) {
return null
}
index += 1
}
if (index >= limit) {
return null
}
step(stats)
return {
end: index + 1,
rawValue: input.slice(start + 1, index),
quote: first
}
}
let index = start
while (index < limit) {
const code = input.charCodeAt(index)
if (isHtmlWhitespace(code) || code === 62) {
break
}
step(stats)
if (code === 34 || code === 39 || code === 60 || code === 61 || code === 96) {
return null
}
index += 1
}
return index === start ? null : { end: index, rawValue: input.slice(start, index), quote: null }
}
function matchSimpleTag(
input: string,
start: number,
name: 'a' | 'sup',
closing: boolean,
limit: number,
stats?: HtmlSuperscriptLinkParseStats
): number | null {
let index = start
if (input.charCodeAt(index) !== 60) {
return null
}
step(stats)
index += 1
if (closing) {
if (input.charCodeAt(index) !== 47) {
return null
}
step(stats)
index += 1
}
for (let nameIndex = 0; nameIndex < name.length; nameIndex += 1) {
step(stats)
if (lowerCode(input.charCodeAt(index)) !== name.charCodeAt(nameIndex)) {
return null
}
index += 1
}
while (index < limit && isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (input.charCodeAt(index) !== 62) {
return null
}
step(stats)
return index + 1
}
function isAttributeNameCode(code: number): boolean {
return (
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
(code >= 48 && code <= 57) ||
code === 95 ||
code === 46 ||
code === 58 ||
code === 45
)
}
function isHtmlWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 12 || code === 13 || code === 32
}
function isSingleLineHtmlWhitespace(code: number): boolean {
return code === 9 || code === 12 || code === 32
}
function lowerCode(code: number): number {
return code >= 65 && code <= 90 ? code + 32 : code
}
function byteLength(value: string): number {
return encoder.encode(value).byteLength
}
function step(stats: HtmlSuperscriptLinkParseStats | undefined, count = 1): void {
if (stats) {
stats.transitions += count
}
}
@@ -0,0 +1,593 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core'
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
import { describe, expect, it, vi } from 'vitest'
import { marked } from 'marked'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import {
createRichMarkdownEditorCodec,
createRichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import {
classifyHtmlSuperscriptLinkAction,
createRichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import {
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
matchHtmlSuperscriptLinkSource,
parseHtmlSuperscriptLinkSource
} from './rich-markdown-html-superscript-link-source'
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
import { findRichMarkdownSearchMatches } from './rich-markdown-search'
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { getSelectedHtmlSuperscriptLinkStatus } from './rich-markdown-selected-link-actions'
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { AppState } from '@/store/types'
import {
inspectRichMarkdownSourceOwningSlice,
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
} from './rich-markdown-source-owning-slice'
const TEST_KEY = '0123456789abcdef0123456789abcdef'
function createEditor(content: string, key = TEST_KEY, element: HTMLElement | null = null): Editor {
const codec = createRichMarkdownEditorCodec(key)
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
return new Editor({
element,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
}
function nodeNames(editor: Editor): string[] {
const names: string[] = []
editor.state.doc.descendants((node) => {
names.push(node.type.name)
})
return names
}
describe('rich Markdown HTML superscript links', () => {
it('parses the reported fragment and preserves its exact source', () => {
const source = '<sup><a href="https://example.com/source">[12]</a></sup>'
const editor = createEditor(`研究结果${source}`)
try {
expect(nodeNames(editor)).toContain('richMarkdownHtmlSuperscriptLink')
expect(editor.getMarkdown()).toBe(`研究结果${source}`)
const citation = editor.state.doc.firstChild?.child(1)
expect(citation?.attrs).toMatchObject({
source,
href: 'https://example.com/source',
label: '[12]',
title: null
})
} finally {
editor.destroy()
}
})
it('retains casing, whitespace, quote style, entities, title, and adjacency', () => {
const first =
"<SUP ><A title='A &amp; B' href='https://example.com/?a=1&amp;b=2'>[&notit;]</A ></SUP >"
const second = '<sup><a href=#section>[13]</a></sup>'
const editor = createEditor(`${first}${second}`)
try {
expect(
nodeNames(editor).filter((name) => name === 'richMarkdownHtmlSuperscriptLink')
).toHaveLength(2)
expect(editor.getMarkdown()).toBe(`${first}${second}`)
const citation = editor.state.doc.firstChild?.firstChild
expect(citation?.attrs.href).toBe('https://example.com/?a=1&b=2')
expect(citation?.attrs.title).toBe('A & B')
} finally {
editor.destroy()
}
})
it('leaves malformed and broadened HTML on the inert raw-source path', () => {
const rejected = [
'<sup><a href="">[12]<a></sup>',
'<sup><a href="x" onclick="go()">[12]</a></sup>',
'<sup><a href="x"><b>[12]</b></a></sup>',
'<sup class="x"><a href="x">[12]</a></sup>',
'<sup><a href="x">[12]\n</a></sup>',
'<sup\n><a href="x">[12]</a></sup>',
'<sup><a\n href="x">[12]</a></sup>',
'<sup><a href =\n "x">[12]</a></sup>',
'<sup><a href="x">[12]</a\n></sup>'
]
for (const source of rejected) {
const editor = createEditor(source)
try {
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
if (!source.includes('\n')) {
expect(editor.getMarkdown()).toBe(source)
}
} finally {
editor.destroy()
}
}
})
it('does not recognize citations inside inline or fenced code', () => {
const source = '<sup><a href="https://example.com">[12]</a></sup>'
const markdown = `\`${source}\`\n\n\`\`\`html\n${source}\n\`\`\``
const editor = createEditor(markdown)
try {
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(editor.getMarkdown()).toBe(markdown)
} finally {
editor.destroy()
}
})
it('preserves authored current-key and legacy transport-looking text', () => {
const transport = createRichMarkdownSourceTransport(TEST_KEY)
const authored = `${transport.create('inline-html', '<b>authored</b>')} [[ORCA_RAW_HTML_INLINE:%3Ci%3Ex%3C%2Fi%3E]]`
const editor = createEditor(authored)
try {
editor.commands.insertContentAt(editor.state.doc.content.size - 1, '!')
expect(editor.getMarkdown()).toBe(`${authored}!`)
expect(nodeNames(editor)).not.toContain('markdownDocLink')
} finally {
editor.destroy()
}
})
it('keeps sequential codecs isolated and exposes the complete marked surface', () => {
const first = createRichMarkdownEditorCodec(TEST_KEY)
const second = createRichMarkdownEditorCodec('fedcba9876543210fedcba9876543210')
expect(Object.getOwnPropertyNames(first.marked).sort()).toEqual(
Object.getOwnPropertyNames(marked).sort()
)
const authoredFirstToken = first.transport.create('inline-html', '<b>K1</b>')
const editor = createEditor(authoredFirstToken, second.transport.key)
try {
expect(editor.getMarkdown()).toBe(authoredFirstToken)
expect(nodeNames(editor)).not.toContain('rawMarkdownHtmlInline')
expect(nodeNames(editor)).not.toContain('markdownDocLink')
} finally {
editor.destroy()
}
})
it('keeps source matching linear across many rejected candidates', () => {
const input = '<sup>'.repeat(2_000)
let transitions = 0
for (let index = 0; index < input.length; index += 5) {
const stats = { transitions: 0 }
expect(matchHtmlSuperscriptLinkSource(input, index, stats)).toBeNull()
transitions += stats.transitions
}
expect(transitions).toBeLessThan(input.length * 4)
})
it('projects only browser-safe clipboard hrefs', () => {
expect(projectMarkdownHrefForClipboard(' https://example.com ')).toBe('https://example.com')
expect(projectMarkdownHrefForClipboard('#section')).toBe('#section')
expect(projectMarkdownHrefForClipboard('./guide.md')).toBe('./guide.md')
expect(projectMarkdownHrefForClipboard('javascript:alert(1)')).toBeNull()
expect(projectMarkdownHrefForClipboard('java\nscript:alert(1)')).toBeNull()
expect(projectMarkdownHrefForClipboard('C:\\repo\\guide.md')).toBeNull()
expect(projectMarkdownHrefForClipboard('\\\\server\\share\\guide.md')).toBeNull()
expect(projectMarkdownHrefForClipboard('//example.com/path')).toBeNull()
})
it('rejects duplicate attributes and parses quoted greater-than characters', () => {
expect(
parseHtmlSuperscriptLinkSource('<sup><a href="https://example.com/?q=>">[1]</a></sup>')?.href
).toBe('https://example.com/?q=>')
expect(parseHtmlSuperscriptLinkSource('<sup><a href="a" href="b">[1]</a></sup>')).toBeNull()
})
it('renders inert live DOM while exposing link semantics through Orca context', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const source = '<sup><a href="https://example.com">[12]</a></sup>'
const codec = createRichMarkdownEditorCodec(TEST_KEY)
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
const editor = new Editor({
element: host,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(source, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
try {
const liveLabel = host.querySelector<HTMLElement>(
'sup[data-rich-markdown-html-superscript-link] > span'
)
expect(liveLabel?.textContent).toBe('[12]')
expect(liveLabel?.getAttribute('role')).toBe('link')
expect(host.querySelector('sup a')).toBeNull()
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)).toEqual({
href: 'https://example.com',
label: '[12]',
openEnabled: true
})
context.update({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'unknown' }
})
expect(liveLabel?.hasAttribute('role')).toBe(false)
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)?.openEnabled).toBe(false)
} finally {
editor.destroy()
host.remove()
}
})
it('keeps whitespace and remote outside-root destinations non-actionable', () => {
const local = {
version: 0,
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' as const }
}
expect(classifyHtmlSuperscriptLinkAction(' ', local)).toBe(false)
expect(classifyHtmlSuperscriptLinkAction('file:///etc/passwd', local)).toBe(true)
expect(
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
...local,
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
})
).toBe(false)
expect(
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
...local,
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'runtime-1' }
})
).toBe(false)
})
it('scopes citation Tab focus to the selected editor bubble', () => {
const editor = createEditor('<sup><a href="https://example.com">[12]</a></sup>')
const firstBubble = document.createElement('div')
firstBubble.dataset.richMarkdownLinkBubbleOwner = 'first-owner'
const firstButton = document.createElement('button')
firstBubble.appendChild(firstButton)
const secondBubble = document.createElement('div')
secondBubble.dataset.richMarkdownLinkBubbleOwner = 'second-owner'
const secondButton = document.createElement('button')
secondBubble.appendChild(secondButton)
document.body.append(firstBubble, secondBubble)
try {
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
const event = {
key: 'Tab',
shiftKey: false,
isComposing: false,
preventDefault: () => {}
} as KeyboardEvent
expect(
handleRichMarkdownCitationKey({
editor,
event,
linkBubbleOwnerId: 'second-owner'
})
).toBe(true)
expect(document.activeElement).toBe(secondButton)
} finally {
editor.destroy()
firstBubble.remove()
secondBubble.remove()
}
})
it('uses a folder workspace path as the citation source root', () => {
const state = {
folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' }],
worktreesByRepo: {}
} as Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
'/workspace/platform'
)
})
it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => {
const source = '<sup><a title="Source" href="./guide.md">[12]</a></sup>'
const host = document.createElement('div')
const editor = createEditor(source, TEST_KEY, host)
try {
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
const html = editor.view.serializeForClipboard(editor.state.selection.content()).dom.innerHTML
const pasted = createEditor('')
try {
pasted.commands.setContent(html, { contentType: 'html' })
expect(pasted.getMarkdown()).toBe(source)
} finally {
pasted.destroy()
}
const template = document.createElement('template')
template.innerHTML = html
const anchor = template.content.querySelector('a')
if (!anchor) {
throw new Error('expected serialized citation anchor')
}
anchor.textContent = '[99]'
const forged = createEditor('')
try {
forged.commands.setContent(template.innerHTML, { contentType: 'html' })
expect(nodeNames(forged)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(forged.state.doc.textContent).toContain('[99]')
} finally {
forged.destroy()
}
anchor.textContent = '[12]'
anchor.before('hidden sibling')
const siblingForged = createEditor('')
try {
siblingForged.commands.setContent(template.innerHTML, { contentType: 'html' })
expect(nodeNames(siblingForged)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(siblingForged.state.doc.textContent).toContain('hidden sibling')
} finally {
siblingForged.destroy()
}
} finally {
editor.destroy()
host.remove()
}
})
it('rejects oversized clipboard source before UTF-8 encoding', () => {
const editor = createEditor('')
const oversizedSource = 'x'.repeat(HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
try {
editor.commands.setContent(
`<sup data-rich-markdown-html-superscript-link="1" data-orca-superscript-link-source="${oversizedSource}"><a>x</a></sup>`,
{ contentType: 'html' }
)
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(encodeSpy.mock.calls.some(([value]) => value === oversizedSource)).toBe(false)
} finally {
encodeSpy.mockRestore()
editor.destroy()
}
})
it('maps citation labels into search, review, and empty-selection Cut', () => {
const source = 'Before <sup><a href="https://example.com">[12]</a></sup> after'
const editor = createEditor(source)
try {
const matches = findRichMarkdownSearchMatches(editor.state.doc, 'e [12] a')
expect(matches).toHaveLength(1)
expect(matches[0]?.touchesReadOnlyAtom).toBe(true)
let citationPosition = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
citationPosition = pos
}
})
const reviewRanges = findRichMarkdownSelectedTextRanges({
editor,
selectedText: 'Before [12] after'
})
expect(
reviewRanges.some(
(range) => range.from <= citationPosition && range.to >= citationPosition + 1
)
).toBe(true)
const citationHost = document.createElement('div')
const citationOnly = createEditor(
'<sup><a href="https://example.com">[12]</a></sup>',
TEST_KEY,
citationHost
)
try {
citationOnly.view.dispatch(
citationOnly.state.tr.setSelection(TextSelection.create(citationOnly.state.doc, 1))
)
const clipboard = new Map<string, string>()
const event = {
clipboardData: {
setData: (type: string, value: string) => clipboard.set(type, value),
getData: (type: string) => clipboard.get(type) ?? ''
},
preventDefault: () => {}
} as unknown as ClipboardEvent
expect(handleRichMarkdownCut(citationOnly.view, event)).toBe(true)
expect(clipboard.get('text/plain')).toBe('[12]')
expect(clipboard.get('text/html')).toContain('data-orca-superscript-link-source')
expect(citationOnly.getMarkdown()).toBe('')
} finally {
citationOnly.destroy()
citationHost.remove()
}
} finally {
editor.destroy()
}
})
it('enforces aggregate UTF-8 bounds even when oversized text precedes the citation', () => {
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
const exactText = 'a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)
const exact = createEditor(`${exactText}${citation}`)
try {
expect(
inspectRichMarkdownSourceOwningSlice(exact.state.doc.slice(0, exact.state.doc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
} finally {
exact.destroy()
}
const over = createEditor(`${exactText}a${citation}`)
try {
expect(
inspectRichMarkdownSourceOwningSlice(over.state.doc.slice(0, over.state.doc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
over.destroy()
}
const astralOver = createEditor(
`${'😀'.repeat(Math.floor(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT / 4))}${citation}`
)
try {
expect(
inspectRichMarkdownSourceOwningSlice(
astralOver.state.doc.slice(0, astralOver.state.doc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
astralOver.destroy()
}
})
it('counts visible leaf serializers in source-owning slice bounds', () => {
const editor = createEditor('')
try {
const schema = editor.state.schema
const hardBreak = schema.nodes.hardBreak
const docLink = schema.nodes.markdownDocLink
const citation = schema.nodes.richMarkdownHtmlSuperscriptLink
if (!hardBreak || !docLink || !citation) {
throw new Error('Expected rich Markdown leaf node types')
}
const citationAttrs = {
source: '<sup><a href="x">[1]</a></sup>',
href: 'x',
label: '[1]',
title: null
}
const exactDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 4)),
hardBreak.create(),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(exactDoc.slice(0, exactDoc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
const overHardBreakDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)),
hardBreak.create(),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(
overHardBreakDoc.slice(0, overHardBreakDoc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
const oversizedLeafDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
docLink.create({ target: 'Guide', label: 'x'.repeat(300_000) }),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(
oversizedLeafDoc.slice(0, oversizedLeafDoc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
editor.destroy()
}
})
it('remaps review text across adjacent citation atoms without inventing spaces', () => {
const editor = createEditor(
'<sup><a href="https://one.example">[1]</a></sup><sup><a href="https://two.example">[2]</a></sup>'
)
try {
const ranges = findRichMarkdownSelectedTextRanges({ editor, selectedText: '[1][2]' })
expect(ranges).toHaveLength(1)
expect(ranges[0]).toEqual({ from: 1, to: 3 })
} finally {
editor.destroy()
}
})
it('preserves block separators in visible text and review remapping', () => {
const editor = createEditor('foo\n\nbar')
try {
expect(getRichMarkdownVisibleText(editor.state.doc)).toBe('foo\nbar')
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'foo bar' })).toEqual([
{ from: 1, to: 4 },
{ from: 6, to: 9 }
])
} finally {
editor.destroy()
}
})
it('maps dense search matches with a monotonic segment walk', () => {
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
const editor = createEditor(`x${citation}`.repeat(200))
try {
const stats = { segmentVisits: 0 }
expect(findRichMarkdownSearchMatches(editor.state.doc, 'x', undefined, stats)).toHaveLength(
200
)
expect(stats.segmentVisits).toBeLessThan(1_000)
} finally {
editor.destroy()
}
})
it('does not let search bridge omitted atoms or block boundaries', () => {
const docLink = createEditor('foo[[Guide]]bar')
try {
expect(findRichMarkdownSearchMatches(docLink.state.doc, 'foobar')).toEqual([])
expect(
findRichMarkdownSearchMatches(docLink.state.doc, 'Guide')[0]?.touchesReadOnlyAtom
).toBe(true)
} finally {
docLink.destroy()
}
const rawHtml = createEditor('foo<kbd>bar')
try {
expect(findRichMarkdownSearchMatches(rawHtml.state.doc, 'foobar')).toEqual([])
} finally {
rawHtml.destroy()
}
const blocks = createEditor('foo\n\nbar')
try {
expect(findRichMarkdownSearchMatches(blocks.state.doc, 'foobar')).toEqual([])
} finally {
blocks.destroy()
}
})
})
@@ -0,0 +1,218 @@
import { Node } from '@tiptap/core'
import type { RichMarkdownSourceTransport } from './rich-markdown-source-transport'
import {
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
parseHtmlSuperscriptLinkSource,
type HtmlSuperscriptLinkSource
} from './rich-markdown-html-superscript-link-source'
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
import {
classifyHtmlSuperscriptLinkAction,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import { translate } from '@/i18n/i18n'
const CLIPBOARD_VERSION = '1'
const MARKER_ATTRIBUTE = 'data-rich-markdown-html-superscript-link'
const SOURCE_ATTRIBUTE = 'data-orca-superscript-link-source'
const clipboardEncoder = new TextEncoder()
export function createRichMarkdownHtmlSuperscriptLink(
transport: RichMarkdownSourceTransport,
context: RichMarkdownHtmlSuperscriptLinkContext
) {
return Node.create({
name: 'richMarkdownHtmlSuperscriptLink',
inline: true,
group: 'inline',
atom: true,
selectable: true,
addAttributes() {
return {
source: { default: '', rendered: false },
href: { default: '', rendered: false },
label: { default: '', rendered: false },
title: { default: null, rendered: false }
}
},
markdownTokenName: 'richMarkdownHtmlSuperscriptLink',
markdownTokenizer: {
name: 'richMarkdownHtmlSuperscriptLink',
level: 'inline',
start: transport.startFor('html-superscript-link'),
tokenize(source) {
const matched = transport.match(source, 'html-superscript-link')
if (!matched) {
return undefined
}
const parsed = parseStructuredPayload(matched.value)
if (!parsed) {
return undefined
}
return {
type: 'richMarkdownHtmlSuperscriptLink',
raw: matched.raw,
citation: parsed
}
}
},
parseMarkdown: (token, helpers) => {
const citation = (token as { citation?: HtmlSuperscriptLinkSource }).citation
if (token.type !== 'richMarkdownHtmlSuperscriptLink' || !citation) {
return []
}
return helpers.createNode('richMarkdownHtmlSuperscriptLink', citation)
},
renderMarkdown: (node) => String(node.attrs?.source ?? ''),
renderText: ({ node }) => String(node.attrs.label ?? ''),
parseHTML() {
return [
{
tag: `sup[${MARKER_ATTRIBUTE}]`,
getAttrs: (element: HTMLElement) => validateClipboardElement(element)
}
]
},
renderHTML({ node }) {
const citation = node.attrs as HtmlSuperscriptLinkSource
const projectedHref = projectMarkdownHrefForClipboard(citation.href)
const anchorAttributes: Record<string, string> = {}
if (projectedHref !== null) {
anchorAttributes.href = projectedHref
}
if (citation.title !== null) {
anchorAttributes.title = citation.title
}
return [
'sup',
{
[MARKER_ATTRIBUTE]: CLIPBOARD_VERSION,
[SOURCE_ATTRIBUTE]: citation.source
},
['a', anchorAttributes, citation.label]
]
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement('sup')
dom.setAttribute(MARKER_ATTRIBUTE, '')
dom.setAttribute('contenteditable', 'false')
const label = document.createElement('span')
label.className = 'rich-markdown-html-superscript-link'
label.textContent = String(node.attrs.label ?? '')
dom.appendChild(label)
const updateActionability = (): void => {
const href = String(node.attrs.href ?? '')
const actionable = classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
label.setAttribute(
'aria-label',
actionable
? translate(
'auto.components.editor.richMarkdownHtmlSuperscriptLink.availableAriaLabel',
'{{value0}}, link to {{value1}}',
{ value0: String(node.attrs.label ?? ''), value1: href }
)
: translate(
'auto.components.editor.richMarkdownHtmlSuperscriptLink.unavailableAriaLabel',
'{{value0}}, citation link unavailable',
{ value0: String(node.attrs.label ?? '') }
)
)
if (actionable) {
label.setAttribute('role', 'link')
} else {
label.removeAttribute('role')
}
label.toggleAttribute('data-actionable', actionable)
}
updateActionability()
const unsubscribe = context.subscribe(updateActionability)
return { dom, destroy: unsubscribe }
}
}
})
}
function parseStructuredPayload(value: string): HtmlSuperscriptLinkSource | null {
let candidate: unknown
try {
candidate = JSON.parse(value)
} catch {
return null
}
if (!isCitationShape(candidate)) {
return null
}
const parsed = parseHtmlSuperscriptLinkSource(candidate.source)
return parsed && sameCitation(parsed, candidate) ? parsed : null
}
function validateClipboardElement(element: HTMLElement): false | Record<string, unknown> {
if (
element.getAttribute(MARKER_ATTRIBUTE) !== CLIPBOARD_VERSION ||
!hasOnlyAttributes(element, [MARKER_ATTRIBUTE, SOURCE_ATTRIBUTE, 'data-pm-slice'])
) {
return false
}
const source = element.getAttribute(SOURCE_ATTRIBUTE)
if (
!source ||
source.length > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
clipboardEncoder.encode(source).byteLength > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT
) {
return false
}
const parsed = parseHtmlSuperscriptLinkSource(source)
const anchor = element.firstElementChild
if (
!parsed ||
element.childNodes.length !== 1 ||
element.children.length !== 1 ||
!anchor ||
element.firstChild !== anchor ||
anchor.tagName !== 'A' ||
!hasOnlyAttributes(anchor, ['href', 'title']) ||
anchor.childNodes.length !== 1 ||
anchor.firstChild?.nodeType !== window.Node.TEXT_NODE ||
anchor.textContent !== parsed.label ||
anchor.getAttribute('title') !== parsed.title ||
anchor.getAttribute('href') !== projectMarkdownHrefForClipboard(parsed.href)
) {
return false
}
return parsed
}
function hasOnlyAttributes(element: Element, allowed: string[]): boolean {
const allowedSet = new Set(allowed)
return Array.from(element.attributes).every((attribute) => allowedSet.has(attribute.name))
}
function isCitationShape(value: unknown): value is HtmlSuperscriptLinkSource {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Record<string, unknown>
return (
Object.keys(candidate).length === 4 &&
typeof candidate.source === 'string' &&
typeof candidate.href === 'string' &&
typeof candidate.label === 'string' &&
(typeof candidate.title === 'string' || candidate.title === null)
)
}
function sameCitation(left: HtmlSuperscriptLinkSource, right: HtmlSuperscriptLinkSource): boolean {
return (
left.source === right.source &&
left.href === right.href &&
left.label === right.label &&
left.title === right.title
)
}
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
function createEditor(content: object): Editor {
return new Editor({
@@ -67,6 +67,18 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
typedEmptyOrderedListMarkerRef: { current: typedMarker },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
linkBubbleOwnerId: 'test-owner',
htmlSuperscriptLinkContext: {
getSnapshot: () => ({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' as const },
version: 0
}),
subscribe: () => () => {},
update: () => {}
},
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
@@ -4,7 +4,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useAppStore } from '@/store'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { editorShortcutMatches } from './editor-shortcuts'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
import {
runSlashCommand,
@@ -18,6 +18,9 @@ import {
exitTrailingEmptyOrderedListItem
} from './rich-markdown-list-continuation'
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
export type KeyHandlerContext = {
isMac: boolean
@@ -44,6 +47,9 @@ export type KeyHandlerContext = {
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
openSelectedHtmlSuperscriptLink?: () => boolean
linkBubbleOwnerId: string
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
}
function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): boolean {
@@ -100,6 +106,16 @@ export function createRichMarkdownKeyHandler(
): (_view: unknown, event: KeyboardEvent) => boolean {
return (_view, event) => {
const mod = ctx.isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (
handleRichMarkdownCitationKey({
editor: ctx.editorRef.current,
event,
linkBubbleOwnerId: ctx.linkBubbleOwnerId,
onOpen: ctx.openSelectedHtmlSuperscriptLink
})
) {
return true
}
if (
isMarkdownPreviewFindShortcut(
event,
@@ -131,29 +147,18 @@ export function createRichMarkdownKeyHandler(
return true
}
// Link: Cmd/Ctrl+K — insert or edit a hyperlink.
if (mod && event.key.toLowerCase() === 'k') {
event.preventDefault()
const ed = ctx.editorRef.current
if (!ed) {
return true
}
if (ctx.isEditingLinkRef.current) {
ctx.setIsEditingLink(false)
if (!ed.isActive('link')) {
ctx.setLinkBubble(null)
}
ed.commands.focus()
return true
}
const pos = getLinkBubblePosition(ed, ctx.rootRef.current)
if (pos) {
const href = ed.isActive('link') ? (ed.getAttributes('link').href as string) || '' : ''
ctx.setLinkBubble({ href, ...pos })
ctx.setIsEditingLink(true)
}
if (
handleRichMarkdownLinkShortcut({
editor: ctx.editorRef.current,
event,
htmlSuperscriptLinkContext: ctx.htmlSuperscriptLinkContext,
isEditing: ctx.isEditingLinkRef.current,
isMac: ctx.isMac,
root: ctx.rootRef.current,
setEditing: ctx.setIsEditingLink,
setLinkBubble: ctx.setLinkBubble
})
) {
return true
}
@@ -17,6 +17,8 @@ type RichMarkdownLargeTextPasteOptions = {
measureYieldAfterCodeUnits?: number
yieldToEventLoop?: () => Promise<void>
canContinue?: (editor: Editor) => boolean
plainTextOverride?: string
htmlTextOverride?: string
}
export type RichMarkdownLargeTextPasteResult =
@@ -188,8 +190,8 @@ export function handleRichMarkdownLargeTextPaste(
return false
}
const text = readPlainText(event)
const html = readHtmlText(event)
const text = options.plainTextOverride ?? readPlainText(event)
const html = options.htmlTextOverride ?? readHtmlText(event)
const directMaxBytes = options.directMaxBytes ?? RICH_MARKDOWN_PASTE_DIRECT_MAX_BYTES
const maxBytes = options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES
const ownershipMeasurement = measureTextControlPasteByteLength(text, {
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { toastError, toastSuccess } = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
describe('copyRichMarkdownLink', () => {
const writeClipboardText = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
;(window as unknown as { api: unknown }).api = { ui: { writeClipboardText } }
})
it('reports success only after the clipboard write resolves', async () => {
let resolveWrite: (() => void) | undefined
writeClipboardText.mockReturnValue(
new Promise<void>((resolve) => {
resolveWrite = resolve
})
)
const copy = copyRichMarkdownLink('https://example.com')
expect(toastSuccess).not.toHaveBeenCalled()
resolveWrite?.()
await copy
expect(toastSuccess).toHaveBeenCalledWith('Copied link')
})
it('reports a rejected clipboard write without throwing', async () => {
writeClipboardText.mockRejectedValue(new Error('clipboard unavailable'))
await expect(copyRichMarkdownLink('https://example.com')).resolves.toBeUndefined()
expect(toastError).toHaveBeenCalledWith('Failed to copy link')
})
})
@@ -0,0 +1,18 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export async function copyRichMarkdownLink(href: string): Promise<void> {
try {
await window.api.ui.writeClipboardText(href)
toast.success(
translate('auto.components.editor.richMarkdownLinkClipboard.copiedLink', 'Copied link')
)
} catch {
toast.error(
translate(
'auto.components.editor.richMarkdownLinkClipboard.copyLinkFailed',
'Failed to copy link'
)
)
}
}
@@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
const TEST_KEY = '0123456789abcdef0123456789abcdef'
function createEditor(content: string): {
editor: Editor
context: ReturnType<typeof createRichMarkdownHtmlSuperscriptLinkContext>
root: HTMLDivElement
} {
const codec = createRichMarkdownEditorCodec(TEST_KEY)
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
const root = document.createElement('div')
document.body.appendChild(root)
const editor = new Editor({
element: root,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
return { editor, context, root }
}
function keyEvent(): KeyboardEvent {
return {
key: 'k',
metaKey: true,
ctrlKey: false,
preventDefault: vi.fn()
} as unknown as KeyboardEvent
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('handleRichMarkdownLinkShortcut', () => {
it('opens the html-superscript action bubble for a citation NodeSelection', () => {
const source = '<sup><a href="https://example.com">1</a></sup>'
const { editor, context, root } = createEditor(`See note ${source}.`)
try {
let citationPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'richMarkdownHtmlSuperscriptLink' && citationPos < 0) {
citationPos = pos
}
})
expect(citationPos).toBeGreaterThanOrEqual(0)
editor.view.dispatch(
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, citationPos))
)
const setLinkBubble = vi.fn()
const setEditing = vi.fn()
const handled = handleRichMarkdownLinkShortcut({
editor,
event: keyEvent(),
htmlSuperscriptLinkContext: context,
isEditing: false,
isMac: true,
root,
setEditing,
setLinkBubble
})
expect(handled).toBe(true)
expect(setEditing).toHaveBeenCalledWith(false)
expect(setLinkBubble).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'html-superscript',
href: 'https://example.com',
label: '1'
})
)
} finally {
editor.destroy()
root.remove()
}
})
it('opens an editable markdown bubble when there is no link selection', () => {
const { editor, context, root } = createEditor('plain text')
try {
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1)))
const setLinkBubble = vi.fn()
const setEditing = vi.fn()
handleRichMarkdownLinkShortcut({
editor,
event: keyEvent(),
htmlSuperscriptLinkContext: context,
isEditing: false,
isMac: true,
root,
setEditing,
setLinkBubble
})
expect(setEditing).toHaveBeenCalledWith(true)
expect(setLinkBubble).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'markdown',
href: ''
})
)
} finally {
editor.destroy()
root.remove()
}
})
})
@@ -0,0 +1,64 @@
import type { Editor } from '@tiptap/react'
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import {
createEditableMarkdownLinkBubble,
getRichMarkdownSelectionLinkBubble
} from './rich-markdown-selected-link-actions'
export function handleRichMarkdownLinkShortcut({
editor,
event,
htmlSuperscriptLinkContext,
isEditing,
isMac,
root,
setEditing,
setLinkBubble
}: {
editor: Editor | null
event: KeyboardEvent
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
isEditing: boolean
isMac: boolean
root: HTMLElement | null
setEditing: (editing: boolean) => void
setLinkBubble: (bubble: LinkBubbleState | null) => void
}): boolean {
const modifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (!modifier || event.key.toLowerCase() !== 'k') {
return false
}
event.preventDefault()
if (!editor) {
return true
}
if (isEditing) {
setEditing(false)
if (!editor.isActive('link')) {
setLinkBubble(null)
}
editor.commands.focus()
return true
}
// Why: NodeSelection on an HTML citation still has a bubble position, but
// markdown setLink/unsetLink cannot edit that atom — open the citation
// action bubble instead of the markdown edit field.
const selectionBubble = getRichMarkdownSelectionLinkBubble(
editor,
root,
htmlSuperscriptLinkContext
)
if (selectionBubble) {
setLinkBubble(selectionBubble)
setEditing(selectionBubble.kind === 'markdown')
return true
}
const position = getLinkBubblePosition(editor, root)
if (position) {
setLinkBubble(createEditableMarkdownLinkBubble('', position))
setEditing(true)
}
return true
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import {
collapseEmptyListContinuationParagraph,
commitEmptyOrderedListMarkerAsText,
@@ -10,12 +10,12 @@ import {
isSingleEmptyTopLevelOrderedList
} from './rich-markdown-list-continuation'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
function createEditor(content: object): Editor {
// Why: each Editor needs its own marked registry; sharing one module-scoped
// extension accumulates tokenizer state across tests.
return new Editor({
element: null,
extensions,
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
content
})
}
@@ -3,6 +3,7 @@
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { resetLocalImageSrcStateForTests } from './useLocalImageSrc'
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
@@ -40,7 +41,7 @@ describe('rich markdown local images', () => {
document.body.appendChild(host)
const editor = new Editor({
element: host,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: '![](diagram.png)',
contentType: 'markdown'
})
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
afterEach(() => {
vi.restoreAllMocks()
@@ -1,7 +1,11 @@
import type { Editor } from '@tiptap/react'
import type { Slice } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
import { handleRichMarkdownTerminalPathPaste } from './rich-markdown-terminal-path-paste'
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
export type RichMarkdownPasteHandlerArgs = {
editor: Editor | null
@@ -9,6 +13,8 @@ export type RichMarkdownPasteHandlerArgs = {
filePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
slice?: Slice
view?: EditorView
}
export function handleRichMarkdownPaste({
@@ -16,7 +22,9 @@ export function handleRichMarkdownPaste({
event,
filePath,
worktreeId,
runtimeEnvironmentId
runtimeEnvironmentId,
slice,
view
}: RichMarkdownPasteHandlerArgs): boolean {
if (
handleRichMarkdownImagePaste({
@@ -30,6 +38,36 @@ export function handleRichMarkdownPaste({
return true
}
const sourceOwningStatus = slice ? inspectRichMarkdownSourceOwningSlice(slice) : null
if (sourceOwningStatus?.containsSourceOwningNode && slice && view) {
if (sourceOwningStatus.canPreserve) {
// Why: dispatch directly so the transaction can carry paste/uiEvent
// metadata that editor.commands.insertContent would omit.
view.dispatch(
view.state.tr
.replaceSelection(slice)
.setMeta('paste', true)
.setMeta('uiEvent', 'paste')
.scrollIntoView()
)
return true
}
const visibleText = getRichMarkdownVisibleText(slice.content)
if (
handleRichMarkdownLargeTextPaste(editor, event, {
plainTextOverride: visibleText,
htmlTextOverride: ''
})
) {
return true
}
if (visibleText && editor) {
event.preventDefault()
editor.commands.insertContent(visibleText)
}
return true
}
if (handleRichMarkdownTerminalPathPaste(editor, event)) {
return true
}
@@ -9,6 +9,9 @@ import {
} from './rich-markdown-range-bounds'
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map'
import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24
const RICH_MARKDOWN_ANNOTATION_EDGE_PADDING_PX = 8
@@ -42,27 +45,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
buttonLeft: number
}
export function countRichMarkdownReviewMarkdownLines(value: string): number {
if (value.length === 0) {
return 1
}
let lineCount = 1
for (let index = 0; index < value.length; index += 1) {
const charCode = value.charCodeAt(index)
if (charCode === 13) {
lineCount += 1
if (value.charCodeAt(index + 1) === 10) {
index += 1
}
continue
}
if (charCode === 10) {
lineCount += 1
}
}
return lineCount
}
function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
}
@@ -293,7 +275,7 @@ export function getRichMarkdownAnnotationTarget(
if (!rect) {
return null
}
const selectedText = window.getSelection()?.toString().trim() ?? ''
const selectedText = getRichMarkdownSelectionVisibleText(editor.state)
if (!selectedText) {
return null
}
@@ -0,0 +1,18 @@
export function countRichMarkdownReviewMarkdownLines(value: string): number {
if (value.length === 0) {
return 1
}
let lineCount = 1
for (let index = 0; index < value.length; index += 1) {
const charCode = value.charCodeAt(index)
if (charCode === 13) {
lineCount += 1
if (value.charCodeAt(index + 1) === 10) {
index += 1
}
} else if (charCode === 10) {
lineCount += 1
}
}
return lineCount
}
@@ -40,13 +40,13 @@ function buildSelectedText(wordCount: number): string {
}
describe('findRichMarkdownSelectedTextRanges', () => {
it('matches selected text across normalized whitespace and text nodes', () => {
it('matches selected text across adjacent text nodes without inventing whitespace', () => {
const editor = editorWithTextNodes([
{ isText: true, text: 'alpha' },
{ isText: true, text: 'beta' }
])
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alpha\n beta' })).toEqual([
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alphabeta' })).toEqual([
{ from: 0, to: 9 }
])
})
@@ -1,23 +1,25 @@
import type { Editor } from '@tiptap/react'
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
import { forEachRichMarkdownVisibleTextSegment } from './rich-markdown-visible-text-map'
type TextPosition = { from: number; to: number } | null
type NormalizedTextChar = {
value: string
pos: number | null
pos: TextPosition
}
type MatchState = {
readonly needle: string
readonly prefixTable: number[]
recentPositions: (number | null)[]
recentPositions: TextPosition[]
recentPositionWriteIndex: number
matchLength: number
positions: (number | null)[] | null
positions: TextPosition[] | null
}
type NormalizationState = {
previousWasWhitespace: boolean
emittedAnyText: boolean
}
export function findRichMarkdownSelectedTextRanges({
@@ -45,31 +47,32 @@ export function findRichMarkdownSelectedTextRanges({
positions: null
}
const normalizationState: NormalizationState = {
previousWasWhitespace: false,
emittedAnyText: false
previousWasWhitespace: false
}
// Why: review text can come from large pasted selections; stream the editor
// text instead of building per-character haystack/needle arrays.
editor.state.doc.nodesBetween(from ?? 0, to ?? editor.state.doc.content.size, (node, pos) => {
if (matchState.positions || !node.isText) {
return
forEachRichMarkdownVisibleTextSegment(
editor.state.doc,
from ?? 0,
to ?? editor.state.doc.content.size,
(segment) => {
for (let index = 0; index < segment.text.length && !matchState.positions; index += 1) {
processRawTextChar(
{
value: segment.text.charAt(index),
pos:
segment.kind === 'separator'
? null
: segment.kind === 'read-only-atom'
? { from: segment.from, to: segment.to }
: { from: segment.from + index, to: segment.from + index + 1 }
},
normalizationState,
matchState
)
}
return !matchState.positions
}
const nodeText = node.text
if (!nodeText) {
return
}
if (normalizationState.emittedAnyText) {
processRawTextChar({ value: ' ', pos: null }, normalizationState, matchState)
}
for (let index = 0; index < nodeText.length && !matchState.positions; index += 1) {
processRawTextChar(
{ value: nodeText.charAt(index), pos: pos + index },
normalizationState,
matchState
)
}
})
)
return matchState.positions ? positionsToRanges(matchState.positions) : []
}
@@ -101,7 +104,6 @@ function processRawTextChar(
if (isRichMarkdownWhitespace(code)) {
if (!normalizationState.previousWasWhitespace) {
processNormalizedTextChar({ value: ' ', pos: char.pos }, matchState)
normalizationState.emittedAnyText = true
}
normalizationState.previousWasWhitespace = true
return
@@ -109,7 +111,6 @@ function processRawTextChar(
processNormalizedTextChar(char, matchState)
normalizationState.previousWasWhitespace = false
normalizationState.emittedAnyText = true
}
function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchState): void {
@@ -128,7 +129,7 @@ function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchSt
}
}
function recordRecentPosition(pos: number | null, matchState: MatchState): void {
function recordRecentPosition(pos: TextPosition, matchState: MatchState): void {
if (matchState.recentPositions.length < matchState.needle.length) {
matchState.recentPositions.push(pos)
matchState.recentPositionWriteIndex =
@@ -141,8 +142,8 @@ function recordRecentPosition(pos: number | null, matchState: MatchState): void
(matchState.recentPositionWriteIndex + 1) % matchState.needle.length
}
function readRecentPositions(matchState: MatchState): (number | null)[] {
const positions: (number | null)[] = []
function readRecentPositions(matchState: MatchState): TextPosition[] {
const positions: TextPosition[] = []
for (let index = 0; index < matchState.needle.length; index += 1) {
const bufferIndex = (matchState.recentPositionWriteIndex + index) % matchState.needle.length
positions.push(matchState.recentPositions[bufferIndex] ?? null)
@@ -168,26 +169,26 @@ function buildPrefixTable(value: string): number[] {
return table
}
function positionsToRanges(positions: (number | null)[]): RichMarkdownAnnotationHighlightRange[] {
function positionsToRanges(positions: TextPosition[]): RichMarkdownAnnotationHighlightRange[] {
const ranges: RichMarkdownAnnotationHighlightRange[] = []
let rangeFrom: number | null = null
let rangeTo: number | null = null
for (const pos of positions) {
if (pos === null) {
for (const position of positions) {
if (position === null) {
continue
}
if (rangeFrom === null || rangeTo === null) {
rangeFrom = pos
rangeTo = pos + 1
rangeFrom = position.from
rangeTo = position.to
continue
}
if (pos === rangeTo) {
rangeTo += 1
if (position.from <= rangeTo) {
rangeTo = Math.max(rangeTo, position.to)
continue
}
ranges.push({ from: rangeFrom, to: rangeTo })
rangeFrom = pos
rangeTo = pos + 1
rangeFrom = position.from
rangeTo = position.to
}
if (rangeFrom !== null && rangeTo !== null) {
ranges.push({ from: rangeFrom, to: rangeTo })
@@ -6,7 +6,13 @@ import { findRichMarkdownSearchMatches } from './rich-markdown-search'
function docFromText(text: string, pos = 0): ProseMirrorNode {
return {
descendants(callback: (node: { isText: boolean; text?: string }, pos: number) => void) {
nodeSize: pos + text.length + 2,
content: { size: pos + text.length },
nodesBetween(
_from: number,
_to: number,
callback: (node: { isText: boolean; text?: string }, pos: number) => void
) {
callback({ isText: true, text }, pos)
}
} as unknown as ProseMirrorNode
@@ -6,12 +6,20 @@ import {
isMarkdownPreviewSearchQueryTooLarge,
type TextMatchOptions
} from './markdown-preview-search'
import {
createRichMarkdownVisibleTextMap,
type RichMarkdownVisibleTextSegment
} from './rich-markdown-visible-text-map'
export type RichMarkdownSearchMatch = {
from: number
to: number
touchesReadOnlyAtom?: boolean
decorationRanges?: { from: number; to: number; kind: 'inline' | 'node' }[]
}
export type RichMarkdownSearchStats = { segmentVisits: number }
type RichMarkdownSearchState = {
activeIndex: number
decorations: DecorationSet
@@ -31,7 +39,8 @@ export const richMarkdownSearchPluginKey = new PluginKey<RichMarkdownSearchState
export function findRichMarkdownSearchMatches(
doc: ProseMirrorNode,
query: string,
options?: TextMatchOptions
options?: TextMatchOptions,
stats?: RichMarkdownSearchStats
): RichMarkdownSearchMatch[] {
if (!query) {
return []
@@ -41,28 +50,80 @@ export function findRichMarkdownSearchMatches(
}
const matches: RichMarkdownSearchMatch[] = []
doc.descendants((node, pos) => {
if (!node.isText) {
return
const visibleMap = createRichMarkdownVisibleTextMap(doc)
const ranges = findTextMatchRanges(visibleMap.text, query, options)
let segmentIndex = 0
for (const range of ranges) {
while (
segmentIndex < visibleMap.segments.length &&
visibleMap.segments[segmentIndex]!.visibleTo <= range.start
) {
if (stats) {
stats.segmentVisits += 1
}
segmentIndex += 1
}
const text = node.text ?? ''
if (!text.trim()) {
return
const firstSegmentIndex = segmentIndex
let lastSegmentIndex = firstSegmentIndex - 1
let touchesReadOnlyAtom = false
let segmentsAreContiguous = true
let touchesSeparator = false
while (
lastSegmentIndex + 1 < visibleMap.segments.length &&
visibleMap.segments[lastSegmentIndex + 1]!.visibleFrom < range.end
) {
lastSegmentIndex += 1
if (stats) {
stats.segmentVisits += 1
}
touchesReadOnlyAtom ||= visibleMap.segments[lastSegmentIndex]!.kind === 'read-only-atom'
touchesSeparator ||= visibleMap.segments[lastSegmentIndex]!.kind === 'separator'
const previous = visibleMap.segments[lastSegmentIndex - 1]
const current = visibleMap.segments[lastSegmentIndex]!
if (lastSegmentIndex > firstSegmentIndex && previous?.to !== current.from) {
segmentsAreContiguous = false
}
}
const ranges = findTextMatchRanges(text, query, options)
for (const range of ranges) {
matches.push({
from: pos + range.start,
to: pos + range.end
})
const first = visibleMap.segments[firstSegmentIndex]
const last = visibleMap.segments[lastSegmentIndex]
if (!first || !last || !segmentsAreContiguous || touchesSeparator) {
continue
}
})
const from = mapSegmentStart(first, range.start)
const to = mapSegmentEnd(last, range.end)
matches.push(
touchesReadOnlyAtom
? {
from,
to,
touchesReadOnlyAtom: true,
decorationRanges: visibleMap.segments
.slice(firstSegmentIndex, lastSegmentIndex + 1)
.map((segment) => ({
from: mapSegmentStart(segment, range.start),
to: mapSegmentEnd(segment, range.end),
kind: segment.kind === 'text' ? ('inline' as const) : ('node' as const)
}))
}
: { from, to }
)
}
return matches
}
function mapSegmentStart(segment: RichMarkdownVisibleTextSegment, visibleFrom: number): number {
return segment.kind === 'text'
? segment.from + Math.max(0, visibleFrom - segment.visibleFrom)
: segment.from
}
function mapSegmentEnd(segment: RichMarkdownVisibleTextSegment, visibleTo: number): number {
return segment.kind === 'text'
? segment.from + Math.min(segment.text.length, visibleTo - segment.visibleFrom)
: segment.to
}
export function createRichMarkdownSearchPlugin(): Plugin<RichMarkdownSearchState> {
return new Plugin<RichMarkdownSearchState>({
key: richMarkdownSearchPluginKey,
@@ -126,11 +187,18 @@ function buildSearchDecorationsFromMatches(
return DecorationSet.empty
}
const decorations = matches.map((match, index) =>
Decoration.inline(match.from, match.to, {
class: 'rich-markdown-search-match',
'data-active': index === activeIndex ? 'true' : undefined
})
const decorations = matches.flatMap((match, index) =>
(match.decorationRanges ?? [{ from: match.from, to: match.to, kind: 'inline' as const }]).map(
(range) => {
const attrs = {
class: 'rich-markdown-search-match',
'data-active': index === activeIndex ? 'true' : undefined
}
return range.kind === 'node'
? Decoration.node(range.from, range.to, attrs)
: Decoration.inline(range.from, range.to, attrs)
}
)
)
return DecorationSet.create(doc, decorations)
@@ -0,0 +1,162 @@
import type { Editor } from '@tiptap/react'
import { NodeSelection } from '@tiptap/pm/state'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
import type { ActivateMarkdownLink } from './rich-markdown-editor-click-routing'
import {
classifyHtmlSuperscriptLinkAction,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import { translate } from '@/i18n/i18n'
export function createEditableMarkdownLinkBubble(
href: string,
position: Pick<LinkBubbleState, 'left' | 'top'>
): LinkBubbleState {
return {
kind: 'markdown',
href,
openEnabled: Boolean(href),
copyEnabled: Boolean(href),
...position
}
}
export function getRichMarkdownSelectionLinkBubble(
editor: Editor,
root: HTMLElement | null,
context: RichMarkdownHtmlSuperscriptLinkContext
): LinkBubbleState | null {
const position = getLinkBubblePosition(editor, root)
if (!position) {
return null
}
if (editor.isActive('link')) {
return createBubble(
'markdown',
String(editor.getAttributes('link').href ?? ''),
position,
context
)
}
const selection = editor.state.selection
if (
!(selection instanceof NodeSelection) ||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
) {
return null
}
return createBubble(
'html-superscript',
String(selection.node.attrs.href ?? ''),
position,
context,
String(selection.node.attrs.label ?? '')
)
}
export function getSelectedHtmlSuperscriptLinkStatus(
editor: Editor | null,
context: RichMarkdownHtmlSuperscriptLinkContext
): { href: string; label: string; openEnabled: boolean } | null {
const selection = editor?.state.selection
if (
!editor ||
!(selection instanceof NodeSelection) ||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
) {
return null
}
const href = String(selection.node.attrs.href ?? '')
return {
href,
label: String(selection.node.attrs.label ?? ''),
openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
}
}
export function formatSelectedHtmlSuperscriptLinkStatus(
status: NonNullable<ReturnType<typeof getSelectedHtmlSuperscriptLinkStatus>>
): string {
const label =
status.label ||
translate('auto.components.editor.RichMarkdownEditor.citationFallbackLabel', 'Citation')
if (status.openEnabled) {
return translate(
'auto.components.editor.RichMarkdownEditor.citationLinkAvailable',
'{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.',
{ value0: label, value1: status.href }
)
}
const actionHint = status.href
? translate(
'auto.components.editor.RichMarkdownEditor.tabForCitationActions',
'Tab for available actions.'
)
: translate(
'auto.components.editor.RichMarkdownEditor.noCitationActions',
'No link actions are available.'
)
return translate(
'auto.components.editor.RichMarkdownEditor.citationLinkUnavailable',
'{{value0}}, citation link unavailable. {{value1}}',
{ value0: label, value1: actionHint }
)
}
export function openSelectedHtmlSuperscriptLink({
activateMarkdownLink,
context,
editor,
root,
runtimeEnvironmentId
}: {
activateMarkdownLink: ActivateMarkdownLink
context: RichMarkdownHtmlSuperscriptLinkContext
editor: Editor | null
root: HTMLElement | null
runtimeEnvironmentId?: string | null
}): boolean {
const selection = editor?.state.selection
if (
!editor ||
!(selection instanceof NodeSelection) ||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink'
) {
return false
}
const href = String(selection.node.attrs.href ?? '')
const snapshot = context.getSnapshot()
if (!classifyHtmlSuperscriptLinkAction(href, snapshot)) {
return true
}
if (href.startsWith('#')) {
scrollToAnchorInEditor(root, href.slice(1))
return true
}
void activateMarkdownLink(href, {
sourceFilePath: snapshot.sourceFilePath,
worktreeId: snapshot.worktreeId,
worktreeRoot: snapshot.worktreeRoot,
runtimeEnvironmentId,
sourceOwner: snapshot.sourceOwner
})
return true
}
function createBubble(
kind: LinkBubbleState['kind'],
href: string,
position: Pick<LinkBubbleState, 'left' | 'top'>,
context: RichMarkdownHtmlSuperscriptLinkContext,
label?: string
): LinkBubbleState {
return {
kind,
href,
label,
openEnabled: classifyHtmlSuperscriptLinkAction(href, context.getSnapshot()),
copyEnabled: Boolean(href),
...position
}
}
@@ -0,0 +1,11 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export function showRichMarkdownSourceOwningCutLimitError(): void {
toast.error(
translate(
'auto.components.editor.richMarkdownSourceOwningCutFeedback.selectLessContent',
'Select less content or use code mode to cut preserved HTML citations.'
)
)
}
@@ -0,0 +1,102 @@
import type { Slice } from '@tiptap/pm/model'
import {
getRichMarkdownLeafVisibleText,
isRichMarkdownVisibleBlockStart
} from './rich-markdown-visible-text-map'
export const RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT = 256 * 1024
export const RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT = 256
export type RichMarkdownSourceOwningSliceStatus = {
containsSourceOwningNode: boolean
canPreserve: boolean
}
export function inspectRichMarkdownSourceOwningSlice(
slice: Slice
): RichMarkdownSourceOwningSliceStatus {
let sourceBytes = 0
let visibleBytes = 0
let nodeCount = 0
let containsSourceOwningNode = false
let canPreserve = true
let sawVisibleBlock = false
slice.content.descendants((node, pos, parent, index) => {
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
if (canPreserve && startsVisibleBlock) {
if (sawVisibleBlock) {
const separator = addUtf8BytesWithinLimit(
visibleBytes,
'\n',
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
)
visibleBytes = separator.byteLength
canPreserve = !separator.exceeded
}
sawVisibleBlock = true
}
if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
containsSourceOwningNode = true
if (!canPreserve) {
return false
}
nodeCount += 1
const sourceMeasurement = addUtf8BytesWithinLimit(
sourceBytes,
String(node.attrs.source ?? ''),
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
)
sourceBytes = sourceMeasurement.byteLength
canPreserve =
!sourceMeasurement.exceeded && nodeCount <= RICH_MARKDOWN_SOURCE_OWNING_NODE_LIMIT
if (!canPreserve) {
return false
}
}
if (!canPreserve) {
// Keep walking only far enough to detect a later source-owning atom.
return true
}
const visible = node.isText
? (node.text ?? '')
: node.isLeaf
? getRichMarkdownLeafVisibleText(node, pos, parent, index)
: ''
if (visible) {
const measurement = addUtf8BytesWithinLimit(
visibleBytes,
visible,
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
)
visibleBytes = measurement.byteLength
canPreserve = !measurement.exceeded
}
return true
})
return { containsSourceOwningNode, canPreserve }
}
function addUtf8BytesWithinLimit(
current: number,
value: string,
limit: number
): { byteLength: number; exceeded: boolean } {
const remaining = limit - current
if (value.length > remaining) {
return { byteLength: limit + 1, exceeded: true }
}
let byteLength = current
for (let index = 0; index < value.length; index += 1) {
const codePoint = value.codePointAt(index) ?? 0
byteLength += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4
if (byteLength > limit) {
return { byteLength, exceeded: true }
}
if (codePoint > 0xffff) {
index += 1
}
}
return { byteLength, exceeded: false }
}
@@ -0,0 +1,86 @@
import type { marked } from 'marked'
import { createTiptapMarkedFacade } from './tiptap-marked-facade'
export type RichMarkdownSourceKind =
| 'literal'
| 'inline-html'
| 'block-html'
| 'document-link'
| 'html-superscript-link'
const TRANSPORT_PREFIX = '[[ORCA_RICH_MD:'
const TRANSPORT_SUFFIX = ']]'
const KEY_PATTERN = /^[a-f0-9]{32}$/
const TRANSPORT_BODY_PATTERN =
/^ORCA_RICH_MD:[a-f0-9]{32}:(?:literal|inline-html|block-html|document-link|html-superscript-link):/
const LEGACY_PREFIXES = ['ORCA_RAW_HTML_INLINE:', 'ORCA_RAW_HTML_BLOCK:', 'ORCA_DOC_LINK:'] as const
export type RichMarkdownSourceTransport = {
readonly key: string
readonly authoredPrefix: string
create: (kind: RichMarkdownSourceKind, value: string) => string
match: (source: string, kind: RichMarkdownSourceKind) => { raw: string; value: string } | null
startFor: (kind: RichMarkdownSourceKind) => string
}
export function isLegacyRichMarkdownTransportBody(value: string): boolean {
return LEGACY_PREFIXES.some((prefix) => value.startsWith(prefix))
}
export function isReservedRichMarkdownTransportBody(value: string): boolean {
// Why: a foreign editor's valid keyed envelope is authored text here, not
// a document link whose target happens to resemble transport metadata.
return isLegacyRichMarkdownTransportBody(value) || TRANSPORT_BODY_PATTERN.test(value)
}
export type RichMarkdownEditorCodec = {
transport: RichMarkdownSourceTransport
marked: typeof marked
}
export function createRichMarkdownEditorCodec(key = createCodecKey()): RichMarkdownEditorCodec {
return {
transport: createRichMarkdownSourceTransport(key),
marked: createTiptapMarkedFacade()
}
}
export function createRichMarkdownSourceTransport(key: string): RichMarkdownSourceTransport {
if (!KEY_PATTERN.test(key)) {
throw new Error('Rich Markdown transport keys must be 128-bit lowercase hex values')
}
const authoredPrefix = `${TRANSPORT_PREFIX}${key}:`
const startFor = (kind: RichMarkdownSourceKind): string => `${authoredPrefix}${kind}:`
return {
key,
authoredPrefix,
startFor,
create: (kind, value) => `${startFor(kind)}${encodeURIComponent(value)}${TRANSPORT_SUFFIX}`,
match: (source, kind) => {
const prefix = startFor(kind)
if (!source.startsWith(prefix)) {
return null
}
const endIndex = source.indexOf(TRANSPORT_SUFFIX, prefix.length)
if (endIndex === -1) {
return null
}
const raw = source.slice(0, endIndex + TRANSPORT_SUFFIX.length)
try {
return {
raw,
value: decodeURIComponent(source.slice(prefix.length, endIndex))
}
} catch {
return null
}
}
}
}
function createCodecKey(): string {
const bytes = new Uint8Array(16)
globalThis.crypto.getRandomValues(bytes)
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
@@ -3,20 +3,20 @@ import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
const extensions = [
StarterKit,
TaskList,
TaskItem.configure({ nested: true }),
Markdown.configure({ markedOptions: { gfm: true } })
]
function createEditor(content: object): Editor {
// Why: each Editor needs its own marked registry; sharing one module-scoped
// extension accumulates tokenizer state across tests.
return new Editor({
element: null,
extensions,
extensions: [
StarterKit,
TaskList,
TaskItem.configure({ nested: true }),
createIsolatedMarkdownExtensionForTests()
],
content
})
}
@@ -74,6 +74,18 @@ function createContext(editor: Editor): KeyHandlerContext {
typedEmptyOrderedListMarkerRef: { current: false },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
linkBubbleOwnerId: 'test-owner',
htmlSuperscriptLinkContext: {
getSnapshot: () => ({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' as const },
version: 0
}),
subscribe: () => () => {},
update: () => {}
},
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
@@ -0,0 +1,132 @@
import type { Fragment, Node as ProseMirrorNode } from '@tiptap/pm/model'
type RichMarkdownVisibleTextContainer = ProseMirrorNode | Fragment
export type RichMarkdownVisibleTextSegment = {
kind: 'text' | 'read-only-atom' | 'separator'
text: string
from: number
to: number
visibleFrom: number
visibleTo: number
}
export type RichMarkdownVisibleTextMap = {
text: string
segments: RichMarkdownVisibleTextSegment[]
}
export function createRichMarkdownVisibleTextMap(
doc: RichMarkdownVisibleTextContainer,
from = 0,
to = getContainerContentSize(doc)
): RichMarkdownVisibleTextMap {
const segments: RichMarkdownVisibleTextSegment[] = []
let text = ''
forEachRichMarkdownVisibleTextSegment(doc, from, to, (segment) => {
const visibleFrom = text.length
text += segment.text
segments.push({ ...segment, visibleFrom, visibleTo: text.length })
return true
})
return { text, segments }
}
export function getRichMarkdownVisibleText(
doc: RichMarkdownVisibleTextContainer,
from = 0,
to = getContainerContentSize(doc)
): string {
return createRichMarkdownVisibleTextMap(doc, from, to).text
}
export function getRichMarkdownSelectionVisibleText(state: {
doc: ProseMirrorNode
selection: { from: number; to: number }
}): string {
return getRichMarkdownVisibleText(state.doc, state.selection.from, state.selection.to).trim()
}
/** Why: shared block-boundary heuristic for visible-text maps and slice byte limits. */
export function isRichMarkdownVisibleBlockStart(node: ProseMirrorNode): boolean {
return node.isTextblock || (node.isBlock && node.isLeaf)
}
export function forEachRichMarkdownVisibleTextSegment(
doc: RichMarkdownVisibleTextContainer,
from: number,
to: number,
visit: (segment: Omit<RichMarkdownVisibleTextSegment, 'visibleFrom' | 'visibleTo'>) => boolean
): void {
let stopped = false
let sawVisibleBlock = false
const inspect = (
node: ProseMirrorNode,
pos: number,
parent: ProseMirrorNode | null = null,
index = 0
) => {
if (stopped) {
return false
}
const startsVisibleBlock = isRichMarkdownVisibleBlockStart(node)
if (startsVisibleBlock) {
if (sawVisibleBlock) {
stopped = !visit({ kind: 'separator', text: '\n', from: pos, to: pos })
if (stopped) {
return false
}
}
sawVisibleBlock = true
if (node.isTextblock) {
return true
}
}
let visible = ''
let segmentFrom = pos
let segmentTo = pos + node.nodeSize
let kind: RichMarkdownVisibleTextSegment['kind'] = 'text'
if (node.isText) {
const source = node.text ?? ''
const startOffset = Math.max(0, from - pos)
const endOffset = Math.min(source.length, to - pos)
if (endOffset <= startOffset) {
return
}
visible = source.slice(startOffset, endOffset)
segmentFrom = pos + startOffset
segmentTo = pos + endOffset
} else if (node.isLeaf) {
visible = getRichMarkdownLeafVisibleText(node, pos, parent, index)
kind = node.isAtom ? 'read-only-atom' : 'text'
} else {
return
}
if (!visible) {
return
}
stopped = !visit({
kind,
text: visible,
from: segmentFrom,
to: segmentTo
})
return !stopped
}
doc.nodesBetween(from, to, inspect)
}
export function getRichMarkdownLeafVisibleText(
node: ProseMirrorNode,
pos: number,
parent: ProseMirrorNode | null,
index: number
): string {
return (
node.type.spec.toText?.({ node, pos, parent, index }) ?? node.type.spec.leafText?.(node) ?? ''
)
}
function getContainerContentSize(container: RichMarkdownVisibleTextContainer): number {
return 'nodeSize' in container ? container.content.size : container.size
}
@@ -1,6 +1,7 @@
import { DOMSerializer } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
/**
* Why: a paragraph that word-wraps across multiple screen lines should be cut
@@ -85,14 +86,15 @@ export function cutVisualLine(
}
event.preventDefault()
const lineText = view.state.doc.textBetween(lineRange.from, lineRange.to, '')
const lineText = createRichMarkdownVisibleTextMap(
view.state.doc,
lineRange.from,
lineRange.to
).text
const slice = view.state.doc.slice(lineRange.from, lineRange.to)
const serializer = DOMSerializer.fromSchema(view.state.schema)
const fragment = serializer.serializeFragment(slice.content)
const div = document.createElement('div')
div.appendChild(fragment)
clipboardEvent.clipboardData.setData('text/html', div.innerHTML)
clipboardEvent.clipboardData.setData('text/plain', lineText)
if (!writeRichMarkdownSliceToClipboard(clipboardEvent.clipboardData, view, slice, lineText)) {
return true
}
let tr = view.state.tr.delete(lineRange.from, lineRange.to)
const clampedPos = Math.max(0, Math.min(lineRange.from, tr.doc.content.size))
@@ -0,0 +1,82 @@
import {
Hooks,
Lexer,
Marked,
Parser,
Renderer,
TextRenderer,
Tokenizer,
getDefaults,
type MarkedOptions,
type Token,
type TokensList,
marked
} from 'marked'
export function createTiptapMarkedFacade(): typeof marked {
const registry = new Marked()
// Why: Tiptap 3.22.5 registers on the injected instance but parses with
// `new instance.Lexer()`, so the constructor must retain the private registry.
class RegistryLexer extends Lexer {
constructor(options?: MarkedOptions) {
super({
...registry.defaults,
...options,
extensions: registry.defaults.extensions
})
}
}
const parser = (tokens: Token[], options?: MarkedOptions) => registry.parser(tokens, options)
const lexer = (src: string, options?: MarkedOptions): TokensList =>
new RegistryLexer(options).lex(src)
const facade = new Proxy(marked, {
apply: (_target, _thisArg, args) => Reflect.apply(registry.parse, registry, args),
get: (target, property, receiver) => {
switch (property) {
case 'defaults':
return registry.defaults
case 'getDefaults':
return getDefaults
case 'Lexer':
return RegistryLexer
case 'Parser':
return Parser
case 'Renderer':
return Renderer
case 'TextRenderer':
return TextRenderer
case 'Tokenizer':
return Tokenizer
case 'Hooks':
return Hooks
case 'parse':
return facade
case 'parseInline':
return registry.parseInline
case 'parser':
return parser
case 'lexer':
return lexer
case 'walkTokens':
return registry.walkTokens.bind(registry)
case 'use':
return (...extensions: Parameters<typeof registry.use>) => {
registry.use(...extensions)
return facade
}
case 'setOptions':
case 'options':
return (options: MarkedOptions) => {
registry.setOptions(options)
return facade
}
default:
return Reflect.get(target, property, receiver)
}
}
}) satisfies typeof marked
return facade
}
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { act, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
const activateMarkdownLinkMock = vi.hoisted(() => vi.fn())
vi.mock('@/store', () => ({
useAppStore: (
selector: (state: { activateMarkdownLink: typeof activateMarkdownLinkMock }) => unknown
) => selector({ activateMarkdownLink: activateMarkdownLinkMock })
}))
import { useLinkBubble } from './useLinkBubble'
describe('useLinkBubble owner hydration', () => {
let container: HTMLDivElement
let root: Root
afterEach(() => {
act(() => root.unmount())
container.remove()
activateMarkdownLinkMock.mockReset()
})
it.each<HttpLinkSourceOwner>([{ kind: 'local' }, { kind: 'ssh', connectionId: 'ssh-1' }])(
'refreshes an open Markdown bubble when ownership becomes $kind',
(sourceOwner) => {
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'unknown' }
})
function Harness(): React.JSX.Element {
const rootRef = useRef<HTMLElement>(null)
const [bubble, setBubble] = useState<LinkBubbleState | null>({
kind: 'markdown',
href: 'https://example.com',
openEnabled: false,
copyEnabled: true,
left: 0,
top: 0
})
const { handleLinkOpen } = useLinkBubble(null, rootRef, bubble, setBubble, () => {}, {
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
htmlSuperscriptLinkContext: context
})
return (
<button data-enabled={String(bubble?.openEnabled)} onClick={handleLinkOpen}>
{bubble?.href}
</button>
)
}
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root.render(<Harness />))
const button = container.querySelector('button')
if (!button) {
throw new Error('Expected link action')
}
expect(button.dataset.enabled).toBe('false')
act(() => button.click())
expect(activateMarkdownLinkMock).not.toHaveBeenCalled()
act(() => {
context.update({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner
})
})
expect(button.dataset.enabled).toBe('true')
expect(button.textContent).toBe('https://example.com')
act(() => button.click())
expect(activateMarkdownLinkMock).toHaveBeenCalledWith(
'https://example.com',
expect.objectContaining({ sourceOwner })
)
}
)
})
@@ -1,9 +1,14 @@
import { useCallback } from 'react'
import { useCallback, useEffect, useSyncExternalStore } from 'react'
import type { Editor } from '@tiptap/react'
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { useAppStore } from '@/store'
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
import {
classifyHtmlSuperscriptLinkAction,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
/**
* Extracts link-editing action handlers from the editor component to reduce
@@ -21,14 +26,30 @@ export function useLinkBubble(
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
}
): {
handleLinkSave: (href: string) => void
handleLinkRemove: () => void
handleLinkEditCancel: () => void
handleLinkOpen: () => void
handleLinkCopy: () => void
toggleLinkFromToolbar: () => void
} {
const citationContextSnapshot = useSyncExternalStore(
linkContext.htmlSuperscriptLinkContext.subscribe,
linkContext.htmlSuperscriptLinkContext.getSnapshot,
linkContext.htmlSuperscriptLinkContext.getSnapshot
)
useEffect(() => {
if (!linkBubble) {
return
}
const openEnabled = classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
if (openEnabled !== linkBubble.openEnabled) {
setLinkBubble({ ...linkBubble, openEnabled })
}
}, [citationContextSnapshot, linkBubble, setLinkBubble])
const startLinkEdit = useCallback(() => {
if (!editor) {
return
@@ -38,7 +59,13 @@ export function useLinkBubble(
const href = editor.isActive('link')
? (editor.getAttributes('link').href as string) || ''
: ''
setLinkBubble({ href, ...pos })
setLinkBubble({
kind: 'markdown',
href,
openEnabled: Boolean(href),
copyEnabled: Boolean(href),
...pos
})
setIsEditingLink(true)
}
}, [editor, rootRef, setLinkBubble, setIsEditingLink])
@@ -99,7 +126,11 @@ export function useLinkBubble(
const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink)
const handleLinkOpen = useCallback(() => {
if (!linkBubble?.href) {
if (
!linkBubble?.href ||
!linkBubble.openEnabled ||
!classifyHtmlSuperscriptLinkAction(linkBubble.href, citationContextSnapshot)
) {
return
}
if (linkBubble.href.startsWith('#')) {
@@ -110,11 +141,14 @@ export function useLinkBubble(
sourceFilePath: linkContext.sourceFilePath,
worktreeId: linkContext.worktreeId,
worktreeRoot: linkContext.worktreeRoot,
runtimeEnvironmentId: linkContext.runtimeEnvironmentId
runtimeEnvironmentId: linkContext.runtimeEnvironmentId,
sourceOwner: citationContextSnapshot.sourceOwner
})
}, [
activateMarkdownLink,
citationContextSnapshot,
linkBubble?.href,
linkBubble?.openEnabled,
linkContext.sourceFilePath,
linkContext.worktreeId,
linkContext.worktreeRoot,
@@ -122,6 +156,13 @@ export function useLinkBubble(
rootRef
])
const handleLinkCopy = useCallback(() => {
if (!linkBubble?.href || !linkBubble.copyEnabled) {
return
}
void copyRichMarkdownLink(linkBubble.href)
}, [linkBubble?.copyEnabled, linkBubble?.href])
const toggleLinkFromToolbar = useCallback(() => {
if (!editor) {
return
@@ -139,6 +180,7 @@ export function useLinkBubble(
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
handleLinkCopy,
toggleLinkFromToolbar
}
}
@@ -6,13 +6,21 @@ import {
type EditorConfigParams
} from './rich-markdown-editor-config'
const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true })
export function useRichMarkdownEditorInstance(params: EditorConfigParams): Editor | null {
const extensions = useMemo(
() =>
createRichMarkdownExtensions({
codec: params.codec,
includePlaceholder: true,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: params.htmlSuperscriptLinkContext
}),
[params.codec, params.htmlSuperscriptLinkContext]
)
const editor = useEditor(
useMemo(
() => ({
extensions: richMarkdownExtensions,
extensions,
...createRichMarkdownEditorConfig(params)
}),
// Dependencies are the same as the params object keys
@@ -11,8 +11,10 @@ import {
setRichMarkdownImageResolverContext,
type RichMarkdownImageResolverSettings
} from './rich-markdown-image-context'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
type RichMarkdownProgrammaticSyncOptions = {
codec: RichMarkdownEditorCodec
content: string
docLinkMenuSetter: Dispatch<SetStateAction<DocLinkMenuState | null>>
editor: Editor | null
@@ -36,6 +38,7 @@ type RichMarkdownEditorStorage = {
}
export function useRichMarkdownProgrammaticSync({
codec,
content,
docLinkMenuSetter,
editor,
@@ -103,7 +106,7 @@ export function useRichMarkdownProgrammaticSync({
}
isApplyingProgrammaticUpdateRef.current = true
try {
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef)
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
@@ -111,6 +114,7 @@ export function useRichMarkdownProgrammaticSync({
syncDocLinkMenu(editor, rootRef.current, docLinkMenuSetter)
}, [
content,
codec,
docLinkMenuSetter,
editor,
fileId,
@@ -124,15 +128,19 @@ export function useRichMarkdownProgrammaticSync({
function applyExternalRichMarkdownContent(
editor: Editor,
content: string,
lastCommittedMarkdownRef: MutableRefObject<string>
lastCommittedMarkdownRef: MutableRefObject<string>,
codec: RichMarkdownEditorCodec
): void {
try {
const hadFocus = editor.isFocused
const { from: prevFrom, to: prevTo } = editor.state.selection
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), {
contentType: 'markdown',
emitUpdate: false
})
editor.commands.setContent(
encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
{
contentType: 'markdown',
emitUpdate: false
}
)
// Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from
// external content, matching onCreate's single-paragraph reflow behavior.
normalizeEmptyListItems(editor)
@@ -84,6 +84,11 @@ export function useRichMarkdownSearch({
})
}, [editor, isSearchOpen, matchCase, searchQuery, wholeWord])
// Why: mirror the guard used by replaceCurrentMatch/replaceAllMatches so the
// disabled state never disagrees with what a click will actually do during the
// debounce window when live matches diverge from the highlight set.
const replaceDisabled = getLiveMatches().some((match) => match.touchesReadOnlyAtom)
// Clamp the user-controlled index to the valid range on every render.
// No state update needed — this is a pure derivation.
const activeMatchIndex =
@@ -160,7 +165,7 @@ export function useRichMarkdownSearch({
const liveActiveMatchIndex =
activeMatchIndex >= 0 && activeMatchIndex < liveMatches.length ? activeMatchIndex : 0
const match = liveMatches[liveActiveMatchIndex]
if (!match) {
if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) {
return
}
// Why: removing the active match shifts the next match into the same index,
@@ -173,7 +178,10 @@ export function useRichMarkdownSearch({
return
}
const liveMatches = getLiveMatches()
if (liveMatches.length === 0) {
if (
liveMatches.length === 0 ||
liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)
) {
return
}
const tr = editor.state.tr
@@ -344,6 +352,7 @@ export function useRichMarkdownSearch({
matchCase,
matchCount,
replaceQuery,
replaceDisabled,
searchQuery,
searchInputRef,
wholeWord
@@ -0,0 +1,59 @@
import { useLayoutEffect, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import type { AppState } from '@/store/types'
export function resolveRichMarkdownWorktreeRoot(
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
worktreeId: string
): string | null {
const workspaceScope = parseWorkspaceKey(worktreeId)
return workspaceScope?.type === 'folder'
? (state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId)
?.folderPath ?? null)
: (getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null)
}
export function useRichMarkdownSuperscriptLinkSetup({
filePath,
runtimeEnvironmentId,
worktreeId
}: {
filePath: string
runtimeEnvironmentId?: string | null
worktreeId: string
}) {
const worktreeRoot = useAppStore((state) => resolveRichMarkdownWorktreeRoot(state, worktreeId))
const runtimeId = runtimeEnvironmentId?.trim()
const connectionIdSelector = useMemo(() => {
return createConnectionIdForFileSelector(worktreeId, filePath, { skip: Boolean(runtimeId) })
}, [filePath, runtimeId, worktreeId])
const connectionId = useAppStore(connectionIdSelector)
const sourceOwner = useMemo<HttpLinkSourceOwner>(() => {
if (runtimeId) {
return { kind: 'runtime', runtimeEnvironmentId: runtimeId }
}
if (connectionId === undefined) {
return { kind: 'unknown' }
}
return connectionId === null ? { kind: 'local' } : { kind: 'ssh', connectionId }
}, [connectionId, runtimeId])
const [codec] = useState(createRichMarkdownEditorCodec)
const [context] = useState(() =>
createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
sourceOwner
})
)
useLayoutEffect(() => {
context.update({ sourceFilePath: filePath, worktreeId, worktreeRoot, sourceOwner })
}, [context, filePath, sourceOwner, worktreeId, worktreeRoot])
return { codec, htmlSuperscriptLinkContext: context, worktreeRoot }
}
@@ -15,6 +15,9 @@ import {
type LinkBubbleState
} from '@/components/editor/RichMarkdownLinkBubble'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
import { createRichMarkdownEditorCodec } from '@/components/editor/rich-markdown-source-transport'
import { createEditableMarkdownLinkBubble } from '@/components/editor/rich-markdown-selected-link-actions'
import { copyRichMarkdownLink } from '@/components/editor/rich-markdown-link-clipboard'
import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize'
import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane'
import {
@@ -71,6 +74,7 @@ export function GitHubMarkdownComposer({
const [linkBubble, setLinkBubble] = useState<LinkBubbleState | null>(null)
const [isEditingLink, setIsEditingLink] = useState(false)
const isTabbed = layout === 'tabbed'
const codec = useMemo(() => createRichMarkdownEditorCodec(), [])
const {
imageUrl,
@@ -89,13 +93,13 @@ export function GitHubMarkdownComposer({
const extensions = useMemo(
() => [
...createRichMarkdownExtensions(),
...createRichMarkdownExtensions({ codec }),
Placeholder.configure({
includeChildren: true,
placeholder
})
],
[placeholder]
[codec, placeholder]
)
const openLinkEditor = useCallback(() => {
@@ -109,7 +113,7 @@ export function GitHubMarkdownComposer({
return
}
const href = editor.isActive('link') ? String(editor.getAttributes('link').href ?? '') : ''
setLinkBubble({ href, ...position })
setLinkBubble(createEditableMarkdownLinkBubble(href, position))
setIsEditingLink(true)
}, [])
@@ -117,7 +121,7 @@ export function GitHubMarkdownComposer({
immediatelyRender: false,
extensions,
editable: !disabled,
content: encodeRawMarkdownHtmlForRichEditor(value),
content: encodeRawMarkdownHtmlForRichEditor(value, codec),
contentType: 'markdown',
editorProps: {
attributes: {
@@ -177,10 +181,12 @@ export function GitHubMarkdownComposer({
if (nextEditor.isActive('link')) {
const position = getLinkBubblePosition(nextEditor, rootRef.current)
if (position) {
setLinkBubble({
href: String(nextEditor.getAttributes('link').href ?? ''),
...position
})
setLinkBubble(
createEditableMarkdownLinkBubble(
String(nextEditor.getAttributes('link').href ?? ''),
position
)
)
return
}
}
@@ -224,7 +230,7 @@ export function GitHubMarkdownComposer({
}
applyingExternalValueRef.current = true
try {
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
contentType: 'markdown',
emitUpdate: false
})
@@ -233,7 +239,7 @@ export function GitHubMarkdownComposer({
} finally {
applyingExternalValueRef.current = false
}
}, [editor, value])
}, [codec, editor, value])
const handleLinkSave = useCallback((href: string) => {
const editor = editorRef.current
@@ -403,6 +409,7 @@ export function GitHubMarkdownComposer({
editorRef.current?.commands.focus()
}}
onOpen={handleLinkOpen}
onCopy={() => void copyRichMarkdownLink(linkBubble.href)}
/>
) : null}
</div>
+22 -2
View File
@@ -11605,7 +11605,8 @@
"1c99b726e0": "Remove link",
"cdfe166f6f": "Edit link",
"bfc813e909": "Open link",
"7b0b945fdc": "Paste or type a link…"
"7b0b945fdc": "Paste or type a link…",
"copyLink": "Copy link"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "This note",
@@ -11632,7 +11633,8 @@
"68d090241d": "Match whole word",
"fd97c7e585": "Replace",
"44682b4159": "Replace in rich markdown editor",
"c2884f5e95": "Replace all"
"c2884f5e95": "Replace all",
"preservedRichContentReadOnly": "Preserved rich content is read-only in rich mode."
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "No blocks found",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "Reload from Disk",
"a95d02c644": "Keep My Edits",
"2c8f1e07b9": "Loading comparison..."
},
"RichMarkdownEditor": {
"citationLinkAvailable": "{{value0}}, link to {{value1}}. Press Enter to open or Tab for link actions.",
"citationFallbackLabel": "Citation",
"citationLinkUnavailable": "{{value0}}, citation link unavailable. {{value1}}",
"tabForCitationActions": "Tab for available actions.",
"noCitationActions": "No link actions are available."
},
"richMarkdownHtmlSuperscriptLink": {
"availableAriaLabel": "{{value0}}, link to {{value1}}",
"unavailableAriaLabel": "{{value0}}, citation link unavailable"
},
"richMarkdownLinkClipboard": {
"copiedLink": "Copied link",
"copyLinkFailed": "Failed to copy link"
},
"richMarkdownSourceOwningCutFeedback": {
"selectLessContent": "Select less content or use code mode to cut preserved HTML citations."
}
},
"diff": {
+22 -2
View File
@@ -11605,7 +11605,8 @@
"1c99b726e0": "Quitar enlace",
"cdfe166f6f": "Editar enlace",
"bfc813e909": "Abrir enlace",
"7b0b945fdc": "Pega o escribe un enlace…"
"7b0b945fdc": "Pega o escribe un enlace…",
"copyLink": "Copiar enlace"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "Esta nota",
@@ -11632,7 +11633,8 @@
"68d090241d": "Coincidir con toda la palabra",
"fd97c7e585": "Reemplazar",
"44682b4159": "Reemplazar en el editor de markdown enriquecido",
"c2884f5e95": "Reemplazar todo"
"c2884f5e95": "Reemplazar todo",
"preservedRichContentReadOnly": "El contenido enriquecido conservado es de solo lectura en el modo enriquecido."
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "No se encontraron bloques",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "Recargar desde disco",
"a95d02c644": "Mantener mis ediciones",
"2c8f1e07b9": "Cargando comparación..."
},
"RichMarkdownEditor": {
"citationLinkAvailable": "{{value0}}, enlace a {{value1}}. Pulsa Intro para abrirlo o Tab para las acciones del enlace.",
"citationFallbackLabel": "Cita",
"citationLinkUnavailable": "{{value0}}, enlace de cita no disponible. {{value1}}",
"tabForCitationActions": "Pulsa Tab para ver las acciones disponibles.",
"noCitationActions": "No hay acciones de enlace disponibles."
},
"richMarkdownHtmlSuperscriptLink": {
"availableAriaLabel": "{{value0}}, enlace a {{value1}}",
"unavailableAriaLabel": "{{value0}}, enlace de cita no disponible"
},
"richMarkdownLinkClipboard": {
"copiedLink": "Enlace copiado",
"copyLinkFailed": "No se pudo copiar el enlace"
},
"richMarkdownSourceOwningCutFeedback": {
"selectLessContent": "Selecciona menos contenido o usa el modo de código para cortar citas HTML conservadas."
}
},
"diff": {
+22 -2
View File
@@ -11605,7 +11605,8 @@
"1c99b726e0": "リンクを削除する",
"cdfe166f6f": "リンクを編集",
"bfc813e909": "リンクを開く",
"7b0b945fdc": "リンクを貼り付けるか入力してください…"
"7b0b945fdc": "リンクを貼り付けるか入力してください…",
"copyLink": "リンクをコピー"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "このメモ",
@@ -11632,7 +11633,8 @@
"68d090241d": "単語全体に一致",
"fd97c7e585": "置換",
"44682b4159": "リッチ markdown エディタで置換",
"c2884f5e95": "すべて置換"
"c2884f5e95": "すべて置換",
"preservedRichContentReadOnly": "保持されたリッチコンテンツはリッチモードでは読み取り専用です。"
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "ブロックが見つかりませんでした",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "ディスクから再読み込み",
"a95d02c644": "編集内容を保持",
"2c8f1e07b9": "比較を読み込み中..."
},
"RichMarkdownEditor": {
"citationLinkAvailable": "{{value0}}、{{value1}}へのリンク。Enterキーで開くか、Tabキーでリンク操作に移動します。",
"citationFallbackLabel": "引用",
"citationLinkUnavailable": "{{value0}}、引用リンクは利用できません。{{value1}}",
"tabForCitationActions": "Tabキーで利用可能な操作に移動します。",
"noCitationActions": "利用できるリンク操作はありません。"
},
"richMarkdownHtmlSuperscriptLink": {
"availableAriaLabel": "{{value0}}、{{value1}}へのリンク",
"unavailableAriaLabel": "{{value0}}、引用リンクは利用できません"
},
"richMarkdownLinkClipboard": {
"copiedLink": "リンクをコピーしました",
"copyLinkFailed": "リンクをコピーできませんでした"
},
"richMarkdownSourceOwningCutFeedback": {
"selectLessContent": "保持されたHTML引用を切り取るには、選択範囲を小さくするかコードモードを使用してください。"
}
},
"diff": {
+22 -2
View File
@@ -11605,7 +11605,8 @@
"1c99b726e0": "링크 삭제",
"cdfe166f6f": "링크 수정",
"bfc813e909": "링크 열기",
"7b0b945fdc": "링크를 붙여넣거나 입력하세요…"
"7b0b945fdc": "링크를 붙여넣거나 입력하세요…",
"copyLink": "링크 복사"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "이 메모",
@@ -11632,7 +11633,8 @@
"68d090241d": "전체 단어 일치",
"fd97c7e585": "바꾸기",
"44682b4159": "리치 markdown 편집기에서 바꾸기",
"c2884f5e95": "모두 바꾸기"
"c2884f5e95": "모두 바꾸기",
"preservedRichContentReadOnly": "보존된 리치 콘텐츠는 리치 모드에서 읽기 전용입니다."
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "블록을 찾을 수 없습니다.",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "디스크에서 다시 로드",
"a95d02c644": "편집 내용 유지",
"2c8f1e07b9": "비교 로딩 중..."
},
"RichMarkdownEditor": {
"citationLinkAvailable": "{{value0}}, {{value1}} 링크. Enter 키로 열거나 Tab 키로 링크 작업으로 이동하세요.",
"citationFallbackLabel": "인용",
"citationLinkUnavailable": "{{value0}}, 인용 링크를 사용할 수 없습니다. {{value1}}",
"tabForCitationActions": "Tab 키로 사용 가능한 작업으로 이동하세요.",
"noCitationActions": "사용 가능한 링크 작업이 없습니다."
},
"richMarkdownHtmlSuperscriptLink": {
"availableAriaLabel": "{{value0}}, {{value1}} 링크",
"unavailableAriaLabel": "{{value0}}, 인용 링크를 사용할 수 없음"
},
"richMarkdownLinkClipboard": {
"copiedLink": "링크를 복사했습니다",
"copyLinkFailed": "링크를 복사하지 못했습니다"
},
"richMarkdownSourceOwningCutFeedback": {
"selectLessContent": "보존된 HTML 인용을 잘라내려면 더 적은 콘텐츠를 선택하거나 코드 모드를 사용하세요."
}
},
"diff": {
+22 -2
View File
@@ -11605,7 +11605,8 @@
"1c99b726e0": "删除链接",
"cdfe166f6f": "编辑链接",
"bfc813e909": "打开链接",
"7b0b945fdc": "粘贴或输入链接..."
"7b0b945fdc": "粘贴或输入链接...",
"copyLink": "复制链接"
},
"RichMarkdownReviewNoteLayer": {
"f3ef92952b": "此注",
@@ -11632,7 +11633,8 @@
"68d090241d": "匹配整个单词",
"fd97c7e585": "替换",
"44682b4159": "在 Markdown 编辑器中替换",
"c2884f5e95": "全部替换"
"c2884f5e95": "全部替换",
"preservedRichContentReadOnly": "保留的富文本内容在富文本模式下为只读。"
},
"RichMarkdownSlashMenu": {
"82c6816ff8": "没有找到块",
@@ -11849,6 +11851,24 @@
"3fa2b8d417": "从磁盘重新加载",
"a95d02c644": "保留我的编辑",
"2c8f1e07b9": "正在加载比较..."
},
"RichMarkdownEditor": {
"citationLinkAvailable": "{{value0}},链接到 {{value1}}。按 Enter 打开,或按 Tab 移至链接操作。",
"citationFallbackLabel": "引用",
"citationLinkUnavailable": "{{value0}},引用链接不可用。{{value1}}",
"tabForCitationActions": "按 Tab 移至可用操作。",
"noCitationActions": "没有可用的链接操作。"
},
"richMarkdownHtmlSuperscriptLink": {
"availableAriaLabel": "{{value0}},链接到 {{value1}}",
"unavailableAriaLabel": "{{value0}},引用链接不可用"
},
"richMarkdownLinkClipboard": {
"copiedLink": "已复制链接",
"copyLinkFailed": "复制链接失败"
},
"richMarkdownSourceOwningCutFeedback": {
"selectLessContent": "请选择较少的内容,或使用代码模式剪切保留的 HTML 引用。"
}
},
"diff": {
@@ -10,6 +10,7 @@ import {
} from './connection-context'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import { createConnectionIdForFileSelector } from './connection-owner-resolution'
const initialState = useAppStore.getInitialState()
@@ -581,4 +582,24 @@ describe('getConnectionIdFromState', () => {
expect(getConnectionIdFromState(state, null)).toBeNull()
})
it('recomputes a retained file-owner selector when ownership slices hydrate', () => {
const selector = createConnectionIdForFileSelector(
'repo-ssh::/home/neil/repo-feature',
'/home/neil/repo-feature/README.md'
)
const unresolved: ConnectionContextState = {
folderWorkspaces: [],
projectGroups: [],
repos: [],
worktreesByRepo: {}
}
expect(selector(unresolved)).toBeUndefined()
const hydrated: ConnectionContextState = {
...unresolved,
repos: [makeRepo({ id: 'repo-ssh', connectionId: 'ssh-hydrated' })]
}
expect(selector(hydrated)).toBe('ssh-hydrated')
})
})
+6 -75
View File
@@ -1,17 +1,11 @@
import { useAppStore } from '@/store'
import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import type { AppState } from '@/store/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../../shared/cross-platform-path'
import {
getFolderWorkspaceCandidateRepos,
getFolderWorkspaceConnectionId
} from './folder-workspace-connection'
getConnectionIdForFileFromState,
getConnectionIdFromState
} from './connection-owner-resolution'
export { getConnectionIdFromState } from './connection-owner-resolution'
/**
* Resolve the SSH connectionId for a worktree. Returns null for local repos,
@@ -22,33 +16,6 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
return getConnectionIdFromState(useAppStore.getState(), worktreeId)
}
export function getConnectionIdFromState(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
worktreeId: string | null
): string | null | undefined {
if (!worktreeId) {
return null
}
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return null
}
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
}
// Why: retained Zustand selectors call this on unrelated writes; reuse the
// immutable-slice indexes instead of flattening every worktree each time.
const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
// Why: SSH worktrees can be restored from session IDs before relay discovery
// repopulates worktreesByRepo. The composite ID still carries the repo ID.
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = getIndexedRepoMap(state.repos).get(repoId)
if (!repo) {
return undefined
}
return repo.connectionId ?? null
}
/**
* True when we can determine the owning host (local vs. a specific SSH target)
* for a worktree. False means the backing repo has not landed in the store yet
@@ -75,41 +42,5 @@ export function getConnectionIdForFile(
worktreeId: string | null,
filePath: string
): string | null | undefined {
const connectionId = getConnectionId(worktreeId)
if (connectionId !== undefined || !worktreeId) {
return connectionId
}
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type !== 'folder') {
return undefined
}
// Why: mixed local/SSH folder workspaces cannot pick one owner globally, but
// a concrete file path can still belong unambiguously to a child repo.
const state = useAppStore.getState()
const candidateRepos = getFolderWorkspaceCandidateRepos(
state,
parsedWorkspaceKey.folderWorkspaceId
)
return resolveConnectionIdForRepoPath(candidateRepos, filePath)
}
function resolveConnectionIdForRepoPath(
repos: readonly { path: string; connectionId?: string | null }[],
filePath: string
): string | null | undefined {
const matchingRepos = repos
.filter((repo) => isPathInsideOrEqual(repo.path, filePath))
.map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)
const longestPathLength = matchingRepos[0]?.normalizedPath.length
if (!longestPathLength) {
return undefined
}
// Why: containment normalizes separators/trailing slashes; ambiguity checks
// need the same representation or equal repo roots can be hidden.
const bestMatches = matchingRepos.filter(
(candidate) => candidate.normalizedPath.length === longestPathLength
)
const connectionIds = new Set(bestMatches.map(({ repo }) => repo.connectionId ?? null))
return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
return getConnectionIdForFileFromState(useAppStore.getState(), worktreeId, filePath)
}
@@ -0,0 +1,100 @@
import type { AppState } from '@/store/types'
import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../../shared/cross-platform-path'
import {
getFolderWorkspaceCandidateRepos,
getFolderWorkspaceConnectionId
} from './folder-workspace-connection'
type ConnectionOwnerState = Pick<
AppState,
'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
>
export function createConnectionIdForFileSelector(
worktreeId: string | null,
filePath: string,
{ skip = false }: { skip?: boolean } = {}
): (state: ConnectionOwnerState) => string | null | undefined {
let previousSlices: ConnectionOwnerState | null = null
let previousResult: string | null | undefined
return (state) => {
if (skip) {
return undefined
}
if (
previousSlices?.folderWorkspaces === state.folderWorkspaces &&
previousSlices.projectGroups === state.projectGroups &&
previousSlices.repos === state.repos &&
previousSlices.worktreesByRepo === state.worktreesByRepo
) {
return previousResult
}
previousSlices = {
folderWorkspaces: state.folderWorkspaces,
projectGroups: state.projectGroups,
repos: state.repos,
worktreesByRepo: state.worktreesByRepo
}
previousResult = getConnectionIdForFileFromState(state, worktreeId, filePath)
return previousResult
}
}
export function getConnectionIdFromState(
state: ConnectionOwnerState,
worktreeId: string | null
): string | null | undefined {
if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return null
}
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)
}
// Why: owner resolution runs from retained Zustand selectors, so unrelated
// store writes must not flatten every worktree or scan every repository.
const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId)
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = getIndexedRepoMap(state.repos).get(repoId)
return repo ? (repo.connectionId ?? null) : undefined
}
export function getConnectionIdForFileFromState(
state: ConnectionOwnerState,
worktreeId: string | null,
filePath: string
): string | null | undefined {
const connectionId = getConnectionIdFromState(state, worktreeId)
if (connectionId !== undefined || !worktreeId) {
return connectionId
}
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type !== 'folder') {
return undefined
}
const candidateRepos = getFolderWorkspaceCandidateRepos(
state,
parsedWorkspaceKey.folderWorkspaceId
)
const matchingRepos = candidateRepos
.filter((repo) => isPathInsideOrEqual(repo.path, filePath))
.map((repo) => ({ repo, normalizedPath: normalizeRuntimePathForComparison(repo.path) }))
.sort((left, right) => right.normalizedPath.length - left.normalizedPath.length)
const longestPathLength = matchingRepos[0]?.normalizedPath.length
if (!longestPathLength) {
return undefined
}
const connectionIds = new Set(
matchingRepos
.filter((candidate) => candidate.normalizedPath.length === longestPathLength)
.map(({ repo }) => repo.connectionId ?? null)
)
return connectionIds.size === 1 ? ([...connectionIds][0] ?? null) : undefined
}
+103 -1
View File
@@ -32,12 +32,14 @@ const storeState = {
allWorktrees: vi.fn(
() => [] as { id: string; projectId?: string; repoId?: string; displayName?: string }[]
),
workspacePortScan: null as { result: WorkspacePortScanResult } | null
workspacePortScan: null as { result: WorkspacePortScanResult } | null,
workspacePortScansByKey: {} as Record<string, WorkspacePortScanResult>
}
beforeEach(() => {
vi.clearAllMocks()
storeState.settings = undefined
storeState.workspacePortScansByKey = {}
registerHttpLinkStoreAccessor(() => storeState)
vi.stubGlobal('window', {
api: {
@@ -110,6 +112,106 @@ describe('openHttpLink', () => {
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
})
it('honors an explicit local document owner despite an unrelated active runtime', () => {
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-other' }
openHttpLink('https://example.com/', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'local' }
})
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
activate: true
})
expect(openUrlMock).not.toHaveBeenCalled()
})
it('routes explicit runtime and SSH document owners to the exact system URL', () => {
storeState.settings = { openLinksInApp: true, localhostWorktreeLabelsEnabled: true }
openHttpLink('http://localhost:5180/runtime', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }
})
openHttpLink('http://localhost:5180/ssh', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
})
expect(openUrlMock).toHaveBeenNthCalledWith(1, 'http://localhost:5180/runtime')
expect(openUrlMock).toHaveBeenNthCalledWith(2, 'http://localhost:5180/ssh')
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(registerLocalhostLabelMock).not.toHaveBeenCalled()
})
it('labels explicit local links from the local scan instead of a merged remote port', async () => {
storeState.settings = {
openLinksInApp: true,
activeRuntimeEnvironmentId: 'env-other',
localhostWorktreeLabelsEnabled: true
}
storeState.repos = [
{ id: 'repo-local', displayName: 'Local' },
{ id: 'repo-remote', displayName: 'Remote' }
]
storeState.worktreesByRepo = {
'repo-local': [{ id: 'wt-local', projectId: 'repo-local' }],
'repo-remote': [{ id: 'wt-remote', projectId: 'repo-remote' }]
}
const port = (repoId: string, worktreeId: string, path: string) => ({
id: `tcp:5180:${worktreeId}`,
kind: 'workspace' as const,
port: 5180,
protocol: 'http' as const,
bindHost: '127.0.0.1',
connectHost: 'localhost',
owner: {
repoId,
worktreeId,
displayName: worktreeId,
path,
confidence: 'cwd' as const
}
})
storeState.workspacePortScan = {
result: {
platform: 'darwin',
scannedAt: 2,
ports: [port('repo-remote', 'wt-remote', '/remote')]
}
}
storeState.workspacePortScansByKey = {
'local:all': {
platform: 'darwin',
scannedAt: 1,
ports: [port('repo-local', 'wt-local', '/local')]
}
}
registerLocalhostLabelMock.mockResolvedValue({ url: 'http://wt-local.orca.localhost:60016/' })
openHttpLink('http://localhost:5180/', {
worktreeId: 'wt-local',
sourceOwner: { kind: 'local' }
})
await Promise.resolve()
expect(registerLocalhostLabelMock).toHaveBeenCalledWith(
expect.objectContaining({ repoId: 'repo-local', worktreeId: 'wt-local' })
)
})
it('keeps unresolved document ownership non-actionable', () => {
storeState.settings = { openLinksInApp: true }
openHttpLink('https://example.com/', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'unknown' }
})
expect(openUrlMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
it('routes to the system browser when no worktree id is provided', () => {
storeState.settings = { openLinksInApp: true }
+40 -7
View File
@@ -9,8 +9,15 @@ import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/wor
export type OpenHttpLinkOptions = {
worktreeId?: string | null
forceSystemBrowser?: boolean
sourceOwner?: HttpLinkSourceOwner
}
export type HttpLinkSourceOwner =
| { kind: 'local' }
| { kind: 'runtime'; runtimeEnvironmentId: string }
| { kind: 'ssh'; connectionId: string }
| { kind: 'unknown' }
type StoreAccessor = () => {
settings?: Partial<
Pick<
@@ -25,6 +32,7 @@ type StoreAccessor = () => {
worktreesByRepo?: Record<string, LocalhostLinkWorktree[]>
allWorktrees?: () => LocalhostLinkWorktree[]
workspacePortScan?: { result: WorkspacePortScanResult } | null
workspacePortScansByKey?: Record<string, WorkspacePortScanResult>
}
type LocalhostLinkRepo = {
@@ -56,11 +64,15 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void {
// branch). Shift+Cmd/Ctrl is the escape hatch: callers pass forceSystemBrowser
// to bypass the setting entirely.
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
const { worktreeId, forceSystemBrowser } = opts
const { worktreeId, forceSystemBrowser, sourceOwner } = opts
if (sourceOwner?.kind === 'unknown') {
return
}
const state = storeAccessor?.()
const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim())
const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive
const routeToOrca =
!remoteRuntimeActive &&
sourceIsLocal &&
!forceSystemBrowser &&
Boolean(worktreeId) &&
state?.settings?.openLinksInApp === true
@@ -75,7 +87,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
// to the global activeWorktreeId deselects the real repo workspace.
state.setActiveWorktree(worktreeId)
}
const localhostRoute = localhostLabelRouteForTerminalLink(url, state)
const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner)
if (!localhostRoute) {
state.createBrowserTab(worktreeId, url, { activate: true })
return
@@ -86,7 +98,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
return
}
const localhostRoute = state ? localhostLabelRouteForTerminalLink(url, state) : null
const localhostRoute = state ? localhostLabelRouteForHttpLink(url, state, sourceOwner) : null
if (!localhostRoute) {
void window.api.shell.openUrl(url)
return
@@ -96,6 +108,24 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
})
}
function localhostLabelRouteForHttpLink(
url: string,
state: ReturnType<StoreAccessor>,
sourceOwner?: HttpLinkSourceOwner
): LocalhostWorktreeLabelRoute | null {
if (sourceOwner && sourceOwner.kind !== 'local') {
return null
}
if (!sourceOwner && state.settings?.activeRuntimeEnvironmentId?.trim()) {
return null
}
const sourceScan =
sourceOwner?.kind === 'local'
? (state.workspacePortScansByKey?.['local:all'] ?? null)
: undefined
return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
}
export async function resolveLocalhostHttpLinkDisplayUrl(url: string): Promise<string | null> {
const state = storeAccessor?.()
if (!state) {
@@ -128,11 +158,13 @@ async function openLabeledLocalhostLink(
function localhostLabelRouteForTerminalLink(
rawUrl: string,
state: ReturnType<StoreAccessor>
state: ReturnType<StoreAccessor>,
ignoreActiveRuntime = false,
sourceScan?: WorkspacePortScanResult | null
): LocalhostWorktreeLabelRoute | null {
if (
state.settings?.localhostWorktreeLabelsEnabled !== true ||
state.settings?.activeRuntimeEnvironmentId?.trim()
(!ignoreActiveRuntime && state.settings?.activeRuntimeEnvironmentId?.trim())
) {
return null
}
@@ -142,7 +174,8 @@ function localhostLabelRouteForTerminalLink(
if (!parsed) {
return null
}
const port = findWorkspacePortByNumber(state.workspacePortScan?.result, Number(parsed.port))
const scan = sourceScan === undefined ? state.workspacePortScan?.result : sourceScan
const port = findWorkspacePortByNumber(scan, Number(parsed.port))
if (!port) {
return null
}
+77 -1
View File
@@ -49,6 +49,10 @@ function createEditorStore(): StoreApi<AppState> {
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
repos: [{ id: 'repo-1', path: '/repo' }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
folderWorkspaces: [],
projectGroups: [],
recordFeatureInteraction: vi.fn(),
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
@@ -62,6 +66,10 @@ function createEditorTabsStore(): StoreApi<AppState> {
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
repos: [{ id: 'repo-1', path: '/repo' }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
folderWorkspaces: [],
projectGroups: [],
recordFeatureInteraction: vi.fn(),
...createTabsSlice(...(args as Parameters<typeof createTabsSlice>)),
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
@@ -4003,6 +4011,47 @@ describe('createEditorSlice activateMarkdownLink', () => {
])
})
it('rejects ambiguous same-path owner fallback and honors an explicit source owner', async () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/docs/note.md',
relativePath: 'docs/note.md',
worktreeId: 'wt-1',
runtimeEnvironmentId: 'env-source',
language: 'markdown',
mode: 'edit'
})
store.getState().openFile(
{
filePath: '/repo/docs/note.md',
relativePath: 'docs/note.md',
worktreeId: 'wt-1',
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
await store.getState().activateMarkdownLink('https://example.com', {
sourceFilePath: '/repo/docs/note.md',
worktreeId: 'wt-1',
worktreeRoot: '/repo'
})
expect(openHttpLinkMock).not.toHaveBeenCalled()
await store.getState().activateMarkdownLink('https://example.com', {
sourceFilePath: '/repo/docs/note.md',
worktreeId: 'wt-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'local' }
})
})
it('stats SSH markdown links through the source worktree connection before opening', async () => {
const store = createEditorStore()
pathExistsMock.mockResolvedValue(true)
@@ -4268,11 +4317,38 @@ describe('createEditorSlice activateMarkdownLink', () => {
worktreeId: 'wt-1',
worktreeRoot: '/repo'
})
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', { worktreeId: 'wt-1' })
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'local' }
})
expect(openUrlMock).not.toHaveBeenCalled()
expect(store.getState().openFiles).toEqual([])
})
it('does not rescan legacy owner state when the source owner is explicit', async () => {
const store = createEditorStore()
for (const key of ['openFiles', 'repos', 'worktreesByRepo', 'folderWorkspaces'] as const) {
Object.defineProperty(store.getState(), key, {
configurable: true,
get: () => {
throw new Error(`explicit owner must not read ${key}`)
}
})
}
await store.getState().activateMarkdownLink('https://example.com', {
sourceFilePath: '/repo/docs/note.md',
worktreeId: 'wt-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
expect(openHttpLinkMock).toHaveBeenCalledWith('https://example.com/', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'local' }
})
})
it('opens in-worktree file links in Orca', async () => {
const store = createEditorStore()
await store.getState().activateMarkdownLink('./image.png', {
+45 -18
View File
@@ -10,7 +10,8 @@ import {
getCheckRunDetailsTabLabel,
type OpenCheckRunDetailsState
} from '@/components/editor/check-run-details-tab'
import { openHttpLink } from '@/lib/http-link-routing'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { detectLanguage } from '@/lib/language-detect'
import type {
@@ -500,6 +501,7 @@ export type EditorSlice = {
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
sourceOwner?: HttpLinkSourceOwner
}
) => Promise<void>
openMarkdownPreview: (
@@ -1335,13 +1337,6 @@ function shouldDeleteUntouchedUntitledFile(file: OpenFile | undefined, hasDraft:
)
}
function getWorktreeConnectionId(state: AppState, worktreeId: string): string | undefined {
const worktree = findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = (state.repos ?? []).find((candidate) => candidate.id === repoId)
return repo?.connectionId ?? undefined
}
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set, get) => ({
editorDrafts: {},
setEditorDraft: (fileId, content) =>
@@ -4170,16 +4165,48 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activateMarkdownLink: async (rawHref, ctx) => {
const initialState = get()
let inferredRuntimeEnvironmentId: string | null | undefined
if (!ctx.sourceOwner && ctx.runtimeEnvironmentId === undefined) {
const inferredRuntimeOwners = new Set(
initialState.openFiles
.filter(
(file) => file.filePath === ctx.sourceFilePath && file.worktreeId === ctx.worktreeId
)
.map((file) => file.runtimeEnvironmentId?.trim() || null)
)
if (inferredRuntimeOwners.size > 1) {
return
}
inferredRuntimeEnvironmentId =
inferredRuntimeOwners.size === 1 ? [...inferredRuntimeOwners][0] : undefined
}
const sourceRuntimeEnvironmentId =
ctx.runtimeEnvironmentId !== undefined
? ctx.runtimeEnvironmentId
: initialState.openFiles.find((file) => file.filePath === ctx.sourceFilePath)
?.runtimeEnvironmentId
const sourceSettings = settingsForRuntimeOwner(
initialState.settings,
sourceRuntimeEnvironmentId
)
const sourceConnectionId = getWorktreeConnectionId(initialState, ctx.worktreeId)
ctx.sourceOwner?.kind === 'runtime'
? ctx.sourceOwner.runtimeEnvironmentId
: ctx.sourceOwner
? null
: ctx.runtimeEnvironmentId !== undefined
? ctx.runtimeEnvironmentId
: inferredRuntimeEnvironmentId
const runtimeOwnerId = sourceRuntimeEnvironmentId?.trim() || null
const sourceSettings = settingsForRuntimeOwner(initialState.settings, runtimeOwnerId)
const resolvedConnectionId =
ctx.sourceOwner || runtimeOwnerId
? undefined
: getConnectionIdForFileFromState(initialState, ctx.worktreeId, ctx.sourceFilePath)
const sourceOwner: HttpLinkSourceOwner =
ctx.sourceOwner ??
(runtimeOwnerId
? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
: resolvedConnectionId === undefined
? { kind: 'unknown' }
: resolvedConnectionId === null
? { kind: 'local' }
: { kind: 'ssh', connectionId: resolvedConnectionId })
if (sourceOwner.kind === 'unknown') {
return
}
const sourceConnectionId = sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
const fileContext = {
settings: sourceSettings,
worktreeId: ctx.worktreeId,
@@ -4194,7 +4221,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
return
}
if (target.kind === 'external') {
openHttpLink(target.url, { worktreeId: ctx.worktreeId })
openHttpLink(target.url, { worktreeId: ctx.worktreeId, sourceOwner })
return
}
if (target.kind === 'file') {