mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
fix(native-chat): underline only file links a click can act on (#22370)
* fix(native-chat): underline only file links a click can act on A chat message could underline a bare file name such as `deck.md` that resolved nowhere, and clicking it did nothing, so it read as a broken link. - Inline code and quoted text become file links only when they name a path (contain a `/` or `\`), matching plain prose; a bare file name stays plain code. - Every file link click now answers: it opens, or says the file was not found, that the host could not be checked, or that the path could not be resolved. - Explicit links like [x](README.md:5) route as files, and linked text keeps `#`, `?` and `%XX` literally instead of re-parsing them as URL syntax. * fix(native-chat): wrap the parsed file location so file URIs in chat text still open Linkified prose, quoted text and inline code wrapped their display text, which the literal wrapped-href route no longer URL-parses, so file:///... resolved as a relative path under the worktree. Wrap pathText[:line[:col]] from the parsed link instead.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { readIpcErrorMessage } from '@/lib/ipc-error'
|
||||
|
||||
export function showFileLinkNotFoundToast(filePath: string): void {
|
||||
toast.error(
|
||||
translate('components.native-chat.fileLinks.notFound', 'File not found: {{value0}}', {
|
||||
value0: filePath
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** The host could not be asked whether the file exists, so the toast must not claim it is gone. */
|
||||
export function showFileLinkUnverifiableToast(filePath: string, error: unknown): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.native-chat.fileLinks.unverifiable',
|
||||
"Couldn't check {{value0}}: {{value1}}",
|
||||
{
|
||||
value0: filePath,
|
||||
value1: readIpcErrorMessage(error) ?? String(error)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** e.g. `~/x` when the workspace gives no way to know the home folder. */
|
||||
export function showFileLinkUnresolvedToast(pathText: string): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.native-chat.fileLinks.unresolved',
|
||||
"Couldn't resolve {{value0}} in this workspace",
|
||||
{ value0: pathText }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import type { NativeChatFileLinkContext } from './native-chat-file-link'
|
||||
import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openDetectedFilePath: vi.fn(),
|
||||
showNotFound: vi.fn(),
|
||||
showUnverifiable: vi.fn(),
|
||||
showUnresolved: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/terminal-file-open-routing', () => ({
|
||||
openDetectedFilePath: mocks.openDetectedFilePath
|
||||
}))
|
||||
vi.mock('./native-chat-file-link-toasts', () => ({
|
||||
showFileLinkNotFoundToast: mocks.showNotFound,
|
||||
showFileLinkUnverifiableToast: mocks.showUnverifiable,
|
||||
showFileLinkUnresolvedToast: mocks.showUnresolved
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => selector({}),
|
||||
{ getState: () => ({ settings: {} }) }
|
||||
)
|
||||
}))
|
||||
|
||||
const context: NativeChatFileLinkContext = {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
runtimeEnvironmentId: null
|
||||
}
|
||||
|
||||
function Transcript(props: {
|
||||
markdown: string
|
||||
linkContext?: NativeChatFileLinkContext
|
||||
}): React.JSX.Element {
|
||||
const onLinkClick = useNativeChatFileLinkClick(props.linkContext ?? context)
|
||||
return (
|
||||
<CommentMarkdown
|
||||
content={props.markdown}
|
||||
variant="document"
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks
|
||||
linkifyFilePaths
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function clickLink(name: string): void {
|
||||
fireEvent.click(screen.getByRole('link', { name }))
|
||||
}
|
||||
|
||||
function failLastOpen(verdict: 'missing' | 'unverifiable', error: unknown = new Error('x')): void {
|
||||
mocks.openDetectedFilePath.mock.calls.at(-1)?.[3].onOpenFailure({ verdict, error })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useNativeChatFileLinkClick', () => {
|
||||
it('does not underline a bare file name the click could not open', () => {
|
||||
render(<Transcript markdown="I updated `deck.md` and 'notes.md'." />)
|
||||
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
expect(screen.getByText('deck.md').tagName).toBe('CODE')
|
||||
})
|
||||
|
||||
it('reports a missing relative path instead of doing nothing', () => {
|
||||
render(<Transcript markdown="I updated `docs/deck.md`." />)
|
||||
|
||||
clickLink('docs/deck.md')
|
||||
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenCalledWith(
|
||||
'/repo/docs/deck.md',
|
||||
null,
|
||||
null,
|
||||
expect.objectContaining({ worktreeId: 'wt-1', onOpenFailure: expect.any(Function) })
|
||||
)
|
||||
failLastOpen('missing')
|
||||
expect(mocks.showNotFound).toHaveBeenCalledWith('/repo/docs/deck.md')
|
||||
})
|
||||
|
||||
it('reports a host that could not answer without claiming the file is gone', () => {
|
||||
render(<Transcript markdown="I updated `docs/deck.md`." />)
|
||||
|
||||
clickLink('docs/deck.md')
|
||||
const error = new Error('SSH connection closed')
|
||||
failLastOpen('unverifiable', error)
|
||||
|
||||
expect(mocks.showUnverifiable).toHaveBeenCalledWith('/repo/docs/deck.md', error)
|
||||
expect(mocks.showNotFound).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a missing absolute path', () => {
|
||||
render(<Transcript markdown="See `/repo/src/app.ts:12`." />)
|
||||
|
||||
clickLink('/repo/src/app.ts:12')
|
||||
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenCalledWith(
|
||||
'/repo/src/app.ts',
|
||||
12,
|
||||
null,
|
||||
expect.anything()
|
||||
)
|
||||
failLastOpen('missing')
|
||||
expect(mocks.showNotFound).toHaveBeenCalledWith('/repo/src/app.ts')
|
||||
})
|
||||
|
||||
it('opens an explicit markdown link to a bare file name with a line', () => {
|
||||
render(<Transcript markdown="[the readme](README.md:5)" />)
|
||||
|
||||
clickLink('the readme')
|
||||
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenCalledWith(
|
||||
'/repo/README.md',
|
||||
5,
|
||||
null,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves URL syntax in explicit markdown links once', () => {
|
||||
render(<Transcript markdown="[plan](docs/plan.md#L7) and [notes](docs/release%20notes.md)" />)
|
||||
|
||||
clickLink('plan')
|
||||
clickLink('notes')
|
||||
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/repo/docs/plan.md',
|
||||
7,
|
||||
null,
|
||||
expect.anything()
|
||||
)
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/repo/docs/release notes.md',
|
||||
null,
|
||||
null,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('opens file URIs written in inline code and prose', () => {
|
||||
render(
|
||||
<Transcript markdown="See `file:///repo/src/app.ts` and file:///repo/docs/release%20notes.md#L4 now." />
|
||||
)
|
||||
|
||||
clickLink('file:///repo/src/app.ts')
|
||||
clickLink('file:///repo/docs/release%20notes.md#L4')
|
||||
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/repo/src/app.ts',
|
||||
null,
|
||||
null,
|
||||
expect.anything()
|
||||
)
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/repo/docs/release notes.md',
|
||||
4,
|
||||
null,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps # in a linked path instead of treating it as a fragment', () => {
|
||||
render(<Transcript markdown="Edit `My C# App/Program.cs` next." />)
|
||||
|
||||
clickLink('My C# App/Program.cs')
|
||||
|
||||
expect(mocks.openDetectedFilePath).toHaveBeenCalledWith(
|
||||
'/repo/My C# App/Program.cs',
|
||||
null,
|
||||
null,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a link it cannot resolve without claiming the file is missing', () => {
|
||||
render(
|
||||
<Transcript
|
||||
markdown="Plan: `~/.claude/plans/plan.md`"
|
||||
linkContext={{ ...context, worktreePath: '/workspaces/repo' }}
|
||||
/>
|
||||
)
|
||||
|
||||
clickLink('~/.claude/plans/plan.md')
|
||||
|
||||
expect(mocks.openDetectedFilePath).not.toHaveBeenCalled()
|
||||
expect(mocks.showUnresolved).toHaveBeenCalledWith('~/.claude/plans/plan.md')
|
||||
expect(mocks.showNotFound).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,30 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
|
||||
import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing'
|
||||
import { resolveNativeChatFileLink, type NativeChatFileLinkContext } from './native-chat-file-link'
|
||||
import {
|
||||
showFileLinkNotFoundToast,
|
||||
showFileLinkUnresolvedToast,
|
||||
showFileLinkUnverifiableToast
|
||||
} from './native-chat-file-link-toasts'
|
||||
|
||||
export function useNativeChatFileLinkClick(
|
||||
context: NativeChatFileLinkContext | null
|
||||
): CommentMarkdownLinkClickHandler | undefined {
|
||||
const openFileLink = useCallback<CommentMarkdownLinkClickHandler>(
|
||||
(event, href) => {
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
const target = resolveNativeChatFileLink(href, context)
|
||||
if (!target || !context) {
|
||||
if (!target) {
|
||||
const route = routeNativeChatHref(href)
|
||||
if (route.kind === 'file') {
|
||||
// Why: e.g. `~/x` when the home folder cannot be inferred; never a dead click.
|
||||
event.preventDefault()
|
||||
showFileLinkUnresolvedToast(route.pathText)
|
||||
}
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -18,7 +33,12 @@ export function useNativeChatFileLinkClick(
|
||||
worktreeId: context.worktreeId,
|
||||
worktreePath: context.worktreePath,
|
||||
runtimeEnvironmentId: context.runtimeEnvironmentId,
|
||||
openWithSystemDefault: event.shiftKey
|
||||
openWithSystemDefault: event.shiftKey,
|
||||
// Why: an underlined link must answer every click, so a miss says why.
|
||||
onOpenFailure: (failure) =>
|
||||
failure.verdict === 'unverifiable'
|
||||
? showFileLinkUnverifiableToast(target.absolutePath, failure.error)
|
||||
: showFileLinkNotFoundToast(target.absolutePath)
|
||||
})
|
||||
},
|
||||
[context]
|
||||
|
||||
@@ -407,7 +407,7 @@ describe('CommentMarkdown link click handler', () => {
|
||||
expect(container.textContent).toContain('"John 3:16"')
|
||||
})
|
||||
|
||||
it('preserves line suffixes on valid spaced path shapes', () => {
|
||||
it('preserves line suffixes on valid spaced path shapes, but not on bare file names', () => {
|
||||
const content =
|
||||
'Open "My Folder/notes:12", `My Notes.md:7`, and "C:\\My Folder\\notes.txt:12:3".'
|
||||
container = document.createElement('div')
|
||||
@@ -428,12 +428,10 @@ describe('CommentMarkdown link click handler', () => {
|
||||
const anchors = Array.from(container.querySelectorAll<HTMLAnchorElement>('a'))
|
||||
expect(anchors.map((anchor) => anchor.textContent)).toEqual([
|
||||
'My Folder/notes:12',
|
||||
'My Notes.md:7',
|
||||
String.raw`C:\My Folder\notes.txt:12:3`
|
||||
])
|
||||
expect(anchors.map((anchor) => routeNativeChatHref(anchor.getAttribute('href')))).toEqual([
|
||||
{ kind: 'file', pathText: 'My Folder/notes:12', line: null },
|
||||
{ kind: 'file', pathText: 'My Notes.md:7', line: null },
|
||||
{ kind: 'file', pathText: String.raw`C:\My Folder\notes.txt:12:3`, line: null }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
createNativeChatFileHref,
|
||||
routeNativeChatHref
|
||||
} from '../../../../shared/native-chat-href-routing'
|
||||
import { parseFileLinkLocation } from '../../../../shared/file-link-location'
|
||||
import {
|
||||
formatFileLinkLocation,
|
||||
parseFileLinkLocation
|
||||
} from '../../../../shared/file-link-location'
|
||||
import { extractTerminalFileLinks, type ParsedTerminalFileLink } from '@/lib/terminal-links'
|
||||
|
||||
type MarkdownNode = {
|
||||
@@ -14,16 +17,16 @@ type MarkdownNode = {
|
||||
|
||||
const ROOTED_PATH_PREFIX_PATTERN = /^(?:~[\\/]|\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/
|
||||
|
||||
function isLinkifiableFile(link: ParsedTerminalFileLink, requireSeparator: boolean): boolean {
|
||||
// Why: a link is underlined only when it names a path; a bare `name.md` resolves nowhere
|
||||
// reliable, so underlining it promises a click that cannot open anything.
|
||||
function isLinkifiableFile(link: ParsedTerminalFileLink, isProse: boolean): boolean {
|
||||
const hasRootedPrefix = ROOTED_PATH_PREFIX_PATTERN.test(link.pathText)
|
||||
const hasLineSuffix = link.line !== null || link.column !== null
|
||||
const hasAlphabeticExtension = /\.[\p{L}][\p{L}\p{N}\p{M}_+-]*$/u.test(link.pathText)
|
||||
const hasPathExtension = /\.[\p{L}\p{N}][\p{L}\p{N}\p{M}_+-]*$/u.test(link.pathText)
|
||||
return (
|
||||
(!requireSeparator || /[\\/]/.test(link.pathText)) &&
|
||||
(hasRootedPrefix ||
|
||||
hasLineSuffix ||
|
||||
(requireSeparator ? hasPathExtension : hasAlphabeticExtension)) &&
|
||||
/[\\/]/.test(link.pathText) &&
|
||||
(hasRootedPrefix || hasLineSuffix || (isProse ? hasPathExtension : hasAlphabeticExtension)) &&
|
||||
routeNativeChatHref(link.displayText).kind === 'file'
|
||||
)
|
||||
}
|
||||
@@ -72,10 +75,11 @@ function hasPartialPathBoundary(value: string, link: ParsedTerminalFileLink): bo
|
||||
)
|
||||
}
|
||||
|
||||
function createFileLinkNode(value: string, child: MarkdownNode): MarkdownNode {
|
||||
// Why: wrap the parsed location, not the display text; a `file://` URI must not reach the literal href.
|
||||
function createFileLinkNode(link: ParsedTerminalFileLink, child: MarkdownNode): MarkdownNode {
|
||||
return {
|
||||
type: 'link',
|
||||
url: createNativeChatFileHref(value),
|
||||
url: createNativeChatFileHref(formatFileLinkLocation(link)),
|
||||
children: [child]
|
||||
}
|
||||
}
|
||||
@@ -122,7 +126,7 @@ function splitTextSegment(value: string): MarkdownNode[] {
|
||||
if (link.startIndex > cursor) {
|
||||
children.push({ type: 'text', value: value.slice(cursor, link.startIndex) })
|
||||
}
|
||||
children.push(createFileLinkNode(link.displayText, { type: 'text', value: link.displayText }))
|
||||
children.push(createFileLinkNode(link, { type: 'text', value: link.displayText }))
|
||||
cursor = link.endIndex
|
||||
}
|
||||
if (cursor < value.length) {
|
||||
@@ -185,14 +189,15 @@ function splitTextNode(value: string): MarkdownNode[] {
|
||||
let cursor = 0
|
||||
for (const match of value.matchAll(QUOTED_TEXT_PATTERN)) {
|
||||
const content = match[1] ?? match[2]
|
||||
if (!content || !exactFileLink(content, true)) {
|
||||
const link = content ? exactFileLink(content, true) : null
|
||||
if (!content || !link) {
|
||||
continue
|
||||
}
|
||||
const matchIndex = match.index ?? 0
|
||||
const quote = match[0][0]
|
||||
children.push(...splitUnquotedText(value.slice(cursor, matchIndex)))
|
||||
children.push({ type: 'text', value: quote })
|
||||
children.push(createFileLinkNode(content, { type: 'text', value: content }))
|
||||
children.push(createFileLinkNode(link, { type: 'text', value: content }))
|
||||
children.push({ type: 'text', value: quote })
|
||||
cursor = matchIndex + match[0].length
|
||||
}
|
||||
@@ -208,13 +213,16 @@ function inlineCodeFileLink(node: MarkdownNode): MarkdownNode | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return exactFileLink(value, true) ? createFileLinkNode(value, node) : null
|
||||
const link = exactFileLink(value, true)
|
||||
return link ? createFileLinkNode(link, node) : null
|
||||
}
|
||||
|
||||
function transformFileLinks(node: MarkdownNode): void {
|
||||
if (node.type === 'link') {
|
||||
if (node.url && routeNativeChatHref(node.url).kind === 'file') {
|
||||
node.url = createNativeChatFileHref(node.url)
|
||||
const route = routeNativeChatHref(node.url)
|
||||
if (route.kind === 'file') {
|
||||
// Why: the wrapped href carries literal location text, so URL syntax is resolved here, once.
|
||||
node.url = createNativeChatFileHref(formatFileLinkLocation(route))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
buildWorkspaceFileContext,
|
||||
canClientOsOpenWorkspaceFile
|
||||
} from '@/lib/workspace-file-host-routing'
|
||||
import { statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import {
|
||||
isMissingRuntimePathError,
|
||||
statRuntimePath,
|
||||
type RuntimeFileOperationArgs
|
||||
} from '@/runtime/runtime-file-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
@@ -20,12 +24,20 @@ import {
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
|
||||
export type FileOpenFailure = {
|
||||
/** `missing` is a verified absence; `unverifiable` means the host could not answer (dropped SSH, timeout, denied path). */
|
||||
verdict: 'missing' | 'unverifiable'
|
||||
error: unknown
|
||||
}
|
||||
|
||||
type TerminalFileOpenDeps = {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
wslDistro?: string | null
|
||||
openWithSystemDefault?: boolean
|
||||
/** Reports a path that could not be verified before opening; skipped once a later open supersedes it. */
|
||||
onOpenFailure?: (failure: FileOpenFailure) => void
|
||||
}
|
||||
|
||||
export function isHtmlFilePath(filePath: string): boolean {
|
||||
@@ -166,7 +178,14 @@ export function openDetectedFilePath(
|
||||
await window.api.fs.authorizeExternalPath({ targetPath: mappedFilePath })
|
||||
}
|
||||
statResult = await statRuntimePath(fileContext, mappedFilePath)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (requestId === latestOpenDetectedFilePathRequestId && deps.onOpenFailure) {
|
||||
// Why: loss of contact with the host is not evidence the file is gone.
|
||||
deps.onOpenFailure({
|
||||
verdict: isMissingRuntimePathError(error) ? 'missing' : 'unverifiable',
|
||||
error
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openDetectedFilePath } from './terminal-link-handlers'
|
||||
import type { FileOpenFailure } from './terminal-file-open-routing'
|
||||
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
|
||||
import {
|
||||
flushAsyncWork,
|
||||
installTerminalLinkTestEnvironment,
|
||||
setPlatform
|
||||
} from './terminal-link-handlers-test-harness'
|
||||
|
||||
const doubles = createTerminalLinkTestDoubles()
|
||||
const { storeState, deps, openFileMock, statMock, authorizeExternalPathMock } = doubles
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorkspace: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionId: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
installTerminalLinkTestEnvironment(doubles)
|
||||
|
||||
type OnOpenFailure = (failure: FileOpenFailure) => void
|
||||
|
||||
describe('openDetectedFilePath on a path it cannot verify', () => {
|
||||
it('reports a verified miss', async () => {
|
||||
setPlatform('Macintosh')
|
||||
const error = new Error("Error invoking remote method 'fs:stat': Error: ENOENT: no such file")
|
||||
statMock.mockRejectedValueOnce(error)
|
||||
const onOpenFailure = vi.fn<OnOpenFailure>()
|
||||
|
||||
openDetectedFilePath('/tmp/src/gone.md', null, null, { ...deps, onOpenFailure })
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(openFileMock).not.toHaveBeenCalled()
|
||||
expect(onOpenFailure).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenFailure).toHaveBeenCalledWith({ verdict: 'missing', error })
|
||||
})
|
||||
|
||||
it('reports a host that could not answer as unverifiable, not missing', async () => {
|
||||
setPlatform('Macintosh')
|
||||
const error = new Error('SSH connection closed')
|
||||
statMock.mockRejectedValueOnce(error)
|
||||
const onOpenFailure = vi.fn<OnOpenFailure>()
|
||||
|
||||
openDetectedFilePath('/tmp/src/present.md', null, null, { ...deps, onOpenFailure })
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(onOpenFailure).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenFailure.mock.calls[0][0]).toEqual({ verdict: 'unverifiable', error })
|
||||
})
|
||||
|
||||
it('reports a refused path authorization as unverifiable', async () => {
|
||||
setPlatform('Macintosh')
|
||||
const error = new Error('Path is outside the allowed roots')
|
||||
authorizeExternalPathMock.mockRejectedValueOnce(error)
|
||||
const onOpenFailure = vi.fn<OnOpenFailure>()
|
||||
|
||||
openDetectedFilePath('/tmp/src/denied.md', null, null, { ...deps, onOpenFailure })
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(onOpenFailure.mock.calls[0][0]).toEqual({ verdict: 'unverifiable', error })
|
||||
})
|
||||
|
||||
it('skips the callback when a later click superseded the failing one', async () => {
|
||||
setPlatform('Macintosh')
|
||||
let rejectFirstStat!: (error: Error) => void
|
||||
statMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirstStat = reject
|
||||
})
|
||||
)
|
||||
const onOpenFailure = vi.fn()
|
||||
|
||||
openDetectedFilePath('/tmp/src/gone.md', null, null, { ...deps, onOpenFailure })
|
||||
await flushAsyncWork()
|
||||
openDetectedFilePath('/tmp/src/other.ts', null, null, deps)
|
||||
rejectFirstStat(new Error('ENOENT'))
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(onOpenFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -17614,6 +17614,11 @@
|
||||
"blocked": "Goal blocked",
|
||||
"limited": "Goal limited",
|
||||
"attachmentsUnsupported": "Remove attachments before setting a goal."
|
||||
},
|
||||
"fileLinks": {
|
||||
"notFound": "File not found: {{value0}}",
|
||||
"unverifiable": "Couldn't check {{value0}}: {{value1}}",
|
||||
"unresolved": "Couldn't resolve {{value0}} in this workspace"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
searchRuntimeFiles
|
||||
} from './runtime-file-search-client'
|
||||
export {
|
||||
isMissingRuntimePathError,
|
||||
listRuntimeMarkdownDocuments,
|
||||
runtimePathExists,
|
||||
statRuntimePath
|
||||
|
||||
@@ -17,3 +17,16 @@ export function parseFileLinkLocation(value: string): ParsedFileLinkLocation | n
|
||||
}
|
||||
return { pathText, line, column }
|
||||
}
|
||||
|
||||
/** Inverse of `parseFileLinkLocation`: `path`, `path:line`, or `path:line:column`. */
|
||||
export function formatFileLinkLocation(location: {
|
||||
pathText: string
|
||||
line: number | null
|
||||
column?: number | null
|
||||
}): string {
|
||||
if (location.line === null) {
|
||||
return location.pathText
|
||||
}
|
||||
const column = location.column == null ? '' : `:${location.column}`
|
||||
return `${location.pathText}:${location.line}${column}`
|
||||
}
|
||||
|
||||
@@ -76,6 +76,33 @@ describe('routeNativeChatHref', () => {
|
||||
expect(routeNativeChatHref(createNativeChatFileHref(` ${href}`))).toEqual({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('keeps wrapped location text literal', () => {
|
||||
expect(routeNativeChatHref(createNativeChatFileHref('My C# App/Program.cs'))).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'My C# App/Program.cs',
|
||||
line: null
|
||||
})
|
||||
expect(routeNativeChatHref(createNativeChatFileHref('assets/icon%20big.png?v'))).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'assets/icon%20big.png?v',
|
||||
line: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a bare file name with a line suffix as a file, not a scheme', () => {
|
||||
expect(routeNativeChatHref('README.md:5')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'README.md:5',
|
||||
line: null
|
||||
})
|
||||
expect(routeNativeChatHref('App.tsx:12:3')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'App.tsx:12:3',
|
||||
line: null
|
||||
})
|
||||
expect(routeNativeChatHref('localhost:3000')).toEqual({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('drops anchors, unknown schemes, malformed file URIs, and empty hrefs', () => {
|
||||
expect(routeNativeChatHref('#section')).toEqual({ kind: 'none' })
|
||||
expect(routeNativeChatHref(undefined)).toEqual({ kind: 'none' })
|
||||
|
||||
@@ -8,9 +8,12 @@ export type NativeChatHrefRoute =
|
||||
|
||||
const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i
|
||||
const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
||||
// Why: `README.md:5` is a file location; the scheme pattern alone reads `README.md:` as a scheme.
|
||||
const BARE_FILE_LOCATION_PATTERN = /^[^\s:/\\?#]+\.[\p{L}\p{N}_+-]+:\d+(?::\d+)?$/u
|
||||
export const NATIVE_CHAT_FILE_HREF_PREFIX = '#orca-native-chat-file='
|
||||
const MAX_NATIVE_CHAT_FILE_HREF_DECODES = 4
|
||||
|
||||
/** Wraps literal file-location text (`path`, `path:line[:col]`); routing never re-parses it as a URL. */
|
||||
export function createNativeChatFileHref(pathText: string): string {
|
||||
return `${NATIVE_CHAT_FILE_HREF_PREFIX}${encodeURIComponent(pathText)}`
|
||||
}
|
||||
@@ -67,16 +70,22 @@ export function routeNativeChatHref(href: string | null | undefined): NativeChat
|
||||
if (!trimmed) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
let isLiteralFileLocation = false
|
||||
for (let depth = 0; depth < MAX_NATIVE_CHAT_FILE_HREF_DECODES; depth += 1) {
|
||||
const encodedFileHref = decodeNativeChatFileHref(trimmed)
|
||||
if (!encodedFileHref) {
|
||||
break
|
||||
}
|
||||
trimmed = encodedFileHref.trim()
|
||||
isLiteralFileLocation = true
|
||||
}
|
||||
if (!trimmed || trimmed.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX)) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
if (isLiteralFileLocation) {
|
||||
// Why: `#`, `?` and `%XX` are legal filename characters, not URL syntax, in wrapped text.
|
||||
return { kind: 'file', pathText: trimmed, line: null }
|
||||
}
|
||||
if (trimmed.startsWith('#')) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
@@ -96,7 +105,11 @@ export function routeNativeChatHref(href: string | null | undefined): NativeChat
|
||||
}
|
||||
return { kind: 'file', pathText, line: parseLineFragment(url.hash.slice(1)) }
|
||||
}
|
||||
if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) {
|
||||
if (
|
||||
!isWindowsAbsolutePathLike(trimmed) &&
|
||||
!BARE_FILE_LOCATION_PATTERN.test(trimmed) &&
|
||||
SCHEME_PATTERN.test(trimmed)
|
||||
) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
const { pathText, line } = stripQueryAndHash(trimmed)
|
||||
|
||||
Reference in New Issue
Block a user