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:
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal 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
25
.github/workflows/ci.yml
vendored
Normal 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
25
.gitignore
vendored
@ -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
30555
.pnp.cjs
generated
Executable file
File diff suppressed because one or more lines are too long
2042
.pnp.loader.mjs
generated
Normal file
2042
.pnp.loader.mjs
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
README.md
Normal file
50
README.md
Normal 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 can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t 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 you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
28
craco.config.js
Normal file
28
craco.config.js
Normal 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
2
jest.mockundefined.js
Normal 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
77
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
43
public/index.html
Normal file
43
public/index.html
Normal 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
BIN
public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 207 KiB |
34
public/manifest.json
Normal file
34
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
130
src/chrome/background.ts
Normal file
130
src/chrome/background.ts
Normal 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
67
src/chrome/content.ts
Normal 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
38
src/chrome/diff.test.ts
Normal 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
134
src/chrome/diff.ts
Normal 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}`)
|
||||||
|
}
|
28
src/chrome/storage.test.ts
Normal file
28
src/chrome/storage.test.ts
Normal 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
36
src/chrome/storage.ts
Normal 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
63
src/chrome/types.ts
Normal 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
90
src/chrome/web.test.ts
Normal 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
66
src/chrome/web.ts
Normal 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
|
||||||
|
}
|
10
src/components/CadDiff.test.tsx
Normal file
10
src/components/CadDiff.test.tsx
Normal 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
|
||||||
|
})
|
52
src/components/CadDiff.tsx
Normal file
52
src/components/CadDiff.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
6
src/components/Loading.test.tsx
Normal file
6
src/components/Loading.test.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { Loading } from './Loading'
|
||||||
|
|
||||||
|
it('renders welcome message', () => {
|
||||||
|
render(<Loading />)
|
||||||
|
})
|
11
src/components/Loading.tsx
Normal file
11
src/components/Loading.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
14
src/components/Settings.test.tsx
Normal file
14
src/components/Settings.test.tsx
Normal 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
120
src/components/Settings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
21
src/components/TokenForm.test.tsx
Normal file
21
src/components/TokenForm.test.tsx
Normal 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)
|
||||||
|
})
|
31
src/components/TokenForm.tsx
Normal file
31
src/components/TokenForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
19
src/components/UserCard.test.tsx
Normal file
19
src/components/UserCard.test.tsx
Normal 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)
|
||||||
|
})
|
23
src/components/UserCard.tsx
Normal file
23
src/components/UserCard.tsx
Normal 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
13
src/index.css
Normal 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
11
src/index.tsx
Normal 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
1
src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
26
src/setupTests.ts
Normal file
26
src/setupTests.ts
Normal 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
21
tsconfig.json
Normal 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"]
|
||||||
|
}
|
Reference in New Issue
Block a user