Skip to content

Commit

Permalink
Docs panel: add support for loading images from the Cloud (#11884)
Browse files Browse the repository at this point in the history
  • Loading branch information
MrFlashAccount authored and Frizi committed Dec 30, 2024
1 parent 9dfd470 commit 7b8ae38
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 73 deletions.
5 changes: 5 additions & 0 deletions app/common/src/text/english.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"submit": "Submit",
"retry": "Retry",

"arbitraryFetchError": "An error occurred while fetching data",
"arbitraryFetchImageError": "An error occurred while fetching an image",

"createFolderError": "Could not create new folder",
"createProjectError": "Could not create new project",
"createDatalinkError": "Could not create new Datalink",
Expand Down Expand Up @@ -174,6 +178,7 @@
"getCustomerPortalUrlBackendError": "Could not get customer portal URL",
"duplicateLabelError": "This label already exists.",
"emptyStringError": "This value must not be empty.",
"resolveProjectAssetPathBackendError": "Could not get asset",

"directoryAssetType": "folder",
"directoryDoesNotExistError": "Unable to find directory. Does it exist?",
Expand Down
29 changes: 18 additions & 11 deletions app/gui/integration-test/dashboard/actions/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as actions from '.'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import invariant from 'tiny-invariant'

const __dirname = dirname(fileURLToPath(import.meta.url))

Expand Down Expand Up @@ -1129,17 +1130,23 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
})

await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route, request) => {
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]
if (!maybeId) return
const projectId = backend.ProjectId(maybeId)
called('getProjectAsset', { projectId })
return route.fulfill({
// This is a mock SVG image. Just a square with a black background.
body: '/mock/svg.svg',
contentType: 'text/plain',
})
})
await get(
remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'),
async (route, request) => {
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]

invariant(maybeId, 'Unable to parse the ID provided')

const projectId = backend.ProjectId(maybeId)

called('getProjectAsset', { projectId })

return route.fulfill({
// This is a mock SVG image. Just a square with a black background.
path: join(__dirname, '../mock/example.png'),
})
},
)

