mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
fix(native-chat): preview GitHub PR links in chats without a GitHub repo (#41359)
* fix(native-chat): preview GitHub PR links in chats without a GitHub repo A chat whose workspace has no GitHub repo (a plain folder) sent every GitHub link to the anonymous web page fetch. Private repos answer that with a 404, so the card said "No preview for this page." Route the lookup through another registered repo on the chat's host whose remote is on the link's GitHub host; main already lets a preview read any repo on such a host with that sign-in. * fix(native-chat): prefer exact clones and repair attachment test import * fix(native-chat): retain direct SSH ownership for GitHub previews * fix(native-chat): invalidate routed previews when repo accounts change * fix(native-chat): resolve current preview routes before cache reuse * fix(native-chat): preserve preview authority across async lookups * fix(native-chat): retain valid preview lookups across catalog updates * Avoid coalescing retired preview requests
This commit is contained in:
+52
-8
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { GitHubIcon } from '@/components/icons/GitHubIcon'
|
||||
import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
|
||||
@@ -12,10 +13,14 @@ import {
|
||||
} from '../../../../../../shared/github/repositories/links'
|
||||
import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { HostAuthorityKey } from '../../../../../../shared/host-authority'
|
||||
import {
|
||||
githubLinkPreviewKey,
|
||||
loadGitHubLinkPreview,
|
||||
readCachedGitHubLinkPreview
|
||||
loadRoutedGitHubLinkPreview,
|
||||
readCachedGitHubLinkPreview,
|
||||
readCachedRoutedGitHubLinkPreview,
|
||||
routedGitHubLinkPreviewKey
|
||||
} from './native-chat-github-link-preview'
|
||||
import {
|
||||
githubLinkBelongsToRepo,
|
||||
@@ -69,24 +74,63 @@ function PeekFooter({ item }: { item: GitHubWorkItem }): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/** Which lookup answers a card: the chat repo's, or one routed through another repo on the chat's host. */
|
||||
export type GitHubPeekSource =
|
||||
| { kind: 'repo'; repo: Repo }
|
||||
| { kind: 'routed'; authorityKey: HostAuthorityKey }
|
||||
|
||||
function peekLookup(source: GitHubPeekSource, link: GitHubIssueOrPRLink) {
|
||||
return source.kind === 'repo'
|
||||
? {
|
||||
key: githubLinkPreviewKey(source.repo, link),
|
||||
read: () => readCachedGitHubLinkPreview(source.repo, link),
|
||||
load: () => loadGitHubLinkPreview(source.repo, link)
|
||||
}
|
||||
: {
|
||||
key: routedGitHubLinkPreviewKey(source.authorityKey, link),
|
||||
read: () => readCachedRoutedGitHubLinkPreview(source.authorityKey, link),
|
||||
load: () => loadRoutedGitHubLinkPreview(source.authorityKey, link)
|
||||
}
|
||||
}
|
||||
|
||||
export function NativeChatGitHubPeekBody({
|
||||
repo,
|
||||
source,
|
||||
link,
|
||||
otherRepoFallback
|
||||
}: {
|
||||
repo: Repo
|
||||
source: GitHubPeekSource
|
||||
link: GitHubIssueOrPRLink
|
||||
/** For a link to another repo, which an older host or a repo the sign-in can't read leaves
|
||||
* unanswered: what such a link showed before, kept until its details arrive, and never a failure.
|
||||
* Null shows just the name until then. Undefined for the chat repo's own links. */
|
||||
otherRepoFallback?: ReactNode
|
||||
}): ReactNode {
|
||||
const t = useTranslate()
|
||||
const { state, retry } = useNativeChatHoverPreview(
|
||||
githubLinkPreviewKey(repo, link),
|
||||
() => readCachedGitHubLinkPreview(repo, link),
|
||||
() => loadGitHubLinkPreview(repo, link)
|
||||
const key = useAppStore((state) =>
|
||||
source.kind === 'repo'
|
||||
? githubLinkPreviewKey(source.repo, link)
|
||||
: routedGitHubLinkPreviewKey(source.authorityKey, link, state.repos)
|
||||
)
|
||||
return (
|
||||
<GitHubPeekContent
|
||||
key={key}
|
||||
lookup={peekLookup(source, link)}
|
||||
link={link}
|
||||
otherRepoFallback={otherRepoFallback}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubPeekContent({
|
||||
lookup,
|
||||
link,
|
||||
otherRepoFallback
|
||||
}: {
|
||||
lookup: ReturnType<typeof peekLookup>
|
||||
link: GitHubIssueOrPRLink
|
||||
otherRepoFallback?: ReactNode
|
||||
}): ReactNode {
|
||||
const t = useTranslate()
|
||||
const { state, retry } = useNativeChatHoverPreview(lookup.key, lookup.read, lookup.load)
|
||||
if (otherRepoFallback && state.status !== 'ready') {
|
||||
return otherRepoFallback
|
||||
}
|
||||
|
||||
+5
-1
@@ -5,7 +5,11 @@ import type { LinkActionRequest } from '@/components/link-actions/link-action-re
|
||||
import { NativeChatLinkPeek } from './NativeChatLinkPeek'
|
||||
import { NativeChatLinkPeekContext } from './native-chat-link-peek-context'
|
||||
|
||||
const previewContext = { githubRepo: null, webPreviews: { runtimeEnvironmentId: null } }
|
||||
const previewContext = {
|
||||
githubRepo: null,
|
||||
githubRoutingAuthorityKey: null,
|
||||
webPreviews: { runtimeEnvironmentId: null }
|
||||
}
|
||||
|
||||
function LinkPreview() {
|
||||
const [request, setRequest] = useState<LinkActionRequest | null>(null)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NativeChatGitHubPeekBody, resolveGitHubPeekLink } from './NativeChatGit
|
||||
import { NativeChatWebPeekBody } from './NativeChatWebLinkPeek'
|
||||
import { NativeChatLinkPeekContext } from './native-chat-link-peek-context'
|
||||
import { previewableWebUrl } from './native-chat-web-link-preview'
|
||||
import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links'
|
||||
|
||||
/** Hover or focus a link to preview it: PRs and issues on its repo's GitHub host, or what a web page says about itself. */
|
||||
export function NativeChatLinkPeek({
|
||||
@@ -13,7 +14,8 @@ export function NativeChatLinkPeek({
|
||||
href: string | undefined
|
||||
children: ReactNode
|
||||
}): ReactNode {
|
||||
const { githubRepo, webPreviews } = useContext(NativeChatLinkPeekContext)
|
||||
const { githubRepo, githubRoutingAuthorityKey, webPreviews } =
|
||||
useContext(NativeChatLinkPeekContext)
|
||||
const body = useMemo(() => {
|
||||
const webUrl = webPreviews ? previewableWebUrl(href) : null
|
||||
const webBody = webUrl ? (
|
||||
@@ -27,15 +29,28 @@ export function NativeChatLinkPeek({
|
||||
if (githubRepo && github) {
|
||||
return (
|
||||
<NativeChatGitHubPeekBody
|
||||
key={href}
|
||||
repo={githubRepo.repo}
|
||||
key={`repo:${githubRepo.repo.id}:${href}`}
|
||||
source={{ kind: 'repo', repo: githubRepo.repo }}
|
||||
link={github.link}
|
||||
otherRepoFallback={github.ownRepo ? undefined : webBody}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// No chat repo on the link's host, as in a folder workspace: another repo on the chat's host
|
||||
// can still answer with its GitHub sign-in. A web page fetch can't read private repos.
|
||||
const routed = href && githubRoutingAuthorityKey ? parseGitHubIssueOrPRLink(href) : null
|
||||
if (routed && githubRoutingAuthorityKey) {
|
||||
return (
|
||||
<NativeChatGitHubPeekBody
|
||||
key={`routed:${githubRoutingAuthorityKey}:${href}`}
|
||||
source={{ kind: 'routed', authorityKey: githubRoutingAuthorityKey }}
|
||||
link={routed}
|
||||
otherRepoFallback={webBody}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return webBody
|
||||
}, [githubRepo, href, webPreviews])
|
||||
}, [githubRepo, githubRoutingAuthorityKey, href, webPreviews])
|
||||
if (!body) {
|
||||
return children
|
||||
}
|
||||
|
||||
+112
-8
@@ -1,23 +1,32 @@
|
||||
import { lookupGitHubWorkItemByOwnerRepoForSource } from '@/lib/work-items/github/github-work-item-source-lookup'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import type { GitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links'
|
||||
import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import { githubRepoIdentityKey } from '../../../../../../shared/github/repositories/repository-identity-key'
|
||||
import { buildTaskSourceContextFromRepo } from '../../../../../../shared/work-items/task-source-context'
|
||||
import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { HostAuthorityKey } from '../../../../../../shared/host-authority'
|
||||
import { createHoverPreviewCache } from './native-chat-hover-preview-cache'
|
||||
import {
|
||||
findGitHubPreviewRouteRepo,
|
||||
githubPreviewRepoAuthorityKey,
|
||||
githubPreviewRepoIdentity,
|
||||
githubPreviewRouteCandidates,
|
||||
readGitHubPreviewRouteRepo
|
||||
} from './native-chat-github-route-repo'
|
||||
|
||||
const previews = createHoverPreviewCache<GitHubWorkItem>({ freshMs: 60_000, maxEntries: 200 })
|
||||
|
||||
/** Repo ids repeat across hosts, and each host answers with its own GitHub account. */
|
||||
export function githubLinkPreviewKey(repo: Repo, link: GitHubIssueOrPRLink): string {
|
||||
return `${getRepoExecutionHostId(repo)}:${repo.id}:${githubRepoIdentityKey(link.slug)}#${link.number}:${link.type}`
|
||||
return `${githubPreviewRepoIdentity(repo)}:${githubRepoIdentityKey(link.slug)}#${link.number}:${link.type}`
|
||||
}
|
||||
|
||||
async function lookup(
|
||||
repo: Repo,
|
||||
link: GitHubIssueOrPRLink,
|
||||
type: 'issue' | 'pr'
|
||||
type: 'issue' | 'pr',
|
||||
accepts?: () => boolean
|
||||
): Promise<GitHubWorkItem | null> {
|
||||
return lookupGitHubWorkItemByOwnerRepoForSource({
|
||||
repoPath: repo.path,
|
||||
@@ -32,14 +41,19 @@ async function lookup(
|
||||
host: link.slug.host,
|
||||
number: link.number,
|
||||
type,
|
||||
preview: true
|
||||
preview: true,
|
||||
accepts
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchPreview(repo: Repo, link: GitHubIssueOrPRLink): Promise<GitHubWorkItem | null> {
|
||||
const item = await lookup(repo, link, link.type)
|
||||
async function fetchPreview(
|
||||
repo: Repo,
|
||||
link: GitHubIssueOrPRLink,
|
||||
accepts?: () => boolean
|
||||
): Promise<GitHubWorkItem | null> {
|
||||
const item = await lookup(repo, link, link.type, accepts)
|
||||
// Hosts without the preview lookup answer null for a PR asked for as an issue (bare #N links).
|
||||
return item || link.type !== 'issue' ? item : lookup(repo, link, 'pr')
|
||||
return item || link.type !== 'issue' ? item : lookup(repo, link, 'pr', accepts)
|
||||
}
|
||||
|
||||
/** Lets a reopened card paint its last answer without a loading frame. */
|
||||
@@ -54,7 +68,14 @@ export function loadGitHubLinkPreview(
|
||||
repo: Repo,
|
||||
link: GitHubIssueOrPRLink
|
||||
): Promise<GitHubWorkItem | null> {
|
||||
return previews.load(githubLinkPreviewKey(repo, link), () => fetchPreview(repo, link))
|
||||
const authority = githubPreviewRepoAuthorityKey(repo)
|
||||
if (!authority) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const identity = githubPreviewRepoIdentity(repo)
|
||||
const read = (repos: readonly Repo[]) =>
|
||||
repos.find((row) => githubPreviewRepoIdentity(row) === identity) ?? null
|
||||
return loadPreviewFromCatalog(authority, link, async (repos) => read(repos), read)
|
||||
}
|
||||
|
||||
/** Asks again even while the last answer is fresh: the pull request may have just changed. */
|
||||
@@ -65,3 +86,86 @@ export function reloadGitHubLinkPreview(
|
||||
previews.expire(githubLinkPreviewKey(repo, link))
|
||||
return loadGitHubLinkPreview(repo, link)
|
||||
}
|
||||
|
||||
/** The same selected route identity names synchronous reads, requests and cache commits. */
|
||||
export function routedGitHubLinkPreviewKey(
|
||||
routeAuthority: HostAuthorityKey,
|
||||
link: GitHubIssueOrPRLink,
|
||||
repos: readonly Repo[] = useAppStore.getState().repos
|
||||
): string {
|
||||
const repo = readGitHubPreviewRouteRepo(repos, routeAuthority, link)
|
||||
return repo
|
||||
? githubLinkPreviewKey(repo, link)
|
||||
: `${routeAuthority}:unrouted:${githubRepoIdentityKey(link.slug)}#${link.number}:${link.type}`
|
||||
}
|
||||
|
||||
export function readCachedRoutedGitHubLinkPreview(
|
||||
routeAuthority: HostAuthorityKey,
|
||||
link: GitHubIssueOrPRLink
|
||||
): GitHubWorkItem | null {
|
||||
return previews.read(routedGitHubLinkPreviewKey(routeAuthority, link))
|
||||
}
|
||||
|
||||
export function loadRoutedGitHubLinkPreview(
|
||||
routeAuthority: HostAuthorityKey,
|
||||
link: GitHubIssueOrPRLink
|
||||
): Promise<GitHubWorkItem | null> {
|
||||
return loadPreviewFromCatalog(
|
||||
routeAuthority,
|
||||
link,
|
||||
(repos) => findGitHubPreviewRouteRepo(repos, routeAuthority, link),
|
||||
(repos) => readGitHubPreviewRouteRepo(repos, routeAuthority, link)
|
||||
)
|
||||
}
|
||||
|
||||
async function loadPreviewFromCatalog(
|
||||
routeAuthority: HostAuthorityKey,
|
||||
link: GitHubIssueOrPRLink,
|
||||
select: (repos: readonly Repo[]) => Promise<Repo | null>,
|
||||
read: (repos: readonly Repo[]) => Repo | null
|
||||
): Promise<GitHubWorkItem | null> {
|
||||
const repos = useAppStore.getState().repos
|
||||
const captured = new Set(
|
||||
githubPreviewRouteCandidates(repos, routeAuthority).map(githubPreviewRepoIdentity)
|
||||
)
|
||||
const retired = new Set<string>()
|
||||
const stop = useAppStore.subscribe((state) => {
|
||||
const live = new Set(
|
||||
githubPreviewRouteCandidates(state.repos, routeAuthority).map(githubPreviewRepoIdentity)
|
||||
)
|
||||
// Restoring a retired binding cannot revive its old request.
|
||||
for (const identity of captured) {
|
||||
if (!live.has(identity)) {
|
||||
retired.add(identity)
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
const repo = await select(repos)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
const identity = githubPreviewRepoIdentity(repo)
|
||||
const accepts = () => {
|
||||
const currentRepos = useAppStore.getState().repos
|
||||
const currentRepo = read(currentRepos)
|
||||
return (
|
||||
captured.has(identity) &&
|
||||
!retired.has(identity) &&
|
||||
currentRepo !== null &&
|
||||
githubPreviewRepoIdentity(currentRepo) === identity
|
||||
)
|
||||
}
|
||||
if (!accepts()) {
|
||||
return null
|
||||
}
|
||||
const item = await previews.load(
|
||||
githubLinkPreviewKey(repo, link),
|
||||
() => fetchPreview(repo, link, accepts),
|
||||
accepts
|
||||
)
|
||||
return accepts() ? item : null
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links'
|
||||
import { clearRepoSlugCacheValues } from '@/lib/workspace/catalog/repo-slug-cache'
|
||||
|
||||
const mocks = await vi.hoisted(async () => {
|
||||
const { createStore } = await import('zustand/vanilla')
|
||||
return {
|
||||
store: createStore<{ repos: Repo[] }>(() => ({ repos: [] })),
|
||||
slugGate: Promise.resolve(),
|
||||
slugStarted: Promise.withResolvers<void>(),
|
||||
endpoint: vi.fn(),
|
||||
rpc: vi.fn()
|
||||
}
|
||||
})
|
||||
vi.mock('@/store/index', () => ({ useAppStore: mocks.store }))
|
||||
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: mocks.rpc }))
|
||||
vi.mock('@/lib/workspace/catalog/repo-slug-index', async () => {
|
||||
const { rememberRepoSlug, repoSlugCacheKey } =
|
||||
await import('@/lib/workspace/catalog/repo-slug-cache')
|
||||
return {
|
||||
resolveRepoSlug: async (repo: Repo) => {
|
||||
mocks.slugStarted.resolve()
|
||||
await mocks.slugGate
|
||||
const key = repoSlugCacheKey(repo)
|
||||
const slug = repo.id === 'nested-u' ? 'acme/private' : 'acme/router'
|
||||
if (key) {
|
||||
rememberRepoSlug(key, slug)
|
||||
}
|
||||
return slug
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { loadRoutedGitHubLinkPreview, readCachedRoutedGitHubLinkPreview } =
|
||||
await import('./native-chat-github-link-preview')
|
||||
|
||||
const alice: Repo = {
|
||||
id: 'router',
|
||||
path: '/router',
|
||||
displayName: 'Router',
|
||||
badgeColor: '',
|
||||
addedAt: 0,
|
||||
ghAccount: { host: 'github.com', user: 'alice' }
|
||||
}
|
||||
const bob: Repo = { ...alice, ghAccount: { host: 'github.com', user: 'bob' } }
|
||||
|
||||
beforeEach(() => {
|
||||
clearRepoSlugCacheValues()
|
||||
mocks.store.setState({ repos: [alice] })
|
||||
mocks.slugGate = Promise.resolve()
|
||||
mocks.slugStarted = Promise.withResolvers<void>()
|
||||
mocks.endpoint.mockReset()
|
||||
mocks.rpc.mockReset()
|
||||
mocks.endpoint.mockImplementation(async ({ number }: { number: number }) => ({
|
||||
type: 'pr',
|
||||
number,
|
||||
title: `${mocks.store.getState().repos[0].ghAccount?.user} response`,
|
||||
url: `https://github.com/acme/private/pull/${number}`
|
||||
}))
|
||||
vi.stubGlobal('window', { api: { gh: { workItemByOwnerRepo: mocks.endpoint } } })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('discards a lookup whose binding changes while its slug is resolving', async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
mocks.slugGate = gate.promise
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/801')!
|
||||
const pending = loadRoutedGitHubLinkPreview('local', link)
|
||||
await mocks.slugStarted.promise
|
||||
mocks.store.setState({ repos: [bob] })
|
||||
gate.resolve()
|
||||
await expect(pending).resolves.toBeNull()
|
||||
|
||||
mocks.store.setState({ repos: [alice] })
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull()
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toMatchObject({
|
||||
title: 'alice response'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not commit a response if the binding changes during the GitHub request', async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const sent = Promise.withResolvers<void>()
|
||||
mocks.endpoint.mockImplementationOnce(async ({ number }: { number: number }) => {
|
||||
sent.resolve()
|
||||
await gate.promise
|
||||
return {
|
||||
type: 'pr',
|
||||
number,
|
||||
title: 'bob response',
|
||||
url: `https://github.com/acme/private/pull/${number}`
|
||||
}
|
||||
})
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/802')!
|
||||
const pending = loadRoutedGitHubLinkPreview('local', link)
|
||||
await sent.promise
|
||||
mocks.store.setState({ repos: [bob] })
|
||||
gate.resolve()
|
||||
await expect(pending).resolves.toBeNull()
|
||||
|
||||
mocks.store.setState({ repos: [alice] })
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull()
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toMatchObject({
|
||||
title: 'alice response'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an in-flight response even if Alice is restored before it settles', async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const sent = Promise.withResolvers<void>()
|
||||
mocks.endpoint.mockImplementationOnce(async ({ number }: { number: number }) => {
|
||||
sent.resolve()
|
||||
await gate.promise
|
||||
return {
|
||||
type: 'pr',
|
||||
number,
|
||||
title: 'bob response',
|
||||
url: `https://github.com/acme/private/pull/${number}`
|
||||
}
|
||||
})
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/803')!
|
||||
const pending = loadRoutedGitHubLinkPreview('local', link)
|
||||
await sent.promise
|
||||
mocks.store.setState({ repos: [bob] })
|
||||
mocks.store.setState({ repos: [alice] })
|
||||
gate.resolve()
|
||||
await expect(pending).resolves.toBeNull()
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull()
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toMatchObject({
|
||||
title: 'alice response'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a valid request alive when an unrelated candidate is removed', async () => {
|
||||
mocks.store.setState({ repos: [alice, { ...bob, id: 'unrelated' }] })
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const sent = Promise.withResolvers<void>()
|
||||
mocks.endpoint.mockImplementationOnce(async ({ number }: { number: number }) => {
|
||||
sent.resolve()
|
||||
await gate.promise
|
||||
return {
|
||||
type: 'pr',
|
||||
number,
|
||||
title: 'alice response',
|
||||
url: `https://github.com/acme/private/pull/${number}`
|
||||
}
|
||||
})
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/805')!
|
||||
const pending = loadRoutedGitHubLinkPreview('local', link)
|
||||
await sent.promise
|
||||
mocks.store.setState({ repos: [alice] })
|
||||
gate.resolve()
|
||||
await expect(pending).resolves.toMatchObject({ title: 'alice response' })
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toMatchObject({
|
||||
title: 'alice response'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps endpoint-local, nested T and nested U lookups on their own repo/account', async () => {
|
||||
const endpoint: Repo = { ...alice, id: 'endpoint', executionHostId: 'runtime:env' }
|
||||
const targetT: Repo = {
|
||||
...alice,
|
||||
id: 'nested-t',
|
||||
executionHostId: 'runtime:env',
|
||||
connectionId: 'T',
|
||||
ghAccount: { host: 'github.com', user: 't' }
|
||||
}
|
||||
const targetU: Repo = {
|
||||
...alice,
|
||||
id: 'nested-u',
|
||||
executionHostId: 'runtime:env',
|
||||
connectionId: 'U',
|
||||
ghAccount: { host: 'github.com', user: 'u' }
|
||||
}
|
||||
mocks.store.setState({ repos: [endpoint, targetT, targetU] })
|
||||
mocks.rpc.mockImplementation(async (_target, _method, params, options) => {
|
||||
options?.beforeSend?.()
|
||||
const repo = mocks.store.getState().repos.find((row) => row.id === params.repo)
|
||||
return {
|
||||
type: 'pr',
|
||||
number: params.number,
|
||||
title: `${repo?.ghAccount?.user} response`,
|
||||
url: `https://github.com/acme/private/pull/${params.number}`
|
||||
}
|
||||
})
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/804')!
|
||||
await expect(loadRoutedGitHubLinkPreview('runtime:env', link)).resolves.toMatchObject({
|
||||
title: 'alice response'
|
||||
})
|
||||
await expect(loadRoutedGitHubLinkPreview('nested:env/T', link)).resolves.toMatchObject({
|
||||
title: 't response'
|
||||
})
|
||||
await expect(loadRoutedGitHubLinkPreview('nested:env/U', link)).resolves.toMatchObject({
|
||||
title: 'u response'
|
||||
})
|
||||
})
|
||||
@@ -11,11 +11,11 @@ import { getKnownExecutionHostIdForWorktree } from '@/lib/workspace/ownership/wo
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { WorktreeRuntimeOwnerState } from '@/lib/workspace/ownership/worktree-runtime-owner-state'
|
||||
import { FILE_LINK_CONTEXT_INPUT_KEYS } from '../actions/native-chat-file-link-context'
|
||||
import { resolveOwner } from '@/lib/workspace/ownership/resolve-owner'
|
||||
import { authorityKey } from '../../../../../../shared/host-authority'
|
||||
import { githubPreviewRepoAuthorityKey } from './native-chat-github-route-repo'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
isUnresolvedOwnerHostId
|
||||
} from '../../../../../../shared/execution-host'
|
||||
import { isUnresolvedOwnerHostId } from '../../../../../../shared/execution-host'
|
||||
import {
|
||||
githubHostFromIdentityKey,
|
||||
githubRepoIdentityKey,
|
||||
@@ -63,8 +63,14 @@ function resolveNativeChatRepo(
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
const owner = resolveOwner(state, { workspaceId: worktreeId })
|
||||
if (owner.kind !== 'resolved') {
|
||||
return null
|
||||
}
|
||||
const owners = state.repos.filter(
|
||||
(candidate) => candidate.id === worktree.repoId && getRepoExecutionHostId(candidate) === hostId
|
||||
(candidate) =>
|
||||
candidate.id === worktree.repoId &&
|
||||
githubPreviewRepoAuthorityKey(candidate) === authorityKey(owner.owner)
|
||||
)
|
||||
const found = owners.length === 1 ? owners[0] : null
|
||||
return found && (!found.kind || found.kind === 'git') ? found : null
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links'
|
||||
import { clearRepoSlugCacheValues } from '@/lib/workspace/catalog/repo-slug-cache'
|
||||
|
||||
const mocks = await vi.hoisted(async () => {
|
||||
const { createStore } = await import('zustand/vanilla')
|
||||
const store = createStore<{ repos: Repo[] }>(() => ({ repos: [] }))
|
||||
return {
|
||||
store,
|
||||
get repos() {
|
||||
return store.getState().repos
|
||||
},
|
||||
set repos(repos: Repo[]) {
|
||||
store.setState({ repos })
|
||||
},
|
||||
slugs: new Map<string, string | null>(),
|
||||
lookup: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store/index', () => ({
|
||||
useAppStore: mocks.store
|
||||
}))
|
||||
vi.mock('@/lib/workspace/catalog/repo-slug-index', async () => {
|
||||
const { rememberRepoSlug, repoSlugCacheKey } =
|
||||
await import('@/lib/workspace/catalog/repo-slug-cache')
|
||||
return {
|
||||
resolveRepoSlug: async (repo: Repo) => {
|
||||
const slug = mocks.slugs.get(repo.id) ?? null
|
||||
const key = repoSlugCacheKey(repo)
|
||||
if (key) {
|
||||
rememberRepoSlug(key, slug)
|
||||
}
|
||||
return slug
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/work-items/github/github-work-item-source-lookup', () => ({
|
||||
lookupGitHubWorkItemByOwnerRepoForSource: mocks.lookup
|
||||
}))
|
||||
|
||||
const { githubPreviewRouteCandidates, pickGitHubPreviewRouteRepo } =
|
||||
await import('./native-chat-github-route-repo')
|
||||
const { loadRoutedGitHubLinkPreview, readCachedRoutedGitHubLinkPreview } =
|
||||
await import('./native-chat-github-link-preview')
|
||||
|
||||
function repo(id: string, extra: Partial<Repo> = {}): Repo {
|
||||
return { id, path: `/src/${id}`, displayName: id, badgeColor: '', addedAt: 0, ...extra }
|
||||
}
|
||||
|
||||
const privatePr = parseGitHubIssueOrPRLink('https://github.com/stablyai/orca-private/pull/685')!
|
||||
|
||||
describe('GitHub preview routing for a chat without a repo of its own', () => {
|
||||
beforeEach(() => {
|
||||
mocks.repos = []
|
||||
mocks.slugs.clear()
|
||||
clearRepoSlugCacheValues()
|
||||
mocks.lookup.mockReset()
|
||||
})
|
||||
|
||||
it('keeps git repos on the chat host only', () => {
|
||||
const local = repo('local')
|
||||
const remote = repo('ssh', { connectionId: 'box' })
|
||||
const runtime = repo('rt', { executionHostId: 'runtime:env' })
|
||||
const folder = repo('folder', { kind: 'folder' })
|
||||
const all = [local, remote, runtime, folder]
|
||||
expect(githubPreviewRouteCandidates(all, 'local')).toEqual([local])
|
||||
expect(githubPreviewRouteCandidates(all, 'runtime:env')).toEqual([runtime])
|
||||
})
|
||||
|
||||
it('separates endpoint-local repos from each nested SSH target on the same runtime', () => {
|
||||
const local = repo('local')
|
||||
const endpoint = repo('endpoint', { executionHostId: 'runtime:env' })
|
||||
const targetT = repo('target-t', { executionHostId: 'runtime:env', connectionId: 'T' })
|
||||
const targetU = repo('target-u', { executionHostId: 'runtime:env', connectionId: 'U' })
|
||||
const directT = repo('direct-t', { connectionId: 'T' })
|
||||
const candidates = [local, endpoint, targetT, targetU, directT]
|
||||
expect(githubPreviewRouteCandidates(candidates, 'local')).toEqual([local])
|
||||
expect(githubPreviewRouteCandidates(candidates, 'runtime:env')).toEqual([endpoint])
|
||||
expect(githubPreviewRouteCandidates(candidates, 'nested:env/T')).toEqual([targetT])
|
||||
expect(githubPreviewRouteCandidates(candidates, 'nested:env/U')).toEqual([targetU])
|
||||
expect(githubPreviewRouteCandidates(candidates, 'ssh:T')).toEqual([directT])
|
||||
})
|
||||
|
||||
it('prefers an exact clone before a fork upstream, then any repo on the GitHub host', () => {
|
||||
const orca = repo('orca')
|
||||
const fork = repo('fork', { upstream: { owner: 'stablyai', repo: 'orca-private' } })
|
||||
const clone = repo('clone')
|
||||
const ghes = repo('ghes')
|
||||
const rows = [
|
||||
{ repo: ghes, originKey: 'git.corp.example/team/app' },
|
||||
{ repo: orca, originKey: 'stablyai/orca' },
|
||||
{ repo: fork, originKey: 'me/orca-private' },
|
||||
{ repo: clone, originKey: 'stablyai/orca-private' }
|
||||
]
|
||||
expect(pickGitHubPreviewRouteRepo(rows, privatePr)).toBe(clone)
|
||||
expect(pickGitHubPreviewRouteRepo(rows.slice(0, 3), privatePr)).toBe(fork)
|
||||
expect(pickGitHubPreviewRouteRepo(rows.slice(0, 2), privatePr)).toBe(orca)
|
||||
expect(pickGitHubPreviewRouteRepo(rows.slice(0, 1), privatePr)).toBeNull()
|
||||
expect(pickGitHubPreviewRouteRepo([{ repo: orca, originKey: null }], privatePr)).toBeNull()
|
||||
})
|
||||
|
||||
it('looks a private PR up through a local repo on github.com, as a preview', async () => {
|
||||
const orca = repo('orca')
|
||||
mocks.repos = [repo('folder', { kind: 'folder' }), orca]
|
||||
mocks.slugs.set('orca', 'stablyai/orca')
|
||||
const item = { type: 'pr', number: 685, title: 'perf: cacheable projections', state: 'draft' }
|
||||
mocks.lookup.mockResolvedValue(item)
|
||||
|
||||
await expect(loadRoutedGitHubLinkPreview('local', privatePr)).resolves.toBe(item)
|
||||
expect(mocks.lookup.mock.calls[0][0]).toMatchObject({
|
||||
repoPath: '/src/orca',
|
||||
repoId: 'orca',
|
||||
owner: 'stablyai',
|
||||
repo: 'orca-private',
|
||||
number: 685,
|
||||
type: 'pr',
|
||||
preview: true
|
||||
})
|
||||
})
|
||||
|
||||
it('answers nothing, leaving the web card, when no repo on the chat host uses GitHub', async () => {
|
||||
mocks.repos = [repo('ghes')]
|
||||
mocks.slugs.set('ghes', 'git.corp.example/team/app')
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/stablyai/orca-private/pull/686')!
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toBeNull()
|
||||
expect(mocks.lookup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the remaining repo after the repo that answered is removed', async () => {
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/701')!
|
||||
const alice = repo('alice', { ghAccount: { host: 'github.com', user: 'alice' } })
|
||||
const bob = repo('bob', { ghAccount: { host: 'github.com', user: 'bob' } })
|
||||
mocks.repos = [alice, bob]
|
||||
mocks.slugs.set('alice', 'acme/alice')
|
||||
mocks.slugs.set('bob', 'acme/bob')
|
||||
mocks.lookup.mockImplementation(async ({ repoId }) => ({ title: `${repoId}'s preview` }))
|
||||
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({
|
||||
title: "alice's preview"
|
||||
})
|
||||
mocks.repos = [bob]
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull()
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({
|
||||
title: "bob's preview"
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves no cached preview when the last eligible repo is removed', async () => {
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/702')!
|
||||
mocks.repos = [repo('alice')]
|
||||
mocks.slugs.set('alice', 'acme/alice')
|
||||
mocks.lookup.mockResolvedValue({ title: "alice's preview" })
|
||||
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({
|
||||
title: "alice's preview"
|
||||
})
|
||||
mocks.repos = []
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull()
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reloads a preview when the answering repo changes its account binding', async () => {
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/703')!
|
||||
const alice = repo('bound', { ghAccount: { host: 'github.com', user: 'alice' } })
|
||||
mocks.repos = [alice]
|
||||
mocks.slugs.set('bound', 'acme/bound')
|
||||
mocks.lookup.mockResolvedValueOnce({ title: "alice's preview" }).mockResolvedValueOnce(null)
|
||||
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({
|
||||
title: "alice's preview"
|
||||
})
|
||||
mocks.repos = [{ ...alice, ghAccount: { host: 'github.com', user: 'bob' } }]
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull()
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('uses a newly resolved exact clone instead of the previously cached fallback', async () => {
|
||||
const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/704')!
|
||||
mocks.repos = [repo('alice'), repo('bob')]
|
||||
mocks.slugs.set('bob', 'acme/bob')
|
||||
mocks.lookup.mockImplementation(async ({ repoId }) => ({ title: `${repoId}'s preview` }))
|
||||
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({
|
||||
title: "bob's preview"
|
||||
})
|
||||
mocks.slugs.set('alice', 'acme/private')
|
||||
await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({
|
||||
title: "alice's preview"
|
||||
})
|
||||
expect(readCachedRoutedGitHubLinkPreview('local', link)).toEqual({
|
||||
title: "alice's preview"
|
||||
})
|
||||
})
|
||||
})
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { resolveRepoSlug } from '@/lib/workspace/catalog/repo-slug-index'
|
||||
import { readRepoSlugCache, repoSlugCacheKey } from '@/lib/workspace/catalog/repo-slug-cache'
|
||||
import type { GitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
getRepoSshConnectionId,
|
||||
toSshExecutionHostId
|
||||
} from '../../../../../../shared/execution-host'
|
||||
import {
|
||||
authorityKey,
|
||||
parseHostAuthorityKey,
|
||||
type HostAuthorityKey
|
||||
} from '../../../../../../shared/host-authority'
|
||||
import {
|
||||
githubHostFromIdentityKey,
|
||||
githubRepoIdentityKey,
|
||||
repoUpstreamIdentityKey
|
||||
} from '../../../../../../shared/github/repositories/repository-identity-key'
|
||||
|
||||
export type RepoWithOriginKey = { repo: Repo; originKey: string | null }
|
||||
|
||||
export function githubPreviewRepoAuthorityKey(repo: Repo): HostAuthorityKey | null {
|
||||
const authority = parseHostAuthorityKey(getRepoExecutionHostId(repo))
|
||||
if (!authority) {
|
||||
return null
|
||||
}
|
||||
const connectionId = getRepoSshConnectionId(repo)
|
||||
return authorityKey(
|
||||
authority.endpoint.kind === 'environment' && connectionId
|
||||
? { ...authority, at: toSshExecutionHostId(connectionId) }
|
||||
: authority
|
||||
)
|
||||
}
|
||||
|
||||
/** One registered route, including its endpoint, nested target and account binding. */
|
||||
export function githubPreviewRepoIdentity(repo: Repo): string {
|
||||
return JSON.stringify([
|
||||
githubPreviewRepoAuthorityKey(repo),
|
||||
repo.id,
|
||||
repo.path,
|
||||
repo.kind ?? 'git',
|
||||
repo.connectionId ?? null,
|
||||
repo.ghAccount?.host ?? null,
|
||||
repo.ghAccount?.user ?? null,
|
||||
repo.upstream ?? null,
|
||||
repo.gitRemoteIdentity ?? null
|
||||
])
|
||||
}
|
||||
|
||||
/** Git repos on the chat's host; their remotes decide which GitHub sign-in a lookup may use. */
|
||||
export function githubPreviewRouteCandidates(
|
||||
repos: readonly Repo[],
|
||||
routeAuthority: HostAuthorityKey
|
||||
): Repo[] {
|
||||
return repos.filter(
|
||||
(repo) =>
|
||||
(!repo.kind || repo.kind === 'git') && githubPreviewRepoAuthorityKey(repo) === routeAuthority
|
||||
)
|
||||
}
|
||||
|
||||
export function readGitHubPreviewRouteRepo(
|
||||
repos: readonly Repo[],
|
||||
routeAuthority: HostAuthorityKey,
|
||||
link: GitHubIssueOrPRLink
|
||||
): Repo | null {
|
||||
return pickGitHubPreviewRouteRepo(
|
||||
githubPreviewRouteCandidates(repos, routeAuthority).map((repo) => {
|
||||
const key = repoSlugCacheKey(repo)
|
||||
const slug = key ? readRepoSlugCache(key) : null
|
||||
return { repo, originKey: slug?.hit ? slug.value : null }
|
||||
}),
|
||||
link
|
||||
)
|
||||
}
|
||||
|
||||
/** The repo to ask about a link when the chat has no repo on its GitHub host: the linked repo's own
|
||||
* clone first, else any repo with a remote on that host, whose sign-in main lets a preview use. */
|
||||
export function pickGitHubPreviewRouteRepo(
|
||||
candidates: readonly RepoWithOriginKey[],
|
||||
link: GitHubIssueOrPRLink
|
||||
): Repo | null {
|
||||
const linkKey = githubRepoIdentityKey(link.slug)
|
||||
const linkHost = githubHostFromIdentityKey(linkKey)
|
||||
const resolved = candidates.filter((row): row is { repo: Repo; originKey: string } =>
|
||||
Boolean(row.originKey)
|
||||
)
|
||||
const clone =
|
||||
resolved.find(({ originKey }) => originKey === linkKey) ??
|
||||
resolved.find(({ repo, originKey }) => repoUpstreamIdentityKey(repo, originKey) === linkKey)
|
||||
return (
|
||||
clone?.repo ??
|
||||
resolved.find(({ originKey }) => githubHostFromIdentityKey(originKey) === linkHost)?.repo ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export async function findGitHubPreviewRouteRepo(
|
||||
repos: readonly Repo[],
|
||||
routeAuthority: HostAuthorityKey,
|
||||
link: GitHubIssueOrPRLink
|
||||
): Promise<Repo | null> {
|
||||
// Slugs are cached per repo, and the sidebar's slug index has usually resolved them already.
|
||||
const candidates = await Promise.all(
|
||||
githubPreviewRouteCandidates(repos, routeAuthority).map(async (repo) => ({
|
||||
repo,
|
||||
originKey: await resolveRepoSlug(repo)
|
||||
}))
|
||||
)
|
||||
return pickGitHubPreviewRouteRepo(candidates, link)
|
||||
}
|
||||
+54
@@ -2,6 +2,60 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createHoverPreviewCache } from './native-chat-hover-preview-cache'
|
||||
|
||||
describe('createHoverPreviewCache', () => {
|
||||
it('starts a fresh request after the pending binding retires and preserves its replacement', async () => {
|
||||
const cache = createHoverPreviewCache<string>({ freshMs: 60_000, maxEntries: 100 })
|
||||
let live = true
|
||||
let finish: (value: string) => void = () => {}
|
||||
const old = cache.load(
|
||||
'a',
|
||||
() => new Promise((resolve) => (finish = resolve)),
|
||||
() => live
|
||||
)
|
||||
live = false
|
||||
let finishNew: (value: string) => void = () => {}
|
||||
const current = cache.load('a', () => new Promise((resolve) => (finishNew = resolve)))
|
||||
finish('old')
|
||||
expect(await old).toBeNull()
|
||||
const joined = cache.load('a', async () => 'unexpected')
|
||||
finishNew('new')
|
||||
expect(await current).toBe('new')
|
||||
expect(await joined).toBe('new')
|
||||
expect(cache.read('a')).toBe('new')
|
||||
})
|
||||
|
||||
it('coalesces pending requests while their binding remains live', async () => {
|
||||
const cache = createHoverPreviewCache<string>({ freshMs: 60_000, maxEntries: 100 })
|
||||
let finish: (value: string) => void = () => {}
|
||||
const first = cache.load(
|
||||
'a',
|
||||
() => new Promise((resolve) => (finish = resolve)),
|
||||
() => true
|
||||
)
|
||||
const second = cache.load(
|
||||
'a',
|
||||
async () => 'unexpected',
|
||||
() => true
|
||||
)
|
||||
finish('result')
|
||||
expect(await first).toBe('result')
|
||||
expect(await second).toBe('result')
|
||||
})
|
||||
|
||||
it('does not publish a response whose owner rejects it at cache commit', async () => {
|
||||
const cache = createHoverPreviewCache<string>({ freshMs: 60_000, maxEntries: 100 })
|
||||
const response = Promise.withResolvers<string>()
|
||||
let current = true
|
||||
const pending = cache.load(
|
||||
'alice',
|
||||
() => response.promise,
|
||||
() => current
|
||||
)
|
||||
response.resolve('bob response')
|
||||
current = false
|
||||
await expect(pending).resolves.toBeNull()
|
||||
expect(cache.read('alice')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the last answer through a reread that fails, and rereads past one started earlier', async () => {
|
||||
const cache = createHoverPreviewCache<string>({ freshMs: 60_000, maxEntries: 100 })
|
||||
await cache.load('pr', async () => 'closed')
|
||||
|
||||
+9
-2
@@ -5,6 +5,7 @@ type Entry<T> = {
|
||||
bytes: number
|
||||
/** Started before an `expire`, so its answer may predate the change. */
|
||||
expired?: boolean
|
||||
accepts?: () => boolean
|
||||
}
|
||||
|
||||
/** One request per key at a time; answers stay fresh for `freshMs`, and empty answers retry on the next open.
|
||||
@@ -19,7 +20,7 @@ export function createHoverPreviewCache<T>({
|
||||
budget?: { maxBytes: number; sizeOf: (value: T) => number }
|
||||
}): {
|
||||
read: (key: string) => T | null
|
||||
load: (key: string, fetch: () => Promise<T | null>) => Promise<T | null>
|
||||
load: (key: string, fetch: () => Promise<T | null>, accepts?: () => boolean) => Promise<T | null>
|
||||
/** The next load asks again, even past one already under way; `read` keeps the old answer. */
|
||||
expire: (key: string) => void
|
||||
} {
|
||||
@@ -48,11 +49,12 @@ export function createHoverPreviewCache<T>({
|
||||
entry.expired = true
|
||||
}
|
||||
},
|
||||
load: (key, fetch) => {
|
||||
load: (key, fetch, accepts) => {
|
||||
const cached = entries.get(key)
|
||||
if (
|
||||
cached &&
|
||||
!cached.expired &&
|
||||
(cached.settledAt !== null || cached.accepts?.() !== false) &&
|
||||
(cached.settledAt === null || Date.now() - cached.settledAt < freshMs)
|
||||
) {
|
||||
return cached.promise
|
||||
@@ -62,6 +64,7 @@ export function createHoverPreviewCache<T>({
|
||||
const entry: Entry<T> = {
|
||||
promise: Promise.resolve(null),
|
||||
settledAt: null,
|
||||
accepts,
|
||||
value: cached?.value,
|
||||
bytes: cached?.value === undefined ? 0 : cached.bytes
|
||||
}
|
||||
@@ -78,6 +81,10 @@ export function createHoverPreviewCache<T>({
|
||||
}
|
||||
entry.promise = fetch().then(
|
||||
(value) => {
|
||||
if (accepts?.() === false) {
|
||||
forget()
|
||||
return null
|
||||
}
|
||||
if (!value) {
|
||||
forget()
|
||||
} else if (entries.get(key) === entry) {
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { useContext } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types'
|
||||
import {
|
||||
resolveNativeChatFileLinkContextForOwner,
|
||||
type NativeChatFileLinkState
|
||||
} from '../actions/native-chat-file-link-context'
|
||||
import {
|
||||
NativeChatLinkPeekContext,
|
||||
NativeChatLinkPeekProvider
|
||||
} from './native-chat-link-peek-context'
|
||||
|
||||
const mocks = vi.hoisted((): { state: NativeChatFileLinkState | null } => ({ state: null }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (select: (state: NativeChatFileLinkState) => unknown) => select(mocks.state!)
|
||||
}))
|
||||
|
||||
function RoutingHost() {
|
||||
return useContext(NativeChatLinkPeekContext).githubRoutingAuthorityKey
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ owner: {}, runtimeEnvironmentId: null, expectedHost: 'local' },
|
||||
{ owner: { connectionId: 'box' }, runtimeEnvironmentId: null, expectedHost: 'ssh:box' },
|
||||
{
|
||||
owner: { executionHostId: 'runtime:env' as const },
|
||||
runtimeEnvironmentId: 'env',
|
||||
expectedHost: 'runtime:env'
|
||||
},
|
||||
{
|
||||
owner: { executionHostId: 'runtime:env' as const, connectionId: 'T' },
|
||||
runtimeEnvironmentId: 'env',
|
||||
expectedHost: 'nested:env/T'
|
||||
},
|
||||
{
|
||||
owner: { executionHostId: 'runtime:env' as const, connectionId: 'U' },
|
||||
runtimeEnvironmentId: 'env',
|
||||
expectedHost: 'nested:env/U'
|
||||
}
|
||||
])(
|
||||
'routes a folder chat preview through $expectedHost',
|
||||
({ owner, runtimeEnvironmentId, expectedHost }) => {
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'chat',
|
||||
projectGroupId: 'group',
|
||||
name: 'Chat',
|
||||
folderPath: '/workspace',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...owner
|
||||
}
|
||||
mocks.state = {
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
folderWorkspaces: [folder],
|
||||
settings: null,
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {},
|
||||
tabsByWorktree: {},
|
||||
floatingWorkspacePath: null
|
||||
}
|
||||
const context = resolveNativeChatFileLinkContextForOwner(mocks.state, 'tab', 'folder:chat')
|
||||
expect(context?.runtimeEnvironmentId).toBe(runtimeEnvironmentId)
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
<NativeChatLinkPeekProvider fileLinkContext={context}>
|
||||
<RoutingHost />
|
||||
</NativeChatLinkPeekProvider>
|
||||
)
|
||||
).toBe(expectedHost)
|
||||
}
|
||||
)
|
||||
+14
-1
@@ -1,16 +1,22 @@
|
||||
import { createContext, useMemo, type ReactNode } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolveOwnerOrLocal } from '@/lib/workspace/ownership/worktree-runtime-owner'
|
||||
import type { NativeChatFileLinkContext } from '../actions/native-chat-file-link-context'
|
||||
import { useNativeChatGitHubRepo, type NativeChatGitHubRepo } from './native-chat-github-repo'
|
||||
import { authorityKey, type HostAuthorityKey } from '../../../../../../shared/host-authority'
|
||||
|
||||
export type NativeChatLinkPeek = {
|
||||
githubRepo: NativeChatGitHubRepo | null
|
||||
/** The chat's host, where GitHub links off `githubRepo`'s hosts find a repo to answer them; null
|
||||
* leaves those links to the web preview. */
|
||||
githubRoutingAuthorityKey: HostAuthorityKey | null
|
||||
/** Null while web link previews are turned off. */
|
||||
webPreviews: { runtimeEnvironmentId: string | null } | null
|
||||
}
|
||||
|
||||
export const NativeChatLinkPeekContext = createContext<NativeChatLinkPeek>({
|
||||
githubRepo: null,
|
||||
githubRoutingAuthorityKey: null,
|
||||
webPreviews: null
|
||||
})
|
||||
|
||||
@@ -27,12 +33,19 @@ export function NativeChatLinkPeekProvider({
|
||||
(state) => state.settings?.nativeChatLinkPreviews !== false
|
||||
)
|
||||
const runtimeEnvironmentId = fileLinkContext?.runtimeEnvironmentId ?? null
|
||||
const githubRoutingAuthorityKey = useAppStore((state) => {
|
||||
const match = fileLinkContext
|
||||
? resolveOwnerOrLocal(state, { workspaceId: fileLinkContext.worktreeId })
|
||||
: null
|
||||
return match?.kind === 'resolved' ? authorityKey(match.owner) : null
|
||||
})
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
githubRepo,
|
||||
githubRoutingAuthorityKey,
|
||||
webPreviews: webPreviewsEnabled ? { runtimeEnvironmentId } : null
|
||||
}),
|
||||
[githubRepo, runtimeEnvironmentId, webPreviewsEnabled]
|
||||
[githubRepo, githubRoutingAuthorityKey, runtimeEnvironmentId, webPreviewsEnabled]
|
||||
)
|
||||
return (
|
||||
<NativeChatLinkPeekContext.Provider value={value}>
|
||||
|
||||
@@ -141,6 +141,9 @@ export async function lookupGitHubWorkItemByOwnerRepoForSource(
|
||||
args.sourceContext
|
||||
))
|
||||
: null
|
||||
if (args.accepts?.() === false) {
|
||||
return null
|
||||
}
|
||||
const item =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<Omit<GitHubWorkItem, 'repoId'> | null>(
|
||||
|
||||
Reference in New Issue
Block a user