mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user