await page.route('mock/svg.svg', (route) => {
return route.fulfill({ body: MOCK_SVG, contentType: 'image/svg+xml' })
Expand Down
23 changes: 22 additions & 1 deletion app/gui/integration-test/dashboard/assetPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { EmailAddress, UserId } from '#/services/Backend'

import { PermissionAction } from '#/utilities/permissions'

import { mockAllAndLogin } from './actions'
import { mockAllAndLogin, TEXT } from './actions'

/** Find an asset panel. */
function locateAssetPanel(page: Page) {
Expand Down Expand Up @@ -87,4 +87,25 @@ test('Asset Panel documentation view', ({ page }) =>
await expect(assetPanel.getByTestId('asset-panel-tab-panel-docs')).toBeVisible()
await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible()
await expect(assetPanel.getByTestId('asset-docs-content')).toHaveText(/Project Goal/)
await expect(assetPanel.getByText(TEXT.arbitraryFetchImageError)).not.toBeVisible()
}))

test('Assets Panel docs images', ({ page }) => {
return mockAllAndLogin({
page,
setupAPI: (api) => {
api.addProject({})
},
})
.do(() => {})
.driveTable.clickRow(0)
.toggleDocsAssetPanel()
.withAssetPanel(async (assetPanel) => {
await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible()

for (const image of await assetPanel.getByRole('img').all()) {
await expect(image).toBeVisible()
await expect(image).toHaveJSProperty('complete', true)
}
})
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 27 additions & 49 deletions app/gui/src/dashboard/components/MarkdownViewer/MarkdownViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/** @file A WYSIWYG editor using Lexical.js. */
/** @file A Markdown viewer component. */

import { useLogger } from '#/providers/LoggerProvider'
import { useText } from '#/providers/TextProvider'
import { useSuspenseQuery } from '@tanstack/react-query'
import type { RendererObject } from 'marked'
import { marked } from 'marked'
import { useMemo } from 'react'
import { BUTTON_STYLES, TEXT_STYLE, type TestIdProps } from '../AriaComponents'
import { type TestIdProps } from '../AriaComponents'
import { DEFAULT_RENDERER } from './defaultRenderer'

/** Props for a {@link MarkdownViewer}. */
export interface MarkdownViewerProps extends TestIdProps {
Expand All @@ -14,67 +16,43 @@ export interface MarkdownViewerProps extends TestIdProps {
readonly renderer?: RendererObject
}

const defaultRenderer: RendererObject = {
/** The renderer for headings. */
heading({ depth, tokens }) {
return `<h${depth} class="${TEXT_STYLE({ variant: 'h1', className: 'my-2' })}">${this.parser.parseInline(tokens)}</h${depth}>`
},
/** The renderer for paragraphs. */
paragraph({ tokens }) {
return `<p class="${TEXT_STYLE({ variant: 'body', className: 'my-1' })}">${this.parser.parseInline(tokens)}</p>`
},
/** The renderer for list items. */
listitem({ tokens }) {
return `<li class="${TEXT_STYLE({ variant: 'body' })}">${this.parser.parseInline(tokens)}</li>`
},
/** The renderer for lists. */
list({ items }) {
return `<ul class="my-1 list-disc pl-3">${items.map((item) => this.listitem(item)).join('\n')}</ul>`
},
/** The renderer for links. */
link({ href, tokens }) {
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="${BUTTON_STYLES({ variant: 'link' }).base()}">${this.parser.parseInline(tokens)}</a>`
},
/** The renderer for images. */
image({ href, title }) {
return `<img src="${href}" alt="${title}" class="my-1 h-auto max-w-full" />`
},
/** The renderer for code. */
code({ text }) {
return `<code class="block my-1 p-2 bg-primary/5 rounded-lg max-w-full overflow-auto max-h-48" >
<pre class="${TEXT_STYLE({ variant: 'body-sm' })}">${text}</pre>
</code>`
},
/** The renderer for blockquotes. */
blockquote({ tokens }) {
return `<blockquote class="${'relative my-1 pl-2 before:bg-primary/20 before:absolute before:left-0 before:top-0 before:h-full before:w-[1.5px] before:rounded-full'}">${this.parser.parse(tokens)}</blockquote>`
},
}

/**
* Markdown viewer component.
* Parses markdown passed in as a `text` prop into HTML and displays it.
*/
export function MarkdownViewer(props: MarkdownViewerProps) {
const { text, imgUrlResolver, renderer = defaultRenderer, testId } = props
const { text, imgUrlResolver, renderer = {}, testId } = props

const markedInstance = useMemo(
() => marked.use({ renderer: Object.assign({}, defaultRenderer, renderer), async: true }),
[renderer],
)
const { getText } = useText()
const logger = useLogger()

const markedInstance = marked.use({ renderer: Object.assign({}, DEFAULT_RENDERER, renderer) })

const { data: markdownToHtml } = useSuspenseQuery({
queryKey: ['markdownToHtml', { text }],
queryFn: () =>
markedInstance.parse(text, {
queryKey: ['markdownToHtml', { text, imgUrlResolver, markedInstance }] as const,
queryFn: ({ queryKey: [, args] }) =>
args.markedInstance.parse(args.text, {
async: true,
walkTokens: async (token) => {
if (token.type === 'image' && 'href' in token && typeof token.href === 'string') {
token.href = await imgUrlResolver(token.href)
const href = token.href

token.raw = href
token.href = await args
.imgUrlResolver(href)
.then((url) => {
return url
})
.catch((error) => {
logger.error(error)
return null
})
token.text = getText('arbitraryFetchImageError')
}
},
}),
})

return (
<div
className="select-text"
Expand Down
46 changes: 46 additions & 0 deletions app/gui/src/dashboard/components/MarkdownViewer/defaultRenderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/** @file The default renderer for Markdown. */
import type { RendererObject } from 'marked'
import { BUTTON_STYLES, TEXT_STYLE } from '../AriaComponents'

/** The default renderer for Markdown. */
export const DEFAULT_RENDERER: Readonly<RendererObject> = {
/** The renderer for headings. */
heading({ depth, tokens }) {
const variant = depth === 1 ? 'h1' : 'subtitle'
return `<h${depth} class="${TEXT_STYLE({ variant: variant, className: 'my-2' })}">${this.parser.parseInline(tokens)}</h${depth}>`
},
/** The renderer for paragraphs. */
paragraph({ tokens }) {
return `<p class="${TEXT_STYLE({ variant: 'body', className: 'my-1' })}">${this.parser.parseInline(tokens)}</p>`
},
/** The renderer for list items. */
listitem({ tokens }) {
return `<li class="${TEXT_STYLE({ variant: 'body' })}">${this.parser.parseInline(tokens)}</li>`
},
/** The renderer for lists. */
list({ items }) {
return `<ul class="my-1 list-disc pl-3">${items.map((item) => this.listitem(item)).join('\n')}</ul>`
},
/** The renderer for links. */
link({ href, tokens }) {
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="${BUTTON_STYLES({ variant: 'link' }).base()}">${this.parser.parseInline(tokens)}</a>`
},
/** The renderer for images. */
image({ href, title, raw }) {
const alt = title ?? ''

return `
<img src="${href}" alt="${alt}" class="my-1 h-auto max-w-full" data-raw=${raw}>
`
},
/** The renderer for code. */
code({ text }) {
return `<code class="block my-1 p-2 bg-primary/5 rounded-lg max-w-full overflow-auto max-h-48" >
<pre class="${TEXT_STYLE({ variant: 'body-sm' })}">${text}</pre>
</code>`
},
/** The renderer for blockquotes. */
blockquote({ tokens }) {
return `<blockquote class="${'relative my-1 pl-2 before:bg-primary/20 before:absolute before:left-0 before:top-0 before:h-full before:w-[1.5px] before:rounded-full'}">${this.parser.parse(tokens)}</blockquote>`
},
}
7 changes: 4 additions & 3 deletions app/gui/src/dashboard/services/RemoteBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1268,14 +1268,15 @@ export default class RemoteBackend extends Backend {
projectId: backend.ProjectId,
relativePath: string,
): Promise<string> {
const response = await this.get<string>(
const response = await this.get<Blob>(
remoteBackendPaths.getProjectAssetPath(projectId, relativePath),
)

if (!responseIsSuccessful(response)) {
return Promise.reject(new Error('Not implemented.'))
return await this.throw(response, 'resolveProjectAssetPathBackendError')
} else {
return await response.text()
const blob = await response.blob()
return URL.createObjectURL(blob)
}
}

Expand Down
2 changes: 1 addition & 1 deletion app/ide-desktop/client/src/projectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export async function version(args: config.Args) {
}

/**
* Handle requests to the `enso-project` protocol.
* Handle requests to the `enso://` protocol.
*
* The protocol is used to fetch project assets from the backend.
* If a given path is not inside a project, the request is rejected with a 403 error.
Expand Down
25 changes: 17 additions & 8 deletions tools/performance/engine-benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,26 @@ One can also download only a CSV file representing all the selected benchmark
results with `bench_download.py --create-csv`.

## Contribute

Run local tests with:

```bash
python -m unittest --verbose bench_tool/test*.py
```

## Relation to GH Actions
The `bench_download.py` script is used in [Benchmarks Upload](https://github.com/enso-org/enso/actions/workflows/bench-upload.yml)
GH Action to download the benchmarks generated by the [Benchmark Engine](https://github.com/enso-org/enso/actions/workflows/engine-benchmark.yml)
and [Benchmark Standard Libraries](https://github.com/enso-org/enso/actions/workflows/std-libs-benchmark.yml) GH Actions.
The `Benchmarks Upload` action is triggered by the `engine-benchmark.yml` and `std-libs-benchmark.yml` actions.

The results from the benchmarks are gathered from the GH artifacts associated with corresponding workflow runs, and
save as JSON files inside https://github.com/enso-org/engine-benchmark-results repo inside its
[cache](https://github.com/enso-org/engine-benchmark-results/tree/main/cache) directory.

The `bench_download.py` script is used in
[Benchmarks Upload](https://github.com/enso-org/enso/actions/workflows/bench-upload.yml)
GH Action to download the benchmarks generated by the
[Benchmark Engine](https://github.com/enso-org/enso/actions/workflows/engine-benchmark.yml)
and
[Benchmark Standard Libraries](https://github.com/enso-org/enso/actions/workflows/std-libs-benchmark.yml)
GH Actions. The `Benchmarks Upload` action is triggered by the
`engine-benchmark.yml` and `std-libs-benchmark.yml` actions.

The results from the benchmarks are gathered from the GH artifacts associated
with corresponding workflow runs, and save as JSON files inside
https://github.com/enso-org/engine-benchmark-results repo inside its
[cache](https://github.com/enso-org/engine-benchmark-results/tree/main/cache)
directory.

0 comments on commit 7b8ae38

Please sign in to comment.