Add rich viewer for files (non-diff) (#246)

* Add rich viewer for files (non-diff)
Fixes #74

* Fix test typo

* WIP toolbar and proper blob element injection

* Add working Preview/Code/Blame toggle

* Add missing aria-label

* Add e2e test and classic/reactUI support

* Re-enable headless e2e

* Update e2e snap for linux

* Lint

* Improve style a bit

* Reorg and cleanup
This commit is contained in:
Pierre Jacquier
2023-06-23 16:37:02 -04:00
committed by GitHub
parent e544a7aadc
commit ebafdecae7
26 changed files with 458 additions and 31 deletions

View File

@ -3,6 +3,7 @@ import { Octokit } from '@octokit/rest'
import { import {
KittycadUser, KittycadUser,
Message, Message,
MessageGetFileBlob,
MessageGetFileDiff, MessageGetFileDiff,
MessageGetGithubCommitData, MessageGetGithubCommitData,
MessageGetGithubPullFilesData, MessageGetGithubPullFilesData,
@ -16,7 +17,7 @@ import {
setStorageGithubToken, setStorageGithubToken,
setStorageKittycadToken, setStorageKittycadToken,
} from './storage' } from './storage'
import { getFileDiff } from './diff' import { getFileBlob, getFileDiff } from './diff'
let github: Octokit | undefined let github: Octokit | undefined
let kittycad: Client | undefined let kittycad: Client | undefined
@ -168,5 +169,18 @@ chrome.runtime.onMessage.addListener(
.catch(error => sendResponse({ error })) .catch(error => sendResponse({ error }))
return true return true
} }
if (message.id === MessageIds.GetFileBlob) {
if (!kittycad || !github) {
sendResponse({ error: noClientError })
return false
}
const { owner, repo, sha, filename } =
message.data as MessageGetFileBlob
getFileBlob(github, kittycad, owner, repo, sha, filename)
.then(r => sendResponse(r))
.catch(error => sendResponse({ error }))
return true
}
} }
) )

View File

