Add manual artifact sharing from HTML and Markdown views (#13369)
(cherry picked from commit 6397668271)
This commit is contained in:
parent
8bd61feb97
commit
ded700760f
|
|
@ -0,0 +1,39 @@
|
|||
import type { ArtifactWriteRequest } from '../../shared/artifacts'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
|
||||
export function artifactWriteBody(request: ArtifactWriteRequest): Record<string, string> {
|
||||
return {
|
||||
content: request.content,
|
||||
contentType: request.contentType,
|
||||
fileName: request.fileName,
|
||||
...(request.title ? { title: request.title } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function artifactRequest<T>(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
|
||||
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
|
||||
...(options.body ? { 'content-type': 'application/json' } : {})
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(20_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { code?: string } | null
|
||||
throw new OrcaCloudRequestError(response.status, body?.code)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
|
@ -58,6 +58,30 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe('ArtifactCloudService same-source races', () => {
|
||||
it('runs the next same-source operation after an earlier failure', async () => {
|
||||
const service = await setup()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 'temporary_failure' }), {
|
||||
status: 500,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const failedShare = service.share(writeRequest)
|
||||
const nextShare = service.share(writeRequest)
|
||||
|
||||
await expect(failedShare).rejects.toMatchObject({ statusCode: 500 })
|
||||
await expect(nextShare).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { artifact: { slug: 'artifact-b' } }
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not let an old update overwrite a newer share mapping', async () => {
|
||||
const service = await setup()
|
||||
let resolveUpdate: ((response: Response) => void) | undefined
|
||||
|
|
@ -77,9 +101,11 @@ describe('ArtifactCloudService same-source races', () => {
|
|||
await service.share(writeRequest)
|
||||
const oldUpdate = service.update(writeRequest)
|
||||
await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
|
||||
await service.share(writeRequest)
|
||||
const newerShare = service.share(writeRequest)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
resolveUpdate?.(createResponse('artifact-a'))
|
||||
await oldUpdate
|
||||
await newerShare
|
||||
await service.update(writeRequest)
|
||||
|
||||
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
|
||||
|
|
@ -108,9 +134,11 @@ describe('ArtifactCloudService same-source races', () => {
|
|||
authToken: 'token-a'
|
||||
})
|
||||
await vi.waitFor(() => expect(resolveDelete).toBeTypeOf('function'))
|
||||
await service.share(writeRequest)
|
||||
const newerShare = service.share(writeRequest)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
resolveDelete?.(new Response(null, { status: 204 }))
|
||||
await oldUnshare
|
||||
await newerShare
|
||||
await service.update(writeRequest)
|
||||
|
||||
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
|
||||
|
|
|
|||
|
|
@ -143,6 +143,165 @@ describe('ArtifactCloudService record authorization', () => {
|
|||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
|
||||
it('creates once and updates on repeated publish', async () => {
|
||||
const { service } = await setup()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'created', item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' } }
|
||||
})
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'updated', item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' } }
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' })
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`${apiUrl}/v1/artifacts/artifact-a`)
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' })
|
||||
})
|
||||
|
||||
it('resolves a persisted public link only for its source and cloud scope', async () => {
|
||||
const { service } = await setup()
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(createResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await service.publish(writeRequest)
|
||||
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({
|
||||
status: 'ok',
|
||||
value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: '/repo/other.html', apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({ status: 'ok', value: null })
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-b' })
|
||||
).resolves.toEqual({ status: 'ok', value: null })
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('serializes concurrent publishes for the same source', async () => {
|
||||
const { service } = await setup()
|
||||
let resolveCreate: ((response: Response) => void) | undefined
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveCreate = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const first = service.publish(writeRequest)
|
||||
const second = service.publish(writeRequest)
|
||||
await vi.waitFor(() => expect(resolveCreate).toBeTypeOf('function'))
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
resolveCreate?.(createResponse())
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([
|
||||
{ status: 'ok', value: { change: 'created' } },
|
||||
{ status: 'ok', value: { change: 'updated' } }
|
||||
])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' })
|
||||
})
|
||||
|
||||
it('serializes manual publish with CLI share for the same source', async () => {
|
||||
const { service } = await setup()
|
||||
let resolvePublish: ((response: Response) => void) | undefined
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolvePublish = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const publish = service.publish(writeRequest)
|
||||
const share = service.share(writeRequest)
|
||||
await vi.waitFor(() => expect(resolvePublish).toBeTypeOf('function'))
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
resolvePublish?.(createResponse('artifact-a'))
|
||||
|
||||
await expect(Promise.all([publish, share])).resolves.toMatchObject([
|
||||
{ status: 'ok', value: { change: 'created' } },
|
||||
{ status: 'ok', value: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
|
||||
])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('serializes account deletion with a mapped source update', async () => {
|
||||
const { service } = await setup()
|
||||
let resolveUpdate: ((response: Response) => void) | undefined
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveUpdate = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await service.share(writeRequest)
|
||||
|
||||
const update = service.update(writeRequest)
|
||||
await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
|
||||
const deletion = service.delete('artifact-a', { apiUrl, authToken: 'token-a' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
resolveUpdate?.(createResponse())
|
||||
|
||||
await expect(Promise.all([update, deletion])).resolves.toMatchObject([
|
||||
{ status: 'ok' },
|
||||
{ status: 'ok' }
|
||||
])
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({ status: 'ok', value: null })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('recreates an artifact when its stored public link was deleted elsewhere', async () => {
|
||||
const { service } = await setup()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createResponse('artifact-a'))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ code: 'not_found' }), { status: 404 }))
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await service.publish(writeRequest)
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'created', item: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
|
||||
})
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'updated', item: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls.map(([, options]) => options?.method)).toEqual([
|
||||
'POST',
|
||||
'PUT',
|
||||
'POST',
|
||||
'PUT'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the idempotency key stable across an auth-refresh retry', async () => {
|
||||
const { service, profileId, userDataPath } = await setup()
|
||||
vi.stubEnv('ORCA_CLOUD_API_URL', 'http://localhost:4100')
|
||||
|
|
@ -327,6 +486,7 @@ describe('ArtifactCloudService publish capability gate', () => {
|
|||
|
||||
it.each([
|
||||
['share', (service: ArtifactCloudService) => service.share(writeRequest)],
|
||||
['publish', (service: ArtifactCloudService) => service.publish(writeRequest)],
|
||||
['update', (service: ArtifactCloudService) => service.update(writeRequest)]
|
||||
])('rejects %s without reaching the network when the capability is off', async (_name, call) => {
|
||||
const { service } = await setup({ value: false })
|
||||
|
|
@ -381,6 +541,12 @@ describe('ArtifactCloudService publish capability gate', () => {
|
|||
await expect(service.list({ apiUrl, authToken: 'token-a' })).resolves.toMatchObject({
|
||||
status: 'ok'
|
||||
})
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({
|
||||
status: 'ok',
|
||||
value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
await expect(
|
||||
service.unshare({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({ status: 'ok', value: undefined })
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import type {
|
|||
ArtifactListOptions,
|
||||
ArtifactListPage,
|
||||
ArtifactListItem,
|
||||
ArtifactPublishedLink,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate'
|
||||
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
|
||||
import {
|
||||
allowsArtifactCloudAuthOverride,
|
||||
|
|
@ -22,12 +23,11 @@ import {
|
|||
getArtifactShareRecord,
|
||||
isArtifactShareLifecycleCurrent,
|
||||
refreshArtifactShareRecordExpiration,
|
||||
removeArtifactShareRecords,
|
||||
saveArtifactShareRecord
|
||||
removeArtifactShareRecords
|
||||
} from './artifact-share-record-store'
|
||||
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
|
||||
|
||||
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
|
||||
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
|
||||
import { ArtifactPublisher } from './artifact-publisher'
|
||||
|
||||
type ArtifactAuthContext = {
|
||||
profileId: string
|
||||
|
|
@ -114,6 +114,8 @@ function explicitTokenAuthContext(
|
|||
}
|
||||
|
||||
export class ArtifactCloudService {
|
||||
private readonly publisher: ArtifactPublisher
|
||||
|
||||
/**
|
||||
* `isSharingEnabled` is the publish capability gate. It is read per call, never cached, so
|
||||
* revoking it in Settings takes effect on the next request. List, unshare, and delete stay
|
||||
|
|
@ -122,7 +124,9 @@ export class ArtifactCloudService {
|
|||
constructor(
|
||||
private readonly userDataPath: string,
|
||||
private readonly isSharingEnabled: () => boolean
|
||||
) {}
|
||||
) {
|
||||
this.publisher = new ArtifactPublisher(userDataPath)
|
||||
}
|
||||
|
||||
list(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
|
||||
return this.withAuth(options, async (token, apiUrl) => {
|
||||
|
|
@ -131,88 +135,121 @@ export class ArtifactCloudService {
|
|||
})
|
||||
}
|
||||
|
||||
getPublishedLink(
|
||||
request: ArtifactCloudOptions & { sourceKey: string }
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
|
||||
return this.withAuth(request, async (_token, _apiUrl, auth) => {
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
return record ? { shareUrl: record.shareUrl } : null
|
||||
})
|
||||
}
|
||||
|
||||
// Why async: the gate must surface as a rejection, not a synchronous throw, so every caller's
|
||||
// promise chain handles it the same way.
|
||||
async share(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
assertArtifactSharingAllowed(this.isSharingEnabled)
|
||||
const idempotencyKey = randomUUID()
|
||||
return this.withAuth(request, async (token, apiUrl, auth) => {
|
||||
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
|
||||
method: 'POST',
|
||||
body: writeBody(request),
|
||||
idempotencyKey
|
||||
})
|
||||
auth.assertCurrent()
|
||||
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
|
||||
slug: response.artifact.slug,
|
||||
editToken: response.editToken,
|
||||
shareUrl: response.shareUrl,
|
||||
expiresAt: response.artifact.expiresAt,
|
||||
...auth.scope
|
||||
})
|
||||
return { artifact: response.artifact, shareUrl: response.shareUrl }
|
||||
})
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.share(request, token, apiUrl, auth, idempotencyKey)
|
||||
)
|
||||
}
|
||||
|
||||
async publish(
|
||||
request: ArtifactWriteRequest
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishResult>> {
|
||||
assertArtifactSharingAllowed(this.isSharingEnabled)
|
||||
const idempotencyKey = randomUUID()
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.publish(request, token, apiUrl, auth, idempotencyKey)
|
||||
)
|
||||
}
|
||||
|
||||
async update(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
assertArtifactSharingAllowed(this.isSharingEnabled)
|
||||
return this.withAuth(request, async (token, apiUrl, auth) => {
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
const response = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: writeBody(request)
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
return this.publisher.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const response = await artifactRequest<ArtifactListItem>(
|
||||
apiUrl,
|
||||
token,
|
||||
`/${record.slug}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: artifactWriteBody(request)
|
||||
}
|
||||
)
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
response.artifact.expiresAt
|
||||
)
|
||||
return response
|
||||
})
|
||||
})
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
response.artifact.expiresAt
|
||||
)
|
||||
return response
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
unshare(
|
||||
request: ArtifactCloudOptions & { sourceKey: string }
|
||||
): Promise<ArtifactCloudOperation<void>> {
|
||||
return this.withAuth(request, async (token, apiUrl, auth) => {
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'DELETE',
|
||||
editToken: record.editToken
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
return this.publisher.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'DELETE',
|
||||
editToken: record.editToken
|
||||
})
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
|
||||
sourceKey: request.sourceKey,
|
||||
slug: record.slug
|
||||
})
|
||||
})
|
||||
})
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
|
||||
sourceKey: request.sourceKey,
|
||||
slug: record.slug
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
delete(id: string, options: ArtifactCloudOptions): Promise<ArtifactCloudOperation<void>> {
|
||||
return this.withAuth(options, async (token, apiUrl, auth) => {
|
||||
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
|
||||
})
|
||||
return this.withAuth(options, (token, apiUrl, auth) =>
|
||||
this.publisher.runForSlug(id, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private async withAuth<T>(
|
||||
|
|
@ -256,40 +293,3 @@ export class ArtifactCloudService {
|
|||
: { status: 'reconnect-required' }
|
||||
}
|
||||
}
|
||||
|
||||
function writeBody(request: ArtifactWriteRequest): Record<string, string> {
|
||||
return {
|
||||
content: request.content,
|
||||
contentType: request.contentType,
|
||||
fileName: request.fileName,
|
||||
...(request.title ? { title: request.title } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function artifactRequest<T>(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
|
||||
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
|
||||
...(options.body ? { 'content-type': 'application/json' } : {})
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(20_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { code?: string } | null
|
||||
throw new OrcaCloudRequestError(response.status, body?.code)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import type {
|
||||
ArtifactListItem,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
|
||||
import {
|
||||
type ArtifactShareScope,
|
||||
getArtifactShareRecord,
|
||||
refreshArtifactShareRecordExpiration,
|
||||
removeArtifactShareRecords,
|
||||
saveArtifactShareRecord
|
||||
} from './artifact-share-record-store'
|
||||
|
||||
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
|
||||
|
||||
type ArtifactPublishAuthContext = {
|
||||
profileId: string
|
||||
scope: ArtifactShareScope
|
||||
assertCurrent: () => void
|
||||
}
|
||||
|
||||
function artifactOperationQueueKey(
|
||||
kind: 'source' | 'slug',
|
||||
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
|
||||
identity: string
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
kind,
|
||||
auth.profileId,
|
||||
auth.scope.cloudUserId,
|
||||
auth.scope.cloudProfileId,
|
||||
auth.scope.cloudOrganizationId,
|
||||
auth.scope.apiOrigin,
|
||||
identity
|
||||
])
|
||||
}
|
||||
|
||||
export class ArtifactPublisher {
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(private readonly userDataPath: string) {}
|
||||
|
||||
async share(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
idempotencyKey: string
|
||||
): Promise<ArtifactListItem> {
|
||||
return this.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item
|
||||
})
|
||||
}
|
||||
|
||||
publish(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
idempotencyKey: string
|
||||
): Promise<ArtifactPublishResult> {
|
||||
return this.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (record) {
|
||||
try {
|
||||
return await this.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: artifactWriteBody(request)
|
||||
})
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
item.artifact.expiresAt
|
||||
)
|
||||
return { change: 'updated', item }
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) {
|
||||
throw error
|
||||
}
|
||||
auth.assertCurrent()
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
|
||||
sourceKey: request.sourceKey,
|
||||
slug: record.slug
|
||||
})
|
||||
}
|
||||
}
|
||||
return this.create(request, token, apiUrl, auth, idempotencyKey)
|
||||
})
|
||||
}
|
||||
|
||||
runForSource<T>(
|
||||
sourceKey: string,
|
||||
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.runSerialized(artifactOperationQueueKey('source', auth, sourceKey), operation)
|
||||
}
|
||||
|
||||
runForSlug<T>(
|
||||
slug: string,
|
||||
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation)
|
||||
}
|
||||
|
||||
private async create(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
idempotencyKey: string
|
||||
): Promise<ArtifactPublishResult> {
|
||||
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
|
||||
method: 'POST',
|
||||
body: artifactWriteBody(request),
|
||||
idempotencyKey
|
||||
})
|
||||
auth.assertCurrent()
|
||||
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
|
||||
slug: response.artifact.slug,
|
||||
editToken: response.editToken,
|
||||
shareUrl: response.shareUrl,
|
||||
expiresAt: response.artifact.expiresAt,
|
||||
...auth.scope
|
||||
})
|
||||
return {
|
||||
change: 'created',
|
||||
item: { artifact: response.artifact, shareUrl: response.shareUrl }
|
||||
}
|
||||
}
|
||||
|
||||
private async runSerialized<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(key) ?? Promise.resolve()
|
||||
let release = (): void => {}
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const ready = previous.catch(() => {})
|
||||
const current = ready.then(() => released)
|
||||
this.queues.set(key, current)
|
||||
await ready
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
release()
|
||||
if (this.queues.get(key) === current) {
|
||||
this.queues.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { describe, expect, it } from 'vitest'
|
|||
// and update the count.
|
||||
const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
|
||||
// HTTP call sites — body consumed or cancelled on every path, including !ok
|
||||
['main/artifacts/artifact-cloud-service.ts', 1],
|
||||
['main/artifacts/artifact-cloud-request.ts', 1],
|
||||
['main/azure-devops/azure-devops-api-request.ts', 1],
|
||||
['main/bitbucket/client.ts', 1],
|
||||
['main/gitea/client.ts', 1],
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ import type {
|
|||
ArtifactListOptions,
|
||||
ArtifactListPage,
|
||||
ArtifactListItem,
|
||||
ArtifactPublishedLink,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import type { ArtifactCloudService } from '../artifacts/artifact-cloud-service'
|
||||
|
|
@ -4638,10 +4640,22 @@ export class OrcaRuntimeService {
|
|||
return this.requireArtifactService().list(options)
|
||||
}
|
||||
|
||||
getPublishedArtifactLink(
|
||||
request: ArtifactCloudOptions & { sourceKey: string }
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
|
||||
return this.requireArtifactService().getPublishedLink(request)
|
||||
}
|
||||
|
||||
shareArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
return this.requireArtifactService().share(request)
|
||||
}
|
||||
|
||||
publishArtifact(
|
||||
request: ArtifactWriteRequest
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishResult>> {
|
||||
return this.requireArtifactService().publish(request)
|
||||
}
|
||||
|
||||
updateArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
return this.requireArtifactService().update(request)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import { ARTIFACT_METHODS } from './artifacts'
|
||||
|
||||
const validRequest = {
|
||||
sourceKey: '/repo/report.html',
|
||||
content: '<h1>Report</h1>',
|
||||
contentType: 'text/html',
|
||||
fileName: 'report.html'
|
||||
}
|
||||
|
||||
function writeSchema(name: string) {
|
||||
const method = ARTIFACT_METHODS.find((candidate) => candidate.name === name)
|
||||
if (!method?.params) {
|
||||
throw new Error(`Missing ${name} schema`)
|
||||
}
|
||||
return method.params
|
||||
}
|
||||
|
||||
describe('artifact RPC schemas', () => {
|
||||
it('registers the local publish upsert', () => {
|
||||
expect(writeSchema('artifacts.publish').safeParse(validRequest).success).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the persisted-link lookup', () => {
|
||||
const schema = writeSchema('artifacts.getPublishedLink')
|
||||
expect(schema.safeParse({ sourceKey: validRequest.sourceKey }).success).toBe(true)
|
||||
expect(schema.safeParse({ sourceKey: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty and oversized artifact requests', () => {
|
||||
const schema = writeSchema('artifacts.publish')
|
||||
expect(schema.safeParse({ ...validRequest, content: '' }).success).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({ ...validRequest, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES + 1) })
|
||||
.success
|
||||
).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({
|
||||
...validRequest,
|
||||
content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import { z } from 'zod'
|
||||
import {
|
||||
ARTIFACT_CLI_MAX_RPC_BYTES,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../../../shared/artifacts'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
|
||||
const CloudOptions = {
|
||||
apiUrl: z.string().optional(),
|
||||
authToken: z.string().optional()
|
||||
apiUrl: z.string().max(2_048).optional(),
|
||||
authToken: z.string().max(16_384).optional()
|
||||
}
|
||||
|
||||
const ListOptions = z.object({
|
||||
|
|
@ -11,26 +15,45 @@ const ListOptions = z.object({
|
|||
cursor: z.string().min(1).max(2_048).optional()
|
||||
})
|
||||
|
||||
const WriteRequest = z.object({
|
||||
sourceKey: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
contentType: z.enum(['text/html', 'text/markdown']),
|
||||
fileName: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
const SourceRequest = z.object({
|
||||
sourceKey: z.string().min(1).max(32_768),
|
||||
...CloudOptions
|
||||
})
|
||||
|
||||
const WriteRequest = z
|
||||
.object({
|
||||
sourceKey: z.string().min(1).max(32_768),
|
||||
content: z.string().min(1).max(ARTIFACT_CLI_MAX_RPC_BYTES),
|
||||
contentType: z.enum(['text/html', 'text/markdown']),
|
||||
fileName: z.string().min(1).max(512),
|
||||
title: z.string().max(512).optional(),
|
||||
...CloudOptions
|
||||
})
|
||||
.refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_CLI_MAX_RPC_BYTES, {
|
||||
message: 'Artifact request exceeds the local RPC size limit.'
|
||||
})
|
||||
|
||||
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'artifacts.list',
|
||||
params: ListOptions,
|
||||
handler: (params, { runtime }) => runtime.listArtifacts(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.getPublishedLink',
|
||||
params: SourceRequest,
|
||||
handler: (params, { runtime }) => runtime.getPublishedArtifactLink(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.share',
|
||||
params: WriteRequest,
|
||||
handler: (params, { runtime }) => runtime.shareArtifact(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.publish',
|
||||
params: WriteRequest,
|
||||
handler: (params, { runtime }) => runtime.publishArtifact(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.update',
|
||||
params: WriteRequest,
|
||||
|
|
@ -38,7 +61,7 @@ export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
|
|||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.unshare',
|
||||
params: z.object({ sourceKey: z.string().min(1), ...CloudOptions }),
|
||||
params: SourceRequest,
|
||||
handler: (params, { runtime }) => runtime.unshareArtifact(params)
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
|
|||
|
|
@ -135,8 +135,8 @@ describe('buildHostCliEnv', () => {
|
|||
const first = JSON.parse(String(build('host-a')))
|
||||
const second = JSON.parse(String(build('host-b')))
|
||||
expect(first.sourceKey).not.toBe(second.sourceKey)
|
||||
expect(first.sourceKey).toContain('host-a')
|
||||
expect(second.sourceKey).toContain('host-b')
|
||||
expect(JSON.parse(first.sourceKey)).toEqual(['ssh', 'host-a', '/srv/repo/report.html'])
|
||||
expect(JSON.parse(second.sourceKey)).toEqual(['ssh', 'host-b', '/srv/repo/report.html'])
|
||||
expect(first.fileName).toBe('report.html')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
import {
|
||||
REMOTE_ARTIFACT_INPUT_ENV,
|
||||
sshArtifactSourceKey,
|
||||
type RemoteArtifactInput
|
||||
} from '../../shared/artifact-cli-bridge'
|
||||
|
||||
|
|
@ -178,7 +179,7 @@ export function buildHostCliEnv(args: {
|
|||
}
|
||||
if (args.artifactInput) {
|
||||
const sourceKey = args.runtimeAuthority
|
||||
? JSON.stringify(['ssh', args.runtimeAuthority.targetId, args.artifactInput.sourceKey])
|
||||
? sshArtifactSourceKey(args.runtimeAuthority.targetId, args.artifactInput.sourceKey)
|
||||
: args.artifactInput.sourceKey
|
||||
env[REMOTE_ARTIFACT_INPUT_ENV] = JSON.stringify({ ...args.artifactInput, sourceKey })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connect: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
getPublishedLink: vi.fn(),
|
||||
copyLink: vi.fn(),
|
||||
openLink: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
openPopover: null as ((open: boolean) => void) | null,
|
||||
state: {
|
||||
orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record<string, unknown>,
|
||||
orcaProfileConnecting: false,
|
||||
settings: { artifactSharingEnabled: true }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
...mocks.state,
|
||||
connectCurrentOrcaProfile: mocks.connect,
|
||||
openSettingsPage: mocks.openSettingsPage,
|
||||
openSettingsTarget: mocks.openSettingsTarget
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({
|
||||
children,
|
||||
onOpenChange
|
||||
}: {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
mocks.openPopover = onOpenChange ?? null
|
||||
return <>{children}</>
|
||||
},
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<span onClick={() => mocks.openPopover?.(true)}>{children}</span>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('./artifact-publish-flow', () => ({
|
||||
publishArtifactFromSurface: mocks.publish
|
||||
}))
|
||||
vi.mock('./artifact-published-link-client', () => ({
|
||||
getPublishedArtifactLink: mocks.getPublishedLink
|
||||
}))
|
||||
vi.mock('./artifact-link-actions', () => ({
|
||||
copyArtifactLink: mocks.copyLink,
|
||||
openArtifactInBrowser: mocks.openLink
|
||||
}))
|
||||
|
||||
import { ArtifactPublishButton } from './ArtifactPublishButton'
|
||||
|
||||
describe('ArtifactPublishButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.publish.mockResolvedValue({
|
||||
change: 'created',
|
||||
item: { shareUrl: 'https://example.com' }
|
||||
})
|
||||
mocks.getPublishedLink.mockResolvedValue(null)
|
||||
mocks.copyLink.mockResolvedValue(true)
|
||||
mocks.openPopover = null
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
|
||||
mocks.state.orcaProfileConnecting = false
|
||||
mocks.state.settings = { artifactSharingEnabled: true }
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('requires explicit confirmation before publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const createRequest = vi.fn()
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={createRequest} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Share public link' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(createRequest))
|
||||
expect(screen.getByText('https://example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Update shared content' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers sign-in and blocks confirmation while signed out', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Share public link' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes disabled publishing to Artifacts settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.settings = { artifactSharingEnabled: false }
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(await screen.findByRole('button', { name: 'Share public link' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Open Artifacts settings' }))
|
||||
|
||||
expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ pane: 'artifacts', repoId: null })
|
||||
expect(mocks.openSettingsPage).toHaveBeenCalledOnce()
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the settings prompt once publishing is enabled', () => {
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText('Artifact sharing is off')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open Artifacts settings' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows and manages an existing public link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const createRequest = vi.fn()
|
||||
mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
|
||||
mocks.publish.mockResolvedValue({
|
||||
change: 'updated',
|
||||
item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={createRequest} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(await screen.findByText('https://share.onorca.dev/a/artifact-a')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Copy link' }))
|
||||
expect(mocks.copyLink).toHaveBeenCalledWith('https://share.onorca.dev/a/artifact-a', {
|
||||
showSuccessToast: false
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Update shared content' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(createRequest))
|
||||
})
|
||||
|
||||
it('keeps existing links available when publishing is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.settings = { artifactSharingEnabled: false }
|
||||
mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
|
||||
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(await screen.findByRole('button', { name: 'Copy link' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Update shared content' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Open Artifacts settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('looks up a persisted link only after the popover opens', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
expect(mocks.getPublishedLink).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
|
||||
await waitFor(() => expect(mocks.getPublishedLink).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('does not start copy feedback after the panel unmounts', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finishCopy: ((copied: boolean) => void) | undefined
|
||||
mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
|
||||
mocks.copyLink.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
finishCopy = resolve
|
||||
})
|
||||
)
|
||||
const timeoutSpy = vi.spyOn(window, 'setTimeout')
|
||||
const view = render(
|
||||
<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy link' }))
|
||||
|
||||
view.unmount()
|
||||
finishCopy?.(true)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(timeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 1_500)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowRight, Loader2, Share2 } from 'lucide-react'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { ArtifactPublishedLinkPanel } from './ArtifactPublishedLinkPanel'
|
||||
import { getPublishedArtifactLink } from './artifact-published-link-client'
|
||||
import { publishArtifactFromSurface } from './artifact-publish-flow'
|
||||
|
||||
type PublishedLinkLookup = {
|
||||
key: string
|
||||
status: 'loading' | 'loaded' | 'error'
|
||||
shareUrl: string | null
|
||||
}
|
||||
|
||||
export function ArtifactPublishButton({
|
||||
sourceKey,
|
||||
createRequest,
|
||||
className,
|
||||
disabled
|
||||
}: {
|
||||
sourceKey: string
|
||||
createRequest: () => Promise<ArtifactWriteRequest>
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [lookupRevision, setLookupRevision] = useState(0)
|
||||
const [linkLookup, setLinkLookup] = useState<PublishedLinkLookup | null>(null)
|
||||
const lookupSequence = useRef(0)
|
||||
const popoverContentRef = useRef<HTMLDivElement>(null)
|
||||
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
|
||||
const connecting = useAppStore((state) => state.orcaProfileConnecting)
|
||||
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const signedIn = authStatus?.state === 'connected'
|
||||
const sharingEnabled = settings?.artifactSharingEnabled === true
|
||||
const accountKey =
|
||||
authStatus?.state === 'connected'
|
||||
? JSON.stringify([
|
||||
authStatus.activeProfileId,
|
||||
authStatus.cloud?.userId ?? null,
|
||||
authStatus.cloud?.cloudProfileId ?? null,
|
||||
authStatus.cloud?.activeOrgId ?? null
|
||||
])
|
||||
: null
|
||||
const lookupKey = accountKey ? JSON.stringify([accountKey, sourceKey]) : null
|
||||
const currentLookup = linkLookup?.key === lookupKey ? linkLookup : null
|
||||
const checkingLink =
|
||||
signedIn && currentLookup?.status !== 'loaded' && currentLookup?.status !== 'error'
|
||||
const publishedLink = currentLookup?.status === 'loaded' ? currentLookup.shareUrl : null
|
||||
const busy = publishing || connecting
|
||||
const blocked = disabled || busy
|
||||
|
||||
useEffect(() => {
|
||||
const sequence = ++lookupSequence.current
|
||||
if (!open || !lookupKey) {
|
||||
setLinkLookup(null)
|
||||
return
|
||||
}
|
||||
setLinkLookup({ key: lookupKey, status: 'loading', shareUrl: null })
|
||||
void getPublishedArtifactLink(sourceKey)
|
||||
.then((shareUrl) => {
|
||||
if (lookupSequence.current === sequence) {
|
||||
setLinkLookup({ key: lookupKey, status: 'loaded', shareUrl })
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to check published artifact link:', error)
|
||||
if (lookupSequence.current === sequence) {
|
||||
setLinkLookup({ key: lookupKey, status: 'error', shareUrl: null })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
lookupSequence.current += 1
|
||||
}
|
||||
}, [lookupKey, lookupRevision, open, sourceKey])
|
||||
|
||||
const publish = async (): Promise<void> => {
|
||||
if (blocked || !signedIn || !sharingEnabled) {
|
||||
return
|
||||
}
|
||||
setPublishing(true)
|
||||
try {
|
||||
const result = await publishArtifactFromSurface(createRequest)
|
||||
if (result) {
|
||||
if (lookupKey) {
|
||||
setLinkLookup({ key: lookupKey, status: 'loaded', shareUrl: result.item.shareUrl })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openArtifactsSettings = (): void => {
|
||||
setOpen(false)
|
||||
openSettingsTarget({ pane: 'artifacts', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
const label = translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.a4a49da6af',
|
||||
'Share as artifact'
|
||||
)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(nextOpen) => !busy && setOpen(nextOpen)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn('shrink-0', className)}
|
||||
disabled={blocked}
|
||||
aria-label={label}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
ref={popoverContentRef}
|
||||
tabIndex={-1}
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-80 p-0"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
popoverContentRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1 border-b border-border/60 px-4 py-3.5">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.confirmTitle',
|
||||
'Share as artifact'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{publishedLink
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.publishedDescription',
|
||||
'Anyone with this link can view the shared file.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.confirmDescription',
|
||||
'This publishes the current file at a link anyone with the URL can view.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
{!signedIn ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-xs font-medium">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.accountTitle',
|
||||
'Orca account'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.accountDescription',
|
||||
'Sign in to create and manage this link.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={connecting || authStatus?.configured !== true}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
{connecting
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.signingIn',
|
||||
'Signing in…'
|
||||
)
|
||||
: authStatus?.state === 'reconnect-required'
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.signInAgain',
|
||||
'Sign in again'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.signIn',
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!sharingEnabled ? (
|
||||
<div className={cn('space-y-2', !signedIn && 'border-t border-border/60 pt-3')}>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.publishingOffTitle',
|
||||
'Artifact sharing is off'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.publishingOffDescription',
|
||||
'Learn about public links and enable sharing in Settings.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={openArtifactsSettings}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.openSettings',
|
||||
'Open Artifacts settings'
|
||||
)}
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checkingLink ? (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.checkingLink',
|
||||
'Checking for an existing link…'
|
||||
)}
|
||||
</div>
|
||||
) : currentLookup?.status === 'error' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.checkFailed',
|
||||
'Could not check for an existing link.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setLookupRevision((current) => current + 1)}
|
||||
>
|
||||
{translate('auto.components.artifacts.ArtifactPublishButton.tryAgain', 'Try again')}
|
||||
</Button>
|
||||
</div>
|
||||
) : publishedLink ? (
|
||||
<ArtifactPublishedLinkPanel
|
||||
key={publishedLink}
|
||||
shareUrl={publishedLink}
|
||||
publishing={publishing}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onUpdate={() => void publish()}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!signedIn || !sharingEnabled || busy}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
|
||||
{publishing
|
||||
? translate('auto.components.artifacts.ArtifactPublishButton.sharing', 'Sharing…')
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.sharePublicLink',
|
||||
'Share public link'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Check, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
|
||||
|
||||
export function ArtifactPublishedLinkPanel({
|
||||
shareUrl,
|
||||
publishing,
|
||||
sharingEnabled,
|
||||
onUpdate
|
||||
}: {
|
||||
shareUrl: string
|
||||
publishing: boolean
|
||||
sharingEnabled: boolean
|
||||
onUpdate: () => void
|
||||
}): React.JSX.Element {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copiedResetTimerRef = useRef<number | null>(null)
|
||||
const mountedRef = useRef(false)
|
||||
|
||||
const clearCopiedResetTimer = useCallback((): void => {
|
||||
if (copiedResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedResetTimerRef.current)
|
||||
copiedResetTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
const setPanelRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
mountedRef.current = node !== null
|
||||
if (!node) {
|
||||
clearCopiedResetTimer()
|
||||
}
|
||||
},
|
||||
[clearCopiedResetTimer]
|
||||
)
|
||||
|
||||
const copyLink = async (): Promise<void> => {
|
||||
if (!(await copyArtifactLink(shareUrl, { showSuccessToast: false }))) {
|
||||
return
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setCopied(true)
|
||||
clearCopiedResetTimer()
|
||||
copiedResetTimerRef.current = window.setTimeout(() => {
|
||||
copiedResetTimerRef.current = null
|
||||
setCopied(false)
|
||||
}, 1_500)
|
||||
}
|
||||
|
||||
const copyLabel = copied
|
||||
? translate('auto.components.artifacts.copySuccess', 'Artifact link copied')
|
||||
: translate('auto.components.artifacts.ArtifactPublishedLinkPanel.copyLink', 'Copy link')
|
||||
|
||||
return (
|
||||
<div ref={setPanelRef} className="space-y-3">
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
<p
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"
|
||||
title={shareUrl}
|
||||
>
|
||||
{shareUrl}
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void copyLink()}
|
||||
aria-label={copyLabel}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{copyLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openArtifactInBrowser(shareUrl)}
|
||||
aria-label={translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
|
||||
'Open link'
|
||||
)}
|
||||
>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
|
||||
'Open link'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{sharingEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={publishing}
|
||||
onClick={onUpdate}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{publishing
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.updating',
|
||||
'Updating…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.update',
|
||||
'Update shared content'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -198,7 +198,9 @@ describe('ArtifactsPage', () => {
|
|||
const heading = await screen.findByText('No shared artifacts')
|
||||
expect(heading.parentElement).toHaveClass('flex-1', 'justify-center')
|
||||
expect(
|
||||
screen.getByText('Ask your agent to share an HTML or Markdown file, and it will appear here.')
|
||||
screen.getByText(
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText(/orca artifacts share/)).not.toBeInTheDocument()
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -328,11 +328,11 @@ export default function ArtifactsPage(): React.JSX.Element {
|
|||
: publishingBlocked
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactsPage.publishingOffCopy',
|
||||
'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.'
|
||||
'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactsPage.emptyCopy',
|
||||
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
|
||||
)}
|
||||
</p>
|
||||
{!nextCursor && publishingBlocked ? (
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export async function copyArtifactLink(shareUrl: string): Promise<void> {
|
||||
export async function copyArtifactLink(
|
||||
shareUrl: string,
|
||||
options: { showSuccessToast?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(shareUrl)
|
||||
toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
|
||||
if (options.showSuccessToast !== false) {
|
||||
toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
toast.error(translate('auto.components.artifacts.copyFailed', 'Could not copy artifact link'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import { publishArtifactFromSurface } from './artifact-publish-flow'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
state: {
|
||||
orcaProfileAuthStatus: { state: 'connected' } as { state: string } | null,
|
||||
connectCurrentOrcaProfile: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mocks.state }
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: mocks.toastError, success: mocks.toastSuccess }
|
||||
}))
|
||||
const request = {
|
||||
sourceKey: '/repo/report.html',
|
||||
content: '<h1>Report</h1>',
|
||||
contentType: 'text/html' as const,
|
||||
fileName: 'report.html'
|
||||
}
|
||||
const published = {
|
||||
change: 'created' as const,
|
||||
item: {
|
||||
artifact: { slug: 'artifact-a' },
|
||||
shareUrl: 'https://share.onorca.dev/a/artifact-a'
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.state.orcaProfileAuthStatus = { state: 'connected' }
|
||||
mocks.state.connectCurrentOrcaProfile = mocks.connect
|
||||
})
|
||||
|
||||
describe('artifact publish flow', () => {
|
||||
it('signs in before preparing and publishing the request', async () => {
|
||||
mocks.state.orcaProfileAuthStatus = { state: 'local' }
|
||||
mocks.connect.mockResolvedValue({ status: 'connected' })
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
|
||||
const createRequest = vi.fn().mockResolvedValue(request)
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
expect(createRequest).toHaveBeenCalledOnce()
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'artifacts.publish',
|
||||
request
|
||||
)
|
||||
})
|
||||
|
||||
it('resumes with fresh content after reconnecting', async () => {
|
||||
mocks.connect.mockResolvedValue({ status: 'connected' })
|
||||
mocks.callRuntimeRpc
|
||||
.mockResolvedValueOnce({ status: 'reconnect-required' })
|
||||
.mockResolvedValueOnce({ status: 'ok', value: published })
|
||||
const createRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(request)
|
||||
.mockResolvedValueOnce({ ...request, content: '<h1>Fresh</h1>' })
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
expect(createRequest).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.callRuntimeRpc.mock.calls[1]?.[2]).toMatchObject({
|
||||
content: '<h1>Fresh</h1>'
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces sign-in failures without preparing the file', async () => {
|
||||
mocks.state.orcaProfileAuthStatus = { state: 'local' }
|
||||
mocks.connect.mockRejectedValue(new Error('login failed'))
|
||||
const createRequest = vi.fn().mockResolvedValue(request)
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull()
|
||||
expect(createRequest).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', undefined)
|
||||
})
|
||||
|
||||
it('rejects an oversized request before RPC', async () => {
|
||||
const createRequest = vi.fn().mockResolvedValue({
|
||||
...request,
|
||||
content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
|
||||
})
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull()
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', {
|
||||
description: 'Artifacts shared from Orca must be smaller than 800 KB.'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation without putting the public link in the toast', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
|
||||
await publishArtifactFromSurface(() => Promise.resolve(request))
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('Artifact shared')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { toast } from 'sonner'
|
||||
import type {
|
||||
ArtifactCloudOperation,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_CLI_MAX_RPC_BYTES,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../../../shared/artifacts'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const LOCAL_RUNTIME = { kind: 'local' } as const
|
||||
|
||||
export type ArtifactPublishPreparationErrorCode =
|
||||
| 'empty'
|
||||
| 'too-large'
|
||||
| 'unreadable'
|
||||
| 'unsupported'
|
||||
| 'binary'
|
||||
|
||||
export class ArtifactPublishPreparationError extends Error {
|
||||
constructor(readonly code: ArtifactPublishPreparationErrorCode) {
|
||||
super(code)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArtifactPublishRequest(
|
||||
request: ArtifactWriteRequest
|
||||
): ArtifactWriteRequest {
|
||||
if (!request.content) {
|
||||
throw new ArtifactPublishPreparationError('empty')
|
||||
}
|
||||
if (artifactWriteRequestByteLength(request) > ARTIFACT_CLI_MAX_RPC_BYTES) {
|
||||
throw new ArtifactPublishPreparationError('too-large')
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
export async function publishArtifactFromSurface(
|
||||
createRequest: () => Promise<ArtifactWriteRequest>
|
||||
): Promise<ArtifactPublishResult | null> {
|
||||
try {
|
||||
if (!(await ensureArtifactAccountConnected())) {
|
||||
return null
|
||||
}
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const request = validateArtifactPublishRequest(await createRequest())
|
||||
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactPublishResult>>(
|
||||
LOCAL_RUNTIME,
|
||||
'artifacts.publish',
|
||||
request
|
||||
)
|
||||
if (result.status === 'ok') {
|
||||
showArtifactPublishedToast(result.value)
|
||||
return result.value
|
||||
}
|
||||
if (result.status === 'unconfigured') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.9a078a0c65',
|
||||
'Artifact sharing is unavailable'
|
||||
),
|
||||
{ description: result.message }
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (attempt === 0 && (await reconnectArtifactAccount())) {
|
||||
continue
|
||||
}
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.bba20daa6d',
|
||||
'Sign in to Orca and try again.'
|
||||
)
|
||||
)
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to publish artifact:', error)
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.54b1805328',
|
||||
'Could not share artifact'
|
||||
),
|
||||
error instanceof ArtifactPublishPreparationError
|
||||
? { description: artifactPreparationErrorDescription(error.code) }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function ensureArtifactAccountConnected(): Promise<boolean> {
|
||||
const state = useAppStore.getState()
|
||||
if (state.orcaProfileAuthStatus?.state === 'connected') {
|
||||
return true
|
||||
}
|
||||
return (await state.connectCurrentOrcaProfile())?.status === 'connected'
|
||||
}
|
||||
|
||||
async function reconnectArtifactAccount(): Promise<boolean> {
|
||||
return (await useAppStore.getState().connectCurrentOrcaProfile())?.status === 'connected'
|
||||
}
|
||||
|
||||
function showArtifactPublishedToast(result: ArtifactPublishResult): void {
|
||||
toast.success(
|
||||
result.change === 'created'
|
||||
? translate('auto.components.artifacts.artifact-publish-flow.430019efd0', 'Artifact shared')
|
||||
: translate('auto.components.artifacts.artifact-publish-flow.2fc727c831', 'Artifact updated')
|
||||
)
|
||||
}
|
||||
|
||||
function artifactPreparationErrorDescription(code: ArtifactPublishPreparationErrorCode): string {
|
||||
switch (code) {
|
||||
case 'empty':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.fbb5018602',
|
||||
'This file is empty.'
|
||||
)
|
||||
case 'too-large':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.6112db5a1c',
|
||||
'Artifacts shared from Orca must be smaller than 800 KB.'
|
||||
)
|
||||
case 'unreadable':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.e2ed5acd8c',
|
||||
"Orca couldn't read this file. Open it from a workspace and try again."
|
||||
)
|
||||
case 'unsupported':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.6d475e9b25',
|
||||
'Only local HTML and Markdown files can be shared as artifacts.'
|
||||
)
|
||||
case 'binary':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.29a406be09',
|
||||
'Artifacts must contain text.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getPublishedArtifactLink } from './artifact-published-link-client'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('published artifact link client', () => {
|
||||
it('returns the locally persisted public link', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
|
||||
await expect(getPublishedArtifactLink('/repo/report.md')).resolves.toBe(
|
||||
'https://share.onorca.dev/a/artifact-a'
|
||||
)
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'artifacts.getPublishedLink',
|
||||
{ sourceKey: '/repo/report.md' }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when the source has not been shared', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: null })
|
||||
await expect(getPublishedArtifactLink('/repo/report.md')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects when the account must reconnect', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'reconnect-required' })
|
||||
await expect(getPublishedArtifactLink('/repo/report.md')).rejects.toThrow('reconnect-required')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import type { ArtifactCloudOperation, ArtifactPublishedLink } from '../../../../shared/artifacts'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
const LOCAL_RUNTIME = { kind: 'local' } as const
|
||||
|
||||
export async function getPublishedArtifactLink(sourceKey: string): Promise<string | null> {
|
||||
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactPublishedLink | null>>(
|
||||
LOCAL_RUNTIME,
|
||||
'artifacts.getPublishedLink',
|
||||
{ sourceKey }
|
||||
)
|
||||
if (result.status === 'ok') {
|
||||
return result.value?.shareUrl ?? null
|
||||
}
|
||||
throw new Error(result.status)
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
|
||||
import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview'
|
||||
|
|
@ -193,6 +193,12 @@ import { MarkupOverlay } from './markup/MarkupOverlay'
|
|||
import { MarkupDrawButton } from './markup/MarkupDrawButton'
|
||||
import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery'
|
||||
import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay'
|
||||
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
|
||||
import {
|
||||
browserFileUrlToAbsolutePath,
|
||||
getShareableBrowserArtifactFile,
|
||||
readBrowserHtmlArtifactRequest
|
||||
} from './browser-artifact-upload'
|
||||
import {
|
||||
BROWSER_GUEST_RECOVERY_ERROR_CODE,
|
||||
createBrowserPageGuestRecovery
|
||||
|
|
@ -591,26 +597,8 @@ function isChromiumErrorPage(url: string): boolean {
|
|||
return url.startsWith('chrome-error://')
|
||||
}
|
||||
|
||||
function fileUrlToAbsolutePath(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
const hostPrefix =
|
||||
parsed.hostname && parsed.hostname !== 'localhost' ? `//${parsed.hostname}` : ''
|
||||
let absolutePath = `${hostPrefix}${decodeURIComponent(parsed.pathname)}`
|
||||
if (/^\/[A-Za-z]:\//.test(absolutePath)) {
|
||||
absolutePath = absolutePath.slice(1)
|
||||
}
|
||||
return absolutePath
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getNotebookPathFromBrowserUrl(url: string): string | null {
|
||||
const filePath = fileUrlToAbsolutePath(url)
|
||||
const filePath = browserFileUrlToAbsolutePath(url)
|
||||
return filePath?.toLowerCase().endsWith('.ipynb') ? filePath : null
|
||||
}
|
||||
|
||||
|
|
@ -2517,6 +2505,7 @@ function BrowserPagePane({
|
|||
const handleInternalFileDragOverRef = useRef<(event: DragEvent<HTMLDivElement>) => void>(() => {})
|
||||
const handleInternalFileDropRef = useRef<(event: DragEvent<HTMLDivElement>) => void>(() => {})
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
const workspaceConnectionId = useAppStore((state) => getConnectionIdFromState(state, worktreeId))
|
||||
const browserDefaultZoomLevel = useAppStore(
|
||||
(state) => state.browserDefaultZoomLevel ?? DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
|
||||
)
|
||||
|
|
@ -4569,6 +4558,8 @@ function BrowserPagePane({
|
|||
const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL
|
||||
const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url)
|
||||
const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url)
|
||||
const shareableArtifactFile =
|
||||
workspaceConnectionId === null ? getShareableBrowserArtifactFile(currentBrowserUrl) : null
|
||||
const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl
|
||||
const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl)
|
||||
const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab
|
||||
|
|
@ -5042,6 +5033,14 @@ function BrowserPagePane({
|
|||
surfaceActive={isActive}
|
||||
/>
|
||||
|
||||
{shareableArtifactFile ? (
|
||||
<ArtifactPublishButton
|
||||
sourceKey={shareableArtifactFile.filePath}
|
||||
className="h-7 w-7"
|
||||
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import type { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
|
||||
import {
|
||||
getShareableBrowserArtifactFile,
|
||||
readBrowserHtmlArtifactRequest
|
||||
} from './browser-artifact-upload'
|
||||
|
||||
const stat = vi.fn()
|
||||
const readFile = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.api = { fs: { stat, readFile } } as never
|
||||
stat.mockResolvedValue({ size: 20, isDirectory: false, mtime: 1 })
|
||||
readFile.mockResolvedValue({ content: '<h1>Mock</h1>', isBinary: false })
|
||||
})
|
||||
|
||||
describe('browser artifact upload', () => {
|
||||
it('recognizes local HTML URLs across path flavors', () => {
|
||||
expect(getShareableBrowserArtifactFile('file:///tmp/Design%20Review.html')).toEqual({
|
||||
fileName: 'Design Review.html',
|
||||
filePath: '/tmp/Design Review.html'
|
||||
})
|
||||
expect(getShareableBrowserArtifactFile('file:///C:/repo/report.HTM')).toEqual({
|
||||
fileName: 'report.HTM',
|
||||
filePath: 'C:\\repo\\report.HTM'
|
||||
})
|
||||
expect(getShareableBrowserArtifactFile('file://server/share/report.html')).toEqual({
|
||||
fileName: 'report.html',
|
||||
filePath: '\\\\server\\share\\report.html'
|
||||
})
|
||||
expect(getShareableBrowserArtifactFile('https://example.com/report.html')).toBeNull()
|
||||
expect(getShareableBrowserArtifactFile('file:///tmp/report.md')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the backing file through the authorized filesystem API', async () => {
|
||||
await expect(readBrowserHtmlArtifactRequest('file:///tmp/report.html')).resolves.toEqual({
|
||||
sourceKey: '/tmp/report.html',
|
||||
content: '<h1>Mock</h1>',
|
||||
contentType: 'text/html',
|
||||
fileName: 'report.html'
|
||||
})
|
||||
expect(stat).toHaveBeenCalledWith({ filePath: '/tmp/report.html' })
|
||||
expect(readFile).toHaveBeenCalledWith({ filePath: '/tmp/report.html' })
|
||||
})
|
||||
|
||||
it('rejects oversized and unreadable files before upload', async () => {
|
||||
stat.mockResolvedValueOnce({
|
||||
size: ARTIFACT_CLI_MAX_RPC_BYTES + 1,
|
||||
isDirectory: false,
|
||||
mtime: 1
|
||||
})
|
||||
await expect(readBrowserHtmlArtifactRequest('file:///tmp/large.html')).rejects.toMatchObject({
|
||||
code: 'too-large'
|
||||
} satisfies Partial<ArtifactPublishPreparationError>)
|
||||
expect(readFile).not.toHaveBeenCalled()
|
||||
|
||||
stat.mockRejectedValueOnce(new Error('access denied'))
|
||||
await expect(readBrowserHtmlArtifactRequest('file:///tmp/private.html')).rejects.toMatchObject({
|
||||
code: 'unreadable'
|
||||
} satisfies Partial<ArtifactPublishPreparationError>)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import { getRuntimePathBasename } from '../../../../shared/cross-platform-path'
|
||||
import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
|
||||
|
||||
export type ShareableBrowserArtifactFile = {
|
||||
fileName: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export function browserFileUrlToAbsolutePath(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
const hostPrefix =
|
||||
parsed.hostname && parsed.hostname !== 'localhost' ? `//${parsed.hostname}` : ''
|
||||
let absolutePath = `${hostPrefix}${decodeURIComponent(parsed.pathname)}`
|
||||
if (/^\/[A-Za-z]:\//.test(absolutePath)) {
|
||||
absolutePath = absolutePath.slice(1)
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(absolutePath) || absolutePath.startsWith('//')) {
|
||||
absolutePath = absolutePath.replaceAll('/', '\\')
|
||||
}
|
||||
return absolutePath
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getShareableBrowserArtifactFile(url: string): ShareableBrowserArtifactFile | null {
|
||||
const filePath = browserFileUrlToAbsolutePath(url)
|
||||
if (!filePath || !/\.html?$/i.test(filePath)) {
|
||||
return null
|
||||
}
|
||||
const fileName = getRuntimePathBasename(filePath)
|
||||
return fileName ? { fileName, filePath } : null
|
||||
}
|
||||
|
||||
export async function readBrowserHtmlArtifactRequest(url: string): Promise<ArtifactWriteRequest> {
|
||||
const file = getShareableBrowserArtifactFile(url)
|
||||
if (!file) {
|
||||
throw new ArtifactPublishPreparationError('unsupported')
|
||||
}
|
||||
try {
|
||||
const stat = await window.api.fs.stat({ filePath: file.filePath })
|
||||
if (stat.isDirectory) {
|
||||
throw new ArtifactPublishPreparationError('unsupported')
|
||||
}
|
||||
if (stat.size > ARTIFACT_CLI_MAX_RPC_BYTES) {
|
||||
throw new ArtifactPublishPreparationError('too-large')
|
||||
}
|
||||
const result = await window.api.fs.readFile({ filePath: file.filePath })
|
||||
if (result.isBinary) {
|
||||
throw new ArtifactPublishPreparationError('binary')
|
||||
}
|
||||
return {
|
||||
sourceKey: file.filePath,
|
||||
content: result.content,
|
||||
contentType: 'text/html',
|
||||
fileName: file.fileName
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactPublishPreparationError) {
|
||||
throw error
|
||||
}
|
||||
throw new ArtifactPublishPreparationError('unreadable')
|
||||
}
|
||||
}
|
||||
|
|
@ -370,7 +370,7 @@ describe('AgentKanbanBoard', () => {
|
|||
renderBoard([card({ bucket: 'done' })])
|
||||
|
||||
expect(screen.getByText('完了')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('エージェントを検索')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Agent を検索')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^フィルター/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from './editor-panel-git-entry-selector'
|
||||
import { createEditorPanelDraftSelector } from './editor-panel-draft-selector'
|
||||
import { attemptEditorFileSave } from './editor-file-save-attempt'
|
||||
import { createCurrentMarkdownArtifactRequest } from './markdown-artifact-upload'
|
||||
|
||||
function EditorPanelInner({
|
||||
activeFileId: activeFileIdProp,
|
||||
|
|
@ -348,6 +349,14 @@ function EditorPanelInner({
|
|||
markdownFrontmatterVisible[markdownDocumentStateFileId] ?? true
|
||||
const isMarkdownTableOfContentsVisible =
|
||||
markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false
|
||||
const createActiveMarkdownArtifactRequest = () =>
|
||||
Promise.resolve(
|
||||
createCurrentMarkdownArtifactRequest(
|
||||
activeFile,
|
||||
markdownDocumentStateFileId,
|
||||
activeMarkdownContent ?? ''
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
// Why: each split pane needs an isolated bridge between its diff editor and header controls.
|
||||
|
|
@ -389,6 +398,9 @@ function EditorPanelInner({
|
|||
onExportMarkdownToPdf={() =>
|
||||
void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
|
||||
}
|
||||
createMarkdownArtifactRequest={
|
||||
activeMarkdownContent === null ? undefined : createActiveMarkdownArtifactRequest
|
||||
}
|
||||
onContentChange={handleContentChange}
|
||||
onContentChangeForFile={handleContentChangeForFile}
|
||||
onDirtyStateHint={handleDirtyStateHint}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { EditorPanelHeader } from './EditorPanelHeader'
|
||||
|
|
@ -41,6 +42,10 @@ vi.mock('./EditorPanelMarkdownActionsMenu', () => ({
|
|||
EditorPanelMarkdownActionsMenu: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/artifacts/ArtifactPublishButton', () => ({
|
||||
ArtifactPublishButton: () => <button data-artifact-publish />
|
||||
}))
|
||||
|
||||
vi.mock('./diff-navigation-context', () => ({
|
||||
useDiffNavigation: () => ({
|
||||
changeCount: 2,
|
||||
|
|
@ -59,43 +64,47 @@ const activeFile: OpenFile = {
|
|||
mode: 'diff'
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
activeFile,
|
||||
copiedPathVisible: false,
|
||||
isSingleDiff: false,
|
||||
isDiffSurface: true,
|
||||
isMarkdown: false,
|
||||
isCsv: false,
|
||||
isNotebook: false,
|
||||
hasEditorToggle: false,
|
||||
availableEditorToggleModes: [],
|
||||
effectiveToggleValue: 'edit',
|
||||
canOpenPreviewToSide: false,
|
||||
canShowMarkdownPreview: false,
|
||||
canShowMarkdownTableOfContents: false,
|
||||
isMarkdownTableOfContentsDisabled: false,
|
||||
shouldShowMarkdownExportAction: false,
|
||||
canExportMarkdownToPdf: false,
|
||||
showMarkdownTableOfContents: false,
|
||||
canShowMarkdownFrontmatterToggle: false,
|
||||
markdownFrontmatterVisible: false,
|
||||
sideBySide: false,
|
||||
openFileState: { canOpen: false },
|
||||
onCopyPath: vi.fn(),
|
||||
onOpenDiffTargetFile: vi.fn(),
|
||||
onOpenPreviewToSide: vi.fn(),
|
||||
onOpenMarkdownPreview: vi.fn(),
|
||||
onOpenContainingFolder: vi.fn(),
|
||||
onToggleSideBySide: vi.fn(),
|
||||
onEditorToggleChange: vi.fn(),
|
||||
onToggleMarkdownTableOfContents: vi.fn(),
|
||||
onToggleMarkdownFrontmatter: vi.fn(),
|
||||
onExportMarkdownToPdf: vi.fn()
|
||||
} satisfies ComponentProps<typeof EditorPanelHeader>
|
||||
|
||||
function renderHeader(overrides: Partial<ComponentProps<typeof EditorPanelHeader>> = {}): string {
|
||||
return renderToStaticMarkup(<EditorPanelHeader {...baseProps} {...overrides} />)
|
||||
}
|
||||
|
||||
describe('EditorPanelHeader', () => {
|
||||
it('shares one tooltip provider across the diff header controls', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorPanelHeader
|
||||
activeFile={activeFile}
|
||||
copiedPathVisible={false}
|
||||
isSingleDiff={false}
|
||||
isDiffSurface
|
||||
isMarkdown={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
hasEditorToggle={false}
|
||||
availableEditorToggleModes={[]}
|
||||
effectiveToggleValue="edit"
|
||||
canOpenPreviewToSide={false}
|
||||
canShowMarkdownPreview={false}
|
||||
canShowMarkdownTableOfContents={false}
|
||||
isMarkdownTableOfContentsDisabled={false}
|
||||
shouldShowMarkdownExportAction={false}
|
||||
canExportMarkdownToPdf={false}
|
||||
showMarkdownTableOfContents={false}
|
||||
canShowMarkdownFrontmatterToggle={false}
|
||||
markdownFrontmatterVisible={false}
|
||||
sideBySide={false}
|
||||
openFileState={{ canOpen: false }}
|
||||
onCopyPath={vi.fn()}
|
||||
onOpenDiffTargetFile={vi.fn()}
|
||||
onOpenPreviewToSide={vi.fn()}
|
||||
onOpenMarkdownPreview={vi.fn()}
|
||||
onOpenContainingFolder={vi.fn()}
|
||||
onToggleSideBySide={vi.fn()}
|
||||
onEditorToggleChange={vi.fn()}
|
||||
onToggleMarkdownTableOfContents={vi.fn()}
|
||||
onToggleMarkdownFrontmatter={vi.fn()}
|
||||
onExportMarkdownToPdf={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const html = renderHeader()
|
||||
|
||||
expect(html.match(/data-tooltip-provider/g)).toHaveLength(1)
|
||||
expect(html.match(/data-tooltip="true"/g)).toHaveLength(3)
|
||||
|
|
@ -103,4 +112,30 @@ describe('EditorPanelHeader', () => {
|
|||
expect(html).toContain('aria-label="Previous change"')
|
||||
expect(html).toContain('aria-label="Next change"')
|
||||
})
|
||||
|
||||
it('offers artifact sharing only on non-diff Markdown surfaces', () => {
|
||||
const createRequest = vi.fn()
|
||||
|
||||
expect(
|
||||
renderHeader({
|
||||
isDiffSurface: false,
|
||||
isMarkdown: true,
|
||||
createMarkdownArtifactRequest: createRequest
|
||||
})
|
||||
).toContain('data-artifact-publish="true"')
|
||||
expect(
|
||||
renderHeader({
|
||||
isDiffSurface: true,
|
||||
isMarkdown: true,
|
||||
createMarkdownArtifactRequest: createRequest
|
||||
})
|
||||
).not.toContain('data-artifact-publish')
|
||||
expect(
|
||||
renderHeader({
|
||||
isDiffSurface: false,
|
||||
isMarkdown: false,
|
||||
createMarkdownArtifactRequest: createRequest
|
||||
})
|
||||
).not.toContain('data-artifact-publish')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
|
|||
import { useDiffNavigation } from './diff-navigation-context'
|
||||
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
|
||||
import { markdownArtifactSourceKey } from './markdown-artifact-upload'
|
||||
|
||||
type EditorPanelHeaderProps = {
|
||||
activeFile: OpenFile
|
||||
|
|
@ -50,6 +53,7 @@ type EditorPanelHeaderProps = {
|
|||
onToggleMarkdownTableOfContents: () => void
|
||||
onToggleMarkdownFrontmatter: () => void
|
||||
onExportMarkdownToPdf: () => void
|
||||
createMarkdownArtifactRequest?: () => Promise<ArtifactWriteRequest>
|
||||
}
|
||||
|
||||
export function EditorPanelHeader({
|
||||
|
|
@ -83,7 +87,8 @@ export function EditorPanelHeader({
|
|||
onEditorToggleChange,
|
||||
onToggleMarkdownTableOfContents,
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf
|
||||
onExportMarkdownToPdf,
|
||||
createMarkdownArtifactRequest
|
||||
}: EditorPanelHeaderProps): React.JSX.Element {
|
||||
const diffComments = useAppStore((s) =>
|
||||
selectWorktreeDiffCommentsOrEmpty(s, activeFile.worktreeId)
|
||||
|
|
@ -311,6 +316,13 @@ export function EditorPanelHeader({
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isMarkdown && !isDiffSurface && createMarkdownArtifactRequest ? (
|
||||
<ArtifactPublishButton
|
||||
sourceKey={markdownArtifactSourceKey(activeFile)}
|
||||
className="size-6 [&_svg]:size-3.5!"
|
||||
createRequest={createMarkdownArtifactRequest}
|
||||
/>
|
||||
) : null}
|
||||
<EditorPanelMarkdownActionsMenu
|
||||
isMarkdown={isMarkdown}
|
||||
isDiffSurface={isDiffSurface}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { DiffContent, FileContent } from './editor-panel-content-types'
|
|||
import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import { getUntitledFileRoot } from './untitled-file-rename-path'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
|
||||
type EditorPanelRenderModel = ReturnType<typeof getEditorPanelRenderModel>
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ type EditorPanelShellProps = {
|
|||
onToggleMarkdownTableOfContents: () => void
|
||||
onToggleMarkdownFrontmatter: () => void
|
||||
onExportMarkdownToPdf: () => void
|
||||
createMarkdownArtifactRequest?: () => Promise<ArtifactWriteRequest>
|
||||
onContentChange: (content: string) => void
|
||||
onContentChangeForFile: (file: OpenFile, content: string) => void
|
||||
onDirtyStateHint: (dirty: boolean) => void
|
||||
|
|
@ -81,6 +83,7 @@ export function EditorPanelShell({
|
|||
onToggleMarkdownTableOfContents,
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf,
|
||||
createMarkdownArtifactRequest,
|
||||
onContentChange,
|
||||
onContentChangeForFile,
|
||||
onDirtyStateHint,
|
||||
|
|
@ -127,6 +130,7 @@ export function EditorPanelShell({
|
|||
onToggleMarkdownTableOfContents={onToggleMarkdownTableOfContents}
|
||||
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
|
||||
onExportMarkdownToPdf={onExportMarkdownToPdf}
|
||||
createMarkdownArtifactRequest={createMarkdownArtifactRequest}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<EditorLoadingFallback />}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
createCurrentMarkdownArtifactRequest,
|
||||
createMarkdownArtifactRequest,
|
||||
markdownArtifactSourceKey
|
||||
} from './markdown-artifact-upload'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
drafts: {} as Record<string, string>,
|
||||
flush: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => ({ editorDrafts: mocks.drafts }) }
|
||||
}))
|
||||
vi.mock('./editor-pending-flush', () => ({
|
||||
flushPendingEditorChange: mocks.flush
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.drafts = {}
|
||||
mocks.flush.mockReset()
|
||||
})
|
||||
|
||||
function openFile(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: '/repo/notes.md',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'worktree-1',
|
||||
language: 'markdown',
|
||||
isDirty: false,
|
||||
mode: 'edit',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('Markdown artifact upload', () => {
|
||||
it('uses the ordinary file path for local and folder workspaces', () => {
|
||||
expect(markdownArtifactSourceKey(openFile())).toBe('/repo/notes.md')
|
||||
expect(createMarkdownArtifactRequest(openFile(), '# Draft')).toEqual({
|
||||
sourceKey: '/repo/notes.md',
|
||||
content: '# Draft',
|
||||
contentType: 'text/markdown',
|
||||
fileName: 'notes.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates source identity by runtime owner', () => {
|
||||
const file = openFile({
|
||||
runtimeEnvironmentId: 'server-1',
|
||||
operationProvenance: {
|
||||
ownershipProjection: 'explicit',
|
||||
generation: {
|
||||
route: {
|
||||
runtimeEnvironmentId: 'server-1',
|
||||
executionHostId: 'ssh:build-box'
|
||||
},
|
||||
runtimeConnectionGeneration: 1,
|
||||
runtimePairingRevision: 1,
|
||||
runtimeSshGeneration: 1,
|
||||
nestedSshGeneration: 1,
|
||||
directSshGeneration: null
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(JSON.parse(markdownArtifactSourceKey(file))).toEqual([
|
||||
'ssh',
|
||||
'build-box',
|
||||
'/repo/notes.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('matches the SSH CLI source identity for external files', () => {
|
||||
expect(
|
||||
JSON.parse(markdownArtifactSourceKey(openFile({ externalSshTargetId: 'build-box' })))
|
||||
).toEqual(['ssh', 'build-box', '/repo/notes.md'])
|
||||
})
|
||||
|
||||
it('flushes and reads the latest unsaved editor buffer', () => {
|
||||
mocks.flush.mockImplementation((fileId: string) => {
|
||||
mocks.drafts[fileId] = '# Latest edit'
|
||||
})
|
||||
|
||||
expect(
|
||||
createCurrentMarkdownArtifactRequest(openFile(), '/repo/notes.md', '# Stale content').content
|
||||
).toBe('# Latest edit')
|
||||
expect(mocks.flush).toHaveBeenCalledWith('/repo/notes.md')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { sshArtifactSourceKey } from '../../../../shared/artifact-cli-bridge'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { basename } from '@/lib/path'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { flushPendingEditorChange } from './editor-pending-flush'
|
||||
|
||||
export function markdownArtifactSourceKey(file: OpenFile): string {
|
||||
const route = file.operationProvenance?.generation.route
|
||||
const host = parseExecutionHostId(route?.executionHostId)
|
||||
if (host?.kind === 'ssh') {
|
||||
return sshArtifactSourceKey(host.targetId, file.filePath)
|
||||
}
|
||||
if (file.externalSshTargetId) {
|
||||
return sshArtifactSourceKey(file.externalSshTargetId, file.filePath)
|
||||
}
|
||||
if (route && (route.runtimeEnvironmentId || route.executionHostId !== 'local')) {
|
||||
return JSON.stringify([
|
||||
'editor',
|
||||
route.runtimeEnvironmentId ?? null,
|
||||
route.executionHostId,
|
||||
file.filePath
|
||||
])
|
||||
}
|
||||
if (file.runtimeEnvironmentId) {
|
||||
return JSON.stringify(['editor', file.runtimeEnvironmentId ?? null, 'remote', file.filePath])
|
||||
}
|
||||
return file.filePath
|
||||
}
|
||||
|
||||
export function createMarkdownArtifactRequest(
|
||||
file: OpenFile,
|
||||
content: string
|
||||
): ArtifactWriteRequest {
|
||||
return {
|
||||
sourceKey: markdownArtifactSourceKey(file),
|
||||
content,
|
||||
contentType: 'text/markdown',
|
||||
fileName: basename(file.filePath)
|
||||
}
|
||||
}
|
||||
|
||||
export function createCurrentMarkdownArtifactRequest(
|
||||
file: OpenFile,
|
||||
contentFileId: string,
|
||||
fallbackContent: string
|
||||
): ArtifactWriteRequest {
|
||||
flushPendingEditorChange(contentFileId)
|
||||
const content = useAppStore.getState().editorDrafts[contentFileId] ?? fallbackContent
|
||||
return createMarkdownArtifactRequest(file, content)
|
||||
}
|
||||
|
|
@ -61,13 +61,15 @@ describe('ArtifactsSettingsPane', () => {
|
|||
)
|
||||
|
||||
expect(screen.getByText('How to use Artifacts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose a file to share')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('For example: “Share this HTML mock as an artifact.”')
|
||||
screen.getByText(
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Share the public link')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy the public link')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Your agent returns a link that anyone with the URL can view.')
|
||||
screen.getByText('After publishing, copy the link and send it to your team.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Manage it in Orca')).toBeInTheDocument()
|
||||
expect(
|
||||
|
|
@ -137,15 +139,15 @@ describe('ArtifactsSettingsPane', () => {
|
|||
expect(mocks.openArtifactsPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the publish capability off by default and describes it as device-wide', () => {
|
||||
it('describes public publishing and existing-link retention', () => {
|
||||
render(<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow publishing public artifact links' })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByText(/your agents and the orca CLI/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/mint links anyone with the URL can open/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/does not delete existing links/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Publish HTML and Markdown files as links/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Existing links remain until you delete them/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Off by default/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('grants and revokes the publish capability through the toggle', async () => {
|
||||
|
|
@ -198,9 +200,11 @@ describe('ArtifactsSettingsPane', () => {
|
|||
<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Allow publishing first')).toBeInTheDocument()
|
||||
expect(screen.getByText(/artifact_sharing_disabled/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Publishing is off, so nothing on this device/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Enable artifact sharing')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Turn on “Allow publishing public artifact links” above.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/Enable artifact sharing above/)).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<ArtifactsSettingsPane
|
||||
|
|
@ -208,8 +212,8 @@ describe('ArtifactsSettingsPane', () => {
|
|||
updateSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Allow publishing first')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Enable artifact sharing')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Choose a file to share')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('points web clients at the desktop app for the opt-in step', () => {
|
||||
|
|
|
|||
|
|
@ -41,16 +41,16 @@ export function ArtifactsSettingsPane({
|
|||
key: 'enable',
|
||||
title: translate(
|
||||
'auto.components.settings.artifacts.enableStepTitle',
|
||||
'Allow publishing first'
|
||||
'Enable artifact sharing'
|
||||
),
|
||||
description: isWebClient
|
||||
? translate(
|
||||
'auto.components.settings.artifacts.enableStepWebDescription',
|
||||
'Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.'
|
||||
'Open Settings → Artifacts in the Orca desktop app on the host device and enable publishing.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.artifacts.enableStepDescription',
|
||||
'Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.'
|
||||
'Turn on “Allow publishing public artifact links” above.'
|
||||
)
|
||||
}
|
||||
]),
|
||||
|
|
@ -58,19 +58,19 @@ export function ArtifactsSettingsPane({
|
|||
key: 'share',
|
||||
title: translate(
|
||||
'auto.components.settings.artifacts.shareStepTitle',
|
||||
'Ask your agent to share it'
|
||||
'Choose a file to share'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.shareStepDescription',
|
||||
'For example: “Share this HTML mock as an artifact.”'
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'link',
|
||||
title: translate('auto.components.settings.artifacts.linkStepTitle', 'Share the public link'),
|
||||
title: translate('auto.components.settings.artifacts.linkStepTitle', 'Copy the public link'),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.linkStepDescription',
|
||||
'Your agent returns a link that anyone with the URL can view.'
|
||||
'After publishing, copy the link and send it to your team.'
|
||||
)
|
||||
},
|
||||
{
|
||||
|
|
@ -78,7 +78,7 @@ export function ArtifactsSettingsPane({
|
|||
title: translate('auto.components.settings.artifacts.manageStepTitle', 'Manage it in Orca'),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.manageStepDescription',
|
||||
'Open Artifacts from the sidebar to revisit or delete links owned by your account.'
|
||||
'Open Artifacts from the sidebar to preview or remove links.'
|
||||
)
|
||||
}
|
||||
]
|
||||
|
|
@ -94,11 +94,11 @@ export function ArtifactsSettingsPane({
|
|||
isWebClient
|
||||
? translate(
|
||||
'auto.components.settings.artifacts.allowPublishingWebDescription',
|
||||
'Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.'
|
||||
'Desktop only. Open Settings → Artifacts on the host device to change this setting.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.artifacts.allowPublishingDescription',
|
||||
'Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.'
|
||||
'Publish HTML and Markdown files as links anyone with the URL can open. Existing links remain until you delete them from Artifacts.'
|
||||
)
|
||||
}
|
||||
checked={sharingEnabled}
|
||||
|
|
@ -153,11 +153,11 @@ export function ArtifactsSettingsPane({
|
|||
{sharingEnabled
|
||||
? translate(
|
||||
'auto.components.settings.artifacts.howToDescription',
|
||||
'Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.'
|
||||
'Publish HTML or Markdown files as public links, then share them with your team.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.artifacts.howToDescriptionDisabled',
|
||||
'Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.'
|
||||
'Enable artifact sharing above to publish HTML or Markdown files as public links.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,11 @@ describe('OrcaAccountSettingsPane', () => {
|
|||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
|
||||
render(<OrcaAccountSettingsPane />)
|
||||
|
||||
expect(screen.getByText('Sign in to use Artifacts and Orca Relay.')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function accountStatusCopy(
|
|||
if (state === 'local') {
|
||||
return translate(
|
||||
'auto.components.settings.orcaAccount.signedOut',
|
||||
'Sign in to use Artifacts and Orca Relay.'
|
||||
'Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.'
|
||||
)
|
||||
}
|
||||
return translate('auto.components.settings.orcaAccount.checking', 'Checking account status…')
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const getArtifactsSettingsSearchEntries = createLocalizedCatalog(() => [
|
|||
),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.allowPublishingSearchDescription',
|
||||
'Let agents and the orca CLI upload files to your Orca account and mint public links.'
|
||||
'Allow Orca to publish HTML and Markdown files as public links.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.artifacts.keywordArtifacts', 'artifacts'),
|
||||
|
|
|
|||
|
|
@ -10432,13 +10432,13 @@
|
|||
"title": "Artifacts",
|
||||
"description": "Share HTML and Markdown files with your team and manage their public links.",
|
||||
"howToTitle": "How to use Artifacts",
|
||||
"howToDescription": "Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.",
|
||||
"shareStepTitle": "Ask your agent to share it",
|
||||
"shareStepDescription": "For example: “Share this HTML mock as an artifact.”",
|
||||
"linkStepTitle": "Share the public link",
|
||||
"linkStepDescription": "Your agent returns a link that anyone with the URL can view.",
|
||||
"howToDescription": "Publish HTML or Markdown files as public links, then share them with your team.",
|
||||
"shareStepTitle": "Choose a file to share",
|
||||
"shareStepDescription": "Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.",
|
||||
"linkStepTitle": "Copy the public link",
|
||||
"linkStepDescription": "After publishing, copy the link and send it to your team.",
|
||||
"manageStepTitle": "Manage it in Orca",
|
||||
"manageStepDescription": "Open Artifacts from the sidebar to revisit or delete links owned by your account.",
|
||||
"manageStepDescription": "Open Artifacts from the sidebar to preview or remove links.",
|
||||
"openArtifacts": "Open Artifacts",
|
||||
"openArtifactsDescription": "View and delete links shared through your account.",
|
||||
"showButton": "Show Artifacts Button",
|
||||
|
|
@ -10447,20 +10447,20 @@
|
|||
"signInTitle": "Sign in to share artifacts",
|
||||
"signInDescription": "Use your Orca account to upload artifacts and manage their public links.",
|
||||
"signInAgain": "Sign in again",
|
||||
"enableStepTitle": "Allow publishing first",
|
||||
"enableStepWebDescription": "Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.",
|
||||
"enableStepDescription": "Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.",
|
||||
"enableStepTitle": "Enable artifact sharing",
|
||||
"enableStepWebDescription": "Open Settings → Artifacts in the Orca desktop app on the host device and enable publishing.",
|
||||
"enableStepDescription": "Turn on “Allow publishing public artifact links” above.",
|
||||
"allowPublishing": "Allow publishing public artifact links",
|
||||
"allowPublishingWebDescription": "Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.",
|
||||
"allowPublishingDescription": "Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.",
|
||||
"howToDescriptionDisabled": "Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.",
|
||||
"allowPublishingSearchDescription": "Let agents and the orca CLI upload files to your Orca account and mint public links."
|
||||
"allowPublishingWebDescription": "Desktop only. Open Settings → Artifacts on the host device to change this setting.",
|
||||
"allowPublishingDescription": "Publish HTML and Markdown files as links anyone with the URL can open. Existing links remain until you delete them from Artifacts.",
|
||||
"howToDescriptionDisabled": "Enable artifact sharing above to publish HTML or Markdown files as public links.",
|
||||
"allowPublishingSearchDescription": "Allow Orca to publish HTML and Markdown files as public links."
|
||||
},
|
||||
"orcaAccount": {
|
||||
"connected": "Connected",
|
||||
"reconnectRequired": "Your session expired. Sign in again to use cloud features.",
|
||||
"unavailable": "Orca sign-in is unavailable in this build.",
|
||||
"signedOut": "Sign in to use Artifacts and Orca Relay.",
|
||||
"signedOut": "Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.",
|
||||
"checking": "Checking account status…",
|
||||
"account": "Orca account",
|
||||
"signOut": "Sign out",
|
||||
|
|
@ -14968,14 +14968,14 @@
|
|||
"signingIn": "Signing in…",
|
||||
"signIn": "Sign in to Orca",
|
||||
"empty": "No shared artifacts",
|
||||
"emptyCopy": "Ask your agent to share an HTML or Markdown file, and it will appear here.",
|
||||
"emptyCopy": "Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.",
|
||||
"openArtifact": "Open artifact",
|
||||
"deleteArtifact": "Delete artifact",
|
||||
"moreAvailable": "More artifacts are available",
|
||||
"moreAvailableCopy": "Load the next page to continue.",
|
||||
"loadMoreFailed": "Could not load more artifacts.",
|
||||
"publishingOff": "Publishing is turned off",
|
||||
"publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.",
|
||||
"publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.",
|
||||
"openArtifactsSettings": "Open Settings → Artifacts",
|
||||
"loadedCountMore": "{{count}} loaded · more available",
|
||||
"loadedCount": "{{count}} shared",
|
||||
|
|
@ -15009,7 +15009,45 @@
|
|||
"updatedRecently": "recently",
|
||||
"expiryUnknown": "Expiry unknown",
|
||||
"expired": "Link expired",
|
||||
"expires": "Link expires {{when}}"
|
||||
"expires": "Link expires {{when}}",
|
||||
"ArtifactPublishButton": {
|
||||
"a4a49da6af": "Share as artifact",
|
||||
"confirmTitle": "Share as artifact",
|
||||
"confirmDescription": "This publishes the current file at a link anyone with the URL can view.",
|
||||
"accountTitle": "Orca account",
|
||||
"accountDescription": "Sign in to create and manage this link.",
|
||||
"signingIn": "Signing in…",
|
||||
"signInAgain": "Sign in again",
|
||||
"signIn": "Sign in",
|
||||
"publishingOffTitle": "Artifact sharing is off",
|
||||
"publishingOffDescription": "Learn about public links and enable sharing in Settings.",
|
||||
"openSettings": "Open Artifacts settings",
|
||||
"sharing": "Sharing…",
|
||||
"sharePublicLink": "Share public link",
|
||||
"publishedDescription": "Anyone with this link can view the shared file.",
|
||||
"checkingLink": "Checking for an existing link…",
|
||||
"checkFailed": "Could not check for an existing link.",
|
||||
"tryAgain": "Try again"
|
||||
},
|
||||
"artifact-publish-flow": {
|
||||
"9a078a0c65": "Artifact sharing is unavailable",
|
||||
"bba20daa6d": "Sign in to Orca and try again.",
|
||||
"54b1805328": "Could not share artifact",
|
||||
"430019efd0": "Artifact shared",
|
||||
"2fc727c831": "Artifact updated",
|
||||
"5cb4f5ec36": "Copy link",
|
||||
"fbb5018602": "This file is empty.",
|
||||
"6112db5a1c": "Artifacts shared from Orca must be smaller than 800 KB.",
|
||||
"e2ed5acd8c": "Orca couldn't read this file. Open it from a workspace and try again.",
|
||||
"6d475e9b25": "Only local HTML and Markdown files can be shared as artifacts.",
|
||||
"29a406be09": "Artifacts must contain text."
|
||||
},
|
||||
"ArtifactPublishedLinkPanel": {
|
||||
"copyLink": "Copy link",
|
||||
"openLink": "Open link",
|
||||
"updating": "Updating…",
|
||||
"update": "Update shared content"
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
export const REMOTE_ARTIFACT_INPUT_ENV = 'ORCA_REMOTE_ARTIFACT_INPUT'
|
||||
|
||||
export function sshArtifactSourceKey(targetId: string, sourceKey: string): string {
|
||||
return JSON.stringify(['ssh', targetId, sourceKey])
|
||||
}
|
||||
|
||||
export type RemoteArtifactInput = {
|
||||
sourceKey: string
|
||||
fileName: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
export const ARTIFACT_CLI_MAX_RPC_BYTES = 800 * 1024
|
||||
|
||||
export function artifactWriteRequestByteLength(request: ArtifactWriteRequest): number {
|
||||
return new TextEncoder().encode(JSON.stringify(request)).byteLength
|
||||
}
|
||||
|
||||
export type ArtifactMetadata = {
|
||||
version: 1
|
||||
slug: string
|
||||
|
|
@ -34,6 +38,15 @@ export type ArtifactWriteRequest = {
|
|||
authToken?: string
|
||||
}
|
||||
|
||||
export type ArtifactPublishResult = {
|
||||
change: 'created' | 'updated'
|
||||
item: ArtifactListItem
|
||||
}
|
||||
|
||||
export type ArtifactPublishedLink = {
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export type ArtifactCloudOptions = {
|
||||
apiUrl?: string
|
||||
authToken?: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue