Open file links from native chat messages (#6998)

* Resolve and open file links from native chat messages

- Add `onLinkClick` and `allowFileUriLinks` props to `CommentMarkdown`
  to support intercepting links and enabling file URI navigation.
- Resolve relative paths, line/column suffixes, and file URIs from
  native chat messages against the active worktree context.
- Handle clicked links in `NativeChatView` by opening the target file in
  the editor (or system default via Shift+click).
- Refactor markdown renderers and parse utilities into standalone,
  reusable modules to share between terminal and chat components.

* Fix file link resolution and click event propagation

Ensure file links open correctly by falling back to repo-scoped
worktrees if a known worktree lacks a path. Also, stop propagation
on markdown anchor clicks to prevent triggering outer card click
handlers.
This commit is contained in:
Jinjing
2026-07-01 03:03:18 -07:00
committed by GitHub
parent e083932018
commit 7a4ec5c671
10 changed files with 1015 additions and 327 deletions
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Image as ImageIcon } from 'lucide-react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { basename } from '@/lib/path'
@@ -123,12 +125,16 @@ function TypingIndicatorRow(): React.JSX.Element {
function MessageRow({
message,
expandSignal,
onScrollMessageToTop
onScrollMessageToTop,
onLinkClick,
allowFileUriLinks = false
}: {
message: NativeChatMessage
expandSignal: boolean
/** Align this message's top to the top of the scroll viewport. */
onScrollMessageToTop: (el: HTMLElement) => void
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
}): React.JSX.Element | null {
const rowRef = useRef<HTMLDivElement | null>(null)
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
@@ -163,7 +169,13 @@ function MessageRow({
{markdown ? (
<>
<ImageAttachmentRefs blocks={prose} />
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
<CommentMarkdown
content={markdown}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
</>
) : (
<ImageAttachmentRefs blocks={prose} />
@@ -196,7 +208,13 @@ function MessageRow({
) : null}
<ImageAttachmentRefs blocks={prose} />
{markdown ? (
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
<CommentMarkdown
content={markdown}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
) : null}
{tools.length > 0 ? <NativeChatToolRun blocks={tools} expandSignal={expandSignal} /> : null}
</div>
@@ -207,7 +225,9 @@ export function NativeChatMessageList({
session,
isWorking,
expandSignal,
fontScale
fontScale,
onLinkClick,
allowFileUriLinks = false
}: {
session: NativeChatLiveSession
isWorking: boolean
@@ -215,6 +235,8 @@ export function NativeChatMessageList({
expandSignal: boolean
/** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */
fontScale: number
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(null)
const [stuckToBottom, setStuckToBottom] = useState(true)
@@ -346,6 +368,8 @@ export function NativeChatMessageList({
message={message}
expandSignal={expandSignal}
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
))}
{showTypingIndicator ? <TypingIndicatorRow /> : null}
@@ -43,6 +43,12 @@ import {
} from './native-chat-typing-redirect'
import { useNativeChatContextMenu } from './use-native-chat-context-menu'
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
import {
resolveNativeChatFileLink,
resolveNativeChatFileLinkContext
} from './native-chat-file-link'
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
onSplitRight: () => {},
@@ -164,6 +170,9 @@ function NativeChatResolvedView({
const [workingInterrupted, setWorkingInterrupted] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
const fileLinkContext = useAppStore(
useShallow((s) => resolveNativeChatFileLinkContext(s, terminalTabId))
)
// Delegate to the composer so a pane-level Cmd/Ctrl+V (or context-menu /
// app-menu paste) attaches a clipboard image when present, falling back to
// text — matching the textarea's own paste behavior and the hosted TUI.
@@ -338,6 +347,24 @@ function NativeChatResolvedView({
setWorkingInterrupted(true)
interactiveSend.cancel()
}, [interactiveSend])
const openNativeChatFileLink = useCallback<CommentMarkdownLinkClickHandler>(
(event, href) => {
const target = resolveNativeChatFileLink(href, fileLinkContext)
if (!target || !fileLinkContext) {
return
}
event.preventDefault()
event.stopPropagation()
openDetectedFilePath(target.absolutePath, target.line, target.column, {
worktreeId: fileLinkContext.worktreeId,
worktreePath: fileLinkContext.worktreePath,
runtimeEnvironmentId: fileLinkContext.runtimeEnvironmentId,
openWithSystemDefault: event.shiftKey
})
},
[fileLinkContext]
)
const nativeChatFileLinkClick = fileLinkContext ? openNativeChatFileLink : undefined
// Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so
// the chord is inert on the loading/empty/error states and elsewhere.
@@ -393,6 +420,8 @@ function NativeChatResolvedView({
isWorking={isWorking}
expandSignal={false}
fontScale={fontScale.scale}
onLinkClick={nativeChatFileLinkClick}
allowFileUriLinks={fileLinkContext !== null}
/>
)}
</div>
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import type { TerminalTab } from '../../../../shared/types'
import type { AppState } from '@/store/types'
import {
resolveNativeChatFileLink,
resolveNativeChatFileLinkContext,
type NativeChatFileLinkContext
} from './native-chat-file-link'
function terminalTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
id: 'tab-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0,
...overrides
}
}
function state(overrides: Partial<AppState> = {}): AppState {
return {
folderWorkspaces: [],
getKnownWorktreeById: (worktreeId: string) =>
worktreeId === 'wt-1' ? ({ id: 'wt-1', path: '/repo/worktree' } as never) : undefined,
projectGroups: [],
repos: [],
settings: { activeRuntimeEnvironmentId: null },
tabsByWorktree: {
'wt-1': [terminalTab()]
},
worktreesByRepo: {
repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo/worktree' } as never]
},
...overrides
} as AppState
}
const context: NativeChatFileLinkContext = {
worktreeId: 'wt-1',
worktreePath: '/repo/worktree',
runtimeEnvironmentId: null
}
describe('resolveNativeChatFileLinkContext', () => {
it('returns the owner worktree path and runtime for a native chat terminal tab', () => {
expect(
resolveNativeChatFileLinkContext(
state({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings']
}),
'tab-1'
)
).toEqual({
worktreeId: 'wt-1',
worktreePath: '/repo/worktree',
runtimeEnvironmentId: 'env-1'
})
})
it('returns null when the terminal tab has no worktree owner', () => {
expect(resolveNativeChatFileLinkContext(state({ tabsByWorktree: {} }), 'tab-1')).toBeNull()
})
it('falls back to repo-scoped worktrees when a known worktree has no path', () => {
expect(
resolveNativeChatFileLinkContext(
state({
getKnownWorktreeById: () => ({ id: 'wt-1' }) as never,
worktreesByRepo: {
repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo/fallback' } as never]
}
}),
'tab-1'
)
).toEqual({
worktreeId: 'wt-1',
worktreePath: '/repo/fallback',
runtimeEnvironmentId: null
})
})
})
describe('resolveNativeChatFileLink', () => {
it('resolves repo-relative file links against the chat worktree', () => {
expect(resolveNativeChatFileLink('docs/guide.md', context)).toEqual({
absolutePath: '/repo/worktree/docs/guide.md',
line: null,
column: null
})
})
it('resolves explicit hrefs for non-markdown file types', () => {
expect(resolveNativeChatFileLink('src/App.tsx#L42', context)).toEqual({
absolutePath: '/repo/worktree/src/App.tsx',
line: 42,
column: null
})
expect(resolveNativeChatFileLink('package.json', context)).toEqual({
absolutePath: '/repo/worktree/package.json',
line: null,
column: null
})
expect(resolveNativeChatFileLink('assets/logo.png?raw=true', context)).toEqual({
absolutePath: '/repo/worktree/assets/logo.png',
line: null,
column: null
})
expect(resolveNativeChatFileLink('CODEOWNERS', context)).toEqual({
absolutePath: '/repo/worktree/CODEOWNERS',
line: null,
column: null
})
})
it('preserves terminal-style line and column suffixes', () => {
expect(resolveNativeChatFileLink('/repo/worktree/src/main.ts:12:4', context)).toEqual({
absolutePath: '/repo/worktree/src/main.ts',
line: 12,
column: 4
})
})
it('resolves encoded file URIs', () => {
expect(resolveNativeChatFileLink('file:///repo/worktree/My%20File.md#L7', context)).toEqual({
absolutePath: '/repo/worktree/My File.md',
line: 7,
column: null
})
})
it('decodes escaped reserved characters in repo-relative hrefs', () => {
expect(resolveNativeChatFileLink('docs/Setup%20%231.md#L3', context)).toEqual({
absolutePath: '/repo/worktree/docs/Setup #1.md',
line: 3,
column: null
})
})
it('ignores http links so normal markdown navigation can handle them', () => {
expect(resolveNativeChatFileLink('https://example.com/docs/guide.md', context)).toBeNull()
})
})
@@ -0,0 +1,190 @@
import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import type { Worktree } from '../../../../shared/types'
import {
parseExplicitFileLinkTarget,
resolveExplicitFileLinkTarget
} from '@/lib/explicit-file-link-target'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { AppState } from '@/store/types'
export type NativeChatFileLinkContext = {
worktreeId: string
worktreePath: string
runtimeEnvironmentId: string | null
}
export type NativeChatResolvedFileLink = {
absolutePath: string
line: number | null
column: number | null
}
type NativeChatFileLinkState = Pick<
AppState,
| 'folderWorkspaces'
| 'getKnownWorktreeById'
| 'projectGroups'
| 'repos'
| 'settings'
| 'tabsByWorktree'
| 'worktreesByRepo'
>
function findTerminalTabWorktreeId(
tabsByWorktree: NativeChatFileLinkState['tabsByWorktree'],
terminalTabId: string
): string | null {
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
// Why: tabsByWorktree stores TerminalTab records; unified tabs carry
// entityId, but the terminal owner lookup must use the backing tab id.
if (tabs.some((tab) => tab.id === terminalTabId)) {
return worktreeId
}
}
return null
}
function findWorktreeFallback(
worktreesByRepo: NativeChatFileLinkState['worktreesByRepo'],
worktreeId: string
): Pick<Worktree, 'id' | 'path'> | null {
for (const worktrees of Object.values(worktreesByRepo)) {
const worktree = worktrees.find((entry) => entry.id === worktreeId)
if (worktree) {
return worktree
}
}
return null
}
export function resolveNativeChatFileLinkContext(
state: NativeChatFileLinkState,
terminalTabId: string
): NativeChatFileLinkContext | null {
const worktreeId = findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId)
if (!worktreeId) {
return null
}
const knownWorktree = state.getKnownWorktreeById(worktreeId)
const worktree = knownWorktree?.path
? knownWorktree
: findWorktreeFallback(state.worktreesByRepo, worktreeId)
if (!worktree?.path) {
return null
}
return {
worktreeId,
worktreePath: worktree.path,
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId)
}
}
function maybeDecodeHrefPath(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function stripQueryAndHash(value: string): { pathText: string; line: number | null } {
const hashIndex = value.indexOf('#')
const queryIndex = value.indexOf('?')
const suffixIndex =
hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex)
const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex)
const hash =
hashIndex === -1
? ''
: value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined)
const line = parseLineFragment(hash)
return { pathText, line }
}
function parseLineFragment(hash: string): number | null {
if (!hash) {
return null
}
let decoded = hash
try {
decoded = decodeURIComponent(hash)
} catch {
decoded = hash
}
const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded)
return match ? Number.parseInt(match[1], 10) : null
}
function hasNonFileUriProtocol(value: string): boolean {
if (isWindowsAbsolutePathLike(value)) {
return false
}
const match = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(value)
return Boolean(match && match[0].toLowerCase() !== 'file:')
}
function resolvePathText(
pathText: string,
fallbackLine: number | null,
context: NativeChatFileLinkContext
): NativeChatResolvedFileLink | null {
const parsed = parseExplicitFileLinkTarget(pathText, { allowRelativeDirectoryPath: true })
if (!parsed) {
return null
}
// Native chat hrefs are explicit agent-authored links, so avoid the terminal
// detector's conservative extension/filename filters.
const resolved = resolveExplicitFileLinkTarget(parsed, context.worktreePath)
if (!resolved) {
return null
}
return {
absolutePath: resolved.absolutePath,
line: resolved.line ?? fallbackLine,
column: resolved.column
}
}
function resolveFileUriLink(
href: string,
context: NativeChatFileLinkContext
): NativeChatResolvedFileLink | null {
let url: URL
try {
url = new URL(href)
} catch {
return null
}
if (url.protocol !== 'file:') {
return null
}
const filePath = fileUriToFilesystemPath(url)
if (!filePath) {
return null
}
return resolvePathText(filePath, parseLineFragment(url.hash.replace(/^#/, '')), context)
}
export function resolveNativeChatFileLink(
href: string | undefined,
context: NativeChatFileLinkContext | null
): NativeChatResolvedFileLink | null {
const rawHref = href?.trim()
if (!rawHref || rawHref.startsWith('#') || !context) {
return null
}
if (rawHref.toLowerCase().startsWith('file:')) {
return resolveFileUriLink(rawHref, context)
}
if (hasNonFileUriProtocol(rawHref)) {
return null
}
const { pathText, line } = stripQueryAndHash(rawHref)
const decodedPathText = maybeDecodeHrefPath(pathText)
return resolvePathText(decodedPathText, line, context)
}
@@ -0,0 +1,149 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CommentMarkdown from './CommentMarkdown'
describe('CommentMarkdown link click handler', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('lets callers intercept rendered document links', () => {
const onLinkClick = vi.fn((event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<CommentMarkdown
variant="document"
content="[docs](docs/guide.md)"
onLinkClick={onLinkClick}
/>
)
})
const anchor = container.querySelector<HTMLAnchorElement>('a[href="docs/guide.md"]')
expect(anchor).not.toBeNull()
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true })
act(() => {
anchor?.dispatchEvent(event)
})
expect(onLinkClick).toHaveBeenCalledWith(expect.any(Object), 'docs/guide.md')
expect(event.defaultPrevented).toBe(true)
})
it('sanitizes file URI links unless the caller opts in', () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<CommentMarkdown variant="document" content="[source](file:///repo/worktree/src/main.ts)" />
)
})
const anchor = container.querySelector<HTMLAnchorElement>('a')
expect(anchor).not.toBeNull()
expect(anchor?.getAttribute('href')).toBeNull()
})
it('sanitizes raw HTML file URI links unless the caller opts in', () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<CommentMarkdown
variant="document"
content='<a href="file:///repo/worktree/src/main.ts">source</a>'
/>
)
})
const anchor = container.querySelector<HTMLAnchorElement>('a')
expect(anchor).not.toBeNull()
expect(anchor?.getAttribute('href')).toBeNull()
})
it('lets opted-in callers intercept rendered file URI links', () => {
const onLinkClick = vi.fn()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<CommentMarkdown
variant="document"
content="[source](file:///repo/worktree/src/main.ts)"
onLinkClick={onLinkClick}
allowFileUriLinks
/>
)
})
const anchor = container.querySelector<HTMLAnchorElement>(
'a[href="file:///repo/worktree/src/main.ts"]'
)
expect(anchor).not.toBeNull()
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true })
act(() => {
anchor?.dispatchEvent(event)
})
expect(onLinkClick).toHaveBeenCalledWith(
expect.any(Object),
'file:///repo/worktree/src/main.ts'
)
expect(event.defaultPrevented).toBe(true)
})
it('lets callers intercept rendered document images', () => {
const onLinkClick = vi.fn((event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<CommentMarkdown
variant="document"
content="![diagram](assets/diagram.png)"
onLinkClick={onLinkClick}
/>
)
})
const image = container.querySelector<HTMLImageElement>('img[alt="diagram"]')
expect(image?.getAttribute('src')).toBe('assets/diagram.png')
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true })
act(() => {
image?.dispatchEvent(event)
})
expect(onLinkClick).toHaveBeenCalledWith(expect.any(Object), 'assets/diagram.png')
expect(event.defaultPrevented).toBe(true)
})
})
@@ -4,9 +4,17 @@ import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import type { Components } from 'react-markdown'
import { cn } from '@/lib/utils'
import { isMermaidFence, isMermaidPre, renderMermaidFence } from './comment-mermaid-fence'
import {
compactCommentMarkdownComponents,
createCompactCommentMarkdownComponents,
createDocumentCommentMarkdownComponents,
documentCommentMarkdownComponents,
isTrustedCompactImageSrc,
type CommentMarkdownLinkClickHandler
} from './comment-markdown-element-renderers'
export type { CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers'
type MarkdownPlugins = NonNullable<React.ComponentProps<typeof Markdown>['rehypePlugins']>
type UrlTransform = NonNullable<React.ComponentProps<typeof Markdown>['urlTransform']>
@@ -34,77 +42,6 @@ type MarkdownNode = {
children?: MarkdownNode[]
}
function isTrustedCompactImageSrc(src: string | undefined): src is string {
if (!src) {
return false
}
const normalized = src.trim().toLowerCase()
return (
normalized.startsWith('blob:') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized)
)
}
function isGitHubUserAttachmentUrl(href: string | undefined): href is string {
if (!href) {
return false
}
try {
const url = new URL(href)
return (
url.protocol === 'https:' &&
url.hostname === 'github.com' &&
url.pathname.startsWith('/user-attachments/assets/')
)
} catch {
return false
}
}
function isBareAutolink(children: React.ReactNode, href: string): boolean {
const text = React.Children.toArray(children).join('').trim()
return text === href
}
function GitHubUserAttachmentVideo({
href,
children
}: {
href: string
children: React.ReactNode
}): React.ReactElement {
const [failed, setFailed] = React.useState(false)
if (failed) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
)
}
return (
<video
src={href}
controls
preload="metadata"
playsInline
className="my-3 max-h-[28rem] max-w-full rounded-md bg-black/80 outline outline-1 outline-black/10 dark:outline-white/10"
onClick={(e) => e.stopPropagation()}
onError={() => setFailed(true)}
>
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
</video>
)
}
const commentMarkdownUrlTransform: UrlTransform = (value, key, node) => {
if (key === 'src' && node?.tagName === 'img' && isTrustedCompactImageSrc(value)) {
return value
@@ -112,198 +49,11 @@ const commentMarkdownUrlTransform: UrlTransform = (value, key, node) => {
return defaultUrlTransform(value)
}
// Why: sidebar comments are rendered at 11px in a narrow card, so we strip
// block-level wrappers that add unwanted margins and only keep inline
// formatting (bold, italic, code, links) plus compact lists and line breaks.
// Using react-markdown (already a project dependency) lets AI agents write
// markdown via `orca worktree set --comment` and have it render nicely.
const compactComponents: Components = {
// Strip <p> wrappers to avoid double margins in the tight card layout.
p: ({ children }) => <span className="comment-md-p">{children}</span>,
// Open links externally — sidebar is not a navigation context.
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
),
// Why: react-markdown calls the `code` component for both inline `code`
// and the <code> inside fenced blocks (<pre><code>…</code></pre>). We
// always apply inline-code styling here; the wrapper div uses a CSS
// descendant selector ([&_pre_code]) at higher specificity to strip
// the pill background/padding when code is inside a <pre>. This is
// more reliable than checking `className` — which is only set when
// the fenced block specifies a language (```js), not for bare ```.
// Why: compact comment previews live in dense cards; keep diagram fences as
// bounded source blocks so async SVG renders do not reshape sidebar lists.
code: ({ children }) => (
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono [overflow-wrap:anywhere]">
{children}
</code>
),
// Compact pre blocks — no syntax highlighting needed for short comments.
pre: ({ children }) => (
<pre className="my-1 max-h-32 max-w-full overflow-x-auto rounded bg-accent p-1.5 text-[10px] font-mono">
{children}
</pre>
),
// Compact lists
ul: ({ children }) => <ul className="my-0.5 ml-3 list-disc space-y-0">{children}</ul>,
ol: ({ children }) => <ol className="my-0.5 ml-3 list-decimal space-y-0">{children}</ol>,
// Why: GFM task list checkboxes are non-functional in a read-only comment
// card (clicking them would just open the edit modal via the parent's
// onClick). Rendering them disabled avoids a misleading interactive
// affordance.
li: ({ children }) => (
<li className="leading-normal [&>input]:pointer-events-none">{children}</li>
),
// Headings render as bold text at the same size — no visual hierarchy needed
// in a tiny sidebar card.
h1: ({ children }) => <span className="font-bold">{children}</span>,
h2: ({ children }) => <span className="font-bold">{children}</span>,
h3: ({ children }) => <span className="font-semibold">{children}</span>,
h4: ({ children }) => <span className="font-semibold">{children}</span>,
h5: ({ children }) => <span className="font-semibold">{children}</span>,
h6: ({ children }) => <span className="font-semibold">{children}</span>,
// Horizontal rules as a subtle divider
hr: () => <hr className="my-1 border-border/50" />,
// Compact blockquotes
blockquote: ({ children }) => (
<blockquote className="my-0.5 border-l-2 border-border/60 pl-2 text-muted-foreground/80">
{children}
</blockquote>
),
// Why: agent replies and workspace notes often carry screenshot markdown
// like "Image #1"; compact cards inline app-managed thumbnails without
// auto-fetching arbitrary remote image URLs.
img: ({ alt, src }) => {
if (!isTrustedCompactImageSrc(src)) {
if (!src) {
return alt ? <span>{alt}</span> : null
}
return (
<a
href={src}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{alt || src}
</a>
)
}
const image = (
<img
src={src}
alt={alt ?? ''}
className="my-1 max-h-32 max-w-full rounded-sm object-contain outline outline-1 outline-border/70"
/>
)
return src ? (
<a href={src} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
{image}
</a>
) : (
image
)
},
// Why: GFM tables in a ~200px sidebar would overflow badly. Wrapping in an
// overflow container keeps the card layout stable while still letting the
// user scroll to see the full table.
table: ({ children }) => (
<div className="my-1 max-w-full overflow-x-auto">
<table className="text-[10px] border-collapse [&_td]:border [&_td]:border-border/40 [&_td]:px-1 [&_td]:py-0.5 [&_th]:border [&_th]:border-border/40 [&_th]:px-1 [&_th]:py-0.5 [&_th]:font-semibold [&_th]:text-left">
{children}
</table>
</div>
)
}
const documentComponents: Components = {
p: ({ children }) => <p className="my-2 first:mt-0 last:mb-0">{children}</p>,
a: ({ href, children }) =>
isGitHubUserAttachmentUrl(href) && isBareAutolink(children, href) ? (
// Why: GitHub's API returns uploaded videos as bare attachment links;
// GitHub.com upgrades them to media embeds in its own renderer.
<GitHubUserAttachmentVideo href={href}>{children}</GitHubUserAttachmentVideo>
) : (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
),
code: ({ className, children }) =>
isMermaidFence(className) ? (
renderMermaidFence(
children,
'my-3 min-w-0 max-w-full overflow-x-auto rounded-md border border-border/60 p-3 [&_.mermaid-block]:min-w-0 [&_.mermaid-block_pre]:my-0 [&_.mermaid-block_pre]:max-h-80 [&_.mermaid-block_pre]:max-w-full [&_.mermaid-block_pre]:overflow-x-auto [&_.mermaid-block_pre]:rounded-md [&_.mermaid-block_pre]:bg-accent [&_.mermaid-block_pre]:p-3 [&_.mermaid-block_pre]:font-mono [&_.mermaid-block_pre]:text-[12px]'
)
) : (
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
{children}
</code>
),
// Mermaid fences render a <div>, which is invalid inside <pre>, so unwrap them.
pre: ({ children }) =>
isMermaidPre(children) ? (
<>{children}</>
) : (
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
{children}
</pre>
),
ul: ({ children }) => <ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>,
li: ({ children }) => (
<li className="leading-relaxed [&>input]:pointer-events-none">{children}</li>
),
h1: ({ children }) => (
<h1 className="mb-2 mt-4 text-[18px] font-semibold leading-tight first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-4 text-[16px] font-semibold leading-tight first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-3 text-[15px] font-semibold leading-tight first:mt-0">{children}</h3>
),
h4: ({ children }) => <h4 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h6>,
hr: () => <hr className="my-4 border-border/60" />,
blockquote: ({ children }) => (
<blockquote className="my-3 border-l-2 border-border/70 pl-3 text-muted-foreground">
{children}
</blockquote>
),
img: ({ alt, src }) => (
<img
src={src}
alt={alt ?? ''}
className="my-3 max-h-96 max-w-full rounded-md object-contain outline outline-1 outline-black/10 dark:outline-white/10"
/>
),
// Why: GitHub issue/PR bodies commonly contain GFM tables. The dashboard
// dialog is wide enough to show them, but still needs overflow containment.
table: ({ children }) => (
<div className="my-3 max-w-full overflow-x-auto rounded-md border border-border/60">
<table className="min-w-full border-collapse text-[13px] [&_td]:border [&_td]:border-border/50 [&_td]:px-2 [&_td]:py-1.5 [&_th]:border [&_th]:border-border/50 [&_th]:bg-muted/60 [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold">
{children}
</table>
</div>
)
const commentMarkdownFileUriUrlTransform: UrlTransform = (value, key, node) => {
if (key === 'href' && node?.tagName === 'a' && value.trim().toLowerCase().startsWith('file:')) {
return value
}
return commentMarkdownUrlTransform(value, key, node)
}
// Why: standard CommonMark collapses single newlines into spaces. The old
@@ -418,6 +168,9 @@ const commentMarkdownSanitizeSchema = {
},
protocols: {
...defaultSchema.protocols,
// Why: native chat opts into file URI links after sanitize; the URL
// transform below still strips them for all other markdown surfaces.
href: [...(defaultSchema.protocols?.href ?? []), 'file'],
src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob']
}
}
@@ -430,6 +183,8 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
content: string
variant?: 'compact' | 'document'
githubRepo?: GitHubRepoReference | null
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
}
// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref
@@ -437,10 +192,27 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
// the child. Without forwarding both, the hover card cannot open or position.
const CommentMarkdown = React.memo(
React.forwardRef<HTMLDivElement, CommentMarkdownProps>(function CommentMarkdown(
{ content, className, variant = 'compact', githubRepo, ...rest },
{
content,
className,
variant = 'compact',
githubRepo,
onLinkClick,
allowFileUriLinks = false,
...rest
},
ref
) {
const components = variant === 'document' ? documentComponents : compactComponents
const components = React.useMemo(() => {
if (!onLinkClick) {
return variant === 'document'
? documentCommentMarkdownComponents
: compactCommentMarkdownComponents
}
return variant === 'document'
? createDocumentCommentMarkdownComponents(onLinkClick)
: createCompactCommentMarkdownComponents(onLinkClick)
}, [variant, onLinkClick])
const activeRemarkPlugins = React.useMemo(
() => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins),
[githubRepo]
@@ -463,7 +235,9 @@ const CommentMarkdown = React.memo(
remarkPlugins={activeRemarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
urlTransform={commentMarkdownUrlTransform}
urlTransform={
allowFileUriLinks ? commentMarkdownFileUriUrlTransform : commentMarkdownUrlTransform
}
>
{content}
</Markdown>
@@ -0,0 +1,264 @@
import React from 'react'
import type { Components } from 'react-markdown'
import { isMermaidFence, isMermaidPre, renderMermaidFence } from './comment-mermaid-fence'
import {
GitHubUserAttachmentVideo,
isGitHubUserAttachmentVideoLink
} from './comment-markdown-github-attachment-video'
export type CommentMarkdownLinkClickHandler = (
event: React.MouseEvent<HTMLElement>,
href: string | undefined
) => void
export function isTrustedCompactImageSrc(src: string | undefined): src is string {
if (!src) {
return false
}
const normalized = src.trim().toLowerCase()
return (
normalized.startsWith('blob:') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized)
)
}
function handleMarkdownAnchorClick(
event: React.MouseEvent<HTMLAnchorElement>,
href: string | undefined,
onLinkClick: CommentMarkdownLinkClickHandler | undefined
): void {
// Why: link clicks should not also trigger an outer row/card click handler;
// images only claim the click when an image handler is wired below.
event.stopPropagation()
if (href?.trim().toLowerCase().startsWith('file:')) {
event.preventDefault()
}
onLinkClick?.(event, href)
}
function handleMarkdownImageClick(
event: React.MouseEvent<HTMLImageElement>,
src: string | undefined,
onLinkClick: CommentMarkdownLinkClickHandler | undefined
): void {
if (!onLinkClick) {
return
}
event.stopPropagation()
onLinkClick(event, src)
}
export function createCompactCommentMarkdownComponents(
onLinkClick?: CommentMarkdownLinkClickHandler
): Components {
return {
// Strip <p> wrappers to avoid double margins in the tight card layout.
p: ({ children }) => <span className="comment-md-p">{children}</span>,
// Open links externally — sidebar is not a navigation context.
a: ({ href, children }) => (
<a
href={href || undefined}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
onClick={(e) => handleMarkdownAnchorClick(e, href, onLinkClick)}
>
{children}
</a>
),
// Why: react-markdown calls the `code` component for both inline `code`
// and the <code> inside fenced blocks (<pre><code>…</code></pre>). We
// always apply inline-code styling here; the wrapper div uses a CSS
// descendant selector ([&_pre_code]) at higher specificity to strip
// the pill background/padding when code is inside a <pre>. This is
// more reliable than checking `className` — which is only set when
// the fenced block specifies a language (```js), not for bare ```.
// Why: compact comment previews live in dense cards; keep diagram fences as
// bounded source blocks so async SVG renders do not reshape sidebar lists.
code: ({ children }) => (
<code className="rounded bg-accent px-1 py-px text-[10px] font-mono [overflow-wrap:anywhere]">
{children}
</code>
),
// Compact pre blocks — no syntax highlighting needed for short comments.
pre: ({ children }) => (
<pre className="my-1 max-h-32 max-w-full overflow-x-auto rounded bg-accent p-1.5 text-[10px] font-mono">
{children}
</pre>
),
// Compact lists
ul: ({ children }) => <ul className="my-0.5 ml-3 list-disc space-y-0">{children}</ul>,
ol: ({ children }) => <ol className="my-0.5 ml-3 list-decimal space-y-0">{children}</ol>,
// Why: GFM task list checkboxes are non-functional in a read-only comment
// card (clicking them would just open the edit modal via the parent's
// onClick). Rendering them disabled avoids a misleading interactive
// affordance.
li: ({ children }) => (
<li className="leading-normal [&>input]:pointer-events-none">{children}</li>
),
// Headings render as bold text at the same size — no visual hierarchy needed
// in a tiny sidebar card.
h1: ({ children }) => <span className="font-bold">{children}</span>,
h2: ({ children }) => <span className="font-bold">{children}</span>,
h3: ({ children }) => <span className="font-semibold">{children}</span>,
h4: ({ children }) => <span className="font-semibold">{children}</span>,
h5: ({ children }) => <span className="font-semibold">{children}</span>,
h6: ({ children }) => <span className="font-semibold">{children}</span>,
// Horizontal rules as a subtle divider
hr: () => <hr className="my-1 border-border/50" />,
// Compact blockquotes
blockquote: ({ children }) => (
<blockquote className="my-0.5 border-l-2 border-border/60 pl-2 text-muted-foreground/80">
{children}
</blockquote>
),
// Why: agent replies and workspace notes often carry screenshot markdown
// like "Image #1"; compact cards inline app-managed thumbnails without
// auto-fetching arbitrary remote image URLs.
img: ({ alt, src }) => {
if (!isTrustedCompactImageSrc(src)) {
if (!src) {
return alt ? <span>{alt}</span> : null
}
return (
<a
href={src || undefined}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 text-foreground/80 hover:text-foreground"
onClick={(e) => handleMarkdownAnchorClick(e, src, onLinkClick)}
>
{alt || src}
</a>
)
}
const image = (
<img
src={src}
alt={alt ?? ''}
className="my-1 max-h-32 max-w-full rounded-sm object-contain outline outline-1 outline-border/70"
/>
)
return src ? (
<a
href={src || undefined}
target="_blank"
rel="noreferrer"
onClick={(e) => handleMarkdownAnchorClick(e, src, onLinkClick)}
>
{image}
</a>
) : (
image
)
},
// Why: GFM tables in a ~200px sidebar would overflow badly. Wrapping in an
// overflow container keeps the card layout stable while still letting the
// user scroll to see the full table.
table: ({ children }) => (
<div className="my-1 max-w-full overflow-x-auto">
<table className="text-[10px] border-collapse [&_td]:border [&_td]:border-border/40 [&_td]:px-1 [&_td]:py-0.5 [&_th]:border [&_th]:border-border/40 [&_th]:px-1 [&_th]:py-0.5 [&_th]:font-semibold [&_th]:text-left">
{children}
</table>
</div>
)
}
}
export function createDocumentCommentMarkdownComponents(
onLinkClick?: CommentMarkdownLinkClickHandler
): Components {
return {
p: ({ children }) => <p className="my-2 first:mt-0 last:mb-0">{children}</p>,
a: ({ href, children }) =>
isGitHubUserAttachmentVideoLink(href, children) ? (
// Why: GitHub's API returns uploaded videos as bare attachment links;
// GitHub.com upgrades them to media embeds in its own renderer.
<GitHubUserAttachmentVideo href={href}>{children}</GitHubUserAttachmentVideo>
) : (
<a
href={href || undefined}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => handleMarkdownAnchorClick(e, href, onLinkClick)}
>
{children}
</a>
),
code: ({ className, children }) =>
isMermaidFence(className) ? (
renderMermaidFence(
children,
'my-3 min-w-0 max-w-full overflow-x-auto rounded-md border border-border/60 p-3 [&_.mermaid-block]:min-w-0 [&_.mermaid-block_pre]:my-0 [&_.mermaid-block_pre]:max-h-80 [&_.mermaid-block_pre]:max-w-full [&_.mermaid-block_pre]:overflow-x-auto [&_.mermaid-block_pre]:rounded-md [&_.mermaid-block_pre]:bg-accent [&_.mermaid-block_pre]:p-3 [&_.mermaid-block_pre]:font-mono [&_.mermaid-block_pre]:text-[12px]'
)
) : (
<code className="rounded bg-accent px-1.5 py-0.5 font-mono text-[0.92em] [overflow-wrap:anywhere]">
{children}
</code>
),
// Mermaid fences render a <div>, which is invalid inside <pre>, so unwrap them.
pre: ({ children }) =>
isMermaidPre(children) ? (
<>{children}</>
) : (
<pre className="my-3 max-h-80 max-w-full overflow-x-auto rounded-md bg-accent p-3 font-mono text-[12px]">
{children}
</pre>
),
ul: ({ children }) => <ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>,
li: ({ children }) => (
<li className="leading-relaxed [&>input]:pointer-events-none">{children}</li>
),
h1: ({ children }) => (
<h1 className="mb-2 mt-4 text-[18px] font-semibold leading-tight first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-4 text-[16px] font-semibold leading-tight first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-3 text-[15px] font-semibold leading-tight first:mt-0">{children}</h3>
),
h4: ({ children }) => <h4 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h4>,
h5: ({ children }) => <h5 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h5>,
h6: ({ children }) => <h6 className="mb-1 mt-3 font-semibold first:mt-0">{children}</h6>,
hr: () => <hr className="my-4 border-border/60" />,
blockquote: ({ children }) => (
<blockquote className="my-3 border-l-2 border-border/70 pl-3 text-muted-foreground">
{children}
</blockquote>
),
img: ({ alt, src }) => {
const imageClassName = [
'my-3 max-h-96 max-w-full rounded-md object-contain',
'outline outline-1 outline-black/10 dark:outline-white/10',
onLinkClick ? 'cursor-pointer' : ''
]
.filter(Boolean)
.join(' ')
return (
<img
src={src}
alt={alt ?? ''}
className={imageClassName}
onClick={(e) => handleMarkdownImageClick(e, src, onLinkClick)}
/>
)
},
// Why: GitHub issue/PR bodies commonly contain GFM tables. The dashboard
// dialog is wide enough to show them, but still needs overflow containment.
table: ({ children }) => (
<div className="my-3 max-w-full overflow-x-auto rounded-md border border-border/60">
<table className="min-w-full border-collapse text-[13px] [&_td]:border [&_td]:border-border/50 [&_td]:px-2 [&_td]:py-1.5 [&_th]:border [&_th]:border-border/50 [&_th]:bg-muted/60 [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold">
{children}
</table>
</div>
)
}
}
export const compactCommentMarkdownComponents: Components = createCompactCommentMarkdownComponents()
export const documentCommentMarkdownComponents: Components =
createDocumentCommentMarkdownComponents()
@@ -0,0 +1,69 @@
import React from 'react'
function isGitHubUserAttachmentUrl(href: string | undefined): href is string {
if (!href) {
return false
}
try {
const url = new URL(href)
return (
url.protocol === 'https:' &&
url.hostname === 'github.com' &&
url.pathname.startsWith('/user-attachments/assets/')
)
} catch {
return false
}
}
function isBareAutolink(children: React.ReactNode, href: string): boolean {
const text = React.Children.toArray(children).join('').trim()
return text === href
}
export function isGitHubUserAttachmentVideoLink(
href: string | undefined,
children: React.ReactNode
): href is string {
return isGitHubUserAttachmentUrl(href) && isBareAutolink(children, href)
}
export function GitHubUserAttachmentVideo({
href,
children
}: {
href: string
children: React.ReactNode
}): React.ReactElement {
const [failed, setFailed] = React.useState(false)
if (failed) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
)
}
return (
<video
src={href}
controls
preload="metadata"
playsInline
className="my-3 max-h-[28rem] max-w-full rounded-md bg-black/80 outline outline-1 outline-black/10 dark:outline-white/10"
onClick={(e) => e.stopPropagation()}
onError={() => setFailed(true)}
>
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
</video>
)
}
@@ -0,0 +1,91 @@
import {
joinAbsolutePath,
normalizeAbsolutePath,
resolveTildePath
} from './terminal-path-normalization'
export type ParsedExplicitFileLinkTarget = {
pathText: string
line: number | null
column: number | null
}
export type ResolvedExplicitFileLinkTarget = Pick<
ParsedExplicitFileLinkTarget,
'line' | 'column'
> & {
absolutePath: string
}
type ParseExplicitFileLinkTargetOptions = {
allowRelativeDirectoryPath?: boolean
}
function canKeepTrailingSeparator(pathText: string): boolean {
// Why: bare roots ("/", "~/", "C:/") are ambiguous link targets, while
// absolute/tilde paths with a real segment are unambiguous directories.
if (/^[\\/]+$/.test(pathText) || /^~[\\/]$/.test(pathText) || /^[A-Za-z]:[\\/]$/.test(pathText)) {
return false
}
return /^(?:~[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(pathText)
}
export function parseExplicitFileLinkTarget(
value: string,
options: ParseExplicitFileLinkTargetOptions = {}
): ParsedExplicitFileLinkTarget | null {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
if (!match) {
return null
}
const pathText = match[1]
const hasLineOrColumn = Boolean(match[2] || match[3])
if (!pathText) {
return null
}
if (/^[\\/]\s/.test(pathText)) {
return null
}
if (/[\\/]$/.test(pathText)) {
const canKeepRelativeDirectory = options.allowRelativeDirectoryPath === true && !hasLineOrColumn
if (hasLineOrColumn || (!canKeepRelativeDirectory && !canKeepTrailingSeparator(pathText))) {
return null
}
}
const line = match[2] ? Number.parseInt(match[2], 10) : null
const column = match[3] ? Number.parseInt(match[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
return null
}
return { pathText, line, column }
}
export function resolveExplicitFileLinkTargetPath(
pathText: string,
cwd: string,
homePath?: string | null
): string | null {
if (/^~[\\/]/.test(pathText)) {
return resolveTildePath(pathText, cwd, homePath)
}
return normalizeAbsolutePath(pathText)?.normalized ?? joinAbsolutePath(cwd, pathText)
}
export function resolveExplicitFileLinkTarget(
parsed: ParsedExplicitFileLinkTarget,
cwd: string,
homePath?: string | null
): ResolvedExplicitFileLinkTarget | null {
const absolutePath = resolveExplicitFileLinkTargetPath(parsed.pathText, cwd, homePath)
if (!absolutePath) {
return null
}
return {
absolutePath,
line: parsed.line,
column: parsed.column
}
}
+6 -54
View File
@@ -1,9 +1,9 @@
/* eslint-disable max-lines -- Why: terminal link parsing depends on ordered passes sharing range state. */
import { normalizeAbsolutePath } from './terminal-path-normalization'
import {
joinAbsolutePath,
normalizeAbsolutePath,
resolveTildePath
} from './terminal-path-normalization'
parseExplicitFileLinkTarget,
resolveExplicitFileLinkTarget
} from './explicit-file-link-target'
export type ParsedTerminalFileLink = {
pathText: string
@@ -88,43 +88,6 @@ function trimBoundaryPunctuation(
}
}
function parsePathWithOptionalLineColumn(value: string): {
pathText: string
line: number | null
column: number | null
} | null {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
if (!match) {
return null
}
const pathText = match[1]
const hasLineOrColumn = Boolean(match[2] || match[3])
if (!pathText) {
return null
}
if (/^[\\/]\s/.test(pathText)) {
return null
}
if (/[\\/]$/.test(pathText) && (hasLineOrColumn || !canKeepTrailingSeparator(pathText))) {
return null
}
const line = match[2] ? Number.parseInt(match[2], 10) : null
const column = match[3] ? Number.parseInt(match[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
return null
}
return { pathText, line, column }
}
function canKeepTrailingSeparator(pathText: string): boolean {
if (/^[\\/]+$/.test(pathText) || /^~[\\/]$/.test(pathText) || /^[A-Za-z]:[\\/]$/.test(pathText)) {
return false
}
return /^(?:~[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(pathText)
}
// Project files that look like filenames despite having no extension. The
// word detector otherwise requires a `.` in the token to keep noise down —
// without this list, `ls` output containing `Makefile` or `LICENSE` would
@@ -333,7 +296,7 @@ function buildLineEndingSpacedPathPrefixRanges(range: DetectedRange): DetectedRa
}
function toParsedLink(range: DetectedRange): ParsedTerminalFileLink | null {
const parsed = parsePathWithOptionalLineColumn(range.text)
const parsed = parseExplicitFileLinkTarget(range.text)
if (!parsed) {
return null
}
@@ -492,18 +455,7 @@ export function resolveTerminalFileLink(
cwd: string,
homePath?: string | null
): ResolvedTerminalFileLink | null {
const absolutePath = /^~[\\/]/.test(parsed.pathText)
? resolveTildePath(parsed.pathText, cwd, homePath)
: (normalizeAbsolutePath(parsed.pathText)?.normalized ?? joinAbsolutePath(cwd, parsed.pathText))
if (!absolutePath) {
return null
}
return {
absolutePath,
line: parsed.line,
column: parsed.column
}
return resolveExplicitFileLinkTarget(parsed, cwd, homePath)
}
export function resolveTerminalFileLinkText(