@ -1,11 +1,13 @@
import React from 'react' import React from 'react'
import { CadDiffPage } from '../components/diff/CadDiffPage' import { CadDiffPage } from '../components/viewer/CadDiffPage'
import { CadBlobPage } from '../components/viewer/CadBlobPage'
import { Commit, DiffEntry, MessageIds, Pull } from './types' import { Commit, DiffEntry, MessageIds, Pull } from './types'
import { import {
getGithubPullUrlParams, getGithubPullUrlParams,
mapInjectableDiffElements, mapInjectableDiffElements,
getGithubCommitUrlParams, getGithubCommitUrlParams,
createReactRoot, createReactRoot,
getGithubBlobUrlParams,
} from './web' } from './web'
import gitHubInjection from 'github-injection' import gitHubInjection from 'github-injection'
@ -30,6 +32,43 @@ async function injectDiff(
root.render(cadDiffPage) root.render(cadDiffPage)
} }
async function injectBlob(
owner: string,
repo: string,
sha: string,
filename: string,
document: Document
) {
let classicUi = false
// React UI (as of 2023-06-23, for signed-in users only)
const childWithProperClass = document.querySelector<HTMLElement>(
'.react-blob-view-header-sticky'
)
let element = childWithProperClass?.parentElement
if (!element) {
// Classic UI
const childWithProperClass =
document.querySelector<HTMLElement>('.js-blob-header')
element = childWithProperClass?.parentElement
classicUi = !!element
}
if (!element) {
throw Error("Couldn't find blob html element to inject")
}
element.classList.add('kittycad-injected-file')
const cadBlobPage = React.createElement(CadBlobPage, {
element,
owner,
repo,
sha,
filename,
classicUi,
})
root.render(cadBlobPage)
}
async function injectPullDiff( async function injectPullDiff(
owner: string, owner: string,
repo: string, repo: string,
@ -88,6 +127,14 @@ async function run() {
await injectCommitDiff(owner, repo, sha, window.document) await injectCommitDiff(owner, repo, sha, window.document)
return return
} }
const blobParams = getGithubBlobUrlParams(url)
if (blobParams) {
const { owner, repo, sha, filename } = blobParams
console.log('Found blob diff: ', owner, repo, sha, filename)
await injectBlob(owner, repo, sha, filename, window.document)
return
}
} }
function waitForLateDiffNodes(callback: () => void) { function waitForLateDiffNodes(callback: () => void) {

View File

@ -1,6 +1,6 @@
import { Octokit } from '@octokit/rest' import { Octokit } from '@octokit/rest'
import { Client, file } from '@kittycad/lib' import { Client, file } from '@kittycad/lib'
import { ContentFile, DiffEntry, FileDiff } from './types' import { ContentFile, DiffEntry, FileBlob, FileDiff } from './types'
import { import {
FileExportFormat_type, FileExportFormat_type,
FileImportFormat_type, FileImportFormat_type,
@ -132,3 +132,25 @@ export async function getFileDiff(
throw Error(`Unsupported status: ${status}`) throw Error(`Unsupported status: ${status}`)
} }
export async function getFileBlob(
github: Octokit,
kittycad: Client,
owner: string,
repo: string,
sha: string,
filename: string
): Promise<FileBlob> {
const extension = filename.split('.').pop()
if (!extension || !extensionToSrcFormat[extension]) {
throw Error(
`Unsupported extension. Given ${extension}, was expecting ${Object.keys(
extensionToSrcFormat
)}`
)
}
const rawBlob = await downloadFile(github, owner, repo, sha, filename)
const blob = await convert(kittycad, rawBlob, extension)
return { blob }
}

View File

@ -20,6 +20,10 @@ export type FileDiff = {
after?: string after?: string
} }
export type FileBlob = {
blob?: string
}
export enum MessageIds { export enum MessageIds {
GetGithubPullFiles = 'GetPullFiles', GetGithubPullFiles = 'GetPullFiles',
GetGithubUser = 'GetGitHubUser', GetGithubUser = 'GetGitHubUser',
@ -27,6 +31,7 @@ export enum MessageIds {
SaveKittycadToken = 'SaveKittyCadToken', SaveKittycadToken = 'SaveKittyCadToken',
GetKittycadUser = 'GetKittyCadUser', GetKittycadUser = 'GetKittyCadUser',
GetFileDiff = 'GetFileDiff', GetFileDiff = 'GetFileDiff',
GetFileBlob = 'GetFileBlob',
GetGithubPull = 'GetGithubPull', GetGithubPull = 'GetGithubPull',
GetGithubCommit = 'GetGithubCommit', GetGithubCommit = 'GetGithubCommit',
} }
@ -51,6 +56,13 @@ export type MessageGetFileDiff = {
file: DiffEntry file: DiffEntry
} }
export type MessageGetFileBlob = {
owner: string
repo: string
sha: string
filename: string
}
export type MessageSaveToken = { export type MessageSaveToken = {
token: string token: string
} }
@ -76,5 +88,6 @@ export type MessageResponse =
| KittycadUser | KittycadUser
| MessageSaveToken | MessageSaveToken
| FileDiff | FileDiff
| FileBlob
| MessageError | MessageError
| void | void

View File

@ -7,6 +7,7 @@ import {
mapInjectableDiffElements, mapInjectableDiffElements,
getSupportedWebDiffElements, getSupportedWebDiffElements,
createReactRoot, createReactRoot,
getGithubBlobUrlParams,
} from './web' } from './web'
const githubPullHtmlSnippet = ` const githubPullHtmlSnippet = `
@ -146,6 +147,27 @@ describe('Function getGithubCommitUrlParams', () => {
}) })
}) })
describe('Function getGithubBlobUrlParams', () => {
it('gets params out of a valid github blob link', () => {
const url =
'https://github.com/KittyCAD/diff-samples/blob/fd9eec79f0464833686ea6b5b34ea07145e32734/models/box.obj'
const params = getGithubBlobUrlParams(url)
expect(params).toBeDefined()
const { owner, repo, sha, filename } = params!
expect(owner).toEqual('KittyCAD')
expect(repo).toEqual('diff-samples')
expect(sha).toEqual('fd9eec79f0464833686ea6b5b34ea07145e32734')
expect(filename).toEqual('models/box.obj')
})
it("doesn't match other URLs", () => {
expect(getGithubPullUrlParams('http://google.com')).toBeUndefined()
expect(
getGithubPullUrlParams('https://github.com/KittyCAD/litterbox')
).toBeUndefined()
})
})
it('finds web elements for supported files', () => { it('finds web elements for supported files', () => {
const elements = getSupportedWebDiffElements(githubPullHtmlDocument) const elements = getSupportedWebDiffElements(githubPullHtmlDocument)
expect(elements).toHaveLength(2) expect(elements).toHaveLength(2)

View File

@ -44,6 +44,34 @@ export function getGithubCommitUrlParams(
return { owner, repo, sha } return { owner, repo, sha }
} }
export type GithubBlobUrlParams = {
owner: string
repo: string
sha: string
filename: string
}
export function getGithubBlobUrlParams(
url: string
): GithubBlobUrlParams | undefined {
const blobRe =
/https:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)\/blob\/(\w+)\/([^\0]+)/
const result = blobRe.exec(url)
if (!result) {
return undefined
}
const [, owner, repo, sha, filename] = result
console.log(
'Found a supported Github Blob URL:',
owner,
repo,
sha,
filename
)
return { owner, repo, sha, filename }
}
export function getSupportedWebDiffElements(document: Document): HTMLElement[] { export function getSupportedWebDiffElements(document: Document): HTMLElement[] {
const fileTypeSelectors = Object.keys(extensionToSrcFormat).map( const fileTypeSelectors = Object.keys(extensionToSrcFormat).map(
t => `.file[data-file-type=".${t}"]` t => `.file[data-file-type=".${t}"]`

View File

@ -0,0 +1,68 @@
import React, { useEffect, useState } from 'react'
import '@react-three/fiber'
import { Box, Text, useTheme } from '@primer/react'
import { FileBlob } from '../../chrome/types'
import { Viewer3D } from './Viewer3D'
import { BufferGeometry, Sphere } from 'three'
import { WireframeColors, WireframeModel } from './WireframeModel'
import { useRef } from 'react'
import { loadGeometry } from '../../utils/three'
import { OrbitControls } from 'three-stdlib'
import { RecenterButton } from './RecenterButton'
import { ErrorMessage } from './ErrorMessage'
export function CadBlob({ blob }: FileBlob): React.ReactElement {
const [geometry, setGeometry] = useState<BufferGeometry>()
const [boundingSphere, setBoundingSphere] = useState<Sphere>()
const controlsRef = useRef<OrbitControls | null>(null)
const [controlsAltered, setControlsAltered] = useState(false)
const { theme } = useTheme()
const colors: WireframeColors = {
face: theme?.colors.fg.default,
edge: theme?.colors.fg.muted,
dashEdge: theme?.colors.fg.subtle,
}
useEffect(() => {
let geometry: BufferGeometry | undefined = undefined
if (blob) {
geometry = loadGeometry(blob)
setGeometry(geometry)
if (geometry && geometry.boundingSphere) {
setBoundingSphere(geometry.boundingSphere)
}
}
}, [blob])
return (
<>
{geometry && (
<Box position="relative">
<Box height={300} backgroundColor="canvas.subtle">
<Viewer3D
geometry={geometry}
boundingSphere={boundingSphere}
controlsRef={controlsRef}
onControlsAltered={() =>
!controlsAltered && setControlsAltered(true)
}
>
<WireframeModel
geometry={geometry}
boundingSphere={boundingSphere}
colors={colors}
/>
</Viewer3D>
</Box>
{controlsAltered && (
<RecenterButton
onClick={() => {
controlsRef.current?.reset()
setControlsAltered(false)
}}
/>
)}
</Box>
)}
{!geometry && <ErrorMessage />}
</>
)
}

View File

@ -0,0 +1,155 @@
import React, { useEffect, useState } from 'react'
import '@react-three/fiber'
import { Box, SegmentedControl, ThemeProvider } from '@primer/react'
import { FileBlob, MessageIds } from '../../chrome/types'
import { createPortal } from 'react-dom'
import { Loading } from '../Loading'
import { CadBlob } from './CadBlob'
function CadBlobPortal({
element,
owner,
repo,
sha,
filename,
classicUi,
}: {
element: HTMLElement
owner: string
repo: string
sha: string
filename: string
classicUi: boolean
}): React.ReactElement {
const [richBlob, setRichBlob] = useState<FileBlob>()
const [richSelected, setRichSelected] = useState(true)
const [toolbarContainer, setToolbarContainer] = useState<HTMLElement>()
const [blobContainer, setBlobContainer] = useState<HTMLElement>()
const [sourceElements, setSourceElements] = useState<HTMLElement[]>([])
useEffect(() => {
let existingToggle: HTMLElement | undefined | null
let toolbar: HTMLElement | undefined | null
let blob: HTMLElement | undefined | null
if (classicUi) {
// no existing toggle
toolbar = element.querySelector<HTMLElement>('.js-blob-header')
blob = element.querySelector<HTMLElement>('.blob-wrapper')
} else {
existingToggle = element.querySelector<HTMLElement>(
'ul[class*=SegmentedControl]'
)
toolbar = existingToggle?.parentElement
blob = element.querySelector<HTMLElement>(
'section[aria-labelledby="file-name-id"]'
)
}
if (toolbar != null) {
setToolbarContainer(toolbar)
if (existingToggle) {
existingToggle.style.display = 'none'
}
}
if (blob != null) {
setBlobContainer(blob)
const sourceElements = Array.from(blob.children) as HTMLElement[]
sourceElements.map(n => (n.style.display = 'none'))
setSourceElements(sourceElements)
}
}, [element])
useEffect(() => {
;(async () => {
const response = await chrome.runtime.sendMessage({
id: MessageIds.GetFileBlob,
data: { owner, repo, sha, filename },
})
if ('error' in response) {
console.log(response.error)
} else {
setRichBlob(response as FileBlob)
}
})()
}, [owner, repo, sha, filename])
return (
<>
{toolbarContainer &&
createPortal(
<SegmentedControl
sx={{ mr: classicUi ? 2 : 0, order: -1 }} // prepend in flex
aria-label="File view"
onChange={(index: number) => {
if (index < 2) {
setRichSelected(index === 0)
sourceElements.map(
n =>
(n.style.display =
index === 0 ? 'none' : 'block')
)
return
}
window.location.href = `https://github.com/${owner}/${repo}/blame/${sha}/${filename}`
}}
>
<SegmentedControl.Button selected={richSelected}>
Preview
</SegmentedControl.Button>
<SegmentedControl.Button selected={!richSelected}>
Code
</SegmentedControl.Button>
{!classicUi && (
<SegmentedControl.Button>
Blame
</SegmentedControl.Button>
)}
</SegmentedControl>,
toolbarContainer
)}
{blobContainer &&
createPortal(
<Box sx={{ display: richSelected ? 'block' : 'none' }}>
{richBlob ? (
<CadBlob blob={richBlob.blob} />
) : (
<Loading />
)}
</Box>,
blobContainer
)}
</>
)
}
export type CadBlobPageProps = {
element: HTMLElement
owner: string
repo: string
sha: string
filename: string
classicUi: boolean
}
export function CadBlobPage({
element,
owner,
repo,
sha,
filename,
classicUi,
}: CadBlobPageProps): React.ReactElement {
return (
<ThemeProvider colorMode="auto">
<CadBlobPortal
element={element}
owner={owner}
repo={repo}
sha={sha}
filename={filename}
classicUi={classicUi}
/>
</ThemeProvider>
)
}

View File

@ -18,6 +18,8 @@ import { BeakerIcon } from '@primer/octicons-react'
import { LegendBox, LegendLabel } from './Legend' import { LegendBox, LegendLabel } from './Legend'
import { getCommonSphere, loadGeometry } from '../../utils/three' import { getCommonSphere, loadGeometry } from '../../utils/three'
import { OrbitControls } from 'three-stdlib' import { OrbitControls } from 'three-stdlib'
import { RecenterButton } from './RecenterButton'
import { ErrorMessage } from './ErrorMessage'
function Viewer3D2Up({ function Viewer3D2Up({
beforeGeometry, beforeGeometry,
@ -88,17 +90,13 @@ function Viewer3D2Up({
</Box> </Box>
)} )}
{controlsAltered && ( {controlsAltered && (
<Box top={2} right={2} position="absolute"> <RecenterButton
<Button onClick={() => {
onClick={() => { afterControlsRef.current?.reset()
afterControlsRef.current?.reset() beforeControlsRef.current?.reset()
beforeControlsRef.current?.reset() setControlsAltered(false)
setControlsAltered(false) }}
}} />
>
Recenter
</Button>
</Box>
)} )}
</> </>
) )
@ -158,16 +156,12 @@ function Viewer3DCombined({
/> />
</LegendBox> </LegendBox>
{controlsAltered && ( {controlsAltered && (
<Box top={2} right={2} position="absolute"> <RecenterButton
<Button onClick={() => {
onClick={() => { controlsRef.current?.reset()
controlsRef.current?.reset() setControlsAltered(false)
setControlsAltered(false) }}
}} />
>
Recenter
</Button>
</Box>
)} )}
</> </>
) )
@ -273,13 +267,7 @@ export function CadDiff({ before, after }: FileDiff): React.ReactElement {
</TabNav> </TabNav>
</Box> </Box>
)} )}
{!beforeGeometry && !afterGeometry && ( {!beforeGeometry && !afterGeometry && <ErrorMessage />}
<Box p={3}>
<Text>
Sorry, the rich diff can't be displayed for this file.
</Text>
</Box>
)}
</> </>
) )
} }

