Stop the rich-markdown editor from mangling pasted Windows file paths

The TipTap Link extension is configured with autolink + linkOnPaste, so
pasting a file path that a terminal rendered as a clickable link (an <a>
with a fabricated http://<filename> href) produced a broken markdown link
like [C:\dir\CLAUDE.md](http://CLAUDE.md) - the directory was lost and the
URL was malformed.

Intercept the paste in handleRichMarkdownPaste, ahead of the Link
extension's paste/autolink plugins, when the clipboard carries a single
filesystem path (Windows drive, UNC, or POSIX) or a lone terminal-style
anchor wrapping one. Such content is inserted as literal plain text so it
round-trips through markdown unchanged. Genuine URLs still autolink.

Fixes #5359
This commit is contained in:
Neil
2026-06-27 21:55:14 -07:00
parent e40ad6a93a
commit e0c15cf286
6 changed files with 341 additions and 1 deletions
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import {
extractTerminalLinkFilesystemPath,
isFilesystemPath
} from './rich-markdown-filesystem-path'
describe('isFilesystemPath', () => {
it('recognizes Windows drive, UNC, and POSIX absolute paths', () => {
expect(isFilesystemPath('C:\\Users\\me\\repo\\CLAUDE.md')).toBe(true)
expect(isFilesystemPath('c:/Users/me/repo/CLAUDE.md')).toBe(true)
expect(isFilesystemPath('\\\\server\\share\\CLAUDE.md')).toBe(true)
expect(isFilesystemPath('/home/me/repo/CLAUDE.md')).toBe(true)
expect(isFilesystemPath(' C:\\Users\\me\\CLAUDE.md ')).toBe(true)
})
it('rejects URLs, prose, and ambiguous fragments', () => {
expect(isFilesystemPath('https://example.com/docs')).toBe(false)
expect(isFilesystemPath('http://CLAUDE.md')).toBe(false)
expect(isFilesystemPath('mailto:me@example.com')).toBe(false)
expect(isFilesystemPath('see C:\\Users\\me when ready')).toBe(false)
expect(isFilesystemPath('CLAUDE.md')).toBe(false)
expect(isFilesystemPath('/')).toBe(false)
expect(isFilesystemPath('/foo')).toBe(false)
expect(isFilesystemPath('a paragraph\nwith C:\\path')).toBe(false)
expect(isFilesystemPath('')).toBe(false)
})
})
describe('extractTerminalLinkFilesystemPath', () => {
it('extracts a path from a single terminal-style anchor with a fabricated href', () => {
const path = 'C:\\Users\\me\\repo\\CLAUDE.md'
expect(
extractTerminalLinkFilesystemPath(`<a href="http://CLAUDE.md">${path}</a>`)
).toBe(path)
})
it('extracts a path when the anchor text itself is a path', () => {
const path = '/home/me/repo/CLAUDE.md'
expect(
extractTerminalLinkFilesystemPath(`<a href="${path}">${path}</a>`)
).toBe(path)
})
it('returns null for a genuine URL anchor', () => {
expect(
extractTerminalLinkFilesystemPath(
'<a href="https://example.com/docs">https://example.com/docs</a>'
)
).toBeNull()
})
it('returns null when the anchor is part of larger rich content', () => {
expect(
extractTerminalLinkFilesystemPath(
'<p>see <a href="http://CLAUDE.md">C:\\repo\\CLAUDE.md</a> please</p>'
)
).toBeNull()
})
it('returns null for multiple anchors or empty html', () => {
expect(
extractTerminalLinkFilesystemPath(
'<a href="http://a">C:\\a</a><a href="http://b">C:\\b</a>'
)
).toBeNull()
expect(extractTerminalLinkFilesystemPath('')).toBeNull()
expect(extractTerminalLinkFilesystemPath('<p>plain</p>')).toBeNull()
})
})
@@ -0,0 +1,75 @@
// Why: terminals (e.g. Claude Code) render bare file paths as clickable links
// by fabricating an `http://<filename>` href. Pasting that <a> into the rich
// editor would let TipTap's Link mark turn `C:\dir\CLAUDE.md` into the broken
// `[CLAUDE.md](http://CLAUDE.md)` (the directory is lost, the URL is malformed).
// These detectors let the paste handler keep such content as plain text.
// Windows drive-letter absolute path, e.g. C:\Users\me\CLAUDE.md or C:/Users/me.
const WINDOWS_DRIVE_PATH = /^[a-zA-Z]:[\\/]/
// UNC path, e.g. \\server\share\file.
const WINDOWS_UNC_PATH = /^\\\\[^\\/]/
// POSIX absolute path with at least one more segment, e.g. /home/me/CLAUDE.md.
// A lone "/" or a single segment like "/foo" is excluded so we don't capture
// things that read more like relative web fragments.
const POSIX_ABSOLUTE_PATH = /^\/[^/\s][^\n]*\/[^\n]*$/
/**
* Returns true when `text` is a single filesystem path that the rich editor
* should paste verbatim rather than autolink. The text is trimmed first because
* terminal selections frequently carry a trailing newline.
*/
export function isFilesystemPath(text: string): boolean {
const trimmed = text.trim()
if (!trimmed) {
return false
}
// Why: multi-line clipboard content is prose, not a path; limiting to a
// single line avoids hijacking ordinary paragraph pastes.
if (/[\n\r]/.test(trimmed)) {
return false
}
if (WINDOWS_DRIVE_PATH.test(trimmed) || WINDOWS_UNC_PATH.test(trimmed)) {
return true
}
// POSIX paths can contain spaces, so only require the leading-slash shape.
return POSIX_ABSOLUTE_PATH.test(trimmed)
}
/**
* When the clipboard HTML is a single terminal-style link wrapping a filesystem
* path, returns that path's visible text so it can be pasted as plain text.
* Returns null for genuine links or anything more complex than one anchor.
*/
export function extractTerminalLinkFilesystemPath(html: string): string | null {
if (!html.trim()) {
return null
}
let doc: Document
try {
doc = new DOMParser().parseFromString(html, 'text/html')
} catch {
return null
}
const body = doc.body
if (!body) {
return null
}
const anchors = body.querySelectorAll('a[href]')
if (anchors.length !== 1) {
return null
}
const anchor = anchors[0]
// Why: only treat the anchor as a path link when it is effectively the whole
// payload (a lone clickable token), not one link inside a larger rich paste.
if ((body.textContent ?? '').trim() !== (anchor.textContent ?? '').trim()) {
return null
}
// Why: terminals (e.g. Claude Code) keep the full path as the anchor's
// visible text while fabricating an `http://<filename>` href. Recognizing the
// path in the visible text catches the broken link regardless of that href.
const visibleText = (anchor.textContent ?? '').trim()
if (visibleText && isFilesystemPath(visibleText)) {
return visibleText
}
return null
}
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
import { handleRichMarkdownFilesystemPathPaste } from './rich-markdown-path-paste'
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
import { handleRichMarkdownPaste } from './rich-markdown-paste-handler'
@@ -7,6 +8,10 @@ vi.mock('./rich-markdown-paste-image', () => ({
handleRichMarkdownImagePaste: vi.fn()
}))
vi.mock('./rich-markdown-path-paste', () => ({
handleRichMarkdownFilesystemPathPaste: vi.fn()
}))
vi.mock('./rich-markdown-large-text-paste', () => ({
handleRichMarkdownLargeTextPaste: vi.fn()
}))
@@ -14,6 +19,7 @@ vi.mock('./rich-markdown-large-text-paste', () => ({
describe('rich markdown paste handler', () => {
beforeEach(() => {
vi.mocked(handleRichMarkdownImagePaste).mockReset()
vi.mocked(handleRichMarkdownFilesystemPathPaste).mockReset()
vi.mocked(handleRichMarkdownLargeTextPaste).mockReset()
})
@@ -38,11 +44,32 @@ describe('rich markdown paste handler', () => {
worktreeId: 'wt-1',
runtimeEnvironmentId: undefined
})
expect(handleRichMarkdownFilesystemPathPaste).not.toHaveBeenCalled()
expect(handleRichMarkdownLargeTextPaste).not.toHaveBeenCalled()
})
it('falls through to large text handling when image paste declines ownership', () => {
it('claims a filesystem path paste before large text fallback', () => {
vi.mocked(handleRichMarkdownImagePaste).mockReturnValue(false)
vi.mocked(handleRichMarkdownFilesystemPathPaste).mockReturnValue(true)
const editor = {} as never
const event = {} as ClipboardEvent
expect(
handleRichMarkdownPaste({
editor,
event,
filePath: '/repo/note.md',
worktreeId: 'wt-1'
})
).toBe(true)
expect(handleRichMarkdownFilesystemPathPaste).toHaveBeenCalledWith(editor, event)
expect(handleRichMarkdownLargeTextPaste).not.toHaveBeenCalled()
})
it('falls through to large text handling when image and path paste decline ownership', () => {
vi.mocked(handleRichMarkdownImagePaste).mockReturnValue(false)
vi.mocked(handleRichMarkdownFilesystemPathPaste).mockReturnValue(false)
vi.mocked(handleRichMarkdownLargeTextPaste).mockReturnValue(true)
const editor = {} as never
const event = {} as ClipboardEvent
@@ -1,5 +1,6 @@
import type { Editor } from '@tiptap/react'
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
import { handleRichMarkdownFilesystemPathPaste } from './rich-markdown-path-paste'
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
export type RichMarkdownPasteHandlerArgs = {
@@ -29,5 +30,11 @@ export function handleRichMarkdownPaste({
return true
}
// Why: must run before the Link extension's paste plugins so a bare path is
// never autolinked into a broken markdown link (e.g. [CLAUDE.md](http://CLAUDE.md)).
if (handleRichMarkdownFilesystemPathPaste(editor, event)) {
return true
}
return handleRichMarkdownLargeTextPaste(editor, event)
}
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { handleRichMarkdownFilesystemPathPaste } from './rich-markdown-path-paste'
function makeEditor(): Editor {
const element = document.createElement('div')
document.body.appendChild(element)
return new Editor({
element,
extensions: createRichMarkdownExtensions(),
content: '',
contentType: 'markdown',
editorProps: {
// Why: mirror the production wiring so the regression test exercises the
// same paste path order (our handler ahead of the Link extension plugins).
handlePaste: (_view, event) =>
handleRichMarkdownFilesystemPathPaste(editorRef, event as ClipboardEvent)
}
})
}
let editorRef: Editor
function makeClipboardEvent(plain: string, html = ''): ClipboardEvent {
const data = new DataTransfer()
if (plain) {
data.setData('text/plain', plain)
}
if (html) {
data.setData('text/html', html)
}
return new ClipboardEvent('paste', {
clipboardData: data,
bubbles: true,
cancelable: true
})
}
function pasteIntoEditor(editor: Editor, plain: string, html = ''): string {
editorRef = editor
editor.view.focus()
const event = makeClipboardEvent(plain, html)
editor.view.dom.dispatchEvent(event)
return editor.getMarkdown().trimEnd()
}
afterEach(() => {
document.body.replaceChildren()
})
describe('rich markdown filesystem path paste (real editor)', () => {
it('keeps a pasted Windows absolute path as plain text, not a broken link', () => {
const editor = makeEditor()
try {
const path = 'C:\\Users\\me\\repo\\CLAUDE.md'
const markdown = pasteIntoEditor(
editor,
path,
`<a href="http://CLAUDE.md">${path}</a>`
)
expect(markdown).toBe(path)
expect(markdown).not.toContain('http://')
expect(markdown).not.toContain('](')
} finally {
editor.destroy()
}
})
it('keeps a pasted UNC path as plain text', () => {
const editor = makeEditor()
try {
const path = '\\\\server\\share\\notes\\CLAUDE.md'
const markdown = pasteIntoEditor(editor, path)
expect(markdown).toBe(path)
expect(markdown).not.toContain('](')
} finally {
editor.destroy()
}
})
it('keeps a pasted POSIX absolute path as plain text', () => {
const editor = makeEditor()
try {
const path = '/home/me/repo/CLAUDE.md'
const markdown = pasteIntoEditor(editor, path)
expect(markdown).toBe(path)
expect(markdown).not.toContain('](')
} finally {
editor.destroy()
}
})
it('still autolinks a genuine https URL', () => {
const editor = makeEditor()
try {
const url = 'https://example.com/docs'
const markdown = pasteIntoEditor(editor, url)
expect(markdown).toBe(`[${url}](${url})`)
} finally {
editor.destroy()
}
})
})
@@ -0,0 +1,54 @@
import type { Editor } from '@tiptap/react'
import {
extractTerminalLinkFilesystemPath,
isFilesystemPath
} from './rich-markdown-filesystem-path'
function readPlainText(event: ClipboardEvent): string {
return event.clipboardData?.getData('text/plain') ?? ''
}
function readHtmlText(event: ClipboardEvent): string {
return event.clipboardData?.getData('text/html') ?? ''
}
/**
* Resolves the plain-text filesystem path the clipboard is carrying, if any.
* The plain-text flavor holds the true path; the HTML flavor is only consulted
* to recognize terminal-style `<a href="http://<filename>">` links.
*/
function resolvePastedFilesystemPath(event: ClipboardEvent): string | null {
const plainText = readPlainText(event)
if (plainText && isFilesystemPath(plainText)) {
return plainText.trim()
}
const html = readHtmlText(event)
if (html) {
return extractTerminalLinkFilesystemPath(html)
}
return null
}
/**
* Intercepts pastes of bare filesystem paths so TipTap's Link mark cannot
* convert them into a broken markdown link. Inserts the path as plain text and
* claims the paste; returns false to let normal handling proceed otherwise.
*/
export function handleRichMarkdownFilesystemPathPaste(
editor: Editor | null,
event: ClipboardEvent
): boolean {
if (event.defaultPrevented || !editor) {
return false
}
const path = resolvePastedFilesystemPath(event)
if (!path) {
return false
}
event.preventDefault()
// Why: insertText writes a literal text node with no link mark, so the path
// round-trips through markdown serialization unchanged (no autolinking).
editor.view.dispatch(editor.state.tr.insertText(path))
return true
}