diff --git a/src/chrome/background.ts b/src/chrome/background.ts index 2d85e07..b4170f4 100644 --- a/src/chrome/background.ts +++ b/src/chrome/background.ts @@ -3,6 +3,7 @@ import { Octokit } from '@octokit/rest' import { KittycadUser, Message, + MessageGetFileBlob, MessageGetFileDiff, MessageGetGithubCommitData, MessageGetGithubPullFilesData, @@ -16,7 +17,7 @@ import { setStorageGithubToken, setStorageKittycadToken, } from './storage' -import { getFileDiff } from './diff' +import { getFileBlob, getFileDiff } from './diff' let github: Octokit | undefined let kittycad: Client | undefined @@ -168,5 +169,18 @@ chrome.runtime.onMessage.addListener( .catch(error => sendResponse({ error })) 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 + } } ) diff --git a/src/chrome/content.ts b/src/chrome/content.ts index 5df9160..e0b463c 100644 --- a/src/chrome/content.ts +++ b/src/chrome/content.ts @@ -1,11 +1,13 @@ 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 { getGithubPullUrlParams, mapInjectableDiffElements, getGithubCommitUrlParams, createReactRoot, + getGithubBlobUrlParams, } from './web' import gitHubInjection from 'github-injection' @@ -30,6 +32,43 @@ async function injectDiff( 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( + '.react-blob-view-header-sticky' + ) + let element = childWithProperClass?.parentElement + if (!element) { + // Classic UI + const childWithProperClass = + document.querySelector('.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( owner: string, repo: string, @@ -88,6 +127,14 @@ async function run() { await injectCommitDiff(owner, repo, sha, window.document) 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) { diff --git a/src/chrome/diff.ts b/src/chrome/diff.ts index d46dba9..3bee2f0 100644 --- a/src/chrome/diff.ts +++ b/src/chrome/diff.ts @@ -1,6 +1,6 @@ import { Octokit } from '@octokit/rest' import { Client, file } from '@kittycad/lib' -import { ContentFile, DiffEntry, FileDiff } from './types' +import { ContentFile, DiffEntry, FileBlob, FileDiff } from './types' import { FileExportFormat_type, FileImportFormat_type, @@ -132,3 +132,25 @@ export async function getFileDiff( throw Error(`Unsupported status: ${status}`) } + +export async function getFileBlob( + github: Octokit, + kittycad: Client, + owner: string, + repo: string, + sha: string, + filename: string +): Promise { + 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 } +} diff --git a/src/chrome/types.ts b/src/chrome/types.ts index 9aad5a1..ababcb7 100644 --- a/src/chrome/types.ts +++ b/src/chrome/types.ts @@ -20,6 +20,10 @@ export type FileDiff = { after?: string } +export type FileBlob = { + blob?: string +} + export enum MessageIds { GetGithubPullFiles = 'GetPullFiles', GetGithubUser = 'GetGitHubUser', @@ -27,6 +31,7 @@ export enum MessageIds { SaveKittycadToken = 'SaveKittyCadToken', GetKittycadUser = 'GetKittyCadUser', GetFileDiff = 'GetFileDiff', + GetFileBlob = 'GetFileBlob', GetGithubPull = 'GetGithubPull', GetGithubCommit = 'GetGithubCommit', } @@ -51,6 +56,13 @@ export type MessageGetFileDiff = { file: DiffEntry } +export type MessageGetFileBlob = { + owner: string + repo: string + sha: string + filename: string +} + export type MessageSaveToken = { token: string } @@ -76,5 +88,6 @@ export type MessageResponse = | KittycadUser | MessageSaveToken | FileDiff + | FileBlob | MessageError | void diff --git a/src/chrome/web.test.ts b/src/chrome/web.test.ts index 18002d9..e17f77d 100644 --- a/src/chrome/web.test.ts +++ b/src/chrome/web.test.ts @@ -7,6 +7,7 @@ import { mapInjectableDiffElements, getSupportedWebDiffElements, createReactRoot, + getGithubBlobUrlParams, } from './web' 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', () => { const elements = getSupportedWebDiffElements(githubPullHtmlDocument) expect(elements).toHaveLength(2) diff --git a/src/chrome/web.ts b/src/chrome/web.ts index 106afa7..c41cec6 100644 --- a/src/chrome/web.ts +++ b/src/chrome/web.ts @@ -44,6 +44,34 @@ export function getGithubCommitUrlParams( 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[] { const fileTypeSelectors = Object.keys(extensionToSrcFormat).map( t => `.file[data-file-type=".${t}"]` diff --git a/src/components/diff/BaseModel.tsx b/src/components/viewer/BaseModel.tsx similarity index 100% rename from src/components/diff/BaseModel.tsx rename to src/components/viewer/BaseModel.tsx diff --git a/src/components/viewer/CadBlob.tsx b/src/components/viewer/CadBlob.tsx new file mode 100644 index 0000000..5338a7e --- /dev/null +++ b/src/components/viewer/CadBlob.tsx @@ -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() + const [boundingSphere, setBoundingSphere] = useState() + const controlsRef = useRef(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 && ( + + + + !controlsAltered && setControlsAltered(true) + } + > + + + + {controlsAltered && ( + { + controlsRef.current?.reset() + setControlsAltered(false) + }} + /> + )} + + )} + {!geometry && } + + ) +} diff --git a/src/components/viewer/CadBlobPage.tsx b/src/components/viewer/CadBlobPage.tsx new file mode 100644 index 0000000..697238c --- /dev/null +++ b/src/components/viewer/CadBlobPage.tsx @@ -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() + const [richSelected, setRichSelected] = useState(true) + const [toolbarContainer, setToolbarContainer] = useState() + const [blobContainer, setBlobContainer] = useState() + const [sourceElements, setSourceElements] = useState([]) + + useEffect(() => { + let existingToggle: HTMLElement | undefined | null + let toolbar: HTMLElement | undefined | null + let blob: HTMLElement | undefined | null + if (classicUi) { + // no existing toggle + toolbar = element.querySelector('.js-blob-header') + blob = element.querySelector('.blob-wrapper') + } else { + existingToggle = element.querySelector( + 'ul[class*=SegmentedControl]' + ) + toolbar = existingToggle?.parentElement + blob = element.querySelector( + '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( + { + 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}` + }} + > + + Preview + + + Code + + {!classicUi && ( + + Blame + + )} + , + toolbarContainer + )} + {blobContainer && + createPortal( + + {richBlob ? ( + + ) : ( + + )} + , + 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 ( + + + + ) +} diff --git a/src/components/diff/CadDiff.tsx b/src/components/viewer/CadDiff.tsx similarity index 89% rename from src/components/diff/CadDiff.tsx rename to src/components/viewer/CadDiff.tsx index 8a436b9..5cecd69 100644 --- a/src/components/diff/CadDiff.tsx +++ b/src/components/viewer/CadDiff.tsx @@ -18,6 +18,8 @@ import { BeakerIcon } from '@primer/octicons-react' import { LegendBox, LegendLabel } from './Legend' import { getCommonSphere, loadGeometry } from '../../utils/three' import { OrbitControls } from 'three-stdlib' +import { RecenterButton } from './RecenterButton' +import { ErrorMessage } from './ErrorMessage' function Viewer3D2Up({ beforeGeometry, @@ -88,17 +90,13 @@ function Viewer3D2Up({ )} {controlsAltered && ( - - - + { + afterControlsRef.current?.reset() + beforeControlsRef.current?.reset() + setControlsAltered(false) + }} + /> )} ) @@ -158,16 +156,12 @@ function Viewer3DCombined({ /> {controlsAltered && ( - - - + { + controlsRef.current?.reset() + setControlsAltered(false) + }} + /> )} ) @@ -273,13 +267,7 @@ export function CadDiff({ before, after }: FileDiff): React.ReactElement { )} - {!beforeGeometry && !afterGeometry && ( - - - Sorry, the rich diff can't be displayed for this file. - - - )} + {!beforeGeometry && !afterGeometry && } ) } diff --git a/src/components/diff/CadDiffPage.tsx b/src/components/viewer/CadDiffPage.tsx similarity index 100% rename from src/components/diff/CadDiffPage.tsx rename to src/components/viewer/CadDiffPage.tsx diff --git a/src/components/diff/Camera.tsx b/src/components/viewer/Camera.tsx similarity index 100% rename from src/components/diff/Camera.tsx rename to src/components/viewer/Camera.tsx diff --git a/src/components/diff/CombinedModel.tsx b/src/components/viewer/CombinedModel.tsx similarity index 100% rename from src/components/diff/CombinedModel.tsx rename to src/components/viewer/CombinedModel.tsx diff --git a/src/components/diff/Controls.tsx b/src/components/viewer/Controls.tsx similarity index 100% rename from src/components/diff/Controls.tsx rename to src/components/viewer/Controls.tsx diff --git a/src/components/viewer/ErrorMessage.test.tsx b/src/components/viewer/ErrorMessage.test.tsx new file mode 100644 index 0000000..1232827 --- /dev/null +++ b/src/components/viewer/ErrorMessage.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react' +import { ErrorMessage } from './ErrorMessage' + +it('renders the error message', async () => { + render() + const text = await screen.findByText(/preview/) + expect(text).toBeDefined() +}) + +it('renders the error message with a custom message', async () => { + render() + const text = await screen.findByText(/custom/) + expect(text).toBeDefined() +}) diff --git a/src/components/viewer/ErrorMessage.tsx b/src/components/viewer/ErrorMessage.tsx new file mode 100644 index 0000000..73168b5 --- /dev/null +++ b/src/components/viewer/ErrorMessage.tsx @@ -0,0 +1,12 @@ +import { Box, Text } from '@primer/react' + +export function ErrorMessage({ message }: { message?: string }) { + return ( + + + {message || + "Sorry, the rich preview can't be displayed for this file."} + + + ) +} diff --git a/src/components/diff/Legend.tsx b/src/components/viewer/Legend.tsx similarity index 100% rename from src/components/diff/Legend.tsx rename to src/components/viewer/Legend.tsx diff --git a/src/components/viewer/RecenterButton.test.tsx b/src/components/viewer/RecenterButton.test.tsx new file mode 100644 index 0000000..6918604 --- /dev/null +++ b/src/components/viewer/RecenterButton.test.tsx @@ -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() + const button = await screen.findByRole('button') + expect(callback.mock.calls).toHaveLength(0) + fireEvent.click(button) + expect(callback.mock.calls).toHaveLength(1) +}) diff --git a/src/components/viewer/RecenterButton.tsx b/src/components/viewer/RecenterButton.tsx new file mode 100644 index 0000000..a903112 --- /dev/null +++ b/src/components/viewer/RecenterButton.tsx @@ -0,0 +1,9 @@ +import { Box, Button } from '@primer/react' + +export function RecenterButton({ onClick }: { onClick: () => void }) { + return ( + + + + ) +} diff --git a/src/components/diff/SourceRichToggle.test.tsx b/src/components/viewer/SourceRichToggle.test.tsx similarity index 100% rename from src/components/diff/SourceRichToggle.test.tsx rename to src/components/viewer/SourceRichToggle.test.tsx diff --git a/src/components/diff/SourceRichToggle.tsx b/src/components/viewer/SourceRichToggle.tsx similarity index 100% rename from src/components/diff/SourceRichToggle.tsx rename to src/components/viewer/SourceRichToggle.tsx diff --git a/src/components/diff/Viewer3D.tsx b/src/components/viewer/Viewer3D.tsx similarity index 100% rename from src/components/diff/Viewer3D.tsx rename to src/components/viewer/Viewer3D.tsx diff --git a/src/components/diff/WireframeModel.tsx b/src/components/viewer/WireframeModel.tsx similarity index 100% rename from src/components/diff/WireframeModel.tsx rename to src/components/viewer/WireframeModel.tsx diff --git a/tests/extension.spec.ts b/tests/extension.spec.ts index e768229..f73b44a 100644 --- a/tests/extension.spec.ts +++ b/tests/extension.spec.ts @@ -39,6 +39,19 @@ async function getFirstDiffScreenshot( 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 ({ page, authorizedBackground, @@ -76,3 +89,13 @@ test('commit diff with a .dae file as LFS', async ({ const screenshot = await getFirstDiffScreenshot(page, url, 'dae') 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() +}) diff --git a/tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-darwin.png b/tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-darwin.png new file mode 100644 index 0000000..d8c6814 Binary files /dev/null and b/tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-darwin.png differ diff --git a/tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-linux.png b/tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-linux.png new file mode 100644 index 0000000..d0b5179 Binary files /dev/null and b/tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-linux.png differ