View File

@ -0,0 +1,14 @@
import { render, screen } from '@testing-library/react'
import { ErrorMessage } from './ErrorMessage'
it('renders the error message', async () => {
render(<ErrorMessage />)
const text = await screen.findByText(/preview/)
expect(text).toBeDefined()
})
it('renders the error message with a custom message', async () => {
render(<ErrorMessage message="custom" />)
const text = await screen.findByText(/custom/)
expect(text).toBeDefined()
})

View File

@ -0,0 +1,12 @@
import { Box, Text } from '@primer/react'
export function ErrorMessage({ message }: { message?: string }) {
return (
<Box p={3}>
<Text>
{message ||
"Sorry, the rich preview can't be displayed for this file."}
</Text>
</Box>
)
}

View File

@ -0,0 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { RecenterButton } from './RecenterButton'
it('renders the recenter button', async () => {
const callback = vi.fn()
render(<RecenterButton onClick={callback} />)
const button = await screen.findByRole('button')
expect(callback.mock.calls).toHaveLength(0)
fireEvent.click(button)
expect(callback.mock.calls).toHaveLength(1)
})

View File

@ -0,0 +1,9 @@
import { Box, Button } from '@primer/react'
export function RecenterButton({ onClick }: { onClick: () => void }) {
return (
<Box top={2} right={2} position="absolute">
<Button onClick={onClick}>Recenter</Button>
</Box>
)
}

