diff --git a/src/renderer/src/components/editor/rich-markdown-filesystem-path.test.ts b/src/renderer/src/components/editor/rich-markdown-filesystem-path.test.ts new file mode 100644 index 00000000000..98865c8b86f --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-filesystem-path.test.ts @@ -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(`${path}`) + ).toBe(path) + }) + + it('extracts a path when the anchor text itself is a path', () => { + const path = '/home/me/repo/CLAUDE.md' + expect( + extractTerminalLinkFilesystemPath(`${path}`) + ).toBe(path) + }) + + it('returns null for a genuine URL anchor', () => { + expect( + extractTerminalLinkFilesystemPath( + 'https://example.com/docs' + ) + ).toBeNull() + }) + + it('returns null when the anchor is part of larger rich content', () => { + expect( + extractTerminalLinkFilesystemPath( + '

see C:\\repo\\CLAUDE.md please

' + ) + ).toBeNull() + }) + + it('returns null for multiple anchors or empty html', () => { + expect( + extractTerminalLinkFilesystemPath( + 'C:\\aC:\\b' + ) + ).toBeNull() + expect(extractTerminalLinkFilesystemPath('')).toBeNull() + expect(extractTerminalLinkFilesystemPath('

plain

')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-filesystem-path.ts b/src/renderer/src/components/editor/rich-markdown-filesystem-path.ts new file mode 100644 index 00000000000..87382fafd61 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-filesystem-path.ts @@ -0,0 +1,75 @@ +// Why: terminals (e.g. Claude Code) render bare file paths as clickable links +// by fabricating an `http://` href. Pasting that 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://` 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 +} diff --git a/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts index a915965f9e8..b5fb5b191b3 100644 --- a/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts @@ -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 diff --git a/src/renderer/src/components/editor/rich-markdown-paste-handler.ts b/src/renderer/src/components/editor/rich-markdown-paste-handler.ts index 63bb6e47081..91354c46994 100644 --- a/src/renderer/src/components/editor/rich-markdown-paste-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-paste-handler.ts @@ -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) } diff --git a/src/renderer/src/components/editor/rich-markdown-path-paste.test.ts b/src/renderer/src/components/editor/rich-markdown-path-paste.test.ts new file mode 100644 index 00000000000..50a76bc679c --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-path-paste.test.ts @@ -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, + `${path}` + ) + 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() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-path-paste.ts b/src/renderer/src/components/editor/rich-markdown-path-paste.ts new file mode 100644 index 00000000000..03064515936 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-path-paste.ts @@ -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 `` 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 +}