From ded700760fd7700647eec197e6742c6768a5b700 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:11:16 -0700 Subject: [PATCH] Add manual artifact sharing from HTML and Markdown views (#13369) (cherry picked from commit 639766827179ffd69f30bd03950fd3f33ce74b54) --- src/main/artifacts/artifact-cloud-request.ts | 39 +++ .../artifact-cloud-service-races.test.ts | 32 +- .../artifacts/artifact-cloud-service.test.ts | 166 ++++++++++ src/main/artifacts/artifact-cloud-service.ts | 212 ++++++------- src/main/artifacts/artifact-publisher.ts | 169 ++++++++++ src/main/global-fetch-call-site-audit.test.ts | 2 +- src/main/runtime/orca-runtime.ts | 14 + .../runtime/rpc/methods/artifacts.test.ts | 45 +++ src/main/runtime/rpc/methods/artifacts.ts | 41 ++- .../ssh-remote-cli-host-passthrough.test.ts | 4 +- .../ssh/ssh-remote-cli-host-passthrough.ts | 3 +- .../artifacts/ArtifactPublishButton.test.tsx | 206 ++++++++++++ .../artifacts/ArtifactPublishButton.tsx | 295 ++++++++++++++++++ .../artifacts/ArtifactPublishedLinkPanel.tsx | 132 ++++++++ .../artifacts/ArtifactsPage.test.tsx | 4 +- .../components/artifacts/ArtifactsPage.tsx | 4 +- .../artifacts/artifact-link-actions.ts | 11 +- .../artifacts/artifact-publish-flow.test.ts | 110 +++++++ .../artifacts/artifact-publish-flow.ts | 144 +++++++++ .../artifact-published-link-client.test.ts | 38 +++ .../artifact-published-link-client.ts | 16 + .../components/browser-pane/BrowserPane.tsx | 39 ++- .../browser-artifact-upload.test.ts | 66 ++++ .../browser-pane/browser-artifact-upload.ts | 70 +++++ .../AgentKanbanBoard.test.tsx | 2 +- .../src/components/editor/EditorPanel.tsx | 12 + .../editor/EditorPanelHeader.test.tsx | 105 ++++--- .../components/editor/EditorPanelHeader.tsx | 14 +- .../components/editor/EditorPanelShell.tsx | 4 + .../editor/markdown-artifact-upload.test.ts | 91 ++++++ .../editor/markdown-artifact-upload.ts | 52 +++ .../settings/ArtifactsSettingsPane.test.tsx | 30 +- .../settings/ArtifactsSettingsPane.tsx | 24 +- .../settings/OrcaAccountSettingsPane.test.tsx | 6 +- .../settings/OrcaAccountSettingsPane.tsx | 2 +- .../settings/artifacts-settings-search.ts | 2 +- src/renderer/src/i18n/locales/en.json | 72 ++++- src/shared/artifact-cli-bridge.ts | 4 + src/shared/artifacts.ts | 13 + 39 files changed, 2067 insertions(+), 228 deletions(-) create mode 100644 src/main/artifacts/artifact-cloud-request.ts create mode 100644 src/main/artifacts/artifact-publisher.ts create mode 100644 src/main/runtime/rpc/methods/artifacts.test.ts create mode 100644 src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx create mode 100644 src/renderer/src/components/artifacts/ArtifactPublishButton.tsx create mode 100644 src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx create mode 100644 src/renderer/src/components/artifacts/artifact-publish-flow.test.ts create mode 100644 src/renderer/src/components/artifacts/artifact-publish-flow.ts create mode 100644 src/renderer/src/components/artifacts/artifact-published-link-client.test.ts create mode 100644 src/renderer/src/components/artifacts/artifact-published-link-client.ts create mode 100644 src/renderer/src/components/browser-pane/browser-artifact-upload.test.ts create mode 100644 src/renderer/src/components/browser-pane/browser-artifact-upload.ts create mode 100644 src/renderer/src/components/editor/markdown-artifact-upload.test.ts create mode 100644 src/renderer/src/components/editor/markdown-artifact-upload.ts diff --git a/src/main/artifacts/artifact-cloud-request.ts b/src/main/artifacts/artifact-cloud-request.ts new file mode 100644 index 000000000..6f1e0bb0f --- /dev/null +++ b/src/main/artifacts/artifact-cloud-request.ts @@ -0,0 +1,39 @@ +import type { ArtifactWriteRequest } from '../../shared/artifacts' +import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' + +export function artifactWriteBody(request: ArtifactWriteRequest): Record { + return { + content: request.content, + contentType: request.contentType, + fileName: request.fileName, + ...(request.title ? { title: request.title } : {}) + } +} + +export async function artifactRequest( + apiUrl: string, + token: string, + path: string, + options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {} +): Promise { + 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 +} diff --git a/src/main/artifacts/artifact-cloud-service-races.test.ts b/src/main/artifacts/artifact-cloud-service-races.test.ts index 2e3f34d21..c31c2a23e 100644 --- a/src/main/artifacts/artifact-cloud-service-races.test.ts +++ b/src/main/artifacts/artifact-cloud-service-races.test.ts @@ -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`) diff --git a/src/main/artifacts/artifact-cloud-service.test.ts b/src/main/artifacts/artifact-cloud-service.test.ts index 6cc8f2388..75da3922f 100644 --- a/src/main/artifacts/artifact-cloud-service.test.ts +++ b/src/main/artifacts/artifact-cloud-service.test.ts @@ -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((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((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((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 }) diff --git a/src/main/artifacts/artifact-cloud-service.ts b/src/main/artifacts/artifact-cloud-service.ts index 78ec82d30..6abb0525e 100644 --- a/src/main/artifacts/artifact-cloud-service.ts +++ b/src/main/artifacts/artifact-cloud-service.ts @@ -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> { return this.withAuth(options, async (token, apiUrl) => { @@ -131,88 +135,121 @@ export class ArtifactCloudService { }) } + getPublishedLink( + request: ArtifactCloudOptions & { sourceKey: string } + ): Promise> { + 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> { assertArtifactSharingAllowed(this.isSharingEnabled) const idempotencyKey = randomUUID() - return this.withAuth(request, async (token, apiUrl, auth) => { - const response = await artifactRequest(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> { + 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> { 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(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( + 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> { - 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(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(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> { - return this.withAuth(options, async (token, apiUrl, auth) => { - await artifactRequest(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(apiUrl, token, `/${encodeURIComponent(id)}`, { + method: 'DELETE' + }) + removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id }) + }) + ) } private async withAuth( @@ -256,40 +293,3 @@ export class ArtifactCloudService { : { status: 'reconnect-required' } } } - -function writeBody(request: ArtifactWriteRequest): Record { - return { - content: request.content, - contentType: request.contentType, - fileName: request.fileName, - ...(request.title ? { title: request.title } : {}) - } -} - -async function artifactRequest( - apiUrl: string, - token: string, - path: string, - options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {} -): Promise { - 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 -} diff --git a/src/main/artifacts/artifact-publisher.ts b/src/main/artifacts/artifact-publisher.ts new file mode 100644 index 000000000..8f03c4e5b --- /dev/null +++ b/src/main/artifacts/artifact-publisher.ts @@ -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, + 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>() + + constructor(private readonly userDataPath: string) {} + + async share( + request: ArtifactWriteRequest, + token: string, + apiUrl: string, + auth: ArtifactPublishAuthContext, + idempotencyKey: string + ): Promise { + 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 { + 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(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( + sourceKey: string, + auth: Pick, + operation: () => Promise + ): Promise { + return this.runSerialized(artifactOperationQueueKey('source', auth, sourceKey), operation) + } + + runForSlug( + slug: string, + auth: Pick, + operation: () => Promise + ): Promise { + return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation) + } + + private async create( + request: ArtifactWriteRequest, + token: string, + apiUrl: string, + auth: ArtifactPublishAuthContext, + idempotencyKey: string + ): Promise { + const response = await artifactRequest(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(key: string, operation: () => Promise): Promise { + const previous = this.queues.get(key) ?? Promise.resolve() + let release = (): void => {} + const released = new Promise((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) + } + } + } +} diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index a2c8c688a..625d9fa2b 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -15,7 +15,7 @@ import { describe, expect, it } from 'vitest' // and update the count. const AUDITED_GLOBAL_FETCH_LINES = new Map([ // 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], diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0ba68b82d..7356b583b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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> { + return this.requireArtifactService().getPublishedLink(request) + } + shareArtifact(request: ArtifactWriteRequest): Promise> { return this.requireArtifactService().share(request) } + publishArtifact( + request: ArtifactWriteRequest + ): Promise> { + return this.requireArtifactService().publish(request) + } + updateArtifact(request: ArtifactWriteRequest): Promise> { return this.requireArtifactService().update(request) } diff --git a/src/main/runtime/rpc/methods/artifacts.test.ts b/src/main/runtime/rpc/methods/artifacts.test.ts new file mode 100644 index 000000000..2a84cf94d --- /dev/null +++ b/src/main/runtime/rpc/methods/artifacts.test.ts @@ -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: '

Report

', + 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) + }) +}) diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index 4712b273b..79d99ba7d 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -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({ diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index 67084cc6b..b4d7ca07f 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -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') }) }) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index bf480c13e..7749584a6 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -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 }) } diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx new file mode 100644 index 000000000..630c05b98 --- /dev/null +++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx @@ -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, + orcaProfileConnecting: false, + settings: { artifactSharingEnabled: true } + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => 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 }) =>
{children}
, + PopoverTrigger: ({ children }: { children: ReactNode }) => ( + mocks.openPopover?.(true)}>{children} + ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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((resolve) => { + finishCopy = resolve + }) + ) + const timeoutSpy = vi.spyOn(window, 'setTimeout') + const view = render( + + ) + 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) + }) +}) diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx new file mode 100644 index 000000000..052a2e8e9 --- /dev/null +++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx @@ -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 + 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(null) + const lookupSequence = useRef(0) + const popoverContentRef = useRef(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 => { + 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 ( + !busy && setOpen(nextOpen)}> + + + + + + + + {label} + + + + { + event.preventDefault() + popoverContentRef.current?.focus({ preventScroll: true }) + }} + > +
+

+ {translate( + 'auto.components.artifacts.ArtifactPublishButton.confirmTitle', + 'Share as artifact' + )} +

+

+ {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.' + )} +

+
+ +
+ {!signedIn ? ( +
+
+

+ {translate( + 'auto.components.artifacts.ArtifactPublishButton.accountTitle', + 'Orca account' + )} +

+

+ {translate( + 'auto.components.artifacts.ArtifactPublishButton.accountDescription', + 'Sign in to create and manage this link.' + )} +

+
+ +
+ ) : null} + + {!sharingEnabled ? ( +
+
+

+ {translate( + 'auto.components.artifacts.ArtifactPublishButton.publishingOffTitle', + 'Artifact sharing is off' + )} +

+

+ {translate( + 'auto.components.artifacts.ArtifactPublishButton.publishingOffDescription', + 'Learn about public links and enable sharing in Settings.' + )} +

+
+ +
+ ) : null} + + {checkingLink ? ( +
+ + {translate( + 'auto.components.artifacts.ArtifactPublishButton.checkingLink', + 'Checking for an existing link…' + )} +
+ ) : currentLookup?.status === 'error' ? ( +
+

+ {translate( + 'auto.components.artifacts.ArtifactPublishButton.checkFailed', + 'Could not check for an existing link.' + )} +

+ +
+ ) : publishedLink ? ( + void publish()} + /> + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx b/src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx new file mode 100644 index 000000000..4e5aa292b --- /dev/null +++ b/src/renderer/src/components/artifacts/ArtifactPublishedLinkPanel.tsx @@ -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(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 => { + 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 ( +
+
+

+ {shareUrl} +

+ + + + + + {copyLabel} + + + + + + + + {translate( + 'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink', + 'Open link' + )} + + +
+ + {sharingEnabled ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx index 1b87ff051..b04cd4395 100644 --- a/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx +++ b/src/renderer/src/components/artifacts/ArtifactsPage.test.tsx @@ -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( diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.tsx index 58d5ad9a2..d442e7a11 100644 --- a/src/renderer/src/components/artifacts/ArtifactsPage.tsx +++ b/src/renderer/src/components/artifacts/ArtifactsPage.tsx @@ -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.' )}

{!nextCursor && publishingBlocked ? ( diff --git a/src/renderer/src/components/artifacts/artifact-link-actions.ts b/src/renderer/src/components/artifacts/artifact-link-actions.ts index 31685b2b7..39ec549e0 100644 --- a/src/renderer/src/components/artifacts/artifact-link-actions.ts +++ b/src/renderer/src/components/artifacts/artifact-link-actions.ts @@ -1,12 +1,19 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -export async function copyArtifactLink(shareUrl: string): Promise { +export async function copyArtifactLink( + shareUrl: string, + options: { showSuccessToast?: boolean } = {} +): Promise { 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 } } diff --git a/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts b/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts new file mode 100644 index 000000000..36ec7d0f2 --- /dev/null +++ b/src/renderer/src/components/artifacts/artifact-publish-flow.test.ts @@ -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: '

Report

', + 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: '

Fresh

' }) + + await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published) + expect(mocks.connect).toHaveBeenCalledOnce() + expect(createRequest).toHaveBeenCalledTimes(2) + expect(mocks.callRuntimeRpc.mock.calls[1]?.[2]).toMatchObject({ + content: '

Fresh

' + }) + }) + + 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') + }) +}) diff --git a/src/renderer/src/components/artifacts/artifact-publish-flow.ts b/src/renderer/src/components/artifacts/artifact-publish-flow.ts new file mode 100644 index 000000000..715bb036b --- /dev/null +++ b/src/renderer/src/components/artifacts/artifact-publish-flow.ts @@ -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 +): Promise { + try { + if (!(await ensureArtifactAccountConnected())) { + return null + } + for (let attempt = 0; attempt < 2; attempt += 1) { + const request = validateArtifactPublishRequest(await createRequest()) + const result = await callRuntimeRpc>( + 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 { + const state = useAppStore.getState() + if (state.orcaProfileAuthStatus?.state === 'connected') { + return true + } + return (await state.connectCurrentOrcaProfile())?.status === 'connected' +} + +async function reconnectArtifactAccount(): Promise { + 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.' + ) + } +} diff --git a/src/renderer/src/components/artifacts/artifact-published-link-client.test.ts b/src/renderer/src/components/artifacts/artifact-published-link-client.test.ts new file mode 100644 index 000000000..7b758db41 --- /dev/null +++ b/src/renderer/src/components/artifacts/artifact-published-link-client.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/artifacts/artifact-published-link-client.ts b/src/renderer/src/components/artifacts/artifact-published-link-client.ts new file mode 100644 index 000000000..48d9818e6 --- /dev/null +++ b/src/renderer/src/components/artifacts/artifact-published-link-client.ts @@ -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 { + const result = await callRuntimeRpc>( + LOCAL_RUNTIME, + 'artifacts.getPublishedLink', + { sourceKey } + ) + if (result.status === 'ok') { + return result.value?.shareUrl ?? null + } + throw new Error(result.status) +} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 4ed12bc4f..90a7e1fdc 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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) => void>(() => {}) const handleInternalFileDropRef = useRef<(event: DragEvent) => 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 ? ( + readBrowserHtmlArtifactRequest(currentBrowserUrl)} + /> + ) : null} +