Minimal working version (#1)

* cra boilerplate

* Dummy chrome extension

* eslint and working url popup

* content script and dummy messaging

* replace .obj diffs with dummy test

* comment and in-order multiple type support

* get pull api data from url

* README title and desc

* api/elements match with filename check

* github token signin signout

* manifest v3, service request for CORS

* working kittycad api in service worker

* First real background message

* Clean up,  better types

* Fix settings

* multiservice settings

* Tweaks

* WIP: download file

* Working downloads and kittycad conversion

* Inject react, add three dependencies

* Working stl canvas

* primer for github-like style

* Loading before model

* diff colors

* colorMode auto

* Popup clean up

* clean up

* Working loading

* Logos

* Add GitHub CI

* Working test

* yarn test in ci

* Little tweak

* Update README

* component tests

* Better test

* Clean up

* UserCard test

* working caddiff test

* Note

* Rename App to Settings

* storage test

* Clean up

* Clean up content script

* further content cleanup

* Fix test

* Little tweaks to modelview

* More tests and testing

* Regex fix

* LFS file download test

* prettier config from kittycad/website

* Little tweaks

* comment

* log level

* Tweaks

* README update

* more prettier

* comment

* Irrelevant comment

* No .vscode and readme update

* Remove .vscode

* Package.json update after vscode removal
This commit is contained in:
Pierre Jacquier
2023-03-02 04:35:07 -05:00
committed by GitHub
parent 730452db19
commit fd44076a18
40 changed files with 48589 additions and 0 deletions

15
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,15 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'daily'
- package-ecosystem: 'github-actions' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'daily'

25
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
install-build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.3.0
- uses: actions/setup-node@v3.6.0
with:
node-version: '16'
cache: 'yarn'
- run: yarn install
- run: yarn build
- run: yarn test

25
.gitignore vendored
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.yarn
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
/.vscode
npm-debug.log*
yarn-debug.log*
yarn-error.log*

30555
.pnp.cjs generated Executable file

File diff suppressed because one or more lines are too long

2042
.pnp.loader.mjs generated Normal file

File diff suppressed because it is too large Load Diff

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# Diff Viewer Chrome Extension
Injects @kittycad/lib powered visual diffs for supported CAD files in GitHub Pull Requests.
## Available Scripts
The project was setup as a Create-React-App boilerplate, with Node 16, yarn 3 as package manager and TypeScript.
https://craco.js.org/ is used to extend the default CRA configs.
From the project directory:
### `yarn install`
Installs all the dependencies needed to build and test the project.
If needed: VS Code requires an additional step to make sure it works with Yarn PnP (more info [here](https://yarnpkg.com/getting-started/editor-sdks#vscode))
```
yarn dlx @yarnpkg/sdks vscode
```
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The generated `build` directory may then be added to Chrome with the **Load unpacked** button at [chrome://extensions](). This needs to be done everytime there's a change.
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
**This will only show the current settings popup in a tab, that won't be able to act as a Chrome extension, so it may only be used for pure UI work.**
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.

28
craco.config.js Normal file
View File

@ -0,0 +1,28 @@
module.exports = {
webpack: {
configure: (webpackConfig, { env, paths }) => {
return {
...webpackConfig,
entry: {
main: [
env === 'development' &&
require.resolve(
'react-dev-utils/webpackHotDevClient'
),
paths.appIndexJs,
].filter(Boolean),
content: './src/chrome/content.ts',
background: './src/chrome/background.ts',
},
output: {
...webpackConfig.output,
filename: 'static/js/[name].js',
},
optimization: {
...webpackConfig.optimization,
runtimeChunk: false,
},
}
},
},
}

2
jest.mockundefined.js Normal file
View File

@ -0,0 +1,2 @@
// From https://stackoverflow.com/a/71088205, this allows to not test the untransformed files themselves
module.exports = undefined

77
package.json Normal file
View File

@ -0,0 +1,77 @@
{
"name": "diff-viewer-extension",
"version": "0.1.0",
"private": true,
"dependencies": {
"@craco/craco": "^7.0.0",
"@kittycad/lib": "^0.0.19",
"@octokit/openapi-types": "^16.0.0",
"@octokit/rest": "^19.0.7",
"@octokit/types": "^9.0.0",
"@primer/react": "^35.19.0",
"@react-three/drei": "^9.56.24",
"@react-three/fiber": "^8.11.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/chrome": "^0.0.212",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/three": "^0.149.0",
"github-injection": "^1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-is": "^18.2.0",
"react-scripts": "5.0.1",
"styled-components": "^5.3.6",
"three": "^0.149.0",
"typescript": "^4.4.2"
},
"scripts": {
"start": "react-scripts start",
"build": "INLINE_RUNTIME_CHUNK=false craco build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"prettier"
]
},
"prettier": {
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"arrowParens": "avoid",
"trailingComma": "es5"
},
"jest": {
"moduleNameMapper": {
"three/examples/jsm": "<rootDir>/jest.mockundefined.js"
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"eslint": "^8.33.0",
"eslint-config-prettier": "^8.6.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.32.2",
"prettier": "^2.8.4"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

43
public/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>KittyCAD Diff Viewer Extension</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

34
public/manifest.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "KittyCAD Diff Viewer",
"description": "KittyCAD Diff Viewer Chrome Extension",
"version": "0.1",
"manifest_version": 3,
"action": {
"default_popup": "index.html",
"default_title": "Open the settings"
},
"icons": {
"16": "logo192.png",
"48": "logo192.png",
"128": "logo192.png"
},
"permissions": ["storage"],
"host_permissions": [
"https://github.com/",
"https://api.github.com/",
"https://media.githubusercontent.com/",
"https://api.kittycad.io/"
],
"content_scripts": [
{
"matches": ["https://github.com/*"],
"js": ["./static/js/content.js"],
"all_frames": false,
"run_at": "document_end"
}
],
"background": {
"service_worker": "./static/js/background.js",
"type": "module"
}
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

130
src/chrome/background.ts Normal file
View File

@ -0,0 +1,130 @@
import { Client, users } from '@kittycad/lib'
import { Octokit } from '@octokit/rest'
import {
KittycadUser,
Message,
MessageGetFileDiff,
MessageGetGithubPullFilesData,
MessageIds,
MessageResponse,
MessageSaveToken,
} from './types'
import {
getStorageGithubToken,
getStorageKittycadToken,
setStorageGithubToken,
setStorageKittycadToken,
} from './storage'
import { getFileDiff } from './diff'
let github: Octokit
let kittycad: Client
async function initGithubApi() {
try {
github = new Octokit({ auth: await getStorageGithubToken() })
const octokitResponse = await github.rest.users.getAuthenticated()
console.log(`Logged in on github.com as ${octokitResponse.data.login}`)
} catch (e) {
console.log('Couldnt initiate the github api client')
}
}
async function initKittycadApi() {
try {
kittycad = new Client(await getStorageKittycadToken())
const kittycadResponse = await users.get_user_self({ client: kittycad })
console.log(
`Logged in on kittycad.io as ${
(kittycadResponse as KittycadUser).email
}`
)
} catch (e) {
console.log("Couldn't initiate the kittycad api client")
}
}
async function saveGithubTokenAndReload(token: string): Promise<void> {
await setStorageGithubToken(token)
await initGithubApi()
}
async function saveKittycadTokenAndReload(token: string): Promise<void> {
await setStorageKittycadToken(token)
await initKittycadApi()
}
;(async () => {
await initKittycadApi()
await initGithubApi()
})()
chrome.runtime.onMessage.addListener(
(
message: Message,
sender: chrome.runtime.MessageSender,
sendResponse: (response: MessageResponse) => void
) => {
console.log(`Received ${message.id} from ${sender.id}`)
if (message.id === MessageIds.GetGithubPullFiles) {
const { owner, repo, pull } =
message.data as MessageGetGithubPullFilesData
github.rest.pulls
.listFiles({ owner, repo, pull_number: pull })
.then(r => sendResponse(r.data))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetGithubPull) {
const { owner, repo, pull } =
message.data as MessageGetGithubPullFilesData
github.rest.pulls
.get({ owner, repo, pull_number: pull })
.then(r => sendResponse(r.data))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetGithubUser) {
github.rest.users
.getAuthenticated()
.then(r => sendResponse(r.data))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetKittycadUser) {
users
.get_user_self({ client: kittycad })
.then(r => sendResponse(r as KittycadUser))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.SaveGithubToken) {
const { token } = message.data as MessageSaveToken
saveGithubTokenAndReload(token)
.then(() => sendResponse({ token }))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.SaveKittycadToken) {
const { token } = message.data as MessageSaveToken
saveKittycadTokenAndReload(token)
.then(() => sendResponse({ token }))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetFileDiff) {
const { owner, repo, sha, parentSha, file } =
message.data as MessageGetFileDiff
getFileDiff(github, kittycad, owner, repo, sha, parentSha, file)
.then(r => sendResponse(r))
.catch(e => sendResponse(e))
return true
}
}
)

67
src/chrome/content.ts Normal file
View File

@ -0,0 +1,67 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { CadDiff } from '../components/CadDiff'
import { Loading } from '../components/Loading'
import { isFilenameSupported } from './diff'
import { DiffEntry, FileDiff, Message, MessageIds, Pull } from './types'
import {
getGithubUrlParams,
getWebPullElements,
getInjectablePullElements,
} from './web'
// https://github.com/OctoLinker/injection
// maintained by octolinker, makes sure pages that are loaded through pjax are available for injection
// no ts support
const gitHubInjection = require('github-injection')
async function injectPullDiff(
owner: string,
repo: string,
pull: number,
document: Document
) {
const allApiFiles = await chrome.runtime.sendMessage<Message, DiffEntry[]>({
id: MessageIds.GetGithubPullFiles,
data: { owner, repo, pull },
})
const apiFiles = allApiFiles.filter(f => isFilenameSupported(f.filename))
console.log(`Found ${apiFiles.length} supported files with the API`)
const elements = getWebPullElements(document)
console.log(`Found ${elements.length} elements in the web page`)
const injectableElements = getInjectablePullElements(elements, apiFiles)
for (const { element } of injectableElements) {
createRoot(element).render(React.createElement(Loading))
}
const pullData = await chrome.runtime.sendMessage<Message, Pull>({
id: MessageIds.GetGithubPull,
data: { owner, repo, pull },
})
const sha = pullData.head.sha
const parentSha = pullData.base.sha
for (const { element, file } of injectableElements) {
const fileDiff = await chrome.runtime.sendMessage<Message, FileDiff>({
id: MessageIds.GetFileDiff,
data: { owner, repo, sha, parentSha, file },
})
createRoot(element).render(React.createElement(CadDiff, fileDiff))
}
}
gitHubInjection(async () => {
const params = getGithubUrlParams(window.location.href)
if (!params) {
console.log("URL doesn't match pull request pattern.")
return
}
const { owner, repo, pull } = params
console.log('Found pull request diff URL', owner, repo, pull)
try {
await injectPullDiff(owner, repo, pull, window.document)
} catch (e) {
console.error(e)
}
})

38
src/chrome/diff.test.ts Normal file
View File

@ -0,0 +1,38 @@
import { Octokit } from '@octokit/rest'
import { downloadFile, isFilenameSupported } from './diff'
it('checks if the filename has a supported extension', () => {
expect(isFilenameSupported('noextension')).toBe(false)
expect(isFilenameSupported('unsupported.txt')).toBe(false)
expect(isFilenameSupported('supported.obj')).toBe(true)
})
describe('Function downloadFile', () => {
it('downloads a public regular github file', async () => {
const github = new Octokit()
// https://github.com/KittyCAD/kittycad.ts/blob/0c61ffe45d8b2c72b3d98600e9c50a8a404226b9/example.obj
const response = await downloadFile(
github,
'KittyCAD',
'kittycad.ts',
'0c61ffe45d8b2c72b3d98600e9c50a8a404226b9',
'example.obj'
)
// TODO: add hash validation or something like that
expect(response).toHaveLength(37077)
})
it('downloads a public LFS github file', async () => {
const github = new Octokit()
// https://github.com/pierremtb/SwGitExample/be3e3897450f28b4166fa1039db06e7d0351dc9b/main/Part1.SLDPRT
const response = await downloadFile(
github,
'pierremtb',
'SwGitExample',
'be3e3897450f28b4166fa1039db06e7d0351dc9b',
'Part1.SLDPRT'
)
// TODO: add hash validation or something like that
expect(response).toHaveLength(70702)
})
})

134
src/chrome/diff.ts Normal file
View File

@ -0,0 +1,134 @@
import { Octokit } from '@octokit/rest'
import { Client, file } from '@kittycad/lib'
import { ContentFile, DiffEntry, FileDiff } from './types'
import {
FileExportFormat_type,
FileImportFormat_type,
} from '@kittycad/lib/dist/types/src/models'
// TODO: check if we could get that from the library
export const supportedSrcFormats = new Set([
'dae',
'dxf',
'fbx',
'obj',
'step',
'stl',
'svg',
])
export function isFilenameSupported(filename: string) {
const parts = filename.split('.')
if (parts.length <= 1) {
return false
}
return supportedSrcFormats.has(parts.pop()!)
}
export async function downloadFile(
octokit: Octokit,
owner: string,
repo: string,
ref: string,
path: string
): Promise<string> {
// First get some info on the blob with the Contents api
const content = await octokit.rest.repos.getContent({
owner,
repo,
path,
ref,
request: { cache: 'reload' }, // download_url provides a token that seems very short-lived
})
const contentFile = content.data as ContentFile
if (!contentFile.download_url) {
throw Error(`No download URL associated with ${path} at ${ref}`)
}
// Then actually use the download_url (that supports LFS files and has a token) to write the file
console.log(`Downloading ${contentFile.download_url}...`)
const response = await fetch(contentFile.download_url)
if (!response.ok) throw response
return await response.text()
}
async function convert(
client: Client,
body: string,
srcFormat: string,
outputFormat = 'stl'
) {
// TODO: think about the best output format for visual diff injection, now defaults to STL
const response = await file.create_file_conversion({
client,
body,
src_format: srcFormat as FileImportFormat_type,
output_format: outputFormat as FileExportFormat_type,
})
if ('error_code' in response) throw response
const { status, id, output } = response
console.log(`File conversion id: ${id}`)
console.log(`File conversion status: ${status}`)
return output
}
export async function getFileDiff(
github: Octokit,
kittycad: Client,
owner: string,
repo: string,
sha: string,
parentSha: string,
file: DiffEntry
): Promise<FileDiff> {
const { filename, status } = file
const extension = filename.split('.').pop()
if (!extension || !supportedSrcFormats.has(extension)) {
throw Error(
`Unsupported extension. Given ${extension}, was expecting ${supportedSrcFormats.values()}`
)
}
if (status === 'modified') {
const beforeBlob = await downloadFile(
github,
owner,
repo,
sha,
filename
)
const before = await convert(kittycad, beforeBlob, extension)
const afterBlob = await downloadFile(
github,
owner,
repo,
parentSha,
filename
)
const after = await convert(kittycad, afterBlob, extension)
return { before, after }
}
if (status === 'added') {
const blob = await downloadFile(github, owner, repo, sha, filename)
const after = await convert(kittycad, blob, extension)
return { after }
}
if (status === 'removed') {
const blob = await downloadFile(
github,
owner,
repo,
parentSha,
filename
)
const before = await convert(kittycad, blob, extension)
return { before }
}
throw Error(`Unsupported status: ${status}`)
}

View File

@ -0,0 +1,28 @@
import {
getStorageGithubToken,
getStorageKittycadToken,
setStorageGithubToken,
setStorageKittycadToken,
} from './storage'
it('saves github token to storage', async () => {
await setStorageGithubToken('token')
expect(chrome.storage.local.set).toHaveBeenCalledWith({ gtk: 'token' })
})
it('reads github token from storage', () => {
getStorageGithubToken()
expect(chrome.storage.local.get).toHaveBeenCalled()
// TODO: improve
})
it('saves kittycad token to storage', async () => {
await setStorageKittycadToken('token')
expect(chrome.storage.local.set).toHaveBeenCalledWith({ ktk: 'token' })
})
it('reads kittycad token from storage', () => {
getStorageKittycadToken()
expect(chrome.storage.local.get).toHaveBeenCalled()
// TODO: improve
})

36
src/chrome/storage.ts Normal file
View File

@ -0,0 +1,36 @@
enum TokenKeys {
Github = 'gtk',
Kittycad = 'ktk',
}
function setStorage(key: TokenKeys, value: string): Promise<void> {
return chrome.storage.local.set({ [key]: value })
}
function getStorage(key: TokenKeys): Promise<string> {
return new Promise<string>((resolve, reject) => {
chrome.storage.local.get([key], result => {
if (result && result[key]) {
resolve(result[key])
} else {
reject('Empty token')
}
})
})
}
export function setStorageGithubToken(token: string): Promise<void> {
return setStorage(TokenKeys.Github, token)
}
export function getStorageGithubToken(): Promise<string> {
return getStorage(TokenKeys.Github)
}
export function setStorageKittycadToken(token: string): Promise<void> {
return setStorage(TokenKeys.Kittycad, token)
}
export function getStorageKittycadToken(): Promise<string> {
return getStorage(TokenKeys.Kittycad)
}

63
src/chrome/types.ts Normal file
View File

@ -0,0 +1,63 @@
import { User_type } from '@kittycad/lib/dist/types/src/models'
import { components } from '@octokit/openapi-types'
// kittycad
export type KittycadUser = User_type
// octokit
export type DiffEntry = components['schemas']['diff-entry']
export type ContentFile = components['schemas']['content-file']
export type User = components['schemas']['simple-user']
export type Pull = components['schemas']['pull-request']
// chrome extension
export type FileDiff = {
before?: string
after?: string
}
export enum MessageIds {
GetGithubPullFiles = 'GetPullFiles',
GetGithubUser = 'GetGitHubUser',
SaveGithubToken = 'SaveGitHubToken',
SaveKittycadToken = 'SaveKittyCadToken',
GetKittycadUser = 'GetKittyCadUser',
GetFileDiff = 'GetFileDiff',
GetGithubPull = 'GetGithubPull',
}
export type MessageGetGithubPullFilesData = {
owner: string
repo: string
pull: number
}
export type MessageGetFileDiff = {
owner: string
repo: string
sha: string
parentSha: string
file: DiffEntry
}
export type MessageSaveToken = {
token: string
}
export type Message = {
id: MessageIds
data?: MessageGetGithubPullFilesData | MessageSaveToken | MessageGetFileDiff
}
export type MessageResponse =
| DiffEntry[]
| Pull
| User
| KittycadUser
| MessageSaveToken
| FileDiff
| Error
| void

90
src/chrome/web.test.ts Normal file
View File

@ -0,0 +1,90 @@
import { DiffEntry } from './types'
import {
getElementFilename,
getGithubUrlParams,
getInjectablePullElements,
getWebPullElements,
} from './web'
const githubPullHtmlSnippet = `
<div class="file js-file js-details-container js-targetable-element show-inline-notes Details Details--on open js-tagsearch-file" data-file-type=".obj">
<div class="file-header d-flex flex-md-row flex-column flex-md-items-center file-header--expandable js-file-header js-skip-tagsearch sticky-file-header js-position-sticky js-position-sticky-stacked">
<div class="file-info flex-auto min-width-0 mb-md-0 mb-2">
<span class="Truncate">
<a title="samples/file_center_of_mass/output.obj" class="Link--primary Truncate-text" href="#diff-5f8df244900f6383db3354c02b8a984a044b272e6bfe4cacc1ec8d4892ad3e21">samples/file_center_of_mass/output.obj</a>
</span>
</div>
</div>
<div class="js-file-content Details-content--hidden position-relative">
<div class="data highlight empty">
Git LFS file not shown
</div>
</div>
</div>
` // from https://github.com/KittyCAD/litterbox/pull/95/files
const parser = new DOMParser()
const githubPullHtmlDocument = parser.parseFromString(
githubPullHtmlSnippet,
'text/html'
)
const githubPullFilesSample: DiffEntry[] = [
{
sha: '2f35d962a711bea7a8bf57481b8717f7dedbe1c5',
filename: 'samples/file_center_of_mass/output.obj',
status: 'modified',
additions: 2,
deletions: 2,
changes: 4,
blob_url:
'https://github.com/KittyCAD/litterbox/blob/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.obj',
raw_url:
'https://github.com/KittyCAD/litterbox/raw/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.obj',
contents_url:
'https://api.github.com/repos/KittyCAD/litterbox/contents/samples%2Ffile_center_of_mass%2Foutput.obj?ref=11510a02d8294cac5943b8ebdc416170f5b738b5',
patch: '@@ -1,3 +1,3 @@\n version https://git-lfs.github.com/spec/v1\n-oid sha256:2a07f53add3eee88b80a0bbe0412cf91df3d3bd9d45934ce849e0440eff90ee1\n-size 62122\n+oid sha256:0c0eb961e7e0589d83693335408b90d3b8adae9f4054c3e396c6eedbc5ed16ec\n+size 62545',
},
]
describe('Function getGithubUrlParams', () => {
it('gets params out of a valid github pull request link', () => {
const pullUrl = 'https://github.com/KittyCAD/kittycad.ts/pull/67/files'
const params = getGithubUrlParams(pullUrl)
expect(params).toBeDefined()
const { owner, repo, pull } = params!
expect(owner).toEqual('KittyCAD')
expect(repo).toEqual('kittycad.ts')
expect(pull).toEqual(67)
})
it("doesn't match other URLs", () => {
expect(getGithubUrlParams('http://google.com')).toBeUndefined()
expect(
getGithubUrlParams('https://github.com/KittyCAD/litterbox')
).toBeUndefined()
})
})
it('finds web elements for supported files', () => {
const elements = getWebPullElements(githubPullHtmlDocument)
expect(elements).toHaveLength(1)
})
it('finds the filename of a supported file element', () => {
const elements = getWebPullElements(githubPullHtmlDocument)
const filename = getElementFilename(elements[0])
expect(filename).toEqual('samples/file_center_of_mass/output.obj')
})
it('finds injectable elements from html and api results', () => {
const elements = getWebPullElements(githubPullHtmlDocument)
const injectableElements = getInjectablePullElements(
elements,
githubPullFilesSample
)
expect(injectableElements).toHaveLength(1)
const { element, file } = injectableElements[0]
expect(element).toBeDefined()
expect(file).toBeDefined()
})

66
src/chrome/web.ts Normal file
View File

@ -0,0 +1,66 @@
import { supportedSrcFormats } from './diff'
import { DiffEntry } from './types'
export type GithubUrlParams =
| {
owner: string
repo: string
pull: number
}
| undefined
export function getGithubUrlParams(url: string): GithubUrlParams {
// TODO: support commit diff
const pullRe =
/https:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)\/pull\/(\d+)\/files/
const result = pullRe.exec(url)
if (!result) {
return undefined
}
const [, owner, repo, pull] = result
return { owner, repo, pull: parseInt(pull) }
}
export function getWebPullElements(document: Document): HTMLElement[] {
const fileTypeSelectors = Array.from(supportedSrcFormats).map(
t => `.file[data-file-type=".${t}"]`
)
const selector = fileTypeSelectors.join(', ')
return [...document.querySelectorAll(selector)].map(n => n as HTMLElement)
}
export function getElementFilename(element: HTMLElement) {
const titleElement = element.querySelector(
'.file-info a[title]'
) as HTMLElement
return titleElement.getAttribute('title')
}
export function getInjectablePullElements(
elements: HTMLElement[],
files: DiffEntry[]
) {
if (elements.length !== files.length) {
throw Error(
`elements and files have different length. Got ${elements.length} and ${files.length}`
)
}
const injectableElements = []
for (const [index, element] of elements.entries()) {
const apiFile = files[index]
const filename = getElementFilename(element)
if (filename !== apiFile.filename) {
throw Error(
"Couldn't match API file with a diff element on the page. Aborting."
)
}
const diffElement = element.querySelector(
'.js-file-content'
) as HTMLElement
injectableElements.push({ element: diffElement, file: apiFile })
}
return injectableElements
}

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { CadDiff } from './CadDiff'
it('renders the CAD diff element', () => {
render(<CadDiff />)
// TODO: find a way to add proper tests for ModelView,
// seems non-trivial with the simulated DOM
// Probably will have to go for end-to-end
})

View File

@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react'
import '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { BufferGeometry } from 'three'
import { Canvas } from '@react-three/fiber'
import { Box, ThemeProvider, useTheme } from '@primer/react'
import { FileDiff } from '../chrome/types'
function ModelView({ file }: { file: string }): React.ReactElement {
const { theme } = useTheme()
const [geometry, setGeometry] = useState<BufferGeometry>()
useEffect(() => {
const loader = new STLLoader()
const geometry = loader.parse(atob(file))
console.log(`Model ${geometry.id} loaded`)
setGeometry(geometry)
}, [file])
return (
<Canvas>
<ambientLight intensity={0.7} />
<pointLight position={[10, 10, 10]} />
<mesh geometry={geometry}>
<meshStandardMaterial color={theme?.colors.fg.default} />
</mesh>
<OrbitControls />
</Canvas>
)
}
export type CadDiffProps = FileDiff
export function CadDiff({ before, after }: CadDiffProps): React.ReactElement {
return (
<ThemeProvider colorMode="auto">
<Box display="flex" height={300}>
<Box flexGrow={1} backgroundColor="danger.subtle">
{before && <ModelView file={before} />}
</Box>
<Box
flexGrow={1}
backgroundColor="success.subtle"
borderLeftWidth={1}
borderLeftColor="border.default"
borderLeftStyle="solid"
>
{after && <ModelView file={after} />}
</Box>
</Box>
</ThemeProvider>
)
}

View File

@ -0,0 +1,6 @@
import { render } from '@testing-library/react'
import { Loading } from './Loading'
it('renders welcome message', () => {
render(<Loading />)
})

View File

@ -0,0 +1,11 @@
import { Box, Spinner } from '@primer/react'
export function Loading() {
return (
<Box display="flex" alignItems="center" justifyContent="space-around">
<Box display="block" py={4}>
<Spinner size="large" />
</Box>
</Box>
)
}

View File

@ -0,0 +1,14 @@
import { render, screen, waitFor } from '@testing-library/react'
import { Settings } from './Settings'
it('renders settings popup with both save buttons', async () => {
render(<Settings />)
// Waiting for loading
await waitFor(() => screen.findByText(/github token/i))
// GitHub and KittyCAD buttons
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toBeEnabled()
expect(buttons[1]).toBeEnabled()
})

120
src/components/Settings.tsx Normal file
View File

@ -0,0 +1,120 @@
import { Box, ThemeProvider } from '@primer/react'
import { useEffect, useState } from 'react'
import { KittycadUser, MessageIds, User } from '../chrome/types'
import { Loading } from './Loading'
import { TokenForm } from './TokenForm'
import { UserCard } from './UserCard'
export function Settings() {
const [githubUser, setGithubUser] = useState<User>()
const [kittycadUser, setKittycadUser] = useState<KittycadUser>()
const [firstInitDone, setFirstInitDone] = useState(false)
async function fetchGithubUser() {
try {
const response = await chrome.runtime.sendMessage({
id: MessageIds.GetGithubUser,
})
if (Object.keys(response).length === 0) throw Error('no response')
const user = response as User
setGithubUser(user)
} catch (e) {
console.error(e)
setGithubUser(undefined)
}
}
async function fetchKittycadUser() {
try {
const response = await chrome.runtime.sendMessage({
id: MessageIds.GetKittycadUser,
})
if (Object.keys(response).length === 0) throw Error('no response')
const user = response as KittycadUser
setKittycadUser(user)
} catch (e) {
console.error(e)
setKittycadUser(undefined)
}
}
async function onToken(id: MessageIds, token: string) {
await chrome.runtime.sendMessage({ id, data: { token } })
}
useEffect(() => {
;(async () => {
await fetchGithubUser()
await fetchKittycadUser()
setFirstInitDone(true)
})()
}, [])
return (
<ThemeProvider colorMode="auto">
<Box backgroundColor="canvas.default" width={300} p={4}>
{firstInitDone ? (
<Box>
<Box>
{githubUser ? (
<UserCard
login={'@' + githubUser.login}
avatar={githubUser.avatar_url}
onSignOut={async () => {
await onToken(
MessageIds.SaveGithubToken,
''
)
setGithubUser(undefined)
}}
/>
) : (
<TokenForm
service="GitHub"
onToken={async (token: string) => {
await onToken(
MessageIds.SaveGithubToken,
token
)
await fetchGithubUser()
}}
/>
)}
</Box>
<Box mt={4}>
{kittycadUser ? (
<UserCard
login={kittycadUser.email}
avatar={
kittycadUser.image ||
'https://kittycad.io/logo-green.png'
}
onSignOut={async () => {
await onToken(
MessageIds.SaveKittycadToken,
''
)
setKittycadUser(undefined)
}}
/>
) : (
<TokenForm
service="KittyCAD"
onToken={async (token: string) => {
await onToken(
MessageIds.SaveKittycadToken,
token
)
await fetchKittycadUser()
}}
/>
)}
</Box>
</Box>
) : (
<Loading />
)}
</Box>
</ThemeProvider>
)
}

View File

@ -0,0 +1,21 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { TokenForm } from './TokenForm'
it('renders a token form and checks its callback', () => {
const service = 'service'
const token = 'token'
const callback = jest.fn()
render(<TokenForm service={service} onToken={callback} />)
expect(screen.getByText(`Enter a ${service} token`)).toBeInTheDocument()
const field = screen.getByRole('textbox')
fireEvent.change(field, { target: { value: token } })
const button = screen.getByRole('button')
expect(button).toBeEnabled()
fireEvent.click(button)
expect(callback.mock.calls).toHaveLength(1)
expect(callback.mock.lastCall[0]).toEqual(token)
})

View File

@ -0,0 +1,31 @@
import { Box, BranchName, Button, FormControl, TextInput } from '@primer/react'
import { useState } from 'react'
export type TokenFormProps = {
service: string
onToken: (token: string) => void
}
export function TokenForm({ service, onToken }: TokenFormProps) {
const [token, setToken] = useState('')
return (
<Box>
<FormControl required>
<FormControl.Label>Enter a {service} token</FormControl.Label>
<TextInput
value={token}
onChange={e => setToken(e.target.value)}
/>
{service === 'GitHub' && (
<FormControl.Caption>
With <BranchName>repo</BranchName> permissions
</FormControl.Caption>
)}
</FormControl>
<Button sx={{ mt: 2 }} onClick={() => onToken(token)}>
Save
</Button>
</Box>
)
}

View File

@ -0,0 +1,19 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { UserCard } from './UserCard'
it('renders a user card and checks its callback button', () => {
const login = 'login'
const avatar = 'avatar'
const callback = jest.fn()
render(<UserCard login={login} avatar={avatar} onSignOut={callback} />)
expect(screen.getByText(login)).toBeInTheDocument()
expect(screen.getByRole('img')).toBeInTheDocument()
const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(callback.mock.calls).toHaveLength(0)
fireEvent.click(button)
expect(callback.mock.calls).toHaveLength(1)
})

View File

@ -0,0 +1,23 @@
import { Avatar, Box, Button, Text } from '@primer/react'
export type UserCardProps = {
login: string
avatar: string
onSignOut: () => void
}
export function UserCard({ login, avatar, onSignOut }: UserCardProps) {
return (
<Box>
<Box display="flex" alignItems="center" mb={2}>
<Avatar src={avatar} size={32} />
<Box flexGrow={1} pl={2}>
<Text color="fg.default" fontSize={20}>
{login}
</Text>
</Box>
</Box>
<Button onClick={onSignOut}>Sign out</Button>
</Box>
)
}

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

11
src/index.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Settings } from './components/Settings'
import './index.css'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<Settings />
</React.StrictMode>
)

1
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

26
src/setupTests.ts Normal file
View File

@ -0,0 +1,26 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom'
// From https://github.com/primer/react/blob/5dd4bb1f7f92647197160298fc1f521b23b4823b/src/utils/test-helpers.tsx#L12
global.CSS = {
escape: jest.fn(),
supports: jest.fn().mockImplementation(() => {
return false
}),
}
// TODO: improve/replace chrome mocks
global.chrome = {
runtime: {
sendMessage: jest.fn(),
},
storage: {
local: {
set: jest.fn(),
get: jest.fn(),
},
},
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2015",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["chrome"]
},
"include": ["src"]
}

14659
yarn.lock Normal file

File diff suppressed because it is too large Load Diff