View File

@ -39,6 +39,19 @@ async function getFirstDiffScreenshot(
return await element.screenshot() return await element.screenshot()
} }
async function getBlobPreviewScreenshot(page: Page, url: string) {
page.on('console', msg => console.log(msg.text()))
await page.goto(url)
// waiting for the canvas (that holds the diff) to show up
await page.waitForSelector('#repo-content-pjax-container canvas')
// screenshot the file diff with its toolbar
const element = await page.waitForSelector('.kittycad-injected-file')
await page.waitForTimeout(1000) // making sure the element fully settled in
return await element.screenshot()
}
test('pull request diff with an .obj file', async ({ test('pull request diff with an .obj file', async ({
page, page,
authorizedBackground, authorizedBackground,
@ -76,3 +89,13 @@ test('commit diff with a .dae file as LFS', async ({
const screenshot = await getFirstDiffScreenshot(page, url, 'dae') const screenshot = await getFirstDiffScreenshot(page, url, 'dae')
expect(screenshot).toMatchSnapshot() expect(screenshot).toMatchSnapshot()
}) })
test('blob preview with an .obj file', async ({
page,
authorizedBackground,
}) => {
const url =
'https://github.com/KittyCAD/diff-samples/blob/fd9eec79f0464833686ea6b5b34ea07145e32734/models/box.obj'
const screenshot = await getBlobPreviewScreenshot(page, url)
expect(screenshot).toMatchSnapshot()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB