├── .nvmrc ├── .yarnrc.yml ├── .gitattributes ├── public ├── logo192.png ├── logo512.png ├── diff-viewer-2-up.jpg └── diff-viewer-combined.jpg ├── .yarn └── sdks │ ├── eslint │ ├── package.json │ ├── lib │ │ └── api.js │ └── bin │ │ └── eslint.js │ ├── prettier │ ├── package.json │ └── index.js │ ├── integrations.yml │ └── typescript │ ├── package.json │ ├── bin │ ├── tsc │ └── tsserver │ └── lib │ ├── tsc.js │ ├── typescript.js │ ├── tsserver.js │ └── tsserverlibrary.js ├── src ├── github-injection.d.ts ├── components │ ├── Loading.test.tsx │ ├── viewer │ │ ├── RecenterButton.tsx │ │ ├── ErrorMessage.tsx │ │ ├── RecenterButton.test.tsx │ │ ├── ErrorMessage.test.tsx │ │ ├── Controls.tsx │ │ ├── Viewer3D.tsx │ │ ├── SourceRichToggle.test.tsx │ │ ├── Legend.tsx │ │ ├── Camera.tsx │ │ ├── BaseModel.tsx │ │ ├── WireframeModel.tsx │ │ ├── SourceRichToggle.tsx │ │ ├── CombinedModel.tsx │ │ ├── CadBlob.tsx │ │ ├── CadDiffPage.tsx │ │ ├── CadBlobPage.tsx │ │ └── CadDiff.tsx │ ├── Loading.tsx │ └── settings │ │ ├── Settings.test.tsx │ │ ├── UserCard.test.tsx │ │ ├── TokenForm.test.tsx │ │ ├── UserCard.tsx │ │ ├── TokenForm.tsx │ │ └── Settings.tsx ├── index.tsx ├── index.css ├── chrome │ ├── storage.test.ts │ ├── storage.ts │ ├── diff.test.ts │ ├── types.ts │ ├── web.ts │ ├── diff.ts │ ├── content.ts │ ├── background.ts │ └── web.test.ts ├── setupTests.ts └── utils │ ├── three.ts │ └── three.test.ts ├── tests ├── extension.spec.ts-snapshots │ ├── blob-preview-with-a-step-file-1-chromium-linux.png │ ├── blob-preview-with-an-obj-file-1-chromium-linux.png │ ├── blob-preview-with-an-stl-file-1-chromium-linux.png │ ├── commit-diff-with-an-added-step-file-1-chromium-linux.png │ ├── commit-diff-with-a-modified-dae-file-as-LFS-1-chromium-linux.png │ ├── commit-diff-with-a-modified-dae-file-as-LFS-2-chromium-linux.png │ ├── pull-request-diff-with-a-modified-obj-file-1-chromium-linux.png │ ├── pull-request-diff-with-a-modified-obj-file-2-chromium-linux.png │ ├── pull-request-diff-with-a-modified-step-file-1-chromium-linux.png │ ├── commit-diff-within-pull-request-with-a-modified-stl-file-1-chromium-linux.png │ └── commit-diff-within-pull-request-with-a-modified-stl-file-2-chromium-linux.png ├── fixtures.ts └── extension.spec.ts ├── tsconfig.node.json ├── index.html ├── .gitignore ├── tsconfig.json ├── playwright.config.ts ├── vite.config.ts ├── manifest.json ├── .github ├── workflows │ └── ci.yml └── dependabot.yml ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.6.0.cjs 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/releases/** binary 2 | /.yarn/plugins/** binary -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/diff-viewer-2-up.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/public/diff-viewer-2-up.jpg -------------------------------------------------------------------------------- /public/diff-viewer-combined.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/public/diff-viewer-combined.jpg -------------------------------------------------------------------------------- /.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "8.43.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "2.8.8-sdk", 4 | "main": "./index.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /src/github-injection.d.ts: -------------------------------------------------------------------------------- 1 | // npm module that doesn't have TS types, used for proper injection timing on github.com 2 | declare module 'github-injection' 3 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "4.9.5-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Loading.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { Loading } from './Loading' 3 | 4 | it('renders welcome message', () => { 5 | render() 6 | }) 7 | -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/blob-preview-with-a-step-file-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/blob-preview-with-a-step-file-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/blob-preview-with-an-obj-file-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/blob-preview-with-an-stl-file-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/blob-preview-with-an-stl-file-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/commit-diff-with-an-added-step-file-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/commit-diff-with-an-added-step-file-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/commit-diff-with-a-modified-dae-file-as-LFS-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/commit-diff-with-a-modified-dae-file-as-LFS-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/commit-diff-with-a-modified-dae-file-as-LFS-2-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/commit-diff-with-a-modified-dae-file-as-LFS-2-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/pull-request-diff-with-a-modified-obj-file-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/pull-request-diff-with-a-modified-obj-file-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/pull-request-diff-with-a-modified-obj-file-2-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/pull-request-diff-with-a-modified-obj-file-2-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/pull-request-diff-with-a-modified-step-file-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/pull-request-diff-with-a-modified-step-file-1-chromium-linux.png -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Zoo Diff Viewer Extension 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/commit-diff-within-pull-request-with-a-modified-stl-file-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/commit-diff-within-pull-request-with-a-modified-stl-file-1-chromium-linux.png -------------------------------------------------------------------------------- /tests/extension.spec.ts-snapshots/commit-diff-within-pull-request-with-a-modified-stl-file-2-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KittyCAD/diff-viewer-extension/HEAD/tests/extension.spec.ts-snapshots/commit-diff-within-pull-request-with-a-modified-stl-file-2-chromium-linux.png -------------------------------------------------------------------------------- /src/components/viewer/RecenterButton.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button } from '@primer/react' 2 | 3 | export function RecenterButton({ onClick }: { onClick: () => void }) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { Settings } from './components/settings/Settings' 4 | import './index.css' 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) 7 | root.render( 8 | 9 | 10 | 11 | ) 12 | -------------------------------------------------------------------------------- /src/components/viewer/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from '@primer/react' 2 | 3 | export function ErrorMessage({ message }: { message?: string }) { 4 | return ( 5 | 6 | 7 | {message || 8 | "Sorry, the rich preview can't be displayed for this file."} 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Spinner } from '@primer/react' 2 | 3 | export function Loading() { 4 | return ( 5 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 4 | 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 5 | 'Helvetica Neue', sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/viewer/RecenterButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react' 2 | import { vi } from 'vitest' 3 | import { RecenterButton } from './RecenterButton' 4 | 5 | it('renders the recenter button', async () => { 6 | const callback = vi.fn() 7 | render() 8 | const button = await screen.findByRole('button') 9 | expect(callback.mock.calls).toHaveLength(0) 10 | fireEvent.click(button) 11 | expect(callback.mock.calls).toHaveLength(1) 12 | }) 13 | -------------------------------------------------------------------------------- /src/components/viewer/ErrorMessage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { ErrorMessage } from './ErrorMessage' 3 | 4 | it('renders the error message', async () => { 5 | render() 6 | const text = await screen.findByText(/preview/) 7 | expect(text).toBeDefined() 8 | }) 9 | 10 | it('renders the error message with a custom message', async () => { 11 | render() 12 | const text = await screen.findByText(/custom/) 13 | expect(text).toBeDefined() 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/settings/Settings.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import { Settings } from './Settings' 3 | 4 | it('renders settings popup with both save buttons', async () => { 5 | render() 6 | 7 | // Waiting for loading 8 | await waitFor(() => screen.findByText(/github token/i)) 9 | 10 | // GitHub and KittyCAD buttons 11 | // TODO: understand why screen.getByRole started to hang 12 | const buttons = screen.getAllByText('Save') 13 | expect(buttons[0]).toBeEnabled() 14 | expect(buttons[1]).toBeEnabled() 15 | }) 16 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint your application uses 20 | module.exports = absRequire(`eslint`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier/index.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier/index.js your application uses 20 | module.exports = absRequire(`prettier/index.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/bin/eslint.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/bin/eslint.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/bin/eslint.js your application uses 20 | module.exports = absRequire(`eslint/bin/eslint.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/typescript.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/typescript.js your application uses 20 | module.exports = absRequire(`typescript/lib/typescript.js`); 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 7 | .pnp.* 8 | .yarn/* 9 | !.yarn/patches 10 | !.yarn/plugins 11 | !.yarn/releases 12 | !.yarn/sdks 13 | !.yarn/versions 14 | 15 | # testing 16 | /coverage 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | .env.local 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | /.vscode 28 | 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | /test-results/ 33 | /playwright-report/ 34 | /playwright/.cache/ 35 | /tests/extension.spec.ts-snapshots/*darwin* 36 | 37 | .env* 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "types": ["chrome"] 20 | }, 21 | "include": ["src"], 22 | "references": [{ "path": "./tsconfig.node.json" }] 23 | } 24 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | timeout: 30 * 1000, 6 | expect: { 7 | timeout: 5000, 8 | }, 9 | fullyParallel: true, 10 | forbidOnly: !!process.env.CI, 11 | retries: process.env.CI ? 2 : 0, 12 | workers: process.env.CI ? 1 : undefined, 13 | reporter: [['html', { open: 'never' }]], 14 | use: { 15 | actionTimeout: 0, 16 | trace: 'on-first-retry', 17 | }, 18 | 19 | projects: [ 20 | { 21 | name: 'chromium', 22 | use: { 23 | ...devices['Desktop Chrome'], 24 | }, 25 | }, 26 | ], 27 | 28 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 29 | // outputDir: 'test-results/', 30 | }) 31 | -------------------------------------------------------------------------------- /src/chrome/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getStorageGithubToken, 3 | getStorageKittycadToken, 4 | setStorageGithubToken, 5 | setStorageKittycadToken, 6 | } from './storage' 7 | 8 | it('saves github token to storage', async () => { 9 | await setStorageGithubToken('token') 10 | expect(chrome.storage.local.set).toHaveBeenCalledWith({ gtk: 'token' }) 11 | }) 12 | 13 | it('reads github token from storage', () => { 14 | getStorageGithubToken() 15 | expect(chrome.storage.local.get).toHaveBeenCalled() 16 | // TODO: improve 17 | }) 18 | 19 | it('saves kittycad token to storage', async () => { 20 | await setStorageKittycadToken('token') 21 | expect(chrome.storage.local.set).toHaveBeenCalledWith({ ktk: 'token' }) 22 | }) 23 | 24 | it('reads kittycad token from storage', () => { 25 | getStorageKittycadToken() 26 | expect(chrome.storage.local.get).toHaveBeenCalled() 27 | // TODO: improve 28 | }) 29 | -------------------------------------------------------------------------------- /src/components/settings/UserCard.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react' 2 | import { UserCard } from './UserCard' 3 | import { vi } from 'vitest' 4 | 5 | it('renders a user card and checks its callback button', () => { 6 | const login = 'login' 7 | const avatar = 'avatar' 8 | const callback = vi.fn() 9 | 10 | render( 11 | 17 | ) 18 | expect(screen.getByText(login)).toBeInTheDocument() 19 | expect(screen.getAllByRole('img')).toHaveLength(2) 20 | 21 | const button = screen.getByRole('button') 22 | expect(button).toBeEnabled() 23 | 24 | expect(callback.mock.calls).toHaveLength(0) 25 | fireEvent.click(button) 26 | expect(callback.mock.calls).toHaveLength(1) 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/viewer/Controls.tsx: -------------------------------------------------------------------------------- 1 | import { OrbitControls } from '@react-three/drei' 2 | import { useThree } from '@react-three/fiber' 3 | import { MutableRefObject } from 'react' 4 | import { Vector3 } from 'three' 5 | // From https://github.com/pmndrs/drei/discussions/719#discussioncomment-1961149 6 | import { OrbitControls as OrbitControlsType } from 'three-stdlib' 7 | 8 | export type ControlsProps = { 9 | target?: Vector3 10 | reference: MutableRefObject 11 | onAltered?: () => void 12 | } 13 | 14 | export function Controls({ target, reference, onAltered }: ControlsProps) { 15 | const camera = useThree(s => s.camera) 16 | const gl = useThree(s => s.gl) 17 | return ( 18 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/settings/TokenForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react' 2 | import { TokenForm } from './TokenForm' 3 | import { vi } from 'vitest' 4 | 5 | it('renders a token form and checks its callback', () => { 6 | const service = 'service' 7 | const token = 'token' 8 | const callback = vi.fn() 9 | 10 | render() 11 | expect(screen.getByText(`Enter a ${service} token`)).toBeInTheDocument() 12 | 13 | // TODO: understand why screen.getByRole started to hang 14 | const field = screen.getByAltText('Text input for token') 15 | fireEvent.change(field, { target: { value: token } }) 16 | 17 | // TODO: understand why screen.getByRole started to hang 18 | const button = screen.getByText('Save') 19 | expect(button).toBeEnabled() 20 | fireEvent.click(button) 21 | 22 | expect(callback.mock.calls).toHaveLength(1) 23 | expect(callback.mock.lastCall[0]).toEqual(token) 24 | }) 25 | -------------------------------------------------------------------------------- /src/components/settings/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarPair, Box, Button, Text } from '@primer/react' 2 | 3 | export type UserCardProps = { 4 | login: string 5 | avatar: string 6 | serviceAvatar: string 7 | onSignOut: () => void 8 | } 9 | 10 | export function UserCard({ 11 | login, 12 | avatar, 13 | serviceAvatar, 14 | onSignOut, 15 | }: UserCardProps) { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {login} 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { nodePolyfills } from 'vite-plugin-node-polyfills' 3 | import { configDefaults } from 'vitest/config' 4 | import react from '@vitejs/plugin-react' 5 | import { crx } from '@crxjs/vite-plugin' 6 | import manifest from './manifest.json' 7 | 8 | export default defineConfig(() => { 9 | return { 10 | build: { 11 | outDir: 'build', 12 | }, 13 | plugins: [ 14 | react(), 15 | crx({ manifest }), 16 | nodePolyfills(), 17 | ], 18 | resolve: { 19 | alias: { 20 | // Replaces node-fetch in kittycad.ts, cross-fetch wouldn't work 21 | 'node-fetch': 'isomorphic-fetch', 22 | }, 23 | }, 24 | test: { 25 | globals: true, 26 | environment: 'happy-dom', 27 | setupFiles: 'src/setupTests.ts', 28 | exclude: [...configDefaults.exclude, 'tests/*'], 29 | }, 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /src/chrome/storage.ts: -------------------------------------------------------------------------------- 1 | enum TokenKeys { 2 | Github = 'gtk', 3 | Kittycad = 'ktk', 4 | } 5 | 6 | function setStorage(key: TokenKeys, value: string): Promise { 7 | return chrome.storage.local.set({ [key]: value }) 8 | } 9 | 10 | function getStorage(key: TokenKeys): Promise { 11 | return new Promise((resolve, reject) => { 12 | chrome.storage.local.get([key], result => { 13 | if (result && result[key]) { 14 | resolve(result[key]) 15 | } else { 16 | reject('Empty token') 17 | } 18 | }) 19 | }) 20 | } 21 | 22 | export function setStorageGithubToken(token: string): Promise { 23 | return setStorage(TokenKeys.Github, token) 24 | } 25 | 26 | export function getStorageGithubToken(): Promise { 27 | return getStorage(TokenKeys.Github) 28 | } 29 | 30 | export function setStorageKittycadToken(token: string): Promise { 31 | return setStorage(TokenKeys.Kittycad, token) 32 | } 33 | 34 | export function getStorageKittycadToken(): Promise { 35 | return getStorage(TokenKeys.Kittycad) 36 | } 37 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Zoo Diff Viewer", 3 | "description": "Zoo Diff Viewer Chrome Extension", 4 | "version": "0.9.0", 5 | "manifest_version": 3, 6 | "action": { 7 | "default_popup": "index.html", 8 | "default_title": "Open the settings" 9 | }, 10 | "icons": { 11 | "16": "logo192.png", 12 | "48": "logo192.png", 13 | "128": "logo192.png" 14 | }, 15 | "permissions": [ 16 | "storage" 17 | ], 18 | "host_permissions": [ 19 | "https://github.com/", 20 | "https://api.github.com/", 21 | "https://media.githubusercontent.com/", 22 | "https://api.kittycad.io/" 23 | ], 24 | "content_scripts": [ 25 | { 26 | "matches": [ 27 | "https://github.com/*" 28 | ], 29 | "js": [ 30 | "src/chrome/content.ts" 31 | ], 32 | "all_frames": false, 33 | "run_at": "document_end" 34 | } 35 | ], 36 | "background": { 37 | "service_worker": "src/chrome/background.ts", 38 | "type": "module" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react' 2 | import { vi, expect } from 'vitest' 3 | import * as matchers from 'vitest-dom/matchers' 4 | 5 | // extends Vitest's expect method with methods from react-testing-library 6 | expect.extend(matchers) 7 | 8 | // From https://github.com/primer/react/blob/5dd4bb1f7f92647197160298fc1f521b23b4823b/src/utils/test-helpers.tsx#L12 9 | global.CSS = { 10 | escape: vi.fn(), 11 | supports: vi.fn().mockImplementation(() => { 12 | return false 13 | }), 14 | } 15 | 16 | // For jest-canvas-mock in tests, from https://github.com/hustcc/jest-canvas-mock/issues/88 17 | global.jest = vi 18 | 19 | // TODO: improve/replace chrome mocks 20 | global.chrome = { 21 | runtime: { 22 | // @ts-ignore TS2322 23 | sendMessage: vi.fn(), 24 | }, 25 | storage: { 26 | local: { 27 | // @ts-ignore TS2322 28 | set: vi.fn(), 29 | // @ts-ignore TS2322 30 | get: vi.fn(), 31 | }, 32 | }, 33 | } 34 | 35 | // runs a cleanup after each test case (e.g. clearing jsdom) 36 | afterEach(() => { 37 | cleanup() 38 | }) 39 | -------------------------------------------------------------------------------- /src/components/viewer/Viewer3D.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, PropsWithChildren } from 'react' 2 | import '@react-three/fiber' 3 | import { BufferGeometry } from 'three' 4 | import { Canvas } from '@react-three/fiber' 5 | import { Camera } from './Camera' 6 | import { Sphere } from 'three' 7 | import { Controls } from './Controls' 8 | import { OrbitControls } from 'three-stdlib' 9 | 10 | type Viewer3DProps = { 11 | geometry: BufferGeometry 12 | boundingSphere?: Sphere 13 | controlsRef: MutableRefObject 14 | onControlsAltered?: () => void 15 | } 16 | 17 | export function Viewer3D({ 18 | controlsRef, 19 | geometry, 20 | boundingSphere, 21 | onControlsAltered, 22 | children, 23 | }: PropsWithChildren) { 24 | return ( 25 | 26 | {children} 27 | 32 | {geometry && } 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/settings/TokenForm.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, FormControl, TextInput } from '@primer/react' 2 | import { useState, PropsWithChildren } from 'react' 3 | 4 | export type TokenFormProps = { 5 | service: string 6 | loading: boolean 7 | onToken: (token: string) => void 8 | } 9 | 10 | export function TokenForm({ 11 | service, 12 | loading, 13 | onToken, 14 | children, 15 | }: PropsWithChildren) { 16 | const [token, setToken] = useState('') 17 | 18 | return ( 19 | 20 | 21 | Enter a {service} token 22 | setToken(e.target.value)} 27 | onKeyDown={e => e.key === 'Enter' && onToken(token)} 28 | /> 29 | {children} 30 | 31 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/components/viewer/SourceRichToggle.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react' 2 | import { SourceRichToggle } from './SourceRichToggle' 3 | import { vi } from 'vitest' 4 | 5 | it('renders a source-rich diff toggle and checks its callbacks', async () => { 6 | const callbackSource = vi.fn() 7 | const callbackRich = vi.fn() 8 | 9 | render( 10 | 16 | ) 17 | 18 | const [sourceButton, richButton] = await screen.findAllByRole('button') 19 | 20 | expect(callbackSource.mock.calls).toHaveLength(0) 21 | expect(callbackRich.mock.calls).toHaveLength(0) 22 | 23 | fireEvent.click(sourceButton) 24 | expect(callbackSource.mock.calls).toHaveLength(1) 25 | 26 | fireEvent.click(richButton) 27 | expect(callbackRich.mock.calls).toHaveLength(1) 28 | }) 29 | 30 | it('renders a disbaled source-rich diff toggle', async () => { 31 | render() 32 | 33 | const [sourceButton, richButton] = await screen.findAllByRole('button') 34 | expect(sourceButton).toBeDisabled() 35 | expect(richButton).toBeDisabled() 36 | }) 37 | -------------------------------------------------------------------------------- /src/components/viewer/Legend.tsx: -------------------------------------------------------------------------------- 1 | import { DotFillIcon } from '@primer/octicons-react' 2 | import { Box, Label, Text } from '@primer/react' 3 | import { PropsWithChildren } from 'react' 4 | 5 | export type LegendLabelProps = { 6 | text: string 7 | color: 'neutral' | 'danger' | 'success' 8 | enabled: boolean 9 | onChange?: (enabled: boolean) => void 10 | } 11 | 12 | export function LegendLabel({ 13 | text, 14 | color, 15 | enabled, 16 | onChange, 17 | }: LegendLabelProps): React.ReactElement { 18 | return ( 19 | 20 | 34 | 35 | ) 36 | } 37 | 38 | export function LegendBox({ children }: PropsWithChildren): React.ReactElement { 39 | return ( 40 | 48 | {children} 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/three.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Box3, 3 | BufferAttribute, 4 | BufferGeometry, 5 | Group, 6 | Mesh, 7 | MeshBasicMaterial, 8 | Sphere, 9 | Vector3, 10 | } from 'three' 11 | import { OBJLoader } from 'three-stdlib' 12 | import { Buffer } from 'buffer' 13 | 14 | export function loadGeometry(file: string): BufferGeometry | undefined { 15 | const loader = new OBJLoader() 16 | const buffer = Buffer.from(file, 'base64').toString() 17 | const group = loader.parse(buffer) 18 | console.log(`Model ${group.id} loaded`) 19 | const geometry = (group.children[0] as Mesh)?.geometry 20 | if (geometry) { 21 | if (!geometry.attributes.uv) { 22 | // UV is needed for @react-three/csg 23 | // see: github.com/KittyCAD/diff-viewer-extension/issues/73 24 | geometry.setAttribute( 25 | 'uv', 26 | new BufferAttribute(new Float32Array([]), 1) 27 | ) 28 | } 29 | geometry.computeBoundingSphere() // will be used for auto-centering 30 | } 31 | return geometry 32 | } 33 | 34 | export function getCommonSphere( 35 | beforeGeometry: BufferGeometry, 36 | afterGeometry: BufferGeometry 37 | ) { 38 | const group = new Group() 39 | const dummyMaterial = new MeshBasicMaterial() 40 | group.add(new Mesh(beforeGeometry, dummyMaterial)) 41 | group.add(new Mesh(afterGeometry, dummyMaterial)) 42 | const boundingBox = new Box3().setFromObject(group) 43 | const center = new Vector3() 44 | boundingBox.getCenter(center) 45 | return boundingBox.getBoundingSphere(new Sphere(center)) 46 | } 47 | -------------------------------------------------------------------------------- /src/chrome/diff.test.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest' 2 | import { downloadFile, isFilenameSupported } from './diff' 3 | 4 | it('checks if the filename has a supported extension', () => { 5 | expect(isFilenameSupported('noextension')).toBe(false) 6 | expect(isFilenameSupported('unsupported.txt')).toBe(false) 7 | expect(isFilenameSupported('supported.obj')).toBe(true) 8 | expect(isFilenameSupported('supported.stl')).toBe(true) 9 | expect(isFilenameSupported('supported.stp')).toBe(true) 10 | expect(isFilenameSupported('supported.step')).toBe(true) 11 | }) 12 | 13 | describe('Function downloadFile', () => { 14 | it('downloads a public regular github file', async () => { 15 | const github = new Octokit() 16 | // https://github.com/KittyCAD/kittycad.ts/blob/0c61ffe45d8b2c72b3d98600e9c50a8a404226b9/example.obj 17 | const response = await downloadFile( 18 | github, 19 | 'KittyCAD', 20 | 'kittycad.ts', 21 | '0c61ffe45d8b2c72b3d98600e9c50a8a404226b9', 22 | 'example.obj' 23 | ) 24 | // TODO: add hash validation or something like that 25 | expect(await response.text()).toHaveLength(37077) 26 | }) 27 | 28 | it('downloads a public LFS github file', async () => { 29 | const github = new Octokit() 30 | // https://github.com/pierremtb/SwGitExample/be3e3897450f28b4166fa1039db06e7d0351dc9b/main/Part1.SLDPRT 31 | const response = await downloadFile( 32 | github, 33 | 'pierremtb', 34 | 'SwGitExample', 35 | 'be3e3897450f28b4166fa1039db06e7d0351dc9b', 36 | 'Part1.SLDPRT' 37 | ) 38 | // TODO: add hash validation or something like that 39 | expect(await response.text()).toHaveLength(70702) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/components/viewer/Camera.tsx: -------------------------------------------------------------------------------- 1 | import { OrthographicCamera } from '@react-three/drei' 2 | import { useEffect, useRef } from 'react' 3 | import { Sphere } from 'three' 4 | 5 | function CameraLighting({ boundingSphere }: { boundingSphere?: Sphere }) { 6 | const ref1 = useRef() 7 | const ref2 = useRef() 8 | useEffect(() => { 9 | if (ref1.current) { 10 | const { radius } = boundingSphere || { radius: 1 } 11 | // move spot light away relative to the object's size 12 | ref1.current.position.setLength(radius * 15) 13 | } 14 | }, [boundingSphere]) 15 | return ( 16 | <> 17 | 27 | 34 | 35 | ) 36 | } 37 | 38 | export function calculateFovFactor(fov: number, canvasHeight: number): number { 39 | const pixelsFromCenterToTop = canvasHeight / 2 40 | // Only interested in the angle from the center to the top of frame 41 | const deg2Rad = Math.PI / 180 42 | const halfFovRadians = (fov * deg2Rad) / 2 43 | return pixelsFromCenterToTop / Math.tan(halfFovRadians) 44 | } 45 | 46 | export function Camera({ boundingSphere }: { boundingSphere?: Sphere }) { 47 | return ( 48 | <> 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/viewer/BaseModel.tsx: -------------------------------------------------------------------------------- 1 | import { useThree } from '@react-three/fiber' 2 | import type { MutableRefObject, PropsWithChildren } from 'react' 3 | import { Suspense, useEffect, useRef } from 'react' 4 | import { Sphere } from 'three' 5 | import { Vector3 } from 'three' 6 | import { calculateFovFactor } from './Camera' 7 | 8 | type BaseModelProps = { 9 | boundingSphere: Sphere | null | undefined 10 | } 11 | 12 | export function BaseModel({ 13 | boundingSphere, 14 | children, 15 | }: PropsWithChildren) { 16 | const camera = useThree(state => state.camera) 17 | const controls = useThree(state => state.controls) as any // TODO: fix type 18 | const canvasHeight = useThree(state => state.size.height) 19 | 20 | // Camera view, adapted from KittyCAD/website 21 | useEffect(() => { 22 | if (boundingSphere && camera && controls && canvasHeight) { 23 | // move the camera away so the object fits in the view 24 | const { radius } = boundingSphere || { radius: 1 } 25 | if (!camera.position.length()) { 26 | const arbitraryNonZeroStartPosition = new Vector3(0.5, 0.5, 1) 27 | camera.position.copy(arbitraryNonZeroStartPosition) 28 | } 29 | const initialZoomOffset = 15 // Far enough to avoid clipping 30 | camera.position.setLength(radius * initialZoomOffset) 31 | 32 | // set zoom for orthographic Camera 33 | const fov = 7.5 // Small enough to have a good initial zoom 34 | const fovFactor = calculateFovFactor(fov, canvasHeight) 35 | camera.zoom = fovFactor / camera.position.length() 36 | camera.updateProjectionMatrix() 37 | controls.saveState() 38 | } 39 | }, [boundingSphere, camera, controls, canvasHeight]) 40 | 41 | return ( 42 | 43 | {children} 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/viewer/WireframeModel.tsx: -------------------------------------------------------------------------------- 1 | import type { MutableRefObject } from 'react' 2 | import { useMemo, useRef } from 'react' 3 | import { BufferGeometry, DoubleSide } from 'three' 4 | import { EdgesGeometry, Sphere } from 'three' 5 | import { BaseModel } from './BaseModel' 6 | 7 | export type WireframeColors = { 8 | face: string 9 | edge: string 10 | dashEdge: string 11 | } 12 | 13 | type Props = { 14 | geometry: BufferGeometry 15 | colors: WireframeColors 16 | boundingSphere?: Sphere 17 | } 18 | 19 | export function WireframeModel({ geometry, boundingSphere, colors }: Props) { 20 | const groupRef = useRef() 21 | const edgeThresholdAngle = 10 22 | const edges = useMemo( 23 | () => new EdgesGeometry(geometry, edgeThresholdAngle), 24 | [geometry] 25 | ) 26 | 27 | return ( 28 | 29 | 30 | 35 | 40 | 41 | line.computeLineDistances()} 45 | > 46 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/viewer/SourceRichToggle.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonGroup, IconButton, Tooltip } from '@primer/react' 2 | import { PackageIcon, CodeIcon } from '@primer/octicons-react' 3 | 4 | export type SourceRichToggleProps = { 5 | disabled: boolean 6 | richSelected: boolean 7 | onSourceSelected?: () => void 8 | onRichSelected?: () => void 9 | } 10 | 11 | export function SourceRichToggle({ 12 | disabled, 13 | richSelected, 14 | onSourceSelected, 15 | onRichSelected, 16 | }: SourceRichToggleProps) { 17 | const commonButtonSx = { 18 | color: 'fg.subtle', 19 | width: '40px', 20 | } 21 | const commonTooltipSx = { 22 | height: '32px', 23 | } 24 | const sourceText = 'Display the source diff' 25 | const richText = 'Display the rich diff' 26 | return ( 27 | 28 | 29 | 42 | 43 | 44 | 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/chrome/types.ts: -------------------------------------------------------------------------------- 1 | import { User_type } from '@kittycad/lib/dist/types/src/models' 2 | import { components } from '@octokit/openapi-types' 3 | 4 | // kittycad 5 | 6 | export type KittycadUser = User_type 7 | 8 | // octokit 9 | 10 | export type DiffEntry = components['schemas']['diff-entry'] 11 | export type ContentFile = components['schemas']['content-file'] 12 | export type User = components['schemas']['simple-user'] 13 | export type Pull = components['schemas']['pull-request'] 14 | export type Commit = components['schemas']['commit'] 15 | 16 | // chrome extension 17 | 18 | export type FileDiff = { 19 | before?: string 20 | after?: string 21 | } 22 | 23 | export type FileBlob = { 24 | blob?: string 25 | } 26 | 27 | export enum MessageIds { 28 | GetGithubPullFiles = 'GetPullFiles', 29 | GetGithubUser = 'GetGitHubUser', 30 | SaveGithubToken = 'SaveGitHubToken', 31 | SaveKittycadToken = 'SaveKittyCadToken', 32 | GetKittycadUser = 'GetKittyCadUser', 33 | GetFileDiff = 'GetFileDiff', 34 | GetFileBlob = 'GetFileBlob', 35 | GetGithubPull = 'GetGithubPull', 36 | GetGithubCommit = 'GetGithubCommit', 37 | } 38 | 39 | export type MessageGetGithubPullFilesData = { 40 | owner: string 41 | repo: string 42 | pull: number 43 | } 44 | 45 | export type MessageGetGithubCommitData = { 46 | owner: string 47 | repo: string 48 | sha: string 49 | } 50 | 51 | export type MessageGetFileDiff = { 52 | owner: string 53 | repo: string 54 | sha: string 55 | parentSha: string 56 | file: DiffEntry 57 | } 58 | 59 | export type MessageGetFileBlob = { 60 | owner: string 61 | repo: string 62 | sha: string 63 | filename: string 64 | } 65 | 66 | export type MessageSaveToken = { 67 | token: string 68 | } 69 | 70 | export type MessageError = { 71 | error: Error 72 | } 73 | 74 | export type Message = { 75 | id: MessageIds 76 | data?: 77 | | MessageGetGithubPullFilesData 78 | | MessageGetGithubCommitData 79 | | MessageSaveToken 80 | | MessageGetFileDiff 81 | } 82 | 83 | export type MessageResponse = 84 | | DiffEntry[] 85 | | Pull 86 | | Commit 87 | | User 88 | | KittycadUser 89 | | MessageSaveToken 90 | | FileDiff 91 | | FileBlob 92 | | MessageError 93 | | void 94 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-22.04 14 | permissions: 15 | contents: write 16 | env: 17 | RELEASE: ${{ github.event.release.name }} 18 | RELEASE_ZIP: zoo-diff-viewer-extension_${{ github.event.release.name || github.sha }}.zip 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version-file: '.nvmrc' 26 | cache: 'yarn' 27 | 28 | - run: yarn install 29 | 30 | - run: yarn build 31 | 32 | - run: yarn test 33 | 34 | - name: Run playwright e2e tests 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} 37 | KITTYCAD_TOKEN: ${{ secrets.KITTYCAD_TOKEN }} 38 | run: | 39 | yarn playwright install chromium --with-deps 40 | yarn playwright test --retries 1 41 | 42 | - name: Create release zip 43 | run: | 44 | cd build 45 | zip -r ../$RELEASE_ZIP * 46 | cd .. 47 | unzip -l $RELEASE_ZIP 48 | 49 | - uses: actions/upload-artifact@v4 50 | with: 51 | path: ${{ env.RELEASE_ZIP }} 52 | name: ${{ env.RELEASE_ZIP }} 53 | 54 | - name: Upload zip to release 55 | if: github.event_name == 'release' 56 | uses: softprops/action-gh-release@v2 57 | with: 58 | files: ${{ env.RELEASE_ZIP }} 59 | 60 | - name: Upload zip to Chrome Web Store 61 | if: github.event_name == 'release' 62 | uses: mobilefirstllc/cws-publish@latest 63 | with: 64 | action: publish 65 | client_id: ${{ secrets.CHROME_WEBSTORE_CLIENT_ID }} 66 | client_secret: ${{ secrets.CHROME_WEBSTORE_CLIENT_SECRET }} 67 | refresh_token: ${{ secrets.CHROME_WEBSTORE_REFRESH_TOKEN }} 68 | extension_id: gccpihmphokfjpohkmkbimnhhnlpmegp 69 | zip_file: ${{ env.RELEASE_ZIP }} 70 | -------------------------------------------------------------------------------- /tests/fixtures.ts: -------------------------------------------------------------------------------- 1 | // From https://playwright.dev/docs/chrome-extensions#testing 2 | import { 3 | test as base, 4 | chromium, 5 | Worker, 6 | type BrowserContext, 7 | } from '@playwright/test' 8 | import path, { dirname } from 'path' 9 | import { fileURLToPath } from 'url'; 10 | import * as dotenv from 'dotenv' 11 | dotenv.config() 12 | 13 | export const test = base.extend<{ 14 | context: BrowserContext 15 | extensionId: string 16 | background: Worker 17 | authorizedBackground: Worker 18 | }>({ 19 | context: async ({}, use) => { 20 | // Due to change to type: module, https://stackoverflow.com/a/50052194 21 | const __dirname = dirname(fileURLToPath(import.meta.url)); 22 | const pathToExtension = path.join(__dirname, '..', 'build') 23 | const context = await chromium.launchPersistentContext('', { 24 | headless: false, 25 | args: [ 26 | `--headless=new`, // headless mode that allows for extensions 27 | `--disable-extensions-except=${pathToExtension}`, 28 | `--load-extension=${pathToExtension}`, 29 | ], 30 | }) 31 | await use(context) 32 | await context.close() 33 | }, 34 | background: async ({ context }, use) => { 35 | let [background] = context.serviceWorkers() 36 | if (!background) 37 | background = await context.waitForEvent('serviceworker') 38 | 39 | // Wait for the chrome object to be available 40 | await new Promise(resolve => setTimeout(resolve, 100)) 41 | await use(background) 42 | }, 43 | authorizedBackground: async ({ background }, use) => { 44 | // Load the env tokens in storage for auth 45 | const githubToken = process.env.GITHUB_TOKEN 46 | const kittycadToken = process.env.KITTYCAD_TOKEN 47 | await background.evaluate( 48 | async ([githubToken, kittycadToken]) => { 49 | await chrome.storage.local.set({ 50 | ktk: kittycadToken, // from src/chrome/storage.ts 51 | gtk: githubToken, // from src/chrome/storage.ts 52 | }) 53 | }, 54 | [githubToken, kittycadToken] 55 | ) 56 | 57 | // Wait for background auth 58 | await new Promise(resolve => setTimeout(resolve, 2000)) 59 | await use(background) 60 | }, 61 | extensionId: async ({ background }, use) => { 62 | const extensionId = background.url().split('/')[2] 63 | await use(extensionId) 64 | }, 65 | }) 66 | export const expect = test.expect 67 | -------------------------------------------------------------------------------- /src/components/viewer/CombinedModel.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useState } from 'react' 2 | import { useTheme } from '@primer/react' 3 | import { BufferGeometry, Sphere } from 'three' 4 | import { Geometry, Base, Subtraction, Intersection } from '@react-three/csg' 5 | import { BaseModel } from './BaseModel' 6 | 7 | type CombinedModelProps = { 8 | beforeGeometry: BufferGeometry 9 | afterGeometry: BufferGeometry 10 | boundingSphere: Sphere 11 | showUnchanged: boolean 12 | showAdditions: boolean 13 | showDeletions: boolean 14 | onRendered?: () => void 15 | } 16 | 17 | export function CombinedModel({ 18 | beforeGeometry, 19 | afterGeometry, 20 | boundingSphere, 21 | showUnchanged, 22 | showAdditions, 23 | showDeletions, 24 | onRendered, 25 | }: CombinedModelProps) { 26 | const { theme } = useTheme() 27 | const commonColor = theme?.colors.fg.muted 28 | const additionsColor = theme?.colors.success.muted 29 | const deletionsColor = theme?.colors.danger.muted 30 | const [rendered, setRendered] = useState(false) 31 | 32 | return ( 33 | 34 | {/* Unchanged */} 35 | { 37 | if (!rendered) { 38 | setRendered(true) 39 | onRendered && onRendered() 40 | } 41 | }} 42 | > 43 | 48 | 49 | 50 | 51 | 52 | 53 | {/* Additions */} 54 | 55 | 60 | 61 | 62 | 63 | 64 | 65 | {/* Deletions */} 66 | 67 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/components/viewer/CadBlob.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import '@react-three/fiber' 3 | import { Box, Text, useTheme } from '@primer/react' 4 | import { FileBlob } from '../../chrome/types' 5 | import { Viewer3D } from './Viewer3D' 6 | import { BufferGeometry, Sphere } from 'three' 7 | import { WireframeColors, WireframeModel } from './WireframeModel' 8 | import { useRef } from 'react' 9 | import { loadGeometry } from '../../utils/three' 10 | import { OrbitControls } from 'three-stdlib' 11 | import { RecenterButton } from './RecenterButton' 12 | import { ErrorMessage } from './ErrorMessage' 13 | 14 | export function CadBlob({ blob }: FileBlob): React.ReactElement { 15 | const [geometry, setGeometry] = useState() 16 | const [boundingSphere, setBoundingSphere] = useState() 17 | const controlsRef = useRef(null) 18 | const [controlsAltered, setControlsAltered] = useState(false) 19 | const { theme } = useTheme() 20 | const colors: WireframeColors = { 21 | face: theme?.colors.fg.default, 22 | edge: theme?.colors.fg.muted, 23 | dashEdge: theme?.colors.fg.subtle, 24 | } 25 | useEffect(() => { 26 | let geometry: BufferGeometry | undefined = undefined 27 | if (blob) { 28 | geometry = loadGeometry(blob) 29 | setGeometry(geometry) 30 | if (geometry && geometry.boundingSphere) { 31 | setBoundingSphere(geometry.boundingSphere) 32 | } 33 | } 34 | }, [blob]) 35 | return ( 36 | <> 37 | {geometry && ( 38 | 39 | 40 | 45 | !controlsAltered && setControlsAltered(true) 46 | } 47 | > 48 | 53 | 54 | 55 | {controlsAltered && ( 56 | { 58 | controlsRef.current?.reset() 59 | setControlsAltered(false) 60 | }} 61 | /> 62 | )} 63 | 64 | )} 65 | {!geometry && } 66 | 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diff-viewer-extension", 3 | "version": "0.9.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@dicebear/avatars": "^4.10.8", 8 | "@dicebear/avatars-bottts-sprites": "^4.10.8", 9 | "@kittycad/lib": "^0.0.52", 10 | "@octokit/openapi-types": "^19.1.0", 11 | "@octokit/rest": "^20.1.0", 12 | "@octokit/types": "^13.5.0", 13 | "@primer/octicons-react": "^19.8.0", 14 | "@primer/react": "^36.6.0", 15 | "@react-three/csg": "^3.2.0", 16 | "@react-three/drei": "^9.106.0", 17 | "@react-three/fiber": "^8.16.2", 18 | "@testing-library/react": "^14.2.1", 19 | "@testing-library/user-event": "^14.5.2", 20 | "@types/chrome": "^0.0.306", 21 | "@types/node": "^20.4.2", 22 | "@types/react": "^18.2.48", 23 | "@types/react-dom": "^18.2.19", 24 | "@types/three": "^0.160.0", 25 | "buffer": "^6.0.3", 26 | "github-injection": "^1.1.0", 27 | "isomorphic-fetch": "^3.0.0", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-is": "^18.2.0", 31 | "styled-components": "^5.3.11", 32 | "three": "^0.160.0", 33 | "three-mesh-bvh": "^0.7.4", 34 | "three-stdlib": "^2.29.10", 35 | "typescript": "^4.9.5" 36 | }, 37 | "scripts": { 38 | "start": "vite", 39 | "build": "vite build", 40 | "test": "vitest", 41 | "e2e": "yarn build && yarn playwright test", 42 | "bump": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 4)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' manifest.json --indent 4)\" > manifest.json" 43 | }, 44 | "eslintConfig": { 45 | "extends": [ 46 | "prettier" 47 | ] 48 | }, 49 | "prettier": { 50 | "tabWidth": 4, 51 | "semi": false, 52 | "singleQuote": true, 53 | "arrowParens": "avoid", 54 | "trailingComma": "es5" 55 | }, 56 | "browserslist": { 57 | "production": [ 58 | ">0.2%", 59 | "not dead", 60 | "not op_mini all" 61 | ], 62 | "development": [ 63 | "last 1 chrome version", 64 | "last 1 firefox version", 65 | "last 1 safari version" 66 | ] 67 | }, 68 | "devDependencies": { 69 | "@babel/runtime": "^7.23.9", 70 | "@crxjs/vite-plugin": "2.0.0-beta.26", 71 | "@playwright/test": "^1.50.1", 72 | "@vitejs/plugin-react": "^4.3.4", 73 | "dotenv": "^16.4.5", 74 | "eslint": "^8.56.0", 75 | "eslint-config-prettier": "^9.1.0", 76 | "eslint-plugin-react": "^7.37.4", 77 | "happy-dom": "^15.10.2", 78 | "jest-canvas-mock": "^2.5.2", 79 | "jsdom": "^26.0.0", 80 | "prettier": "^3.3.3", 81 | "vite": "^5.4.12", 82 | "vite-plugin-node-polyfills": "^0.21.0", 83 | "vitest": "^1.6.1", 84 | "vitest-dom": "^0.1.1" 85 | }, 86 | "packageManager": "yarn@3.6.0", 87 | "engines": { 88 | "node": ">=18.0.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > The extension in its current form has been temporarily unpublished from the store. A rewrite leveraging more modern Zoo APIs and integrating properly with Github's new UI is needed before it can be published again. The work for this will be tracked at https://github.com/KittyCAD/diff-viewer-extension/issues/743. Sorry for any inconvenience! 3 | 4 | ![Zoo](/public/logo192.png) 5 | 6 | ## Zoo Diff Viewer Extension 7 | 8 | View changes to your models directly within GitHub with our extension for all Chrome, Edge, and Chromium-powered browsers. Use the industry-standard version control platform, supercharged with a rich CAD visualizer. Open-source and powered by the KittyCAD API. 9 | 10 | ### Review model changes visually 11 | 12 | Upload your models to GitHub and make safe, incremental changes to them with a full version history. And with our extension, you can now visually review your model with clear indications of what has changed between versions. Our extension overrides some of the GitHub interface to provide you with a full 3D view of your files, and two review modes: 13 | 14 | - 2-up view: For when you just need to see the before and after state of the model; and 15 | ![2-up view](/public/diff-viewer-2-up.jpg) 16 | 17 | - Combined view (experimental): See the additions, deletions, and unchanged portions of your model in one 3D viewer. 18 | 19 | ![Combined view](/public/diff-viewer-combined.jpg) 20 | 21 | ## Try it now 22 | 23 | Live on the [Google Chrome Store](https://chrome.google.com/webstore/detail/kittycad-diff-viewer/gccpihmphokfjpohkmkbimnhhnlpmegp). 24 | 25 | ## Running a development build 26 | 27 | The project uses Vite, with Node 18, yarn 3 as package manager and TypeScript. 28 | 29 | From the project directory: 30 | 31 | ### `yarn install` 32 | 33 | Installs all the dependencies needed to build and test the project. 34 | 35 | ### `yarn build` 36 | 37 | Builds the extension for production to the `build` folder.\ 38 | It correctly bundles React in production mode and optimizes the build for the best performance. 39 | 40 | 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. 41 | 42 | ### `yarn start` 43 | 44 | Runs the extension in the development mode. 45 | 46 | The generated `build` directory may then be added to Chrome with the **Load unpacked** button at [chrome://extensions](). Background/content scripts and React views should reload as changes are made. 47 | 48 | ### `yarn test` 49 | 50 | Launches the unit tests runner in the interactive watch mode. 51 | 52 | ### `yarn e2e` 53 | 54 | Builds the extension and runs end-to-end tests through an automated Chromium instance. 55 | 56 | ## Release a new version 57 | 58 | 1. Bump the versions in the source code by creating a new PR, committing the changes from 59 | 60 | ```bash 61 | VERSION=x.y.z npm run bump 62 | ``` 63 | 64 | 2. Merge the PR 65 | 66 | 3. Create a new release and tag pointing to the bump version commit using semantic versioning `v{x}.{y}.{z}` 67 | 68 | A new Action should run, uploading artifacts to the release itself and to the Chrome Web Store at https://chrome.google.com/webstore/detail/kittycad-diff-viewer/gccpihmphokfjpohkmkbimnhhnlpmegp 69 | -------------------------------------------------------------------------------- /src/utils/three.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest-canvas-mock' 2 | import { BoxGeometry } from 'three' 3 | import { getCommonSphere, loadGeometry } from './three' 4 | 5 | describe('Function loadGeometry', () => { 6 | it('loads a three geometry from a 10x10mm box', () => { 7 | const box_10 = Buffer.from( 8 | ` 9 | v 0.000000 0.000000 0.000000 10 | v 10.000000 0.000000 0.000000 11 | v 0.000000 0.000000 10.000000 12 | v 10.000000 0.000000 10.000000 13 | v 10.000000 10.000000 0.000000 14 | v 10.000000 10.000000 10.000000 15 | v 0.000000 10.000000 0.000000 16 | v 0.000000 10.000000 10.000000 17 | vn 0.000000 -1.000000 0.000000 18 | vn 0.000000 -1.000000 0.000000 19 | vn 1.000000 0.000000 0.000000 20 | vn 1.000000 -0.000000 0.000000 21 | vn 0.000000 1.000000 -0.000000 22 | vn 0.000000 1.000000 0.000000 23 | vn -1.000000 0.000000 0.000000 24 | vn -1.000000 -0.000000 0.000000 25 | vn 0.000000 0.000000 -1.000000 26 | vn 0.000000 0.000000 -1.000000 27 | vn 0.000000 0.000000 1.000000 28 | vn 0.000000 0.000000 1.000000 29 | f 1//1 2//1 3//1 30 | f 3//2 2//2 4//2 31 | f 2//3 5//3 4//3 32 | f 4//4 5//4 6//4 33 | f 5//5 7//5 6//5 34 | f 6//6 7//6 8//6 35 | f 7//7 1//7 8//7 36 | f 8//8 1//8 3//8 37 | f 1//9 5//9 2//9 38 | f 7//10 5//10 1//10 39 | f 6//11 3//11 4//11 40 | f 6//12 8//12 3//12 41 | ` 42 | ).toString('base64') 43 | const geometry = loadGeometry(box_10) 44 | expect(geometry?.attributes.position.count).toEqual(6 * 2 * 3) 45 | expect(geometry?.attributes.normal.count).toEqual(6 * 2 * 3) 46 | expect(geometry?.attributes.uv).toBeDefined() 47 | expect(geometry?.boundingSphere?.center.x).toEqual(5) 48 | expect(geometry?.boundingSphere?.center.y).toEqual(5) 49 | expect(geometry?.boundingSphere?.center.z).toEqual(5) 50 | const diagonal = 10 * Math.sqrt(3) 51 | expect(geometry?.boundingSphere?.radius).toBeCloseTo(diagonal / 2) 52 | }) 53 | 54 | it('fails with an empty or invalid input', () => { 55 | expect(loadGeometry('invalid')).toBeUndefined() 56 | }) 57 | }) 58 | 59 | describe('Function getCommonSphere', () => { 60 | // 1mm cube with "top-right" corner at origin 61 | const box_1_geom = new BoxGeometry(1, 1, 1) 62 | box_1_geom.translate(-1 / 2, -1 / 2, -1 / 2) 63 | // 2mm cube with "bottom left" corner at origin 64 | const box_2_geom = new BoxGeometry(2, 2, 2) 65 | box_2_geom.translate(2 / 2, 2 / 2, 2 / 2) 66 | 67 | it('gets the common bounding sphere between two geometries', () => { 68 | const sphere = getCommonSphere(box_1_geom, box_2_geom) 69 | expect(sphere.center.x).toEqual((2 - 1) / 2) 70 | expect(sphere.center.y).toEqual((2 - 1) / 2) 71 | expect(sphere.center.z).toEqual((2 - 1) / 2) 72 | const bothDiagonals = (1 + 2) * Math.sqrt(3) 73 | expect(sphere.radius).toBeCloseTo(bothDiagonals / 2) 74 | }) 75 | 76 | it('gets the same bounding sphere from two identical geometries', () => { 77 | box_1_geom.computeBoundingSphere() 78 | const sphere_1 = getCommonSphere(box_1_geom, box_1_geom) 79 | expect(sphere_1.equals(box_1_geom.boundingSphere!)).toBeTruthy() 80 | 81 | box_2_geom.computeBoundingSphere() 82 | const sphere_2 = getCommonSphere(box_2_geom, box_2_geom) 83 | expect(sphere_2.equals(box_2_geom.boundingSphere!)).toBeTruthy() 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /src/components/viewer/CadDiffPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import '@react-three/fiber' 3 | import { Box, ThemeProvider } from '@primer/react' 4 | import { DiffEntry, FileDiff, MessageIds } from '../../chrome/types' 5 | import { createPortal } from 'react-dom' 6 | import { Loading } from '../Loading' 7 | import { CadDiff } from './CadDiff' 8 | import { SourceRichToggle } from './SourceRichToggle' 9 | import { ColorModeWithAuto } from '@primer/react/lib/ThemeProvider' 10 | import { ErrorMessage } from './ErrorMessage' 11 | 12 | function CadDiffPortal({ 13 | element, 14 | file, 15 | owner, 16 | repo, 17 | sha, 18 | parentSha, 19 | }: { 20 | element: HTMLElement 21 | file: DiffEntry 22 | owner: string 23 | repo: string 24 | sha: string 25 | parentSha: string 26 | }): React.ReactElement { 27 | const [loading, setLoading] = useState(true) 28 | const [richDiff, setRichDiff] = useState() 29 | const [richSelected, setRichSelected] = useState(true) 30 | const [toolbarContainer, setToolbarContainer] = useState() 31 | const [diffContainer, setDiffContainer] = useState() 32 | const [sourceElements, setSourceElements] = useState([]) 33 | 34 | useEffect(() => { 35 | const toolbar = element.querySelector('.file-info') 36 | if (toolbar != null) { 37 | setToolbarContainer(toolbar) 38 | 39 | // STL files might have a toggle already 40 | const existingToggle = element.querySelector( 41 | '.js-prose-diff-toggle-form' 42 | ) 43 | if (existingToggle) { 44 | existingToggle.style.display = 'none' 45 | } 46 | } 47 | 48 | const diff = element.querySelector('.js-file-content') 49 | if (diff != null) { 50 | setDiffContainer(diff) 51 | const sourceElements = Array.from(diff.children) as HTMLElement[] 52 | sourceElements.map(n => (n.style.display = 'none')) 53 | setSourceElements(sourceElements) 54 | } 55 | }, [element]) 56 | 57 | useEffect(() => { 58 | ;(async () => { 59 | setLoading(true) 60 | const response = await chrome.runtime.sendMessage({ 61 | id: MessageIds.GetFileDiff, 62 | data: { owner, repo, sha, parentSha, file }, 63 | }) 64 | if ('error' in response) { 65 | console.log(response.error) 66 | setLoading(false) 67 | } else { 68 | setRichDiff(response as FileDiff) 69 | setLoading(false) 70 | } 71 | })() 72 | }, [file, owner, repo, sha, parentSha]) 73 | 74 | return ( 75 | <> 76 | {toolbarContainer && 77 | createPortal( 78 | { 82 | sourceElements.map(n => (n.style.display = 'block')) 83 | setRichSelected(false) 84 | }} 85 | onRichSelected={() => { 86 | sourceElements.map(n => (n.style.display = 'none')) 87 | setRichSelected(true) 88 | }} 89 | />, 90 | toolbarContainer 91 | )} 92 | {diffContainer && 93 | createPortal( 94 | 95 | {loading ? ( 96 | 97 | ) : ( 98 | <> 99 | {richDiff ? ( 100 | 104 | ) : ( 105 | 106 | )} 107 | 108 | )} 109 | , 110 | diffContainer 111 | )} 112 | 113 | ) 114 | } 115 | 116 | export type CadDiffPageProps = { 117 | map: { element: HTMLElement; file: DiffEntry }[] 118 | owner: string 119 | repo: string 120 | sha: string 121 | parentSha: string 122 | colorMode: ColorModeWithAuto 123 | } 124 | 125 | export function CadDiffPage({ 126 | map, 127 | owner, 128 | repo, 129 | sha, 130 | parentSha, 131 | colorMode, 132 | }: CadDiffPageProps): React.ReactElement { 133 | return ( 134 | 135 | {map.map(m => ( 136 | 145 | ))} 146 | 147 | ) 148 | } 149 | -------------------------------------------------------------------------------- /src/chrome/web.ts: -------------------------------------------------------------------------------- 1 | import { ColorModeWithAuto } from '@primer/react/lib/ThemeProvider' 2 | import { createRoot, Root } from 'react-dom/client' 3 | import { isFilenameSupported, extensionToSrcFormat } from './diff' 4 | import { DiffEntry } from './types' 5 | 6 | export type GithubPullUrlParams = { 7 | owner: string 8 | repo: string 9 | pull: number 10 | } 11 | 12 | export function getGithubPullUrlParams( 13 | url: string 14 | ): GithubPullUrlParams | undefined { 15 | const pullRe = 16 | /https:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)\/pull\/(\d+)\/files/ 17 | const result = pullRe.exec(url) 18 | if (!result) { 19 | return undefined 20 | } 21 | 22 | const [, owner, repo, pull] = result 23 | console.log('Found a supported Github Pull Request URL:', owner, repo, pull) 24 | return { owner, repo, pull: parseInt(pull) } 25 | } 26 | 27 | export type GithubCommitUrlParams = { 28 | owner: string 29 | repo: string 30 | sha: string 31 | } 32 | 33 | export function getGithubCommitUrlParams( 34 | url: string 35 | ): GithubCommitUrlParams | undefined { 36 | const pullRe = 37 | /https:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)\/commit\/(\w+)/ 38 | const result = pullRe.exec(url) 39 | if (!result) { 40 | return undefined 41 | } 42 | 43 | const [, owner, repo, sha] = result 44 | console.log('Found a supported Github Commit URL:', owner, repo, sha) 45 | return { owner, repo, sha } 46 | } 47 | 48 | export type GithubCommitWithinPullUrlParams = { 49 | owner: string 50 | repo: string 51 | pull: number 52 | sha: string 53 | } 54 | 55 | export function getGithubCommitWithinPullUrlParams( 56 | url: string 57 | ): GithubCommitWithinPullUrlParams | undefined { 58 | const pullRe = 59 | /https:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)\/pull\/(\d+)\/commits\/(\w+)/ 60 | const result = pullRe.exec(url) 61 | if (!result) { 62 | return undefined 63 | } 64 | 65 | const [, owner, repo, pull, sha] = result 66 | console.log( 67 | 'Found a supported Github Commit witin Pull URL:', 68 | owner, 69 | repo, 70 | pull, 71 | sha 72 | ) 73 | return { owner, repo, sha, pull: parseInt(pull) } 74 | } 75 | 76 | export type GithubBlobUrlParams = { 77 | owner: string 78 | repo: string 79 | sha: string 80 | filename: string 81 | } 82 | 83 | export function getGithubBlobUrlParams( 84 | url: string 85 | ): GithubBlobUrlParams | undefined { 86 | const blobRe = 87 | /https:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)\/blob\/(\w+)\/([^\0]+)/ 88 | const result = blobRe.exec(url) 89 | if (!result) { 90 | return undefined 91 | } 92 | 93 | const [, owner, repo, sha, filename] = result 94 | console.log( 95 | 'Found a supported Github Blob URL:', 96 | owner, 97 | repo, 98 | sha, 99 | filename 100 | ) 101 | return { owner, repo, sha, filename } 102 | } 103 | 104 | export function getSupportedWebDiffElements(document: Document): HTMLElement[] { 105 | const fileTypeSelectors = Object.keys(extensionToSrcFormat).map( 106 | t => `.file[data-file-type=".${t}"]` 107 | ) 108 | const selector = fileTypeSelectors.join(', ') 109 | return [...document.querySelectorAll(selector)].map(n => n as HTMLElement) 110 | } 111 | 112 | export function getElementFilename(element: HTMLElement) { 113 | const titleElement = element.querySelector( 114 | '.file-info a[title]' 115 | ) as HTMLElement 116 | return titleElement.getAttribute('title') 117 | } 118 | 119 | export function mapInjectableDiffElements( 120 | document: Document, 121 | files: DiffEntry[] 122 | ) { 123 | const supportedFiles = files.filter(f => isFilenameSupported(f.filename)) 124 | console.log(`Found ${supportedFiles.length} supported files with the API`) 125 | 126 | const supportedElements = getSupportedWebDiffElements(document) 127 | console.log(`Found ${supportedElements.length} elements in the web page`) 128 | 129 | if (supportedElements.length !== supportedFiles.length) { 130 | throw Error( 131 | `elements and files have different length. Got ${supportedElements.length} and ${supportedFiles.length}` 132 | ) 133 | } 134 | 135 | const injectableElements: { element: HTMLElement; file: DiffEntry }[] = [] 136 | for (const [index, element] of supportedElements.entries()) { 137 | const file = supportedFiles[index] 138 | const filename = getElementFilename(element) 139 | if (filename !== file.filename) { 140 | throw Error( 141 | "Couldn't match API file with a diff element on the page. Aborting." 142 | ) 143 | } 144 | injectableElements.push({ element, file }) 145 | } 146 | 147 | return injectableElements 148 | } 149 | 150 | export function createReactRoot( 151 | document: Document, 152 | id: string = 'kittycad-root' 153 | ): Root { 154 | // TODO: there's probably a better way than this to create a root? 155 | const node = document.createElement('div') 156 | node.id = id 157 | document.body.appendChild(node) 158 | return createRoot(node) 159 | } 160 | 161 | export function getGithubColorMode(document: Document): ColorModeWithAuto { 162 | const html = document.querySelector('html') 163 | const attr = 'data-color-mode' 164 | if (!html || !html.getAttribute(attr)) { 165 | return 'auto' 166 | } 167 | 168 | return html.getAttribute(attr) as ColorModeWithAuto 169 | } 170 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE. This dependabot file was generated 2 | # by https://github.com/KittyCAD/ciso Changes to this file should be addressed in 3 | # the ciso repository. 4 | 5 | version: 2 6 | updates: 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | day: saturday 12 | timezone: America/Los_Angeles 13 | open-pull-requests-limit: 5 14 | groups: 15 | security: 16 | applies-to: security-updates 17 | exclude-patterns: 18 | - kittycad* 19 | update-types: 20 | - minor 21 | - patch 22 | security-major: 23 | applies-to: security-updates 24 | exclude-patterns: 25 | - kittycad* 26 | update-types: 27 | - major 28 | patch: 29 | applies-to: version-updates 30 | exclude-patterns: 31 | - kittycad* 32 | update-types: 33 | - patch 34 | major: 35 | applies-to: version-updates 36 | exclude-patterns: 37 | - kittycad* 38 | update-types: 39 | - major 40 | minor: 41 | applies-to: version-updates 42 | exclude-patterns: 43 | - kittycad* 44 | update-types: 45 | - minor 46 | - patch 47 | cooldown: 48 | default-days: 7 49 | exclude: 50 | - '*kcl*' 51 | - '*zoo*' 52 | - '*kittycad*' 53 | - package-ecosystem: npm 54 | directory: / 55 | schedule: 56 | interval: weekly 57 | day: saturday 58 | timezone: America/Los_Angeles 59 | open-pull-requests-limit: 5 60 | groups: 61 | security: 62 | applies-to: security-updates 63 | exclude-patterns: 64 | - kittycad* 65 | update-types: 66 | - minor 67 | - patch 68 | security-major: 69 | applies-to: security-updates 70 | exclude-patterns: 71 | - kittycad* 72 | update-types: 73 | - major 74 | patch: 75 | applies-to: version-updates 76 | exclude-patterns: 77 | - kittycad* 78 | update-types: 79 | - patch 80 | major: 81 | applies-to: version-updates 82 | exclude-patterns: 83 | - kittycad* 84 | update-types: 85 | - major 86 | minor: 87 | applies-to: version-updates 88 | exclude-patterns: 89 | - kittycad* 90 | update-types: 91 | - minor 92 | - patch 93 | cooldown: 94 | default-days: 7 95 | exclude: 96 | - '*kcl*' 97 | - '*zoo*' 98 | - '*kittycad*' 99 | - package-ecosystem: npm 100 | directory: /.yarn/sdks/prettier 101 | schedule: 102 | interval: weekly 103 | day: saturday 104 | timezone: America/Los_Angeles 105 | open-pull-requests-limit: 5 106 | groups: 107 | security: 108 | applies-to: security-updates 109 | exclude-patterns: 110 | - kittycad* 111 | update-types: 112 | - minor 113 | - patch 114 | security-major: 115 | applies-to: security-updates 116 | exclude-patterns: 117 | - kittycad* 118 | update-types: 119 | - major 120 | patch: 121 | applies-to: version-updates 122 | exclude-patterns: 123 | - kittycad* 124 | update-types: 125 | - patch 126 | major: 127 | applies-to: version-updates 128 | exclude-patterns: 129 | - kittycad* 130 | update-types: 131 | - major 132 | minor: 133 | applies-to: version-updates 134 | exclude-patterns: 135 | - kittycad* 136 | update-types: 137 | - minor 138 | - patch 139 | cooldown: 140 | default-days: 7 141 | exclude: 142 | - '*kcl*' 143 | - '*zoo*' 144 | - '*kittycad*' 145 | - package-ecosystem: npm 146 | directory: /.yarn/sdks/typescript 147 | schedule: 148 | interval: weekly 149 | day: saturday 150 | timezone: America/Los_Angeles 151 | open-pull-requests-limit: 5 152 | groups: 153 | security: 154 | applies-to: security-updates 155 | exclude-patterns: 156 | - kittycad* 157 | update-types: 158 | - minor 159 | - patch 160 | security-major: 161 | applies-to: security-updates 162 | exclude-patterns: 163 | - kittycad* 164 | update-types: 165 | - major 166 | patch: 167 | applies-to: version-updates 168 | exclude-patterns: 169 | - kittycad* 170 | update-types: 171 | - patch 172 | major: 173 | applies-to: version-updates 174 | exclude-patterns: 175 | - kittycad* 176 | update-types: 177 | - major 178 | minor: 179 | applies-to: version-updates 180 | exclude-patterns: 181 | - kittycad* 182 | update-types: 183 | - minor 184 | - patch 185 | cooldown: 186 | default-days: 7 187 | exclude: 188 | - '*kcl*' 189 | - '*zoo*' 190 | - '*kittycad*' 191 | - package-ecosystem: npm 192 | directory: /.yarn/sdks/eslint 193 | schedule: 194 | interval: weekly 195 | day: saturday 196 | timezone: America/Los_Angeles 197 | open-pull-requests-limit: 5 198 | groups: 199 | security: 200 | applies-to: security-updates 201 | exclude-patterns: 202 | - kittycad* 203 | update-types: 204 | - minor 205 | - patch 206 | security-major: 207 | applies-to: security-updates 208 | exclude-patterns: 209 | - kittycad* 210 | update-types: 211 | - major 212 | patch: 213 | applies-to: version-updates 214 | exclude-patterns: 215 | - kittycad* 216 | update-types: 217 | - patch 218 | major: 219 | applies-to: version-updates 220 | exclude-patterns: 221 | - kittycad* 222 | update-types: 223 | - major 224 | minor: 225 | applies-to: version-updates 226 | exclude-patterns: 227 | - kittycad* 228 | update-types: 229 | - minor 230 | - patch 231 | cooldown: 232 | default-days: 7 233 | exclude: 234 | - '*kcl*' 235 | - '*zoo*' 236 | - '*kittycad*' 237 | -------------------------------------------------------------------------------- /src/chrome/diff.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest' 2 | import { api_calls, Client, file } from '@kittycad/lib' 3 | import { ContentFile, DiffEntry, FileBlob, FileDiff } from './types' 4 | import { 5 | FileExportFormat_type, 6 | FileImportFormat_type, 7 | } from '@kittycad/lib/dist/types/src/models' 8 | import { Buffer } from 'buffer' 9 | 10 | export const extensionToSrcFormat: { 11 | [extension: string]: FileImportFormat_type 12 | } = { 13 | // expected one of `fbx`, `gltf`, `obj`, `ply`, `sldprt`, `step`, `stl` 14 | fbx: 'fbx', 15 | gltf: 'gltf', 16 | obj: 'obj', 17 | ply: 'ply', 18 | sldprt: 'sldprt', 19 | stp: 'step', 20 | step: 'step', 21 | stl: 'stl', 22 | 23 | // Disabled in new format api 24 | // dae: 'dae', 25 | } 26 | 27 | export function isFilenameSupported(filename: string): boolean { 28 | const extension = filename.split('.').pop() 29 | return !!(extension && extensionToSrcFormat[extension]) 30 | } 31 | 32 | export async function downloadFile( 33 | octokit: Octokit, 34 | owner: string, 35 | repo: string, 36 | ref: string, 37 | path: string 38 | ): Promise { 39 | // First get some info on the blob with the Contents api 40 | const content = await octokit.rest.repos.getContent({ 41 | owner, 42 | repo, 43 | path, 44 | ref, 45 | request: { cache: 'reload' }, // download_url provides a token that seems very short-lived 46 | }) 47 | const contentFile = content.data as ContentFile 48 | 49 | if (!contentFile.download_url) { 50 | throw Error(`No download URL associated with ${path} at ${ref}`) 51 | } 52 | 53 | // Then actually use the download_url (that supports LFS files and has a token) to write the file 54 | console.log(`Downloading ${contentFile.download_url}...`) 55 | const response = await fetch(contentFile.download_url) 56 | if (!response.ok) throw response 57 | return await response.blob() 58 | } 59 | 60 | async function convert( 61 | client: Client, 62 | blob: Blob, 63 | extension: string, 64 | outputFormat = 'obj' 65 | ) { 66 | const body = await blob.arrayBuffer() 67 | if (extension === outputFormat) { 68 | console.log( 69 | 'Skipping conversion, as extension is equal to outputFormat' 70 | ) 71 | return Buffer.from(body).toString('base64') 72 | } 73 | const response = await file.create_file_conversion({ 74 | client, 75 | body, 76 | src_format: extensionToSrcFormat[extension], 77 | output_format: outputFormat as FileExportFormat_type, 78 | }) 79 | const key = `source.${outputFormat}` 80 | if ('error_code' in response) throw response 81 | const { id } = response 82 | let { status, outputs } = response 83 | console.log(`File conversion: ${id}, ${status}`) 84 | let retries = 0 85 | while (status !== 'completed' && status !== 'failed') { 86 | if (retries >= 60) { 87 | console.log('Async conversion took too long, aborting.') 88 | break 89 | } 90 | retries++ 91 | await new Promise(resolve => setTimeout(resolve, 1000)) 92 | const response = await api_calls.get_async_operation({ client, id }) 93 | if ('error_code' in response) throw response 94 | status = response.status 95 | console.log(`File conversion: ${id}, ${status} (retry #${retries})`) 96 | if ('outputs' in response) { 97 | outputs = response.outputs 98 | } 99 | } 100 | return outputs[key] 101 | } 102 | 103 | export async function getFileDiff( 104 | github: Octokit, 105 | kittycad: Client, 106 | owner: string, 107 | repo: string, 108 | 109 | sha: string, 110 | parentSha: string, 111 | file: DiffEntry 112 | ): Promise { 113 | const { filename, status } = file 114 | const extension = filename.split('.').pop() 115 | if (!extension || !extensionToSrcFormat[extension]) { 116 | throw Error( 117 | `Unsupported extension. Given ${extension}, was expecting ${Object.keys( 118 | extensionToSrcFormat 119 | )}` 120 | ) 121 | } 122 | 123 | if (status === 'modified') { 124 | const beforeBlob = await downloadFile( 125 | github, 126 | owner, 127 | repo, 128 | parentSha, 129 | filename 130 | ) 131 | const before = await convert(kittycad, beforeBlob, extension) 132 | const afterBlob = await downloadFile(github, owner, repo, sha, filename) 133 | const after = await convert(kittycad, afterBlob, extension) 134 | return { before, after } 135 | } 136 | 137 | if (status === 'added') { 138 | const blob = await downloadFile(github, owner, repo, sha, filename) 139 | const after = await convert(kittycad, blob, extension) 140 | return { after } 141 | } 142 | 143 | if (status === 'removed') { 144 | const blob = await downloadFile( 145 | github, 146 | owner, 147 | repo, 148 | parentSha, 149 | filename 150 | ) 151 | const before = await convert(kittycad, blob, extension) 152 | return { before } 153 | } 154 | 155 | throw Error(`Unsupported status: ${status}`) 156 | } 157 | 158 | export async function getFileBlob( 159 | github: Octokit, 160 | kittycad: Client, 161 | owner: string, 162 | repo: string, 163 | sha: string, 164 | filename: string 165 | ): Promise { 166 | const extension = filename.split('.').pop() 167 | if (!extension || !extensionToSrcFormat[extension]) { 168 | throw Error( 169 | `Unsupported extension. Given ${extension}, was expecting ${Object.keys( 170 | extensionToSrcFormat 171 | )}` 172 | ) 173 | } 174 | 175 | const rawBlob = await downloadFile(github, owner, repo, sha, filename) 176 | const blob = await convert(kittycad, rawBlob, extension) 177 | return { blob } 178 | } 179 | -------------------------------------------------------------------------------- /src/chrome/content.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { CadDiffPage } from '../components/viewer/CadDiffPage' 3 | import { CadBlobPage } from '../components/viewer/CadBlobPage' 4 | import { Commit, DiffEntry, MessageIds, Pull } from './types' 5 | import { 6 | getGithubPullUrlParams, 7 | mapInjectableDiffElements, 8 | getGithubCommitUrlParams, 9 | createReactRoot, 10 | getGithubBlobUrlParams, 11 | getGithubCommitWithinPullUrlParams, 12 | getGithubColorMode, 13 | } from './web' 14 | import gitHubInjection from 'github-injection' 15 | import { isFilenameSupported } from './diff' 16 | 17 | const root = createReactRoot(document) 18 | 19 | async function injectDiff( 20 | owner: string, 21 | repo: string, 22 | sha: string, 23 | parentSha: string, 24 | files: DiffEntry[], 25 | document: Document 26 | ) { 27 | const map = mapInjectableDiffElements(document, files) 28 | const colorMode = getGithubColorMode(document) 29 | const cadDiffPage = React.createElement(CadDiffPage, { 30 | owner, 31 | repo, 32 | sha, 33 | parentSha, 34 | map, 35 | colorMode, 36 | }) 37 | root.render(cadDiffPage) 38 | } 39 | 40 | async function injectBlob( 41 | owner: string, 42 | repo: string, 43 | sha: string, 44 | filename: string, 45 | document: Document 46 | ) { 47 | const childWithProperClass = document.querySelector( 48 | '.react-blob-view-header-sticky' 49 | ) 50 | let element = childWithProperClass?.parentElement 51 | if (!element) { 52 | throw Error("Couldn't find blob html element to inject") 53 | } 54 | 55 | element.classList.add('kittycad-injected-file') 56 | const colorMode = getGithubColorMode(document) 57 | const cadBlobPage = React.createElement(CadBlobPage, { 58 | element, 59 | owner, 60 | repo, 61 | sha, 62 | filename, 63 | colorMode, 64 | }) 65 | root.render(cadBlobPage) 66 | } 67 | 68 | async function injectPullDiff( 69 | owner: string, 70 | repo: string, 71 | pull: number, 72 | document: Document 73 | ) { 74 | const filesResponse = await chrome.runtime.sendMessage({ 75 | id: MessageIds.GetGithubPullFiles, 76 | data: { owner, repo, pull }, 77 | }) 78 | if ('error' in filesResponse) throw filesResponse.error 79 | const files = filesResponse as DiffEntry[] 80 | const pullDataResponse = await chrome.runtime.sendMessage({ 81 | id: MessageIds.GetGithubPull, 82 | data: { owner, repo, pull }, 83 | }) 84 | if ('error' in pullDataResponse) throw pullDataResponse.error 85 | const pullData = pullDataResponse as Pull 86 | const sha = pullData.head.sha 87 | const parentSha = pullData.base.sha 88 | await injectDiff(owner, repo, sha, parentSha, files, document) 89 | } 90 | 91 | async function injectCommitDiff( 92 | owner: string, 93 | repo: string, 94 | sha: string, 95 | document: Document 96 | ) { 97 | const response = await chrome.runtime.sendMessage({ 98 | id: MessageIds.GetGithubCommit, 99 | data: { owner, repo, sha }, 100 | }) 101 | if ('error' in response) throw response.error 102 | const commit = response as Commit 103 | if (!commit.files) throw Error('Found no file changes in commit') 104 | if (!commit.parents.length) throw Error('Found no commit parent') 105 | const parentSha = commit.parents[0].sha 106 | await injectDiff(owner, repo, sha, parentSha, commit.files, document) 107 | } 108 | 109 | async function run() { 110 | const url = window.location.href 111 | const pullParams = getGithubPullUrlParams(url) 112 | if (pullParams) { 113 | const { owner, repo, pull } = pullParams 114 | console.log('Found PR diff: ', owner, repo, pull) 115 | await injectPullDiff(owner, repo, pull, window.document) 116 | return 117 | } 118 | 119 | const commitParams = getGithubCommitUrlParams(url) 120 | if (commitParams) { 121 | const { owner, repo, sha } = commitParams 122 | console.log('Found commit diff: ', owner, repo, sha) 123 | await injectCommitDiff(owner, repo, sha, window.document) 124 | return 125 | } 126 | 127 | const commitWithinPullParams = getGithubCommitWithinPullUrlParams(url) 128 | if (commitWithinPullParams) { 129 | const { owner, repo, pull, sha } = commitWithinPullParams 130 | console.log('Found commit diff within pull: ', owner, repo, pull, sha) 131 | // TODO: understand if more things are needed here for this special case 132 | await injectCommitDiff(owner, repo, sha, window.document) 133 | return 134 | } 135 | 136 | const blobParams = getGithubBlobUrlParams(url) 137 | if (blobParams) { 138 | const { owner, repo, sha, filename } = blobParams 139 | if (isFilenameSupported(filename)) { 140 | console.log('Found supported blob: ', owner, repo, sha, filename) 141 | await injectBlob(owner, repo, sha, filename, window.document) 142 | return 143 | } 144 | } 145 | } 146 | 147 | function waitForLateDiffNodes(callback: () => void) { 148 | // Containers holding diff nodes, in which new nodes might be added 149 | // Inspired from https://github.com/OctoLinker/OctoLinker/blob/55e1efdad91453846b83db1192a157694ee3438c/packages/core/app.js#L57-L109 150 | const elements = [ 151 | ...document.getElementsByClassName('js-diff-load-container'), 152 | ...document.getElementsByClassName('js-diff-progressive-container'), 153 | ] 154 | const observer = new MutationObserver(records => { 155 | records.forEach(record => { 156 | if (record.addedNodes.length > 0) { 157 | console.log('Re-running, as new nodes were added') 158 | callback() 159 | } 160 | }) 161 | }) 162 | elements.forEach(element => { 163 | observer.observe(element, { 164 | childList: true, 165 | }) 166 | }) 167 | } 168 | 169 | gitHubInjection(() => { 170 | run() 171 | waitForLateDiffNodes(() => run()) 172 | }) 173 | -------------------------------------------------------------------------------- /tests/extension.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElementHandle, Page } from '@playwright/test' 2 | import { test, expect } from './fixtures' 3 | 4 | test('popup page', async ({ page, extensionId }) => { 5 | await page.goto(`chrome-extension://${extensionId}/index.html`) 6 | await expect(page.locator('body')).toContainText('Enter a GitHub token') 7 | await expect(page.locator('body')).toContainText('Enter a KittyCAD token') 8 | }) 9 | 10 | test('authorized popup page', async ({ 11 | page, 12 | extensionId, 13 | authorizedBackground, 14 | }) => { 15 | await page.goto(`chrome-extension://${extensionId}/index.html`) 16 | await page.waitForSelector('button') 17 | await expect(page.locator('body')).toContainText('Sign out') 18 | await expect(page.locator('button')).toHaveCount(2) 19 | }) 20 | 21 | async function getFirstDiffElement(page: Page, url: string, extension: string) { 22 | page.on('console', msg => console.log(msg.text())) 23 | await page.goto(url) 24 | 25 | // waiting for the canvas (that holds the diff) to show up 26 | await page.waitForSelector( 27 | `.js-file[data-file-type=".${extension}"] .js-file-content canvas` 28 | ) 29 | 30 | // screenshot the file diff with its toolbar 31 | const element = await page.waitForSelector( 32 | `.js-file[data-file-type=".${extension}"]` 33 | ) 34 | await page.waitForTimeout(1000) // making sure the element fully settled in 35 | return element 36 | } 37 | 38 | async function enableCombined(page: Page, element: ElementHandle) { 39 | const button = await element.$('.kittycad-combined-button') 40 | await button.click() 41 | await page.waitForTimeout(1000) 42 | } 43 | 44 | async function getBlobPreviewElement(page: Page, url: string) { 45 | page.on('console', msg => console.log(msg.text())) 46 | await page.goto(url) 47 | 48 | // waiting for the canvas (that holds the diff) to show up 49 | await page.waitForSelector('#repo-content-pjax-container canvas') 50 | 51 | // screenshot the file diff with its toolbar 52 | const element = await page.waitForSelector('.kittycad-injected-file') 53 | await page.waitForTimeout(1000) // making sure the element fully settled in 54 | return element 55 | } 56 | 57 | test('pull request diff with a modified .obj file', async ({ 58 | page, 59 | authorizedBackground, 60 | }) => { 61 | const url = 'https://github.com/KittyCAD/diff-samples/pull/2/files' 62 | const element = await getFirstDiffElement(page, url, 'obj') 63 | const screenshot = await element.screenshot() 64 | expect(screenshot).toMatchSnapshot() 65 | 66 | await enableCombined(page, element) 67 | const screenshot2 = await element.screenshot() 68 | expect(screenshot2).toMatchSnapshot() 69 | }) 70 | 71 | test('commit diff within pull request with a modified .stl file', async ({ 72 | page, 73 | authorizedBackground, 74 | }) => { 75 | const url = 'https://github.com/KittyCAD/diff-samples/pull/2/commits/1dc0d43a94dba95279fcfc112bb5dd4dfaac01ae' 76 | const element = await getFirstDiffElement(page, url, 'stl') 77 | const screenshot = await element.screenshot() 78 | expect(screenshot).toMatchSnapshot() 79 | 80 | await enableCombined(page, element) 81 | const screenshot2 = await element.screenshot() 82 | expect(screenshot2).toMatchSnapshot() 83 | }) 84 | 85 | test('pull request diff with a modified .step file', async ({ 86 | page, 87 | authorizedBackground, 88 | }) => { 89 | const url = 'https://github.com/KittyCAD/diff-samples/pull/2/files' 90 | const element = await getFirstDiffElement(page, url, 'step') 91 | const screenshot = await element.screenshot() 92 | expect(screenshot).toMatchSnapshot() 93 | 94 | // TODO: understand why this one makes the CI fail (guess: page crashes, low resources?) 95 | // await enableCombined(page, element) 96 | // const screenshot2 = await element.screenshot() 97 | // expect(screenshot2).toMatchSnapshot() 98 | }) 99 | 100 | // TODO: fix this test https://github.com/KittyCAD/diff-viewer-extension/issues/711 101 | // test('commit diff with an added .step file', async ({ 102 | // page, 103 | // authorizedBackground, 104 | // }) => { 105 | // const url = 106 | // 'https://github.com/KittyCAD/diff-samples/commit/fd9eec79f0464833686ea6b5b34ea07145e32734' 107 | // const element = await getFirstDiffElement(page, url, 'step') 108 | // const screenshot = await element.screenshot() 109 | // expect(screenshot).toMatchSnapshot() 110 | // }) 111 | 112 | // TODO: re-enable when .dae are supported 113 | // test('commit diff with a modified .dae file as LFS', async ({ 114 | // page, 115 | // authorizedBackground, 116 | // }) => { 117 | // const url = 118 | // 'https://github.com/KittyCAD/diff-samples/commit/b009cfd6dd1eb2d0c3ec0d31a21360766ad084e4' 119 | // const element = await getFirstDiffElement(page, url, 'dae') 120 | // const screenshot = await element.screenshot() 121 | // expect(screenshot).toMatchSnapshot() 122 | 123 | // await enableCombined(page, element) 124 | // const screenshot2 = await element.screenshot() 125 | // expect(screenshot2).toMatchSnapshot() 126 | // }) 127 | 128 | test('blob preview with an .obj file', async ({ 129 | page, 130 | authorizedBackground, 131 | }) => { 132 | const url = 133 | 'https://github.com/KittyCAD/diff-samples/blob/fd9eec79f0464833686ea6b5b34ea07145e32734/models/box.obj' 134 | const element = await getBlobPreviewElement(page, url) 135 | const screenshot = await element.screenshot() 136 | expect(screenshot).toMatchSnapshot() 137 | }) 138 | 139 | 140 | test('blob preview with a .step file', async ({ 141 | page, 142 | authorizedBackground, 143 | }) => { 144 | const url = 145 | 'https://github.com/KittyCAD/diff-samples/blob/fd9eec79f0464833686ea6b5b34ea07145e32734/models/box.step' 146 | const element = await getBlobPreviewElement(page, url) 147 | const screenshot = await element.screenshot() 148 | expect(screenshot).toMatchSnapshot() 149 | }) 150 | 151 | test('blob preview with an .stl file', async ({ 152 | page, 153 | authorizedBackground, 154 | }) => { 155 | const url = 156 | 'https://github.com/KittyCAD/diff-samples/blob/fd9eec79f0464833686ea6b5b34ea07145e32734/models/box.stl' 157 | const element = await getBlobPreviewElement(page, url) 158 | const screenshot = await element.screenshot() 159 | expect(screenshot).toMatchSnapshot() 160 | }) 161 | -------------------------------------------------------------------------------- /src/components/viewer/CadBlobPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import '@react-three/fiber' 3 | import { Box, SegmentedControl, ThemeProvider } from '@primer/react' 4 | import { FileBlob, MessageIds } from '../../chrome/types' 5 | import { createPortal } from 'react-dom' 6 | import { Loading } from '../Loading' 7 | import { CadBlob } from './CadBlob' 8 | import { ColorModeWithAuto } from '@primer/react/lib/ThemeProvider' 9 | import { ErrorMessage } from './ErrorMessage' 10 | 11 | function CadBlobPortal({ 12 | element, 13 | owner, 14 | repo, 15 | sha, 16 | filename, 17 | }: { 18 | element: HTMLElement 19 | owner: string 20 | repo: string 21 | sha: string 22 | filename: string 23 | }): React.ReactElement { 24 | const [loading, setLoading] = useState(true) 25 | const [richBlob, setRichBlob] = useState() 26 | const [richSelected, setRichSelected] = useState(true) 27 | const [toolbarContainer, setToolbarContainer] = useState() 28 | const [blobContainer, setBlobContainer] = useState() 29 | const [sourceElements, setSourceElements] = useState([]) 30 | 31 | useEffect(() => { 32 | const existingToggle = element.querySelector( 33 | 'ul[class*=SegmentedControl]' 34 | ) 35 | const toolbar = existingToggle?.parentElement 36 | let blob: HTMLElement | undefined | null = 37 | element.querySelector( 38 | 'section[aria-labelledby*=file-name-id]' 39 | ) 40 | const isPreviewAlreadyEnabled = 41 | existingToggle && existingToggle.childElementCount > 2 // Preview, Code, Blame 42 | if (isPreviewAlreadyEnabled) { 43 | const existingPreview = element.querySelector('iframe') 44 | blob = existingPreview?.parentElement 45 | if (blob && blob.parentElement) { 46 | setBlobContainer(blob.parentElement) 47 | blob.style.display = 'none' 48 | } 49 | // No toolbar, no sourceElements. Only a replacement of the existing (STL) preview 50 | return 51 | } 52 | 53 | if (toolbar != null) { 54 | setToolbarContainer(toolbar) 55 | if (existingToggle) { 56 | existingToggle.style.display = 'none' 57 | } 58 | } 59 | 60 | if (blob != null) { 61 | setBlobContainer(blob) 62 | const sourceElements = Array.from(blob.children) as HTMLElement[] 63 | sourceElements.map(n => (n.style.display = 'none')) 64 | setSourceElements(sourceElements) 65 | } 66 | }, [element]) 67 | 68 | useEffect(() => { 69 | ;(async () => { 70 | setLoading(true) 71 | const response = await chrome.runtime.sendMessage({ 72 | id: MessageIds.GetFileBlob, 73 | data: { owner, repo, sha, filename }, 74 | }) 75 | if ('error' in response) { 76 | console.log(response.error) 77 | setLoading(false) 78 | } else { 79 | setRichBlob(response as FileBlob) 80 | setLoading(false) 81 | } 82 | })() 83 | }, [owner, repo, sha, filename]) 84 | 85 | return ( 86 | <> 87 | {toolbarContainer && 88 | createPortal( 89 | { 93 | if (index < 2) { 94 | setRichSelected(index === 0) 95 | sourceElements.map( 96 | n => 97 | (n.style.display = 98 | index === 0 ? 'none' : 'block') 99 | ) 100 | return 101 | } 102 | window.location.href = `https://github.com/${owner}/${repo}/blame/${sha}/${filename}` 103 | }} 104 | > 105 | 106 | Preview 107 | 108 | 109 | Code 110 | 111 | Blame 112 | , 113 | toolbarContainer 114 | )} 115 | {blobContainer && 116 | createPortal( 117 | 123 | {loading ? ( 124 | 125 | ) : ( 126 | <> 127 | {richBlob ? ( 128 | 129 | ) : ( 130 | 131 | )} 132 | 133 | )} 134 | , 135 | blobContainer 136 | )} 137 | 138 | ) 139 | } 140 | 141 | export type CadBlobPageProps = { 142 | element: HTMLElement 143 | owner: string 144 | repo: string 145 | sha: string 146 | filename: string 147 | colorMode: ColorModeWithAuto 148 | } 149 | 150 | export function CadBlobPage({ 151 | element, 152 | owner, 153 | repo, 154 | sha, 155 | filename, 156 | colorMode, 157 | }: CadBlobPageProps): React.ReactElement { 158 | return ( 159 | 160 | 167 | 168 | ) 169 | } 170 | -------------------------------------------------------------------------------- /src/chrome/background.ts: -------------------------------------------------------------------------------- 1 | import { Client, users } from '@kittycad/lib' 2 | import { Octokit } from '@octokit/rest' 3 | import { 4 | KittycadUser, 5 | Message, 6 | MessageGetFileBlob, 7 | MessageGetFileDiff, 8 | MessageGetGithubCommitData, 9 | MessageGetGithubPullFilesData, 10 | MessageIds, 11 | MessageResponse, 12 | MessageSaveToken, 13 | } from './types' 14 | import { 15 | getStorageGithubToken, 16 | getStorageKittycadToken, 17 | setStorageGithubToken, 18 | setStorageKittycadToken, 19 | } from './storage' 20 | import { getFileBlob, getFileDiff } from './diff' 21 | 22 | let github: Octokit | undefined 23 | let kittycad: Client | undefined 24 | 25 | async function initGithubApi() { 26 | try { 27 | github = new Octokit({ auth: await getStorageGithubToken() }) 28 | const octokitResponse = await github.rest.users.getAuthenticated() 29 | console.log(`Logged in on github.com as ${octokitResponse.data.login}`) 30 | } catch (e) { 31 | console.log('Couldnt initiate the github api client') 32 | github = undefined 33 | } 34 | } 35 | 36 | async function initKittycadApi() { 37 | try { 38 | kittycad = new Client(await getStorageKittycadToken()) 39 | const response = await users.get_user_self({ client: kittycad }) 40 | if ('error_code' in response) throw response 41 | const { email } = response 42 | if (!email) throw Error('Empty user, token is probably wrong') 43 | console.log(`Logged in on kittycad.io as ${email}`) 44 | } catch (e) { 45 | console.log("Couldn't initiate the kittycad api client") 46 | kittycad = undefined 47 | } 48 | } 49 | 50 | async function saveGithubTokenAndReload(token: string): Promise { 51 | github = undefined 52 | await setStorageGithubToken(token) 53 | await initGithubApi() 54 | } 55 | 56 | async function saveKittycadTokenAndReload(token: string): Promise { 57 | kittycad = undefined 58 | await setStorageKittycadToken(token) 59 | await initKittycadApi() 60 | } 61 | 62 | ;(async () => { 63 | // Delay to allow for external storage sets before auth, like in e2e 64 | await new Promise(resolve => setTimeout(resolve, 1000)) 65 | await initKittycadApi() 66 | await initGithubApi() 67 | })() 68 | 69 | const noClientError = new Error('API client is undefined') 70 | 71 | chrome.runtime.onMessage.addListener( 72 | ( 73 | message: Message, 74 | sender: chrome.runtime.MessageSender, 75 | sendResponse: (response: MessageResponse) => void 76 | ) => { 77 | console.log(`Received ${message.id} from ${sender.id}`) 78 | if (message.id === MessageIds.GetGithubPullFiles) { 79 | if (!github) { 80 | sendResponse({ error: noClientError }) 81 | return false 82 | } 83 | const { owner, repo, pull } = 84 | message.data as MessageGetGithubPullFilesData 85 | github.rest.pulls 86 | .listFiles({ owner, repo, pull_number: pull }) 87 | .then(r => sendResponse(r.data)) 88 | .catch(error => sendResponse({ error })) 89 | return true 90 | } 91 | 92 | if (message.id === MessageIds.GetGithubPull) { 93 | if (!github) { 94 | sendResponse({ error: noClientError }) 95 | return false 96 | } 97 | const { owner, repo, pull } = 98 | message.data as MessageGetGithubPullFilesData 99 | github.rest.pulls 100 | .get({ owner, repo, pull_number: pull }) 101 | .then(r => sendResponse(r.data)) 102 | .catch(error => sendResponse({ error })) 103 | return true 104 | } 105 | 106 | if (message.id === MessageIds.GetGithubCommit) { 107 | if (!github) { 108 | sendResponse({ error: noClientError }) 109 | return false 110 | } 111 | const { owner, repo, sha } = 112 | message.data as MessageGetGithubCommitData 113 | github.rest.repos 114 | .getCommit({ owner, repo, ref: sha }) 115 | .then(r => sendResponse(r.data)) 116 | .catch(error => sendResponse({ error })) 117 | return true 118 | } 119 | 120 | if (message.id === MessageIds.GetGithubUser) { 121 | if (!github) { 122 | sendResponse({ error: noClientError }) 123 | return false 124 | } 125 | github.rest.users 126 | .getAuthenticated() 127 | .then(r => sendResponse(r.data)) 128 | .catch(error => sendResponse({ error })) 129 | return true 130 | } 131 | 132 | if (message.id === MessageIds.GetKittycadUser) { 133 | if (!kittycad) { 134 | sendResponse({ error: noClientError }) 135 | return false 136 | } 137 | users 138 | .get_user_self({ client: kittycad }) 139 | .then(r => sendResponse(r as KittycadUser)) 140 | .catch(error => sendResponse({ error })) 141 | return true 142 | } 143 | 144 | if (message.id === MessageIds.SaveGithubToken) { 145 | const { token } = message.data as MessageSaveToken 146 | saveGithubTokenAndReload(token) 147 | .then(() => sendResponse({ token })) 148 | .catch(error => sendResponse({ error })) 149 | return true 150 | } 151 | 152 | if (message.id === MessageIds.SaveKittycadToken) { 153 | const { token } = message.data as MessageSaveToken 154 | saveKittycadTokenAndReload(token) 155 | .then(() => sendResponse({ token })) 156 | .catch(error => sendResponse({ error })) 157 | return true 158 | } 159 | 160 | if (message.id === MessageIds.GetFileDiff) { 161 | if (!kittycad || !github) { 162 | sendResponse({ error: noClientError }) 163 | return false 164 | } 165 | const { owner, repo, sha, parentSha, file } = 166 | message.data as MessageGetFileDiff 167 | getFileDiff(github, kittycad, owner, repo, sha, parentSha, file) 168 | .then(r => sendResponse(r)) 169 | .catch(error => sendResponse({ error })) 170 | return true 171 | } 172 | 173 | if (message.id === MessageIds.GetFileBlob) { 174 | if (!kittycad || !github) { 175 | sendResponse({ error: noClientError }) 176 | return false 177 | } 178 | const { owner, repo, sha, filename } = 179 | message.data as MessageGetFileBlob 180 | getFileBlob(github, kittycad, owner, repo, sha, filename) 181 | .then(r => sendResponse(r)) 182 | .catch(error => sendResponse({ error })) 183 | return true 184 | } 185 | } 186 | ) 187 | -------------------------------------------------------------------------------- /src/components/settings/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Details, 4 | FormControl, 5 | Link, 6 | Text, 7 | ThemeProvider, 8 | useDetails, 9 | } from '@primer/react' 10 | import { PropsWithChildren, useEffect, useState } from 'react' 11 | import { KittycadUser, MessageIds, User } from '../../chrome/types' 12 | import { Loading } from '../Loading' 13 | import { TokenForm } from './TokenForm' 14 | import { UserCard } from './UserCard' 15 | import { createAvatar } from '@dicebear/avatars' 16 | import * as avatarStyles from '@dicebear/avatars-bottts-sprites' 17 | 18 | function BaseHelper({ children }: PropsWithChildren<{}>) { 19 | const { getDetailsProps, open } = useDetails({ closeOnOutsideClick: true }) 20 | return ( 21 |
22 | 23 | {!open && Need help?} 24 | 25 | 26 | {children} 27 | 28 |
29 | ) 30 | } 31 | 32 | function GithubHelper() { 33 | return ( 34 | 35 |
  • 36 | Open{' '} 37 | 41 | this link 42 | 43 |
  • 44 |
  • Click on 'Generate token'
  • 45 |
  • Copy the provided token
  • 46 |
  • Paste it in the input above
  • 47 |
    48 | ) 49 | } 50 | 51 | function KittycadHelper() { 52 | return ( 53 | 54 |
  • 55 | Open{' '} 56 | 60 | this link 61 | 62 |
  • 63 |
  • Click on 'Generate an API token'
  • 64 |
  • Copy the provided token
  • 65 |
  • Paste it in the input above
  • 66 |
    67 | ) 68 | } 69 | 70 | export function Settings() { 71 | const [githubUser, setGithubUser] = useState() 72 | const [kittycadUser, setKittycadUser] = useState() 73 | const [githubLoading, setGithubLoading] = useState(false) 74 | const [kittycadLoading, setKittycadLoading] = useState(false) 75 | const [firstInitDone, setFirstInitDone] = useState(false) 76 | 77 | async function fetchGithubUser() { 78 | try { 79 | setGithubLoading(true) 80 | const response = await chrome.runtime.sendMessage({ 81 | id: MessageIds.GetGithubUser, 82 | }) 83 | if ('error' in response) throw response.error 84 | setGithubUser(response as User) 85 | setGithubLoading(false) 86 | } catch (e) { 87 | console.error(e) 88 | setGithubUser(undefined) 89 | setGithubLoading(false) 90 | } 91 | } 92 | 93 | async function fetchKittycadUser() { 94 | try { 95 | setKittycadLoading(true) 96 | const response = await chrome.runtime.sendMessage({ 97 | id: MessageIds.GetKittycadUser, 98 | }) 99 | if ('error' in response) throw response.error 100 | setKittycadUser(response as KittycadUser) 101 | setKittycadLoading(false) 102 | } catch (e) { 103 | console.error(e) 104 | setKittycadUser(undefined) 105 | setKittycadLoading(false) 106 | } 107 | } 108 | 109 | async function onToken(id: MessageIds, token: string) { 110 | await chrome.runtime.sendMessage({ id, data: { token } }) 111 | } 112 | 113 | function getDefaultKittycadAvatar(email: string): string { 114 | // from https://github.com/KittyCAD/website/blob/0d891781865a72d9aff0ed72078d557b6f1dcf8e/components/HeaderAccountMenu.tsx#L34 115 | return createAvatar(avatarStyles, { 116 | seed: email || 'some-seed', 117 | dataUri: true, 118 | }) 119 | } 120 | 121 | useEffect(() => { 122 | ;(async () => { 123 | await fetchGithubUser() 124 | await fetchKittycadUser() 125 | setFirstInitDone(true) 126 | })() 127 | }, []) 128 | 129 | return ( 130 | // Setting colorMode to 'auto' as this popup is part of Chrome 131 | 132 | 133 | {firstInitDone ? ( 134 | 135 | 136 | {githubUser ? ( 137 | { 142 | await onToken( 143 | MessageIds.SaveGithubToken, 144 | '' 145 | ) 146 | setGithubUser(undefined) 147 | }} 148 | /> 149 | ) : ( 150 | { 154 | await onToken( 155 | MessageIds.SaveGithubToken, 156 | token 157 | ) 158 | await fetchGithubUser() 159 | }} 160 | > 161 | 162 | 163 | )} 164 | 165 | 166 | {kittycadUser ? ( 167 | { 177 | await onToken( 178 | MessageIds.SaveKittycadToken, 179 | '' 180 | ) 181 | setKittycadUser(undefined) 182 | }} 183 | /> 184 | ) : ( 185 | { 189 | await onToken( 190 | MessageIds.SaveKittycadToken, 191 | token 192 | ) 193 | await fetchKittycadUser() 194 | }} 195 | > 196 | 197 | 198 | )} 199 | 200 | 201 | ) : ( 202 | 203 | )} 204 | 205 | 206 | ) 207 | } 208 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserver.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserver.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserver.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); 226 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserverlibrary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserverlibrary.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserverlibrary.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); 226 | -------------------------------------------------------------------------------- /src/chrome/web.test.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { DiffEntry } from './types' 3 | import { 4 | getElementFilename, 5 | getGithubCommitUrlParams, 6 | getGithubPullUrlParams, 7 | mapInjectableDiffElements, 8 | getSupportedWebDiffElements, 9 | createReactRoot, 10 | getGithubBlobUrlParams, 11 | getGithubCommitWithinPullUrlParams, 12 | getGithubColorMode, 13 | } from './web' 14 | 15 | const githubPullHtmlSnippet = ` 16 |
    17 |
    18 | 23 |
    24 |
    25 | // was a code diff 26 |
    27 |
    28 |
    29 | 30 |
    31 |
    32 | 37 |
    38 |
    39 |
    40 | Git LFS file not shown 41 |
    42 |
    43 |
    44 | 45 |
    46 |
    47 |
    48 | 49 | seesaw.obj 50 | 51 |
    52 |
    53 |
    54 | Git LFS file not shown 55 |
    56 |
    57 |
    58 | ` // from https://github.com/KittyCAD/litterbox/pull/95/files 59 | const parser = new DOMParser() 60 | const githubPullHtmlDocument = parser.parseFromString( 61 | githubPullHtmlSnippet, 62 | 'text/html' 63 | ) 64 | 65 | const githubPullFilesSample: DiffEntry[] = [ 66 | { 67 | sha: 'c24ca35738a99e6bf834e0ee141db27c62fce499', 68 | filename: 'samples/file_center_of_mass/output.json', 69 | status: 'modified', 70 | additions: 3, 71 | deletions: 3, 72 | changes: 6, 73 | blob_url: 74 | 'https://github.com/KittyCAD/litterbox/blob/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.json', 75 | raw_url: 76 | 'https://github.com/KittyCAD/litterbox/raw/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.json', 77 | contents_url: 78 | 'https://api.github.com/repos/KittyCAD/litterbox/contents/samples%2Ffile_center_of_mass%2Foutput.json?ref=11510a02d8294cac5943b8ebdc416170f5b738b5', 79 | patch: '@@ -1,8 +1,8 @@\n {\n "title": "output.json",\n "center_of_mass": [\n- -1.7249649e-08,\n- 2.96097,\n- -0.36378\n+ -0.12732863,\n+ 1.0363415,\n+ -9.5138624e-08\n ]\n }\n\\ No newline at end of file', 80 | }, 81 | { 82 | sha: '2f35d962a711bea7a8bf57481b8717f7dedbe1c5', 83 | filename: 'samples/file_center_of_mass/output.obj', 84 | status: 'modified', 85 | additions: 2, 86 | deletions: 2, 87 | changes: 4, 88 | blob_url: 89 | 'https://github.com/KittyCAD/litterbox/blob/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.obj', 90 | raw_url: 91 | 'https://github.com/KittyCAD/litterbox/raw/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.obj', 92 | contents_url: 93 | 'https://api.github.com/repos/KittyCAD/litterbox/contents/samples%2Ffile_center_of_mass%2Foutput.obj?ref=11510a02d8294cac5943b8ebdc416170f5b738b5', 94 | 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', 95 | }, 96 | { 97 | sha: '2f35d962a711bea7a8bf57481b8717f7dedbe1c5', 98 | filename: 'seesaw.obj', 99 | status: 'modified', 100 | additions: 2, 101 | deletions: 2, 102 | changes: 4, 103 | blob_url: 104 | 'https://github.com/KittyCAD/litterbox/blob/11510a02d8294cac5943b8ebdc416170f5b738b5/seesaw.obj', 105 | raw_url: 106 | 'https://github.com/KittyCAD/litterbox/raw/11510a02d8294cac5943b8ebdc416170f5b738b5/seesaw.obj', 107 | contents_url: 108 | 'https://api.github.com/repos/KittyCAD/litterbox/contents/seesaw.obj?ref=11510a02d8294cac5943b8ebdc416170f5b738b5', 109 | 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', 110 | }, 111 | ] 112 | 113 | describe('Function getGithubPullUrlParams', () => { 114 | it('gets params out of a valid github pull request link', () => { 115 | const url = 'https://github.com/KittyCAD/kittycad.ts/pull/67/files' 116 | const params = getGithubPullUrlParams(url) 117 | expect(params).toBeDefined() 118 | const { owner, repo, pull } = params! 119 | expect(owner).toEqual('KittyCAD') 120 | expect(repo).toEqual('kittycad.ts') 121 | expect(pull).toEqual(67) 122 | }) 123 | 124 | it("doesn't match other URLs", () => { 125 | expect(getGithubPullUrlParams('http://google.com')).toBeUndefined() 126 | expect( 127 | getGithubPullUrlParams('https://github.com/KittyCAD/litterbox') 128 | ).toBeUndefined() 129 | }) 130 | }) 131 | 132 | describe('Function getGithubCommitUrlParams', () => { 133 | it('gets params out of a valid github commit link', () => { 134 | const url = 135 | 'https://github.com/KittyCAD/litterbox/commit/4ddf899550addf41d6bf1b790ce79e46501411b3' 136 | const params = getGithubCommitUrlParams(url) 137 | expect(params).toBeDefined() 138 | const { owner, repo, sha } = params! 139 | expect(owner).toEqual('KittyCAD') 140 | expect(repo).toEqual('litterbox') 141 | expect(sha).toEqual('4ddf899550addf41d6bf1b790ce79e46501411b3') 142 | }) 143 | 144 | it("doesn't match other URLs", () => { 145 | expect(getGithubPullUrlParams('http://google.com')).toBeUndefined() 146 | expect( 147 | getGithubPullUrlParams('https://github.com/KittyCAD/litterbox') 148 | ).toBeUndefined() 149 | }) 150 | }) 151 | 152 | describe('Function getGithubCommitWithinPullUrlParams', () => { 153 | it('gets params out of a valid github commit link within a PR', () => { 154 | const url = 155 | 'https://github.com/KittyCAD/diff-samples/pull/2/commits/1dc0d43a94dba95279fcfc112bb5dd4dfaac01ae' 156 | const params = getGithubCommitWithinPullUrlParams(url) 157 | expect(params).toBeDefined() 158 | const { owner, repo, pull, sha } = params! 159 | expect(owner).toEqual('KittyCAD') 160 | expect(repo).toEqual('diff-samples') 161 | expect(pull).toEqual(2) 162 | expect(sha).toEqual('1dc0d43a94dba95279fcfc112bb5dd4dfaac01ae') 163 | }) 164 | 165 | it("doesn't match other URLs", () => { 166 | expect( 167 | getGithubCommitWithinPullUrlParams('http://google.com') 168 | ).toBeUndefined() 169 | expect( 170 | getGithubCommitWithinPullUrlParams( 171 | 'https://github.com/KittyCAD/litterbox/commit/4ddf899550addf41d6bf1b790ce79e46501411b3' 172 | ) 173 | ).toBeUndefined() 174 | }) 175 | }) 176 | 177 | describe('Function getGithubBlobUrlParams', () => { 178 | it('gets params out of a valid github blob link', () => { 179 | const url = 180 | 'https://github.com/KittyCAD/diff-samples/blob/fd9eec79f0464833686ea6b5b34ea07145e32734/models/box.obj' 181 | const params = getGithubBlobUrlParams(url) 182 | expect(params).toBeDefined() 183 | const { owner, repo, sha, filename } = params! 184 | expect(owner).toEqual('KittyCAD') 185 | expect(repo).toEqual('diff-samples') 186 | expect(sha).toEqual('fd9eec79f0464833686ea6b5b34ea07145e32734') 187 | expect(filename).toEqual('models/box.obj') 188 | }) 189 | 190 | it("doesn't match other URLs", () => { 191 | expect(getGithubPullUrlParams('http://google.com')).toBeUndefined() 192 | expect( 193 | getGithubPullUrlParams('https://github.com/KittyCAD/litterbox') 194 | ).toBeUndefined() 195 | }) 196 | }) 197 | 198 | it('finds web elements for supported files', () => { 199 | const elements = getSupportedWebDiffElements(githubPullHtmlDocument) 200 | expect(elements).toHaveLength(2) 201 | }) 202 | 203 | it('finds the filename of a supported file element', () => { 204 | const elements = getSupportedWebDiffElements(githubPullHtmlDocument) 205 | const filename = getElementFilename(elements[0]) 206 | expect(filename).toEqual('samples/file_center_of_mass/output.obj') 207 | }) 208 | 209 | it('finds injectable elements from html and api results', () => { 210 | const injectableElements = mapInjectableDiffElements( 211 | githubPullHtmlDocument, 212 | githubPullFilesSample 213 | ) 214 | expect(injectableElements).toHaveLength(2) 215 | const { element, file } = injectableElements[0] 216 | expect(element).toBeDefined() 217 | expect(file).toBeDefined() 218 | }) 219 | 220 | it('adds a div element, creates a react root inside, and can render', () => { 221 | const root = createReactRoot(document) 222 | expect(root).toBeDefined() 223 | expect(() => root.render(React.createElement('a'))).not.toThrow() 224 | }) 225 | 226 | it('finds the right color mode from GitHub', () => { 227 | const lightHtml = ` 228 | 231 | 232 | ` 233 | const lightDocument = parser.parseFromString(lightHtml, 'text/html') 234 | expect(getGithubColorMode(lightDocument)).toEqual('light') 235 | 236 | const darkHtml = ` 237 | 240 | 241 | ` 242 | const darkDocument = parser.parseFromString(darkHtml, 'text/html') 243 | expect(getGithubColorMode(darkDocument)).toEqual('dark') 244 | 245 | const sysHtml = ` 246 | 249 | 250 | ` 251 | const sysDocument = parser.parseFromString(sysHtml, 'text/html') 252 | expect(getGithubColorMode(sysDocument)).toEqual('auto') 253 | 254 | const irrelevantHtml = '' 255 | const irrDocument = parser.parseFromString(irrelevantHtml, 'text/html') 256 | expect(getGithubColorMode(irrDocument)).toEqual('auto') 257 | }) 258 | -------------------------------------------------------------------------------- /src/components/viewer/CadDiff.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import '@react-three/fiber' 3 | import { Box, useTheme, TabNav, Octicon } from '@primer/react' 4 | import { FileDiff } from '../../chrome/types' 5 | import { Viewer3D } from './Viewer3D' 6 | import { BufferGeometry, Sphere } from 'three' 7 | import { WireframeColors, WireframeModel } from './WireframeModel' 8 | import { useRef } from 'react' 9 | import { CombinedModel } from './CombinedModel' 10 | import { BeakerIcon } from '@primer/octicons-react' 11 | import { LegendBox, LegendLabel } from './Legend' 12 | import { getCommonSphere, loadGeometry } from '../../utils/three' 13 | import { OrbitControls } from 'three-stdlib' 14 | import { RecenterButton } from './RecenterButton' 15 | import { ErrorMessage } from './ErrorMessage' 16 | import { Loading } from '../Loading' 17 | 18 | function Viewer3D2Up({ 19 | beforeGeometry, 20 | afterGeometry, 21 | boundingSphere, 22 | }: { 23 | beforeGeometry?: BufferGeometry 24 | afterGeometry?: BufferGeometry 25 | boundingSphere?: Sphere 26 | }) { 27 | const beforeControlsRef = useRef(null) 28 | const afterControlsRef = useRef(null) 29 | const [controlsAltered, setControlsAltered] = useState(false) 30 | const { theme } = useTheme() 31 | const beforeColors: WireframeColors = { 32 | face: theme?.colors.fg.default, 33 | edge: theme?.colors.danger.muted, 34 | dashEdge: theme?.colors.danger.subtle, 35 | } 36 | const afterColors: WireframeColors = { 37 | face: theme?.colors.fg.default, 38 | edge: theme?.colors.success.muted, 39 | dashEdge: theme?.colors.success.subtle, 40 | } 41 | return ( 42 | <> 43 | {beforeGeometry && ( 44 | 45 | 50 | !controlsAltered && setControlsAltered(true) 51 | } 52 | > 53 | 58 | 59 | 60 | )} 61 | {afterGeometry && ( 62 | 70 | 75 | !controlsAltered && setControlsAltered(true) 76 | } 77 | > 78 | 83 | 84 | 85 | )} 86 | {controlsAltered && ( 87 | { 89 | afterControlsRef.current?.reset() 90 | beforeControlsRef.current?.reset() 91 | setControlsAltered(false) 92 | }} 93 | /> 94 | )} 95 | 96 | ) 97 | } 98 | 99 | function Viewer3DCombined({ 100 | beforeGeometry, 101 | afterGeometry, 102 | boundingSphere, 103 | }: { 104 | beforeGeometry: BufferGeometry 105 | afterGeometry: BufferGeometry 106 | boundingSphere: Sphere 107 | }) { 108 | const controlsRef = useRef(null) 109 | const [controlsAltered, setControlsAltered] = useState(false) 110 | const [showUnchanged, setShowUnchanged] = useState(true) 111 | const [showAdditions, setShowAdditions] = useState(true) 112 | const [showDeletions, setShowDeletions] = useState(true) 113 | const [rendering, setRendering] = useState(true) 114 | return ( 115 | <> 116 | {rendering && ( 117 | 118 | 119 | 120 | )} 121 | 126 | !controlsAltered && setControlsAltered(true) 127 | } 128 | > 129 | setRendering(false)} 137 | /> 138 | 139 | 140 | setShowUnchanged(enabled)} 145 | /> 146 | setShowAdditions(enabled)} 151 | /> 152 | setShowDeletions(enabled)} 157 | /> 158 | 159 | {controlsAltered && ( 160 | { 162 | controlsRef.current?.reset() 163 | setControlsAltered(false) 164 | }} 165 | /> 166 | )} 167 | 168 | ) 169 | } 170 | 171 | export function CadDiff({ before, after }: FileDiff): React.ReactElement { 172 | let [showCombined, setShowCombined] = useState(false) 173 | const [beforeGeometry, setBeforeGeometry] = useState() 174 | const [afterGeometry, setAfterGeometry] = useState() 175 | const [boundingSphere, setBoundingSphere] = useState() 176 | useEffect(() => { 177 | let beforeGeometry: BufferGeometry | undefined = undefined 178 | let afterGeometry: BufferGeometry | undefined = undefined 179 | if (before) { 180 | beforeGeometry = loadGeometry(before) 181 | setBeforeGeometry(beforeGeometry) 182 | } 183 | if (after) { 184 | afterGeometry = loadGeometry(after) 185 | setAfterGeometry(afterGeometry) 186 | } 187 | if (beforeGeometry && afterGeometry) { 188 | const boundingSphere = getCommonSphere( 189 | beforeGeometry, 190 | afterGeometry 191 | ) 192 | setBoundingSphere(boundingSphere) 193 | } else if (beforeGeometry && beforeGeometry.boundingSphere) { 194 | setBoundingSphere(beforeGeometry.boundingSphere) 195 | } else if (afterGeometry && afterGeometry.boundingSphere) { 196 | setBoundingSphere(afterGeometry.boundingSphere) 197 | } 198 | }, [before, after]) 199 | return ( 200 | <> 201 | {(beforeGeometry || afterGeometry) && ( 202 | 209 | {beforeGeometry && 210 | afterGeometry && 211 | boundingSphere && 212 | showCombined && ( 213 | 218 | )} 219 | {!showCombined && ( 220 | 225 | )} 226 | 227 | )} 228 | {beforeGeometry && afterGeometry && boundingSphere && ( 229 | 239 | 248 | setShowCombined(false)} 251 | sx={{ cursor: 'pointer' }} 252 | > 253 | 2-up 254 | 255 | setShowCombined(true)} 259 | sx={{ cursor: 'pointer' }} 260 | > 261 | Combined 262 | 268 | 269 | 270 | 271 | )} 272 | {!beforeGeometry && !afterGeometry && } 273 | 274 | ) 275 | } 276 | --------------------------------------------------------------------------------