diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index 76a031a43d5f..37b207501301 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -29,6 +29,7 @@ const customOneDarkTheme = { import { Check, Copy } from './icons'; import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity'; import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity'; +import { remarkLinkifyPaths, OPEN_FILE_PROTOCOL, isTrustedGeneratedFileLink, decodeFileLinkHref } from '../utils/linkifyPaths'; import { ConfirmationModal } from './ui/ConfirmationModal'; import { defineMessages, useIntl } from '../i18n'; @@ -197,6 +198,19 @@ const customUrlTransform = (url: string): string => { return url; }; +function getAnchorText(children: React.ReactNode): string { + if (typeof children === 'string' || typeof children === 'number') { + return String(children); + } + if (Array.isArray(children)) { + return children.map(getAnchorText).join(''); + } + if (React.isValidElement<{ children?: React.ReactNode }>(children)) { + return getAnchorText(children.props.children); + } + return ''; +}; + const MarkdownContent = memo(function MarkdownContent({ content, className = '', @@ -258,7 +272,7 @@ const MarkdownContent = memo(function MarkdownContent({ > { + const href = props.href; + if (href && href.startsWith(OPEN_FILE_PROTOCOL)) { + const filePath = decodeFileLinkHref(href); + const label = getAnchorText(props.children); + if (filePath && isTrustedGeneratedFileLink(href, label)) { + return ( + { + e.preventDefault(); + e.stopPropagation(); + window.electron.openPathInExplorer(filePath); + }} + className="file-path-link" + title={`Show in Finder: ${filePath}`} + /> + ); + } + } return ( { + try { + shell.showItemInFolder(expandTilde(path)); + return true; + } catch (error) { + console.error('Error showing path in explorer:', error); + return false; + } + }); + ipcMain.handle('launch-app', async (event, gooseApp: GooseApp) => { try { const launchingWindow = BrowserWindow.fromWebContents(event.sender); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index eca1e0ea0fe3..e444d123b7d9 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -186,6 +186,7 @@ type ElectronAPI = { hasAcceptedRecipeBefore: (recipe: Recipe) => Promise; recordRecipeHash: (recipe: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; + openPathInExplorer: (filePath: string) => Promise; launchApp: (app: GooseApp) => Promise; refreshApp: (app: GooseApp) => Promise; closeApp: (appName: string) => Promise; @@ -344,6 +345,8 @@ const electronAPI: ElectronAPI = { recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe), openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), + openPathInExplorer: (filePath: string) => + ipcRenderer.invoke('open-path-in-explorer', filePath), launchApp: (app: GooseApp) => ipcRenderer.invoke('launch-app', app), refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app), closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName), diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index 65761549b093..89d5384e8d7e 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -581,6 +581,18 @@ pre:has(> code.bg-inline-code) { content: ''; } +a.file-path-link { + color: inherit; + text-decoration: none; + cursor: pointer; + border-bottom: 1px dashed currentColor; +} + +a.file-path-link:hover { + border-bottom-style: solid; + opacity: 0.8; +} + .user-message p { margin-bottom: 0 !important; } diff --git a/ui/desktop/src/utils/linkifyPaths.test.ts b/ui/desktop/src/utils/linkifyPaths.test.ts new file mode 100644 index 000000000000..6acd67c8bfa7 --- /dev/null +++ b/ui/desktop/src/utils/linkifyPaths.test.ts @@ -0,0 +1,699 @@ +import { describe, it, expect } from 'vitest'; +import type { Root } from 'mdast'; +import { findPaths, OPEN_FILE_PROTOCOL, remarkLinkifyPaths, isTrustedGeneratedFileLink } from './linkifyPaths'; + +describe('path linkification', () => { + describe('Unix absolute paths', () => { + it('detects Unix absolute paths', () => { + const matches = findPaths('Created file at /home/user/project/src/main.rs'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/project/src/main.rs'); + }); + + it('does not match bare root path', () => { + const matches = findPaths('Go to / for root'); + expect(matches).toHaveLength(0); + }); + + it('detects top-level absolute directories', () => { + expect(findPaths('Saved to /tmp')[0][1]).toBe('/tmp'); + expect(findPaths('Saved to /workspace')[0][1]).toBe('/workspace'); + }); + + it('detects paths in questions with trailing question marks', () => { + expect(findPaths('Can you check /tmp/out?')[0][1]).toBe('/tmp/out'); + }); + + it('detects single-segment Windows absolute directories', () => { + expect(findPaths('See C:\\Users for details')[0][1]).toBe('C:\\Users'); + expect(findPaths('See C:\\Windows for details')[0][1]).toBe('C:\\Windows'); + }); + + it('detects multiple paths in one line', () => { + const matches = findPaths('Compare /etc/hosts and /etc/resolv.conf'); + expect(matches).toHaveLength(2); + expect(matches[0][1]).toBe('/etc/hosts'); + expect(matches[1][1]).toBe('/etc/resolv.conf'); + }); + + it('detects paths with spaces in segment names', () => { + const matches = findPaths('Saved /Users/me/My Project/result.txt for review'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/My Project/result.txt'); + }); + + it('detects lowercase folder names with spaces at end of path', () => { + const matches = findPaths('Output in /home/user/my documents'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/my documents'); + }); + }); + + describe('Tilde paths', () => { + it('detects tilde paths', () => { + const matches = findPaths('Config at ~/.config/app/settings.json'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('~/.config/app/settings.json'); + }); + + it('does not match bare tilde', () => { + const matches = findPaths('Go to ~ for home'); + expect(matches).toHaveLength(0); + }); + }); + + describe('Windows paths', () => { + it('detects Windows paths', () => { + const matches = findPaths('File at C:\\Users\\dev\\project\\index.ts'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('C:\\Users\\dev\\project\\index.ts'); + }); + + it('detects Windows paths with spaces in segment names', () => { + const matches = findPaths('File at C:\\Users\\dev\\My Project\\index.ts'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('C:\\Users\\dev\\My Project\\index.ts'); + }); + + it('does not treat prose labels as Windows paths', () => { + expect(findPaths('Option A:retry')).toHaveLength(0); + expect(findPaths('Status C:passed')).toHaveLength(0); + }); + }); + + describe('Edge cases', () => { + it('detects paths after punctuation', () => { + const matches = findPaths('Saved to "/var/log/app.log"'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/var/log/app.log'); + }); + + it('detects paths wrapped in parentheses or brackets', () => { + expect(findPaths('See (/tmp/out)')[0][1]).toBe('/tmp/out'); + expect(findPaths('See (C:\\Users\\dev\\file.txt)')[0][1]).toBe('C:\\Users\\dev\\file.txt'); + expect(findPaths('See [/tmp/out]')[0][1]).toBe('/tmp/out'); + }); + + it('detects paths with brackets and question marks in filenames', () => { + expect(findPaths('Saved /tmp/report[1].pdf')[0][1]).toBe('/tmp/report[1].pdf'); + expect(findPaths('Saved /tmp/what?now.txt')[0][1]).toBe('/tmp/what?now.txt'); + }); + + it('detects paths with bracketed suffixes without extension', () => { + expect(findPaths('Saved /tmp/report[1]')[0][1]).toBe('/tmp/report[1]'); + }); + + it('detects paths with inline parenthesized suffixes in filenames', () => { + expect(findPaths('Saved /tmp/report(1).pdf')[0][1]).toBe('/tmp/report(1).pdf'); + expect(findPaths('Saved /tmp/foo(bar).txt')[0][1]).toBe('/tmp/foo(bar).txt'); + }); + + it('does not absorb inline parenthetical prose without extension', () => { + expect(findPaths('Created /tmp/out(temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report(temp) for debugging')[0][1]).toBe('/tmp/report'); + }); + + it('detects paths with bracketed suffixes after spaced filename segments', () => { + expect(findPaths('Saved /tmp/My report[1].pdf')[0][1]).toBe('/tmp/My report[1].pdf'); + }); + + it('detects paths with long lowercase spaced filename segments', () => { + expect(findPaths('Saved /tmp/project notes.txt')[0][1]).toBe('/tmp/project notes.txt'); + }); + + it('detects extension-bearing spaced filenames after titlecase basenames', () => { + expect(findPaths('Saved /tmp/Project notes.txt')[0][1]).toBe('/tmp/Project notes.txt'); + }); + + it('detects extension-bearing spaced filenames after acronym basenames', () => { + expect(findPaths('Saved /tmp/API response.json')[0][1]).toBe('/tmp/API response.json'); + }); + + it('detects multi-dot extension-bearing spaced filenames', () => { + expect(findPaths('Saved /tmp/My draft.final.md')[0][1]).toBe('/tmp/My draft.final.md'); + }); + + it('detects paths with multiple lowercase spaced filename words', () => { + expect(findPaths('Saved /tmp/project notes draft.txt')[0][1]).toBe( + '/tmp/project notes draft.txt' + ); + expect(findPaths('Saved /tmp/project notes draft.txt.')[0][1]).toBe( + '/tmp/project notes draft.txt' + ); + }); + + it('detects spaced filenames with hyphenated or underscored intermediate words', () => { + expect(findPaths('Saved /tmp/project release-notes draft.txt')[0][1]).toBe( + '/tmp/project release-notes draft.txt' + ); + expect(findPaths('Saved /tmp/project release_notes draft.txt')[0][1]).toBe( + '/tmp/project release_notes draft.txt' + ); + expect(findPaths('Saved /tmp/project release-notes draft.txt.')[0][1]).toBe( + '/tmp/project release-notes draft.txt' + ); + expect(findPaths('Saved /tmp/Project release-notes draft.txt')[0][1]).toBe( + '/tmp/Project release-notes draft.txt' + ); + }); + + it('still rejects prose after two-char basenames with spaced filename lookahead', () => { + expect(findPaths('Created /tmp/ui successfully.')[0][1]).toBe('/tmp/ui'); + expect(findPaths('Check /tmp/go output')[0][1]).toBe('/tmp/go'); + }); + + it('detects paths with uppercase extension words after spaced segments', () => { + expect(findPaths('Saved /tmp/project notes Draft.txt')[0][1]).toBe( + '/tmp/project notes Draft.txt' + ); + expect(findPaths('Saved /tmp/project notes FINAL.pdf')[0][1]).toBe( + '/tmp/project notes FINAL.pdf' + ); + expect(findPaths('Saved /tmp/My Project Report.DOCX')[0][1]).toBe( + '/tmp/My Project Report.DOCX' + ); + }); + + it('detects multi-word spaced filenames after titlecase basenames', () => { + expect(findPaths('Saved /tmp/Project notes draft.txt')[0][1]).toBe( + '/tmp/Project notes draft.txt' + ); + expect(findPaths('Saved /tmp/Project notes draft.txt.')[0][1]).toBe( + '/tmp/Project notes draft.txt' + ); + expect(findPaths('Saved /tmp/Project notes Draft.txt')[0][1]).toBe( + '/tmp/Project notes Draft.txt' + ); + }); + + it('detects multi-word spaced filenames after acronym basenames', () => { + expect(findPaths('Saved /tmp/API response data.json')[0][1]).toBe( + '/tmp/API response data.json' + ); + expect(findPaths('Saved /tmp/API response data.json.')[0][1]).toBe( + '/tmp/API response data.json' + ); + expect(findPaths('Saved /tmp/API response DATA.json')[0][1]).toBe( + '/tmp/API response DATA.json' + ); + }); + + it('detects paths with mixed-case multi-word filenames ending in extension', () => { + expect(findPaths('Saved /tmp/project notes draft Final.txt')[0][1]).toBe( + '/tmp/project notes draft Final.txt' + ); + expect(findPaths('Saved /tmp/project notes Draft.txt.')[0][1]).toBe( + '/tmp/project notes Draft.txt' + ); + }); + + it('strips trailing line and column suffixes from paths', () => { + expect(findPaths('error at /workspace/src/lib.rs:42:7')[0][1]).toBe('/workspace/src/lib.rs'); + expect(findPaths('error at /workspace/src/lib.rs:42')[0][1]).toBe('/workspace/src/lib.rs'); + expect(findPaths('error at /workspace/goose/Cargo.toml:12:5')[0][1]).toBe( + '/workspace/goose/Cargo.toml' + ); + expect(findPaths('See /project/README.md:3 for details')[0][1]).toBe('/project/README.md'); + }); + + it('strips line and column suffixes from extensionless diagnostic filenames', () => { + expect(findPaths('error at /workspace/Dockerfile:12')[0][1]).toBe('/workspace/Dockerfile'); + expect(findPaths('error at /workspace/Makefile:8:1')[0][1]).toBe('/workspace/Makefile'); + expect(findPaths('See /project/README:5 for details')[0][1]).toBe('/project/README'); + expect(findPaths('error at /crates/goose-sdk/justfile:12')[0][1]).toBe( + '/crates/goose-sdk/justfile' + ); + expect(findPaths('error at /repo/dockerfile:3:1')[0][1]).toBe('/repo/dockerfile'); + expect(findPaths('error at /repo/makefile:42')[0][1]).toBe('/repo/makefile'); + expect(findPaths('error at /ci/Jenkinsfile:7')[0][1]).toBe('/ci/Jenkinsfile'); + expect(findPaths('error at /app/Procfile:2')[0][1]).toBe('/app/Procfile'); + }); + + it('preserves colon-number suffixes in filenames', () => { + expect(findPaths('Saved /tmp/snapshot:1')[0][1]).toBe('/tmp/snapshot:1'); + expect(findPaths('Saved /tmp/2026-06-18T18:30:00')[0][1]).toBe('/tmp/2026-06-18T18:30:00'); + }); + + it('detects paths followed by tab-separated text', () => { + expect(findPaths('See /tmp/out\tOK')[0][1]).toBe('/tmp/out'); + }); + + it('detects paths with spaced bracket suffixes', () => { + expect(findPaths('Saved /tmp/log [draft]')[0][1]).toBe('/tmp/log [draft]'); + }); + + it('does not absorb bracket status annotations after paths', () => { + expect(findPaths('Created /tmp/out [OK] for upload')[0][1]).toBe('/tmp/out'); + }); + + it('does not absorb explanatory parentheticals after paths', () => { + expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/out (temp) for debugging')[0][1]).toBe('/tmp/out'); + }); + + it('does not absorb parentheticals followed only by sentence punctuation', () => { + expect(findPaths('Created /tmp/out (temporary).')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (temp).')[0][1]).toBe('/tmp/report'); + expect(findPaths('Created /tmp/out(temporary).')[0][1]).toBe('/tmp/out'); + }); + + it('detects paths with dots and underscores', () => { + const matches = findPaths('Read /home/user/.env.local'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/.env.local'); + }); + + it('strips trailing sentence punctuation', () => { + const matches = findPaths('Created /tmp/result.txt.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/result.txt'); + }); + + it('does not include trailing prose before sentence punctuation', () => { + const matches = findPaths('Created /tmp/result successfully.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/result'); + }); + + it('does not extend short single-char basenames with prose', () => { + const matches = findPaths('Created /tmp/a successfully.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/a'); + }); + + it('does not extend short basenames with following prose', () => { + const matches = findPaths('See /tmp/my for details'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/my'); + }); + + it('does not extend short basenames with generic nouns', () => { + expect(findPaths('Check /tmp/log file for details')[0][1]).toBe('/tmp/log'); + }); + + it('does not extend two-char basenames with following prose', () => { + expect(findPaths('Created /tmp/ui successfully.')[0][1]).toBe('/tmp/ui'); + expect(findPaths('Check /tmp/go output')[0][1]).toBe('/tmp/go'); + expect(findPaths('Created /tmp/ui successfully for review')[0][1]).toBe('/tmp/ui'); + expect(findPaths('Check /tmp/go output please')[0][1]).toBe('/tmp/go'); + }); + + it('still detects determiner-led spaced folder names after two-char tokens', () => { + expect(findPaths('Output in /home/user/my documents')[0][1]).toBe('/home/user/my documents'); + expect(findPaths('Saved /tmp/my backup data')[0][1]).toBe('/tmp/my backup'); + }); + + it('does not extend paths through connective prose before another slash', () => { + const matches = findPaths('Review /tmp/output and/or /tmp/logs'); + expect(matches[0][1]).toBe('/tmp/output'); + expect(matches[1][1]).toBe('/tmp/logs'); + }); + + it('detects paths with spaced directory names before separators', () => { + expect(findPaths('/Users/me/Library/Application Support/Goose')[0][1]).toBe( + '/Users/me/Library/Application Support/Goose' + ); + expect(findPaths('See C:\\Program Files\\Goose')[0][1]).toBe('C:\\Program Files\\Goose'); + }); + + it('detects paths with parenthesized spaced directory names before separators', () => { + expect(findPaths('See C:\\Program Files (x86)\\Goose')[0][1]).toBe( + 'C:\\Program Files (x86)\\Goose' + ); + expect(findPaths('/Users/me/Apps (Beta)/app')[0][1]).toBe('/Users/me/Apps (Beta)/app'); + }); + + it('includes spaced folder names before sentence punctuation', () => { + const matches = findPaths('Saved to /home/user/my documents.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/home/user/my documents'); + }); + + it('includes spaced folder names before newline terminators', () => { + expect(findPaths('Saved /home/user/my documents\nDone')[0][1]).toBe( + '/home/user/my documents' + ); + }); + + it('detects paths after assignment separators', () => { + expect(findPaths('artifact=/tmp/out.log')[0][1]).toBe('/tmp/out.log'); + expect(findPaths('--output=/tmp/out')[0][1]).toBe('/tmp/out'); + }); + + it('does not linkify paths inside URL query values', () => { + expect(findPaths('See `https://host/download?file=/tmp/out`')).toHaveLength(0); + expect(findPaths('Visit example.com?file=/tmp/out')).toHaveLength(0); + expect(findPaths('Visit example.com?artifact=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com?path=/tmp/out&other=1')).toHaveLength(0); + }); + + it('does not linkify paths inside URL query values with bracketed keys', () => { + expect(findPaths('See https://host/download?file[]=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?items[0]=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?data[key]=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com?file[]=/tmp/out&other=1')).toHaveLength(0); + }); + + it('does not linkify paths inside URL query values with percent-encoded keys', () => { + expect(findPaths('See https://host/download?file%5B%5D=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?items%5B0%5D=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com?file%5B%5D=/tmp/out')).toHaveLength(0); + }); + + it('does not linkify paths inside URL fragment or path-parameter values', () => { + expect(findPaths('See https://host/page#file=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download;file=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/page#artifact=/tmp/out.log')).toHaveLength(0); + expect(findPaths('See https://host/download;output=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/page#path=/tmp/out&other=1')).toHaveLength(0); + }); + + it('does not linkify paths inside URL query values with hyphenated keys', () => { + expect(findPaths('See https://host/download?file-name=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download?content-type=/tmp/out.log')).toHaveLength(0); + expect(findPaths('See https://host/download?x-custom-param=/tmp/out')).toHaveLength(0); + expect(findPaths('Visit example.com?file-name=/tmp/out&other=1')).toHaveLength(0); + }); + + it('does not linkify paths inside URL path assignment values', () => { + expect(findPaths('See https://host/download/file=/tmp/out')).toHaveLength(0); + expect(findPaths('See https://host/download/output=/tmp/out.log')).toHaveLength(0); + expect(findPaths('Visit example.com/download/file=/tmp/out')).toHaveLength(0); + expect(findPaths('Visit example.com/path/to/output=/tmp/out')).toHaveLength(0); + }); + + it('still linkifies assignment-style paths outside URL context', () => { + expect(findPaths('artifact=/tmp/out')[0][1]).toBe('/tmp/out'); + expect(findPaths('--output=/tmp/out')[0][1]).toBe('/tmp/out'); + expect(findPaths('See ?file=/tmp/out')).toHaveLength(0); + expect(findPaths('See #file=/tmp/out')).toHaveLength(0); + expect(findPaths('See ;file=/tmp/out')).toHaveLength(0); + expect(findPaths('See ?file[]=/tmp/out')).toHaveLength(0); + expect(findPaths('See ?file-name=/tmp/out')).toHaveLength(0); + }); + + it('does not match URLs', () => { + const matches = findPaths('Visit https://example.com/page for info'); + expect(matches).toHaveLength(0); + }); + + it('generates correct open-file URLs', () => { + const path = '/home/user/project/src/main.rs'; + expect(OPEN_FILE_PROTOCOL + encodeURI(path)).toBe( + 'open-file:///home/user/project/src/main.rs' + ); + }); + + it('encodes spaces in open-file URLs', () => { + const path = '/Users/me/My Project/result.txt'; + expect(OPEN_FILE_PROTOCOL + encodeURI(path)).toBe( + 'open-file:///Users/me/My%20Project/result.txt' + ); + }); + + it('handles paths with hyphens', () => { + const matches = findPaths('Check /usr/local/lib/my-app/config.yaml'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/usr/local/lib/my-app/config.yaml'); + }); + + it('does not match content inside code blocks', () => { + const matches = findPaths('Use `Array` for generics'); + expect(matches).toHaveLength(0); + }); + + it('matches paths inside inline code markers', () => { + const matches = findPaths('`/usr/local/bin/node`'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/usr/local/bin/node'); + }); + + it('detects paths with parenthesized download suffixes', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (1).pdf'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (1).pdf'); + }); + + it('detects paths with word parenthesized filename suffixes', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (final).pdf'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (final).pdf'); + }); + + it('detects paths with spaced or hyphenated parenthesized suffixes', () => { + expect(findPaths('Saved /Users/me/Downloads/report (final copy).pdf')[0][1]).toBe( + '/Users/me/Downloads/report (final copy).pdf' + ); + expect(findPaths('Saved /tmp/report (final-draft).pdf')[0][1]).toBe( + '/tmp/report (final-draft).pdf' + ); + }); + + it('detects paths with alphanumeric parenthesized filename suffixes', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (v2).pdf'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (v2).pdf'); + }); + + it('detects paths with dotted version parenthesized suffixes', () => { + expect(findPaths('Saved /tmp/report (v1.2).pdf')[0][1]).toBe('/tmp/report (v1.2).pdf'); + expect(findPaths('Saved /tmp/report (v1.2.3).pdf')[0][1]).toBe('/tmp/report (v1.2.3).pdf'); + expect(findPaths('Saved /tmp/report(v1.2).pdf')[0][1]).toBe('/tmp/report(v1.2).pdf'); + }); + + it('detects paths with underscored parenthesized suffixes', () => { + expect(findPaths('Saved /tmp/report (final_v2).pdf')[0][1]).toBe('/tmp/report (final_v2).pdf'); + expect(findPaths('Saved /tmp/report (draft_v2).pdf')[0][1]).toBe('/tmp/report (draft_v2).pdf'); + expect(findPaths('Saved /tmp/report(final_v2).pdf')[0][1]).toBe('/tmp/report(final_v2).pdf'); + }); + + it('detects paths with spaced version parenthesized suffixes', () => { + expect(findPaths('Saved /tmp/report (final copy v2).pdf')[0][1]).toBe( + '/tmp/report (final copy v2).pdf' + ); + }); + + it('does not absorb dotted parenthetical prose without extension', () => { + expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (temp) for debugging')[0][1]).toBe('/tmp/report'); + expect(findPaths('Created /tmp/out(temporary) for debugging')[0][1]).toBe('/tmp/out'); + }); + + it('preserves closing parens in parenthesized paths without extension', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (1)'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (1)'); + }); + + it('strips sentence punctuation after parenthesized paths', () => { + const matches = findPaths('Saved /Users/me/Downloads/report (1).'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Downloads/report (1)'); + }); + + it('does not append prose after parenthesized filename suffixes', () => { + const matches = findPaths('Saved /tmp/report (1) successfully.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/report (1)'); + }); + + it('detects paths with commas in filenames', () => { + const matches = findPaths('Saved /tmp/report,final.txt'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/report,final.txt'); + }); + + it('detects paths with apostrophes in filenames', () => { + const matches = findPaths("Saved /tmp/it's.txt"); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe("/tmp/it's.txt"); + }); + + it('detects paths with apostrophes in parenthesized filename suffixes', () => { + expect(findPaths("Saved /tmp/report (John's copy).pdf")[0][1]).toBe( + "/tmp/report (John's copy).pdf" + ); + expect(findPaths("Saved /tmp/report(John's).pdf")[0][1]).toBe("/tmp/report(John's).pdf"); + }); + + it('detects paths with Unicode in parenthesized filename suffixes', () => { + expect(findPaths('Saved /tmp/report (最終).pdf')[0][1]).toBe('/tmp/report (最終).pdf'); + expect(findPaths('Saved /tmp/report(最終).pdf')[0][1]).toBe('/tmp/report(最終).pdf'); + expect(findPaths('Saved /tmp/report (コピー v2).pdf')[0][1]).toBe( + '/tmp/report (コピー v2).pdf' + ); + }); + + it('still rejects prose parentheticals without extension evidence', () => { + expect(findPaths('Created /tmp/out (temporary) for debugging')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (temp) for debugging')[0][1]).toBe('/tmp/report'); + expect(findPaths('Created /tmp/out (temporary).')[0][1]).toBe('/tmp/out'); + expect(findPaths('Created /tmp/report (see notes) for review')[0][1]).toBe('/tmp/report'); + }); + + it('detects paths with colons in timestamped filenames', () => { + const matches = findPaths('Saved /tmp/2026-06-18T18:30:00.log'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/2026-06-18T18:30:00.log'); + }); + + it('does not extend paths across comma-separated prose', () => { + const matches = findPaths('Created /tmp/report, then continued'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/report'); + }); + + it('detects paths with numeric suffixes in segment names', () => { + const matches = findPaths('Saved /Users/me/Project 2026.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/Project 2026'); + }); + + it('does not extend paths with trailing numeric counts', () => { + const matches = findPaths('Created /tmp/out 2 files'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/out'); + }); + + it('does not extend paths with bare 4+ digit prose counts', () => { + expect(findPaths('Created /tmp/out 2026 files')[0][1]).toBe('/tmp/out'); + }); + + it('does not extend capitalized basenames with bare 4+ digit prose counts', () => { + expect(findPaths('Created /tmp/Report 2026 files')[0][1]).toBe('/tmp/Report'); + }); + + it('does not extend capitalized paths with short numeric counts', () => { + expect(findPaths('Created /tmp/Out 2 files')[0][1]).toBe('/tmp/Out'); + }); + + it('does not extend paths with capitalized prose', () => { + const matches = findPaths('See /tmp/out Please review.'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/tmp/out'); + }); + + it('does not extend capitalized basenames with following prose', () => { + expect(findPaths('See /tmp/Result Please review.')[0][1]).toBe('/tmp/Result'); + }); + + it('detects paths with @ and percent-encoded characters in filenames', () => { + expect(findPaths('File /tmp/foo@bar.txt')[0][1]).toBe('/tmp/foo@bar.txt'); + expect(findPaths('File /tmp/foo%20bar.txt')[0][1]).toBe('/tmp/foo%20bar.txt'); + }); + + it('does not linkify partial paths before unsupported characters', () => { + expect(findPaths('See /tmp/foo#bar.txt')).toHaveLength(0); + }); + + it('detects paths with Unicode segment names', () => { + const matches = findPaths('Saved /Users/me/デスクトップ/out.txt'); + expect(matches).toHaveLength(1); + expect(matches[0][1]).toBe('/Users/me/デスクトップ/out.txt'); + }); + + it('does not linkify partial paths before unsupported characters', () => { + const matches = findPaths('See /Users/me/🎉/out.txt'); + expect(matches).toHaveLength(0); + }); + + it('returns no matches for long prose without paths', () => { + const prose = 'word '.repeat(10_000); + const start = Date.now(); + const matches = findPaths(prose); + const elapsed = Date.now() - start; + + expect(matches).toHaveLength(0); + expect(elapsed).toBeLessThan(500); + }); + }); +}); + +describe('remarkLinkifyPaths', () => { + it('does not linkify paths inside link references', () => { + const tree: Root = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'linkReference', + identifier: 'log', + label: 'log', + referenceType: 'full', + children: [{ type: 'text', value: 'log /tmp/out' }], + }, + ], + }, + ], + }; + + const transform = (remarkLinkifyPaths as unknown as () => (tree: Root) => void)(); + transform(tree); + + const paragraph = tree.children[0]; + expect(paragraph.type).toBe('paragraph'); + if (paragraph.type !== 'paragraph') return; + + const linkRef = paragraph.children[0]; + expect(linkRef.type).toBe('linkReference'); + if (linkRef.type !== 'linkReference') return; + + expect(linkRef.children).toHaveLength(1); + expect(linkRef.children[0]).toEqual({ type: 'text', value: 'log /tmp/out' }); + }); + + it('does not linkify paths inside formatted link labels', () => { + const tree: Root = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://example.com', + children: [ + { + type: 'strong', + children: [{ type: 'text', value: '/tmp/out' }], + }, + ], + }, + ], + }, + ], + }; + + const transform = (remarkLinkifyPaths as unknown as () => (tree: Root) => void)(); + transform(tree); + + const paragraph = tree.children[0]; + expect(paragraph.type).toBe('paragraph'); + if (paragraph.type !== 'paragraph') return; + + const link = paragraph.children[0]; + expect(link.type).toBe('link'); + if (link.type !== 'link') return; + + expect(link.url).toBe('https://example.com'); + expect(link.children).toHaveLength(1); + expect(link.children[0].type).toBe('strong'); + if (link.children[0].type !== 'strong') return; + expect(link.children[0].children[0]).toEqual({ type: 'text', value: '/tmp/out' }); + }); +}); + +describe('file link trust', () => { + it('trusts links whose label matches the decoded path', () => { + const path = '/Users/me/My Project/file.txt'; + const href = OPEN_FILE_PROTOCOL + encodeURI(path); + expect(isTrustedGeneratedFileLink(href, path)).toBe(true); + }); + + it('rejects authored links with mismatched labels', () => { + const href = OPEN_FILE_PROTOCOL + encodeURI('/Users/me/Secrets'); + expect(isTrustedGeneratedFileLink(href, 'release notes')).toBe(false); + }); +}); diff --git a/ui/desktop/src/utils/linkifyPaths.ts b/ui/desktop/src/utils/linkifyPaths.ts new file mode 100644 index 000000000000..e959fb700808 --- /dev/null +++ b/ui/desktop/src/utils/linkifyPaths.ts @@ -0,0 +1,615 @@ +import { visit, SKIP } from 'unist-util-visit'; +import type { Plugin } from 'unified'; +import type { Root, Text, InlineCode, Link, Parent } from 'mdast'; + +const OPEN_FILE_PROTOCOL = 'open-file://'; + +const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/; + +const EXTENSIONLESS_DIAGNOSTIC_FILENAMES = new Set([ + 'dockerfile', + 'justfile', + 'makefile', + 'jenkinsfile', + 'rakefile', + 'gemfile', + 'procfile', + 'vagrantfile', + 'brewfile', + 'fastfile', + 'containerfile', + 'snakefile', + 'cmakelists', + 'gnumakefile', +]); + +const URL_PARAM_DELIMITERS = new Set(['?', '&', '#', ';']); + +type PathMatch = [index: number, path: string]; +type Separator = '/' | '\\'; + +function isPathChar(char: string): boolean { + if (/[a-zA-Z0-9._+@%-]/.test(char)) return true; + return /\p{L}|\p{N}/u.test(char); +} + +function stripTrailingPunctuation(path: string): string { + return path.replace(TRAILING_PUNCTUATION_RE, ''); +} + +function stripLineColumnSuffix(path: string): string { + const extMatch = path.match(/\.[A-Za-z0-9]+(:\d+(?::\d+)?)$/); + if (extMatch) { + return path.slice(0, -extMatch[1].length); + } + + const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + const basename = path.slice(lastSep + 1); + const lineColMatch = basename.match(/^([A-Za-z][A-Za-z0-9_-]*)(:\d+(?::\d+)?)$/); + if (lineColMatch) { + const name = lineColMatch[1]; + const suffix = lineColMatch[2]; + if (/^[A-Z]/.test(name) || EXTENSIONLESS_DIAGNOSTIC_FILENAMES.has(name.toLowerCase())) { + return path.slice(0, -suffix.length); + } + } + + return path; +} + +function isUrlPathAt(text: string, index: number): boolean { + let i = index - 1; + if (i < 0) return false; + + if (text[i] === '/') { + i--; + if (i >= 0 && text[i] === '/') i--; + } + if (i < 0 || text[i] !== ':') return false; + + i--; + const schemeEnd = i; + while (i >= 0 && /[a-zA-Z0-9+.-]/.test(text[i])) { + i--; + } + + const scheme = text.slice(i + 1, schemeEnd + 1); + return scheme.length > 0 && /[a-zA-Z]/.test(scheme[0]); +} + +function isQueryParamKeyChar(char: string): boolean { + return /[-a-zA-Z0-9_.$[\]%]/.test(char); +} + +function isUrlPathSlashBeforeAssignment(text: string, slashPos: number): boolean { + if (text[slashPos] !== '/') return false; + + let i = slashPos - 1; + while (i >= 0 && /[a-zA-Z0-9._-]/.test(text[i])) { + i--; + } + if (i >= 2 && text[i] === '/' && text[i - 1] === '/' && text[i - 2] === ':') { + let j = i - 3; + while (j >= 0 && /[a-zA-Z0-9+.-]/.test(text[j])) { + j--; + } + const scheme = text.slice(j + 1, i - 2); + if (scheme.length > 0 && /[a-zA-Z]/.test(scheme[0])) { + return true; + } + } + if (i >= 0 && text[i] === '/') { + return isUrlPathSlashBeforeAssignment(text, i); + } + const token = text.slice(i + 1, slashPos); + return ( + token.length > 0 && + /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$/.test(token) && + /\.[a-zA-Z]{2,}$/.test(token) + ); +} + +function isAssignmentEqualsStart(text: string, index: number): boolean { + if (text[index - 1] !== '=') return false; + let i = index - 2; + while (i >= 0 && isQueryParamKeyChar(text[i])) { + i--; + } + if (i >= 0 && text[i] === '/' && isUrlPathSlashBeforeAssignment(text, i)) { + return false; + } + return !(i >= 0 && URL_PARAM_DELIMITERS.has(text[i])); +} + +function isCandidatePathStart(text: string, index: number, afterBlockComment: boolean): boolean { + if (index === 0) return true; + if (isUrlPathAt(text, index)) return false; + const prev = text[index - 1]; + if (prev === '=') return isAssignmentEqualsStart(text, index); + if (/[\s('"`[(,;]/.test(prev)) return true; + return afterBlockComment; +} + +function couldStartPath(text: string, index: number): boolean { + const char = text[index]; + return char === '/' || char === '~' || /[A-Za-z]/.test(char); +} + +function getLastToken(text: string, segmentStart: number, beforeIndex: number): string { + const segment = text.slice(segmentStart, beforeIndex); + const lastSpace = segment.lastIndexOf(' '); + return lastSpace === -1 ? segment : segment.slice(lastSpace + 1); +} + +function isParenthesizedSuffix(token: string): boolean { + return /^\([^)]+\)$/.test(token); +} + +function hasFileExtension(word: string): boolean { + return /\.[A-Za-z0-9]+$/.test(word); +} + +function isSpacedFilenameWord(word: string): boolean { + return /^[a-z][a-z0-9_-]*$/.test(word); +} + +function canContinueWithNumericWord(word: string, prevToken: string, after: string | undefined): boolean { + return ( + word.length >= 4 && + /^[A-Z]/.test(prevToken) && + (after === undefined || isPathTerminator(after)) + ); +} + +function hasFollowingSeparator(text: string, endIndex: number, separator: Separator): boolean { + let i = endIndex; + while (i < text.length && text[i] === ' ') i++; + return i < text.length && text[i] === separator; +} + +function isDeterminerLedSpacedFilename(prevToken: string, word: string): boolean { + return prevToken === 'my' && /^[a-z][a-z0-9]*$/.test(word) && word.length >= 4; +} + +function isPathLikeSpacedWord( + word: string, + prevToken: string, + after: string | undefined, + text: string, + endIndex: number, + separator: Separator +): boolean { + if (isParenthesizedSuffix(prevToken)) return false; + if (/^\d+$/.test(word)) { + return canContinueWithNumericWord(word, prevToken, after); + } + if (hasFileExtension(word)) return true; + if (/^[A-Z]/.test(word)) { + return ( + /^[A-Z]/.test(prevToken) && + word.length >= 4 && + prevToken.length >= 2 && + prevToken.length <= 3 + ); + } + if ( + word.length >= 4 && + prevToken.length >= 2 && + prevToken.length <= 2 && + !prevToken.includes('.') + ) { + return ( + readExtensionWordAhead(text, endIndex) || + hasFollowingSeparator(text, endIndex, separator) + ); + } + return false; +} + +function readExtensionWordAhead(text: string, fromIndex: number): boolean { + let i = fromIndex; + while (i < text.length) { + if (text[i] !== ' ') return false; + i++; + let j = i; + while (j < text.length && isPathChar(text[j])) { + j++; + } + if (j === i) return false; + const word = text.slice(i, j).replace(TRAILING_PUNCTUATION_RE, ''); + if (hasFileExtension(word)) return true; + if (!isSpacedFilenameWord(word)) return false; + i = j; + } + return false; +} + +function isSpacedFilenameContinuation( + word: string, + prevToken: string, + text: string, + endIndex: number, + separator: Separator +): boolean { + if (/^\[[^\]]+\]$/.test(word)) { + const inner = word.slice(1, -1); + return ( + prevToken.length >= 2 && + (/^[a-z][a-z0-9]*$/.test(inner) || /^\d+$/.test(inner)) + ); + } + if (isDeterminerLedSpacedFilename(prevToken, word)) return true; + if (isPathLikeSpacedWord(word, prevToken, text[endIndex], text, endIndex, separator)) { + return true; + } + return ( + isSpacedFilenameWord(word) && + /^[A-Za-z]/.test(prevToken) && + readExtensionWordAhead(text, endIndex) + ); +} + +function isLinkLikeParent(parent: Parent | undefined): boolean { + return parent?.type === 'link' || parent?.type === 'linkReference'; +} + +function isParenthesizedFilenameChar(char: string): boolean { + if (char === ' ' || char === '-' || char === "'") return true; + return isPathChar(char); +} + +function isParenthesizedFilenameContent(content: string): boolean { + if (/^\d+$/.test(content)) return true; + if (content.length === 0) return false; + let hasSubstantive = false; + for (const char of content) { + if (!isParenthesizedFilenameChar(char)) return false; + if (/[\p{L}\p{N}a-zA-Z0-9._]/u.test(char)) { + hasSubstantive = true; + } + } + return hasSubstantive; +} + +function hasFilenameSuffixAfterParen(text: string, afterParen: number, endIndex: number): boolean { + const suffix = text.slice(afterParen, endIndex).replace(TRAILING_PUNCTUATION_RE, ''); + return suffix.length > 0 && hasFileExtension(suffix); +} + +function readParenthesizedContent(text: string, openIndex: number): number { + let i = openIndex + 1; + const contentStart = i; + while (i < text.length && text[i] !== ')') { + if (text[i] === '(') return openIndex; + i++; + } + if (i >= text.length) return openIndex; + + const content = text.slice(contentStart, i); + if (!isParenthesizedFilenameContent(content)) return openIndex; + + i++; + const afterParen = i; + if (text[i] === '/' || text[i] === '\\') { + return afterParen; + } + while (i < text.length && isPathChar(text[i])) { + i++; + } + if (/^\d+$/.test(content)) { + return i; + } + if (i > afterParen && hasFilenameSuffixAfterParen(text, afterParen, i)) { + return i; + } + if (!/[ -]/.test(content) && /^[a-zA-Z0-9]{1,4}$/.test(content) && /\d/.test(content)) { + return afterParen; + } + return openIndex; +} + +function readParenthesizedInlineSuffix(text: string, parenIndex: number): number { + if (text[parenIndex] !== '(') return parenIndex; + return readParenthesizedContent(text, parenIndex); +} + +function readParenthesizedSuffix(text: string, spaceIndex: number): number { + if (text[spaceIndex] !== ' ') return spaceIndex; + if (text[spaceIndex + 1] !== '(') return spaceIndex; + const end = readParenthesizedContent(text, spaceIndex + 1); + return end > spaceIndex + 1 ? end : spaceIndex; +} + +function hasParenthesizedDirectoryBeforeSeparator( + text: string, + fromIndex: number, + separator: Separator +): boolean { + let i = fromIndex; + while (i < text.length && text[i] === ' ') i++; + if (text[i] !== '(') return false; + const end = readParenthesizedContent(text, i); + return end > i && text[end] === separator; +} + +function isConnectiveSpacedWord(word: string): boolean { + return /^(?:and|or)$/i.test(word); +} + +function readSpacedContinuation( + text: string, + spaceIndex: number, + segmentStart: number, + separator: Separator +): number { + if (text[spaceIndex] !== ' ') return spaceIndex; + + let j = spaceIndex + 1; + while (j < text.length && isPathChar(text[j])) { + j++; + } + const bracketEnd = readBracketedSuffix(text, j); + if (bracketEnd > j) { + j = bracketEnd; + while (j < text.length && isPathChar(text[j])) { + j++; + } + } + while (j > spaceIndex + 1 && /[.,;:!?)'"]/.test(text[j - 1] ?? '')) { + j--; + } + if (j === spaceIndex + 1) return spaceIndex; + + const word = text.slice(spaceIndex + 1, j); + const prevToken = getLastToken(text, segmentStart, spaceIndex); + const after = text[j]; + + if (after === separator) { + return isConnectiveSpacedWord(word) ? spaceIndex : j; + } + + if (after === undefined || /[.,;:!?)'\]"]/.test(after)) { + return isSpacedFilenameContinuation(word, prevToken, text, j, separator) ? j : spaceIndex; + } + + if (after !== undefined && /\s/.test(after)) { + if (after === ' ') { + const rest = text.slice(j).trimStart(); + if (rest.startsWith(separator)) return spaceIndex; + if (hasParenthesizedDirectoryBeforeSeparator(text, j, separator)) return j; + } + return isSpacedFilenameContinuation(word, prevToken, text, j, separator) ? j : spaceIndex; + } + + return spaceIndex; +} + +function isFilenamePunctuation(char: string): boolean { + return char === ',' || char === "'" || char === ':' || char === '?'; +} + +function readBracketedSuffix(text: string, bracketIndex: number): number { + if (text[bracketIndex] !== '[') return bracketIndex; + + let j = bracketIndex + 1; + if (j >= text.length) return bracketIndex; + + while (j < text.length && isPathChar(text[j])) { + j++; + } + if (j >= text.length || text[j] !== ']') return bracketIndex; + + return j + 1; +} + +function readSegment(text: string, start: number, separator: Separator): { end: number } | null { + let i = start; + if (i >= text.length || !isPathChar(text[i])) return null; + + while (i < text.length) { + if (isPathChar(text[i])) { + i++; + continue; + } + const bracketEnd = readBracketedSuffix(text, i); + if (bracketEnd > i) { + i = bracketEnd; + continue; + } + const parenEnd = readParenthesizedInlineSuffix(text, i); + if (parenEnd > i) { + i = parenEnd; + continue; + } + if ( + isFilenamePunctuation(text[i]) && + i + 1 < text.length && + isPathChar(text[i + 1]) + ) { + i++; + continue; + } + break; + } + + while (i < text.length && text[i] === ' ') { + const parenEnd = readParenthesizedSuffix(text, i); + if (parenEnd > i) { + i = parenEnd; + continue; + } + const continuationEnd = readSpacedContinuation(text, i, start, separator); + if (continuationEnd === i) break; + i = continuationEnd; + } + + return i > start ? { end: i } : null; +} + +function isPathTerminator(char: string): boolean { + return /[.,;:!?'"`()\]]/.test(char); +} + +function isPathBoundary(char: string): boolean { + return /\s/.test(char) || isPathTerminator(char); +} + +function parsePathAt(text: string, index: number): PathMatch | null { + let i = index; + let separator: Separator = '/'; + let minSegments: number; + + if (text[i] === '/') { + i++; + minSegments = 1; + } else if (text[i] === '~' && text[i + 1] === '/') { + i += 2; + minSegments = 1; + } else if (/[A-Za-z]/.test(text[i] ?? '') && text[i + 1] === ':') { + i += 2; + if (text[i] !== '/' && text[i] !== '\\') { + return null; + } + separator = text[i] as Separator; + i++; + minSegments = 1; + } else { + return null; + } + + let segmentCount = 0; + while (i < text.length) { + const segment = readSegment(text, i, separator); + if (!segment) { + if (segmentCount >= minSegments && i < text.length) { + const next = text[i]; + if (next !== separator && !isPathBoundary(next)) { + return null; + } + } + break; + } + i = segment.end; + segmentCount++; + if (i < text.length && text[i] === separator) { + i++; + continue; + } + if (i < text.length && !isPathBoundary(text[i])) { + return null; + } + break; + } + + if (segmentCount < minSegments) return null; + + const path = stripLineColumnSuffix(stripTrailingPunctuation(text.slice(index, i))); + if (path.length === 0) return null; + + return [index, path]; +} + +export function findPaths(text: string): PathMatch[] { + const matches: PathMatch[] = []; + let afterBlockComment = false; + + for (let i = 0; i < text.length; i++) { + if (text[i] === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length - 1 && !(text[i] === '*' && text[i + 1] === '/')) { + i++; + } + i += 1; + afterBlockComment = true; + continue; + } + + if ( + couldStartPath(text, i) && + isCandidatePathStart(text, i, afterBlockComment) + ) { + const match = parsePathAt(text, i); + if (match) { + matches.push(match); + i = match[0] + match[1].length - 1; + afterBlockComment = false; + continue; + } + } + + afterBlockComment = false; + } + + return matches; +} + +function linkifyNode(node: Text | InlineCode, index: number, parent: Parent): void { + const text = node.value; + const paths = findPaths(text); + if (paths.length === 0) return; + + const newNodes: Array = []; + let lastIndex = 0; + for (const [pathIndex, path] of paths) { + if (pathIndex > lastIndex) { + newNodes.push({ + type: node.type, + value: text.slice(lastIndex, pathIndex), + } as Text | InlineCode); + } + newNodes.push({ + type: 'link', + url: OPEN_FILE_PROTOCOL + encodeURI(path), + title: null, + children: [ + { + type: 'text', + value: path, + }, + ], + }); + lastIndex = pathIndex + path.length; + } + if (lastIndex < text.length) { + newNodes.push({ + type: node.type, + value: text.slice(lastIndex), + } as Text | InlineCode); + } + parent.children.splice(index, 1, ...newNodes); +} + +export const remarkLinkifyPaths: Plugin<[], Root> = function () { + return (tree: Root) => { + visit(tree, (node, index, parent) => { + if (node.type === 'link' || node.type === 'linkReference') { + return SKIP; + } + + if (node.type !== 'text' && node.type !== 'inlineCode') { + return undefined; + } + if (index === undefined || !parent || isLinkLikeParent(parent)) { + return undefined; + } + linkifyNode(node, index, parent); + return undefined; + }); + }; +}; + +export { OPEN_FILE_PROTOCOL }; + +export function decodeFileLinkHref(href: string): string | undefined { + if (!href.startsWith(OPEN_FILE_PROTOCOL)) return undefined; + try { + return decodeURIComponent(href.slice(OPEN_FILE_PROTOCOL.length)); + } catch { + return undefined; + } +} + +export function isTrustedGeneratedFileLink(href: string, label: string): boolean { + const filePath = decodeFileLinkHref(href); + return filePath !== undefined && label === filePath; +}