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:
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(),
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user