mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
refactor(chat): share native chat href routing
This commit is contained in:
@@ -1,91 +1,18 @@
|
||||
// Decides what a tapped markdown href should do on mobile: open the system
|
||||
// browser/mail client, open a file in the worktree, or nothing. Mirrors the
|
||||
// desktop native-chat contract where hrefs are explicit agent-authored links:
|
||||
// file: URIs and scheme-less (relative or absolute) hrefs are file targets.
|
||||
import { fileUriToFilesystemPath } from '../../../src/shared/file-uri-path'
|
||||
import { isWindowsAbsolutePathLike } from '../../../src/shared/cross-platform-path'
|
||||
import { routeNativeChatHref } from '../../../src/shared/native-chat-href-routing'
|
||||
|
||||
export type MarkdownHrefRoute =
|
||||
| { kind: 'web'; url: string }
|
||||
| { kind: 'file'; pathText: string }
|
||||
| { kind: 'none' }
|
||||
|
||||
const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i
|
||||
const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
||||
|
||||
function parseLineFragment(hash: string): number | null {
|
||||
if (!hash) {
|
||||
return null
|
||||
}
|
||||
let decoded = hash
|
||||
try {
|
||||
decoded = decodeURIComponent(hash)
|
||||
} catch {
|
||||
// Keep the raw fragment when decoding fails.
|
||||
}
|
||||
const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded)
|
||||
return match ? Number.parseInt(match[1]!, 10) : null
|
||||
}
|
||||
|
||||
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)
|
||||
return { pathText, line: parseLineFragment(hash) }
|
||||
}
|
||||
|
||||
function maybeDecodeHrefPath(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// The open flow parses a trailing :line back out via splitFilePathLineSuffix.
|
||||
function withLineSuffix(pathText: string, line: number | null): string {
|
||||
return line === null ? pathText : `${pathText}:${line}`
|
||||
}
|
||||
|
||||
export function routeMarkdownHref(href: string): MarkdownHrefRoute {
|
||||
const trimmed = href.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
return { kind: 'none' }
|
||||
const route = routeNativeChatHref(href)
|
||||
if (route.kind !== 'file') {
|
||||
return route
|
||||
}
|
||||
if (WEB_SCHEME_PATTERN.test(trimmed)) {
|
||||
return { kind: 'web', url: trimmed }
|
||||
}
|
||||
if (/^file:/i.test(trimmed)) {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
const filePath = fileUriToFilesystemPath(url)
|
||||
if (!filePath) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
return {
|
||||
kind: 'file',
|
||||
pathText: withLineSuffix(filePath, parseLineFragment(url.hash.slice(1)))
|
||||
}
|
||||
}
|
||||
// Other schemes (editor:, javascript:, data:, …) are dropped; a Windows drive
|
||||
// prefix only looks like a scheme.
|
||||
if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
const { pathText, line } = stripQueryAndHash(trimmed)
|
||||
const decoded = maybeDecodeHrefPath(pathText)
|
||||
if (!decoded) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
return { kind: 'file', pathText: withLineSuffix(decoded, line) }
|
||||
return { kind: 'file', pathText: withLineSuffix(route.pathText, route.line) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
parseExplicitFileLinkTarget,
|
||||
@@ -82,50 +81,6 @@ export function resolveNativeChatFileLinkContext(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -148,43 +103,16 @@ function resolvePathText(
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (rawHref.toLowerCase().startsWith('file:')) {
|
||||
return resolveFileUriLink(rawHref, context)
|
||||
}
|
||||
if (hasNonFileUriProtocol(rawHref)) {
|
||||
const route = routeNativeChatHref(href)
|
||||
if (route.kind !== 'file') {
|
||||
return null
|
||||
}
|
||||
|
||||
const { pathText, line } = stripQueryAndHash(rawHref)
|
||||
const decodedPathText = maybeDecodeHrefPath(pathText)
|
||||
return resolvePathText(decodedPathText, line, context)
|
||||
return resolvePathText(route.pathText, route.line, context)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { routeNativeChatHref } from './native-chat-href-routing'
|
||||
|
||||
describe('routeNativeChatHref', () => {
|
||||
it('classifies web and mail links', () => {
|
||||
expect(routeNativeChatHref('https://example.com/docs')).toEqual({
|
||||
kind: 'web',
|
||||
url: 'https://example.com/docs'
|
||||
})
|
||||
expect(routeNativeChatHref(' mailto:dev@example.com ')).toEqual({
|
||||
kind: 'web',
|
||||
url: 'mailto:dev@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses relative file hrefs and line fragments', () => {
|
||||
expect(routeNativeChatHref('docs/plan.md?plain=1#line-7')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md',
|
||||
line: 7
|
||||
})
|
||||
expect(routeNativeChatHref('docs/plan.md#usage')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md',
|
||||
line: null
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes relative and file URI paths', () => {
|
||||
expect(routeNativeChatHref('docs/release%20notes.md')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/release notes.md',
|
||||
line: null
|
||||
})
|
||||
expect(routeNativeChatHref('file:///Users/me/wt/My%20File.tsx#L12')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: '/Users/me/wt/My File.tsx',
|
||||
line: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Windows drive paths out of the scheme filter', () => {
|
||||
expect(routeNativeChatHref(String.raw`C:\repo\src\index.ts`)).toEqual({
|
||||
kind: 'file',
|
||||
pathText: String.raw`C:\repo\src\index.ts`,
|
||||
line: null
|
||||
})
|
||||
})
|
||||
|
||||
it('drops anchors, unknown schemes, malformed file URIs, and empty hrefs', () => {
|
||||
expect(routeNativeChatHref('#section')).toEqual({ kind: 'none' })
|
||||
expect(routeNativeChatHref(undefined)).toEqual({ kind: 'none' })
|
||||
expect(routeNativeChatHref('editor://file/x.ts')).toEqual({ kind: 'none' })
|
||||
expect(routeNativeChatHref('javascript:alert(1)')).toEqual({ kind: 'none' })
|
||||
expect(routeNativeChatHref('file:///tmp/%E0%A4%A.txt')).toEqual({ kind: 'none' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { isWindowsAbsolutePathLike } from './cross-platform-path'
|
||||
import { fileUriToFilesystemPath } from './file-uri-path'
|
||||
|
||||
export type NativeChatHrefRoute =
|
||||
| { kind: 'web'; url: string }
|
||||
| { kind: 'file'; pathText: string; line: number | null }
|
||||
| { kind: 'none' }
|
||||
|
||||
const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i
|
||||
const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
||||
|
||||
function parseLineFragment(hash: string): number | null {
|
||||
if (!hash) {
|
||||
return null
|
||||
}
|
||||
let decoded = hash
|
||||
try {
|
||||
decoded = decodeURIComponent(hash)
|
||||
} catch {
|
||||
// Keep the raw fragment when decoding fails.
|
||||
}
|
||||
const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded)
|
||||
return match ? Number.parseInt(match[1]!, 10) : null
|
||||
}
|
||||
|
||||
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)
|
||||
return { pathText, line: parseLineFragment(hash) }
|
||||
}
|
||||
|
||||
function maybeDecodeHrefPath(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function routeNativeChatHref(href: string | null | undefined): NativeChatHrefRoute {
|
||||
const trimmed = href?.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
if (WEB_SCHEME_PATTERN.test(trimmed)) {
|
||||
return { kind: 'web', url: trimmed }
|
||||
}
|
||||
if (/^file:/i.test(trimmed)) {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
const pathText = fileUriToFilesystemPath(url)
|
||||
if (!pathText) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
return { kind: 'file', pathText, line: parseLineFragment(url.hash.slice(1)) }
|
||||
}
|
||||
if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
const { pathText, line } = stripQueryAndHash(trimmed)
|
||||
const decodedPathText = maybeDecodeHrefPath(pathText)
|
||||
return decodedPathText ? { kind: 'file', pathText: decodedPathText, line } : { kind: 'none' }
|
||||
}
|
||||
Reference in New Issue
Block a user