Compare commits

..

No commits in common. "develop" and "release-v3.13.1" have entirely different histories.

77 changed files with 3434 additions and 3050 deletions

18
.eslintrc.json Normal file
View file

@ -0,0 +1,18 @@
{
"root": true,
"env": {
"es2022": true
},
"overrides": [
{
"files": ["webpack.config.ts", "*.tsx?"],
"excludedFiles": ["*.d.ts"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
}
}
]
}

View file

@ -22,6 +22,8 @@ jobs:
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- name: Install Dependencies - name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: | run: |
yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline

View file

@ -47,32 +47,17 @@ jobs:
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- name: Install Dependencies - name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: | run: |
yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline
- name: Install Playwright Browsers
run: npx playwright install chromium
- name: E2E Test - name: E2E Test
uses: mymindstorm/puppeteer-headful@8f745c770f7f4c0f9f332d7c43a775f90e53779a
env: env:
CI: 'true' CI: 'true'
run: xvfb-run yarn test
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: with:
name: playwright-report args: yarn test
path: playwright-report/
retention-days: 30
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results
path: test-results/
retention-days: 30
unit-test: unit-test:
needs: build needs: build
@ -101,6 +86,8 @@ jobs:
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- name: Install Dependencies - name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: | run: |
yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline

2
.gitignore vendored
View file

@ -7,5 +7,3 @@ dist-firefox
yarn-error.log yarn-error.log
/vscode-icons /vscode-icons
firefox-profile firefox-profile
/test-results
/playwright-report

View file

@ -1,4 +1,4 @@
*-profile/ *-profile/
dist/ dist/
/vscode-icons vscode-icons/
Safari Safari

20
.swcrc
View file

@ -1,20 +0,0 @@
{
"$schema": "https://swc.rs/schema.json",
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": false,
"dynamicImport": true
},
"transform": {
"react": {
"runtime": "automatic"
}
},
"target": "es2022"
},
"module": {
"type": "es6"
}
}

View file

@ -23,7 +23,7 @@ Gitako is a free file tree extension for GitHub, available on Chrome, Firefox an
<a href="https://chrome.google.com/webstore/detail/gitako-github-file-tree/giljefjcheohhamkjphiebfjnlphnokk"><img width="64" alt="Chrome" src="./assets/Chrome.svg" /></a> <a href="https://chrome.google.com/webstore/detail/gitako-github-file-tree/giljefjcheohhamkjphiebfjnlphnokk"><img width="64" alt="Chrome" src="./assets/Chrome.svg" /></a>
<a href="https://microsoftedge.microsoft.com/addons/detail/alpoloddcggjhakjemghahlkofjekbca"><img width="64" alt="Edge" src="./assets/Edge.svg" /></a> <a href="https://microsoftedge.microsoft.com/addons/detail/alpoloddcggjhakjemghahlkofjekbca"><img width="64" alt="Edge" src="./assets/Edge.svg" /></a>
<a href="https://addons.mozilla.org/firefox/addon/gitako-github-file-tree/"><img width="64" alt="Firefox" src="./assets/Firefox.svg" /></a> <a href="https://addons.mozilla.org/en-US/firefox/addon/gitako-github-file-tree/"><img width="64" alt="Firefox" src="./assets/Firefox.svg" /></a>
It is more recommended for Edge users to install from Chrome store. It may delay for weeks before updates got published to Edge store because its review process is slow. It is more recommended for Edge users to install from Chrome store. It may delay for weeks before updates got published to Edge store because its review process is slow.

12
__tests__/.eslintrc.json Normal file
View file

@ -0,0 +1,12 @@
{
"env": {
"jest": true
},
"overrides": [
{
"files": ["*.ts"],
"excludedFiles": ["*.d.ts"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
}
]
}

View file

@ -0,0 +1,2 @@
// TS files cannot be transformed without this babel config
module.exports = require('../babel.config')

View file

@ -0,0 +1,49 @@
/**
* Confirm basic behaviors of puppeteer assertions
*/
import { testURL } from '../testURL'
import { expectToFind, expectToNotFind, expectToReject } from '../utils'
describe(`in random page`, () => {
beforeAll(() => page.goto(testURL`https://google.com`))
it('wait for hidden non-exist element should resolve null', async () => {
expect(
await page.waitForSelector('.non-exist-element', { hidden: true, timeout: 1000 }),
).toBeNull()
})
it('wait for exist element', async () => {
expectToFind('*')
})
it('wait for exist element', async () => {
expectToNotFind('.non-exist-element')
})
it('wait for non-exist element reject should throw', async () => {
await expectToReject(page.waitForSelector('.non-exist-element', { timeout: 1000 }))
})
// Cases below are expected to fail to show how async test works
// // This is expected to fail!
// it('wait for non-exist element reject should not throw', async () => {
// await expect(
// page.waitForSelector('.non-exist-element', { timeout: 1000 }),
// ).rejects.not.toThrow()
// })
// // This is expected to fail!
// it('wait for non-exist element should resolve throw', async () => {
// await expect(page.waitForSelector('.non-exist-element', { timeout: 1000 })).resolves.toThrow()
// })
// // This is expected to fail!
// it('wait for non-exist element should resolve not throw', async () => {
// await expect(
// page.waitForSelector('.non-exist-element', { timeout: 1000 }),
// ).resolves.not.toThrow()
// })
})

View file

@ -0,0 +1,15 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { getTextContent, sleep } from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/GitakoExtension/test-empty`))
it('should render error message', async () => {
await sleep(5000)
expect(await getTextContent(selectors.gitako.errorMessage)).toBe(
'This project seems to be empty.',
)
})
})

View file

@ -0,0 +1,20 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind, sleep, waitForRedirect } from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`))
it('expand to target on load and after redirect', async () => {
await sleep(3000)
// Expect Gitako sidebar to have expanded src to see contents
await expectToFind(selectors.gitako.fileItemOf('src/components'))
await page.click(selectors.github.fileListItemLinkOf('components'))
await waitForRedirect()
// Expect Gitako sidebar to have expanded components and see contents
await expectToFind(selectors.gitako.fileItemOf('src/components/Gitako.tsx'))
})
})

View file

@ -0,0 +1,10 @@
import { testURL } from '../testURL'
import { expectToNotFind } from '../utils'
describe(`in GitHub homepage`, () => {
beforeAll(() => page.goto(testURL`https://github.com`))
it('should not render Gitako', async () => {
await expectToNotFind('.gitako-side-bar .gitako-side-bar-body-wrapper')
})
})

View file

@ -0,0 +1,25 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/commits/develop`))
it('should not break go back in history', async () => {
for (let i = 0; i < 3; i++) {
const commitLinks = await page.$$(selectors.github.commitLinks)
if (commitLinks.length < 2) throw new Error(`No enough commits`)
commitLinks[i].click()
await waitForRedirect()
await expectToFind(selectors.github.commitPage)
await sleep(1000)
page.goBack()
await sleep(1000)
// The selector for file content
await expectToNotFind(selectors.github.commitPage)
}
})
})

View file

@ -0,0 +1,28 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`))
it('should not break go back in history', async () => {
for (let i = 0; i < 3; i++) {
const fileItems = await page.$$(selectors.github.fileListItemFileLinks)
if (fileItems.length < 2) throw new Error(`No enough files`)
await waitForRedirect(async () => {
await fileItems[i].click()
})
await expectToFind(selectors.github.fileContent)
await sleep(1000)
page.goBack()
await sleep(1000)
// The selector for file content
await expectToNotFind(selectors.github.fileContent)
}
})
})

View file

@ -0,0 +1,39 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import {
collapseFloatModeSidebar,
expandFloatModeSidebar,
getTextContent,
patientClick,
sleep,
waitForRedirect,
} from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`))
it('should work with PJAX', async () => {
await sleep(3000)
await expandFloatModeSidebar()
await patientClick(selectors.gitako.fileItemOf('src/analytics.ts'))
await waitForRedirect()
await collapseFloatModeSidebar()
await page.click(selectors.github.navBarItemIssues)
await waitForRedirect()
await page.click(selectors.github.navBarItemPulls)
await waitForRedirect()
page.goBack()
await sleep(1000)
page.goBack()
await sleep(1000)
expect(await getTextContent(selectors.github.breadcrumbFileName)).toBe('/analytics.ts')
})
})

View file

@ -0,0 +1,34 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import {
expandFloatModeSidebar,
expectToFind,
expectToNotFind,
patientClick,
sleep,
waitForRedirect,
} from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/test/multiple-changes`))
it('should work with PJAX', async () => {
await sleep(3000)
await expandFloatModeSidebar()
await patientClick(selectors.gitako.fileItemOf('.babelrc'))
await waitForRedirect()
await expectToFind(selectors.github.fileContent)
await waitForRedirect(async () => {
await sleep(1000) // This prevents failing in some cases due to some mystery scheduling issue of puppeteer or jest
page.goBack()
})
// The selector for file content
await expectToNotFind(selectors.github.fileContent)
})
})

View file

@ -0,0 +1,37 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expandFloatModeSidebar, expectToFind, expectToNotFind, scroll } from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() =>
page.goto(
testURL`https://github.com/EnixCoda/Gitako/tree/test/200-changed-files-200-lines-each`,
),
)
it('should render Gitako', async () => {
await expectToFind(selectors.gitako.bodyWrapper)
})
it('should render file list', async () => {
await expectToFind(selectors.gitako.fileItem)
})
it('should render while scroll', async () => {
await expandFloatModeSidebar()
const filesEle = await page.waitForSelector(selectors.gitako.files)
// node of tsconfig.json should NOT be rendered before scroll down
await expectToNotFind(selectors.gitako.fileItemOf('tsconfig.json'))
const box = await filesEle?.boundingBox()
if (box) {
await page.mouse.move(box.x + 40, box.y + 40)
await scroll({ totalDistance: 10000, stepDistance: 100 })
// node of tsconfig.json should be rendered now
await expectToFind(selectors.gitako.fileItemOf('tsconfig.json'))
}
})
})

View file

@ -0,0 +1,15 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind } from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/pull/71`))
it('should render Gitako', async () => {
await expectToFind(selectors.gitako.bodyWrapper)
})
it('should render file list', async () => {
await expectToFind(selectors.gitako.fileItem)
})
})

1
__tests__/global.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare var page: Puppeteer.Page

185
__tests__/jest.config.js Normal file
View file

@ -0,0 +1,185 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// Respect "browser" field in package.json when resolving modules
// browser: false,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/6j/kp9yt47x4872_k7d4gdjbwfm0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
// clearMocks: false,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: null,
// The directory where Jest should output its coverage files
// coverageDirectory: null,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: null,
// A path to a custom dependency extractor
// dependencyExtractor: null,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: null,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: null,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
maxWorkers: 1,
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: 'jest-puppeteer',
// Run tests from one or more projects
// projects: null,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: null,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: null,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: 'node',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: ['**/?(*.)+(spec|test).ts?(x)'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/', '.d.ts$'],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: null,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
testTimeout: 30 * 1000,
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: null,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: null,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

View file

@ -0,0 +1,7 @@
const baseConfig = require('./jest.config')
module.exports = {
...baseConfig,
testMatch: [...baseConfig.testMatch, '**/__tests__/cases/**/*.ts?(x)'],
setupFilesAfterEnv: ['<rootDir>/setup.ts'],
}

4
__tests__/puppeteer.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
import * as Puppeteer from 'puppeteer'
export as namespace Puppeteer
export = Puppeteer

1
__tests__/setup.ts Normal file
View file

@ -0,0 +1 @@
jest.retryTimes(3)

10
__tests__/tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"include": ["."],
"exclude": ["tsconfig.json"],
"compilerOptions": {
"target": "ES5",
"module": "CommonJS",
"baseUrl": null
}
}

137
__tests__/utils.ts Normal file
View file

@ -0,0 +1,137 @@
export async function expectToResolve<T>(promise: Promise<T>) {
const pass = jest.fn()
await promise.then(pass)
expect(pass).toHaveBeenCalled()
}
export async function expectToReject<T>(promise: Promise<T>) {
const pass = jest.fn()
await promise.catch(pass)
expect(pass).toHaveBeenCalled()
}
export async function expectToFind(selector: string) {
await expectToResolve(page.waitForSelector(selector, { timeout: 2000 }))
}
export async function expectToNotFind(selector: string) {
await expectToReject(page.waitForSelector(selector, { timeout: 1000 }))
}
export function sleep(timeout: number) {
return new Promise(resolve => setTimeout(resolve, timeout))
}
export async function scroll({
totalDistance,
stepDistance = 100,
}: {
totalDistance: number
stepDistance?: number
}) {
let distance = 0
while ((distance += stepDistance) < totalDistance) {
await page.mouse.wheel({ deltaY: stepDistance })
}
}
export function assert(condition: boolean, err?: Error | string): asserts condition {
if (!condition) throw typeof err === 'string' ? new Error(err) : err
}
let counter = 0
export async function listenTo(
event: string,
target: 'document' | 'window',
callback: <Args extends unknown[]>(...args: Args) => void,
oneTime?: boolean,
) {
const callbackName = 'onEvent' + ++counter
await page.exposeFunction(callbackName, callback)
await page.evaluate(
(event: string, target: 'window' | 'document', callbackName: string, oneTime?: boolean) => {
const t = target === 'document' ? document : window
const onEvent = (...args: unknown[]): void => {
const method = window[callbackName as keyof Window]
method?.(...args)
if (oneTime) t.removeEventListener(event, onEvent)
}
t.addEventListener(event, onEvent)
},
event,
target,
callbackName,
oneTime || false,
)
}
export function once(event: string, target: 'document' | 'window') {
return new Promise(resolve => {
listenTo(
event,
target,
(...args) => {
resolve(args)
},
true,
)
})
}
export async function waitForLegacyPJAXRedirect(action?: () => void | Promise<void>) {
const promise = once('pjax:end', 'document')
await action?.()
return promise
}
export async function waitForTurboRedirect(action?: () => void | Promise<void>) {
const promise = once('turbo:load', 'document')
await action?.()
return promise
}
export async function waitForRedirect(action?: () => void | Promise<void>) {
let fired = false
const $action =
action &&
(() => {
if (fired) return
fired = true
return action()
})
return Promise.race([
waitForLegacyPJAXRedirect($action),
waitForTurboRedirect($action),
sleep(3 * 1000),
])
}
export async function patientClick(selector: string) {
await page.waitForSelector(selector)
await page.click(selector)
}
export async function expandFloatModeSidebar() {
const rect = await (
await page.$('.gitako-toggle-show-button')
)?.evaluate(button => {
const { x, y, width, height } = button.getBoundingClientRect()
// pass required properties to avoid serialization issues
return { x, y, width, height }
})
if (rect) {
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await sleep(500)
}
}
export async function collapseFloatModeSidebar() {
await page.mouse.move(600, 600, {
steps: 100,
})
await sleep(500)
}
export function getTextContent(query: string) {
return page.evaluate(query => document.querySelector(query)?.textContent, query)
}

26
babel.config.js Normal file
View file

@ -0,0 +1,26 @@
// `.babelrc` is not loaded by babel-loader for files under node_modules, but `babel.config.js` is
module.exports = {
env: {
test: {
plugins: ['babel-plugin-transform-es2015-modules-commonjs'],
},
},
presets: [
[
'@babel/preset-env',
{
modules: false,
targets: {
esmodules: true,
},
exclude: [
'@babel/plugin-transform-async-to-generator',
'@babel/plugin-proposal-object-rest-spread',
],
},
],
'@babel/preset-typescript',
'@babel/preset-react',
],
plugins: ['@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-class-properties'],
}

View file

@ -1,30 +0,0 @@
/**
* Confirm basic behaviors of playwright assertions
*/
import { expect, test } from './fixtures'
import { testURL } from './testURL'
test.describe('in random page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://google.com`)
})
test('wait for hidden non-exist element should resolve null', async ({ extensionPage }) => {
await expect(extensionPage.locator('.non-exist-element')).toHaveCount(0)
})
test('wait for exist element', async ({ extensionPage }) => {
await expect(extensionPage.locator('*').first()).toBeVisible()
})
test('wait for non-exist element should not be visible', async ({ extensionPage }) => {
await expect(extensionPage.locator('.non-exist-element')).not.toBeVisible()
})
test('wait for non-exist element should timeout', async ({ extensionPage }) => {
await expect(async () => {
await extensionPage.waitForSelector('.non-exist-element', { timeout: 1000 })
}).rejects.toThrow()
})
})

View file

@ -1,17 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { getTextContent, sleep } from './utils'
test.describe('in Gitako empty project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/GitakoExtension/test-empty`)
})
test('should render error message', async ({ extensionPage }) => {
await sleep(5000)
const textContent = await getTextContent(extensionPage, selectors.gitako.errorMessage)
expect(textContent).toBe('This project seems to be empty.')
})
})

View file

@ -1,27 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { sleep, waitForRedirect } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)
})
test('expand to target on load and after redirect', async ({ extensionPage }) => {
await sleep(3000)
// Expect Gitako sidebar to have expanded src to see contents
await expect(extensionPage.locator(selectors.gitako.fileItemOf('src/components'))).toBeVisible({
timeout: 5000,
})
await extensionPage.click(selectors.github.fileListItemLinkOf('components'))
await waitForRedirect(extensionPage)
// Expect Gitako sidebar to have expanded components and see contents
await expect(
extensionPage.locator(selectors.gitako.fileItemOf('src/components/Gitako.tsx')),
).toBeVisible({ timeout: 5000 })
})
})

View file

@ -1,29 +0,0 @@
import { test as base, chromium, type BrowserContext, type Page } from '@playwright/test'
import path from 'path'
const EXTENSION_PATH = path.resolve(__dirname, '..', 'dist')
export const test = base.extend<{
context: BrowserContext
extensionPage: Page
}>({
// eslint-disable-next-line no-empty-pattern
context: async ({}, use) => {
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--no-sandbox`,
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
})
await use(context)
await context.close()
},
extensionPage: async ({ context }, use) => {
const page = await context.newPage()
await use(page)
},
})
export const expect = test.expect

View file

@ -1,14 +0,0 @@
import { expect, test } from './fixtures'
import { testURL } from './testURL'
test.describe('in GitHub homepage', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com`)
})
test('should not render Gitako', async ({ extensionPage }) => {
await expect(
extensionPage.locator('.gitako-side-bar .gitako-side-bar-body-wrapper'),
).not.toBeVisible({ timeout: 2000 })
})
})

View file

@ -1,31 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { sleep, waitForRedirect } from './utils'
test.describe('in Gitako commits page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/commits/develop`)
})
test('should not break go back in history', async ({ extensionPage }) => {
for (let i = 0; i < 3; i++) {
const commitLinks = await extensionPage.locator(selectors.github.commitLinks).all()
if (commitLinks.length < 2) throw new Error(`No enough commits`)
await commitLinks[i].click()
await waitForRedirect(extensionPage)
await expect(extensionPage.locator(selectors.github.commitPage).first()).toBeVisible({
timeout: 5000,
})
await sleep(1000)
await extensionPage.goBack()
await sleep(1000)
// The selector for commit page should not be visible
await expect(extensionPage.locator(selectors.github.commitPage)).not.toBeVisible({
timeout: 2000,
})
}
})
})

View file

@ -1,32 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { sleep, waitForRedirect } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)
})
test('should not break go back in history', async ({ extensionPage }) => {
for (let i = 0; i < 3; i++) {
const fileItems = await extensionPage.locator(selectors.github.fileListItemFileLinks).all()
if (fileItems.length < 2) throw new Error(`No enough files`)
await waitForRedirect(extensionPage, async () => {
await fileItems[i].click()
})
await expect(extensionPage.locator(selectors.github.fileContent)).toBeVisible({
timeout: 5000,
})
await sleep(1000)
await extensionPage.goBack()
await sleep(1000)
// The selector for file content should not be visible
await expect(extensionPage.locator(selectors.github.fileContent)).not.toBeVisible({
timeout: 2000,
})
}
})
})

View file

@ -1,41 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import {
collapseFloatModeSidebar,
expandFloatModeSidebar,
getTextContent,
patientClick,
sleep,
waitForRedirect,
} from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)
})
test('should work with PJAX', async ({ extensionPage }) => {
await sleep(3000)
await expandFloatModeSidebar(extensionPage)
await patientClick(extensionPage, selectors.gitako.fileItemOf('src/analytics.ts'))
await waitForRedirect(extensionPage)
await collapseFloatModeSidebar(extensionPage)
await extensionPage.click(selectors.github.navBarItemIssues)
await waitForRedirect(extensionPage)
await extensionPage.click(selectors.github.navBarItemPulls)
await waitForRedirect(extensionPage)
await extensionPage.goBack()
await sleep(1000)
await extensionPage.goBack()
await sleep(1000)
const textContent = await getTextContent(extensionPage, selectors.github.breadcrumbFileName)
expect(textContent).toBe('/analytics.ts')
})
})

View file

@ -1,30 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { expandFloatModeSidebar, patientClick, sleep, waitForRedirect } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/test/multiple-changes`)
})
test('should work with PJAX', async ({ extensionPage }) => {
await sleep(3000)
await expandFloatModeSidebar(extensionPage)
await patientClick(extensionPage, selectors.gitako.fileItemOf('.babelrc'))
await waitForRedirect(extensionPage)
await expect(extensionPage.locator(selectors.github.fileContent)).toBeVisible({ timeout: 5000 })
await waitForRedirect(extensionPage, async () => {
await sleep(1000) // This prevents failing in some cases due to some mystery scheduling issue
await extensionPage.goBack()
})
// The selector for file content should not be visible
await expect(extensionPage.locator(selectors.github.fileContent)).not.toBeVisible({
timeout: 2000,
})
})
})

View file

@ -1,46 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { expandFloatModeSidebar, scroll } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(
testURL`https://github.com/EnixCoda/Gitako/tree/test/200-changed-files-200-lines-each`,
)
})
test('should render Gitako', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.bodyWrapper)).toBeVisible({ timeout: 5000 })
})
test('should render file list', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.fileItem).first()).toBeVisible({
timeout: 5000,
})
})
test('should render while scroll', async ({ extensionPage }) => {
await expandFloatModeSidebar(extensionPage)
await extensionPage.waitForSelector(selectors.gitako.files)
// node of tsconfig.json should NOT be rendered before scroll down
await expect(
extensionPage.locator(selectors.gitako.fileItemOf('tsconfig.json')),
).not.toBeVisible({
timeout: 2000,
})
const filesEle = extensionPage.locator(selectors.gitako.files)
const box = await filesEle.boundingBox()
if (box) {
await extensionPage.mouse.move(box.x + 40, box.y + 40)
await scroll(extensionPage, { totalDistance: 10000, stepDistance: 100 })
// node of tsconfig.json should be rendered now
await expect(extensionPage.locator(selectors.gitako.fileItemOf('tsconfig.json'))).toBeVisible(
{ timeout: 5000 },
)
}
})
})

View file

@ -1,19 +0,0 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
test.describe('in Gitako pull request page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/pull/71`)
})
test('should render Gitako', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.bodyWrapper)).toBeVisible({ timeout: 5000 })
})
test('should render file list', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.fileItem).first()).toBeVisible({
timeout: 5000,
})
})
})

View file

@ -1,15 +0,0 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"module": "ESNext",
"target": "ESNext",
"strict": true,
"lib": ["dom", "es2017.object", "es2016", "ES2019.Array", "ES2020.String", "ES2022.Error"],
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["./**/*.ts"]
}

View file

@ -1,98 +0,0 @@
import type { Page } from '@playwright/test'
export function sleep(timeout: number) {
return new Promise(resolve => setTimeout(resolve, timeout))
}
export async function scroll(
page: Page,
{
totalDistance,
stepDistance = 100,
}: {
totalDistance: number
stepDistance?: number
},
) {
let distance = 0
while ((distance += stepDistance) < totalDistance) {
await page.mouse.wheel(0, stepDistance)
}
}
export function assert(condition: boolean, err?: Error | string): asserts condition {
if (!condition) throw typeof err === 'string' ? new Error(err) : err
}
export async function waitForLegacyPJAXRedirect(page: Page, action?: () => void | Promise<void>) {
const promise = page.evaluate(() => {
return new Promise<void>(resolve => {
const onEvent = () => {
document.removeEventListener('pjax:end', onEvent)
resolve()
}
document.addEventListener('pjax:end', onEvent)
})
})
await action?.()
return promise
}
export async function waitForTurboRedirect(page: Page, action?: () => void | Promise<void>) {
const promise = page.evaluate(() => {
return new Promise<void>(resolve => {
const onEvent = () => {
document.removeEventListener('turbo:load', onEvent)
resolve()
}
document.addEventListener('turbo:load', onEvent)
})
})
await action?.()
return promise
}
export async function waitForRedirect(page: Page, action?: () => void | Promise<void>) {
let fired = false
const $action =
action &&
(() => {
if (fired) return
fired = true
return action()
})
return Promise.race([
waitForLegacyPJAXRedirect(page, $action),
waitForTurboRedirect(page, $action),
sleep(3 * 1000),
])
}
export async function patientClick(page: Page, selector: string) {
await page.waitForSelector(selector)
await page.click(selector)
}
export async function expandFloatModeSidebar(page: Page) {
const button = page.locator('.gitako-toggle-show-button')
const rect = await button.boundingBox()
if (rect) {
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await sleep(500)
}
}
export async function collapseFloatModeSidebar(page: Page) {
await page.mouse.move(600, 600, {
steps: 100,
})
await sleep(500)
}
export async function getTextContent(
page: Page,
query: string,
): Promise<string | null | undefined> {
return page.evaluate((query: string) => document.querySelector(query)?.textContent, query)
}

View file

@ -1,98 +0,0 @@
import eslint from '@eslint/js'
import eslintConfigPrettier from 'eslint-config-prettier'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
ignores: [
'dist*/',
'node_modules/',
'Safari/',
'vscode-icons/',
'server/',
'**/*.d.ts',
'playwright-report/',
'test-results/',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.es2022,
},
},
},
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
plugins: {
react,
'react-hooks': reactHooks,
},
languageOptions: {
globals: {
...globals.browser,
},
},
settings: {
react: {
version: 'detect',
},
},
rules: {
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react-hooks/rules-of-hooks': 'off', // for IIFC
},
},
{
files: ['e2e/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
},
},
},
{
files: ['scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
files: ['scripts/vscode-icons/**/*.js'],
languageOptions: {
globals: {
...globals.node,
...globals.browser,
},
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
files: ['*.config.{js,ts,cjs,cts}', '*.{cjs,cts}'],
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
)

21
jest-puppeteer.config.js Normal file
View file

@ -0,0 +1,21 @@
const path = require('path')
if (process.arch === 'arm64' && process.platform === 'darwin') {
require('dotenv').config()
}
const CRX_PATH = path.resolve(__dirname, 'dist')
module.exports = {
launch: {
// set by mujo-code/puppeteer-headful on GitHub actions
// also for usages on ARM chip Mac
executablePath: process.env.PUPPETEER_EXEC_PATH,
// required for enabling extensions
headless: false,
args: [
`--no-sandbox`,
`--disable-extensions-except=${CRX_PATH}`,
`--load-extension=${CRX_PATH}`,
],
},
}

View file

@ -142,7 +142,7 @@ module.exports = {
testMatch: ['**/?(*.)+(spec|test).ts?(x)'], testMatch: ['**/?(*.)+(spec|test).ts?(x)'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/', '.d.ts$', '<rootDir>/vscode-icons/', '<rootDir>/e2e/'], testPathIgnorePatterns: ['/node_modules/', '.d.ts$', '<rootDir>/vscode-icons/'],
// The regexp pattern or array of patterns that Jest uses to detect test files // The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [], // testRegex: [],
@ -162,27 +162,12 @@ module.exports = {
// timers: "real", // timers: "real",
// A map from regular expressions to paths to transformers // A map from regular expressions to paths to transformers
transform: { // transform: null,
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ['/node_modules/(?!(webext-.*|superstruct)/)'], // transformIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined, // unmockedModulePathPatterns: undefined,

View file

@ -1,6 +1,6 @@
{ {
"name": "gitako", "name": "gitako",
"version": "3.15.4", "version": "3.13.1",
"description": "File tree for GitHub, and more than that.", "description": "File tree for GitHub, and more than that.",
"repository": "https://github.com/EnixCoda/Gitako", "repository": "https://github.com/EnixCoda/Gitako",
"author": "EnixCoda", "author": "EnixCoda",
@ -11,18 +11,17 @@
"node": ">=18" "node": ">=18"
}, },
"scripts": { "scripts": {
"dev": "VERSION=dev-v$(node scripts/get-version.js) webpack-dashboard -- webpack --watch", "dev": "VERSION=dev-v$(node scripts/get-version.js) NODE_OPTIONS=--openssl-legacy-provider webpack-dashboard -- webpack --watch",
"dev:all": "GITAKO_TARGET= yarn run dev", "dev:all": "GITAKO_TARGET= yarn run dev",
"debug-firefox": "web-ext run --source-dir=dist-firefox --keep-profile-changes --start-url https://github.com/EnixCoda/Gitako", "debug-firefox": "web-ext run --source-dir=dist-firefox --keep-profile-changes --start-url https://github.com/EnixCoda/Gitako",
"prepare": "husky install", "prepare": "husky install",
"postinstall": "patch-package", "postinstall": "patch-package",
"postversion": "sh scripts/post-version.sh", "postversion": "sh scripts/post-version.sh",
"build": "VERSION=v$(node scripts/get-version.js) NODE_ENV=production webpack", "build": "VERSION=v$(node scripts/get-version.js) NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack",
"build:all": "GITAKO_TARGET= yarn run build", "build:all": "GITAKO_TARGET= yarn run build",
"build:analyze": "ANALYZE= yarn run build", "build:analyze": "ANALYZE= yarn run build",
"test": "playwright test", "test": "NODE_ENV=test jest --config __tests__/jest.puppeteer.config.js",
"test:ui": "playwright test --ui", "test:unit": "NODE_ENV=test jest --config jest.config.js"
"test:unit": "NODE_ENV=test jest --config jest.config.cjs"
}, },
"dependencies": { "dependencies": {
"@primer/css": "^20.4.3", "@primer/css": "^20.4.3",
@ -45,12 +44,15 @@
"webextension-polyfill": "^0.11.0" "webextension-polyfill": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.0", "@babel/cli": "^7.17.6",
"@babel/core": "^7.17.9",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
"@sentry/cli": "^1.64.2", "@sentry/cli": "^1.64.2",
"@swc/core": "^1.15.8",
"@swc/jest": "^0.2.39",
"@testing-library/react": "^13.3.0", "@testing-library/react": "^13.3.0",
"@types/dotenv": "^6",
"@types/firefox-webext-browser": "^120.0.3", "@types/firefox-webext-browser": "^120.0.3",
"@types/history": "^5.0.0", "@types/history": "^5.0.0",
"@types/ini": "^1.3.31", "@types/ini": "^1.3.31",
@ -58,44 +60,46 @@
"@types/js-base64": "^3.3.1", "@types/js-base64": "^3.3.1",
"@types/node": "^18", "@types/node": "^18",
"@types/nprogress": "^0.0.29", "@types/nprogress": "^0.0.29",
"@types/puppeteer": "^5.4.3",
"@types/react": "^18.0.9", "@types/react": "^18.0.9",
"@types/react-dom": "^18.0.3", "@types/react-dom": "^18.0.3",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",
"@types/styled-components": "^5.1.25", "@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^8.28.0", "@typescript-eslint/parser": "^5.33.1",
"babel-loader": "^8.2.5",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
"clean-webpack-plugin": "^4.0.0", "clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^13.0.1", "copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2", "css-loader": "^2.1.0",
"dotenv": "^6.2.0", "dotenv": "^6.2.0",
"dotenv-webpack": "^8.1.0", "dotenv-webpack": "^8.1.0",
"eslint": "^9.39.0", "eslint": "^8.15.0",
"eslint-config-prettier": "^10.1.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^4.5.0",
"file-loader": "^6.2.0", "file-loader": "^3.0.1",
"fork-ts-checker-webpack-plugin": "^9.1.0", "fork-ts-checker-webpack-plugin": "^9.0.2",
"globals": "^16.0.0",
"husky": "^8.0.1", "husky": "^8.0.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-puppeteer": "^10.0.1",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"lint-staged": "^13.0.3", "lint-staged": "^13.0.3",
"mini-css-extract-plugin": "^2.10.0", "mini-css-extract-plugin": "^2.9.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prettier": "^3.8.0", "prettier": "^2.8.3",
"puppeteer": "^22.12.1",
"raw-loader": "^4.0.0", "raw-loader": "^4.0.0",
"sass": "^1.26.2", "sass": "^1.26.2",
"sass-loader": "^16.0.6", "sass-loader": "^8.0.2",
"swc-loader": "^0.2.7",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.9.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.53.0", "url-loader": "^1.1.2",
"url-loader": "^4.1.1",
"web-ext": "^7.11.0", "web-ext": "^7.11.0",
"webpack": "^5.104.1", "webpack": "^5.91.0",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1", "webpack-cli": "^5.1.4",
"webpack-dashboard": "^3.3.8" "webpack-dashboard": "^3.3.8"
}, },
"prettier": { "prettier": {

View file

@ -1,45 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
import * as dotenv from 'dotenv'
import * as path from 'path'
import { fileURLToPath } from 'url'
if (process.arch === 'arm64' && process.platform === 'darwin') {
dotenv.config()
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const EXTENSION_PATH = path.resolve(__dirname, 'dist')
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: 'html',
timeout: 60000,
expect: {
timeout: 10000,
},
use: {
trace: 'on-first-retry',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Load the extension
launchOptions: {
args: [
`--no-sandbox`,
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
headless: false,
},
},
},
],
})

View file

@ -1,6 +1,6 @@
const path = require('path') const path = require('path')
const { promises: fs, existsSync } = require('fs') const { promises: fs, existsSync } = require('fs')
const { chromium } = require('@playwright/test') const puppeteer = require('puppeteer')
const generateFileIconIndex = require('./generate-file-icon-index') const generateFileIconIndex = require('./generate-file-icon-index')
const generateFolderIconIndex = require('./generate-folder-icon-index') const generateFolderIconIndex = require('./generate-folder-icon-index')
const { emitDirPath, checkEmitDir } = require('./check-emit-dir') const { emitDirPath, checkEmitDir } = require('./check-emit-dir')
@ -8,9 +8,8 @@ const { emitDirPath, checkEmitDir } = require('./check-emit-dir')
let browser let browser
async function getPage() { async function getPage() {
const headless = process.env.HEADLESS !== 'false' const headless = process.env.HEADLESS !== 'false'
browser = browser || (await chromium.launch({ headless })) browser = browser || (await puppeteer.launch({ headless }))
const context = await browser.newContext() return await browser.newPage()
return await context.newPage()
} }
async function generateCSV() { async function generateCSV() {

6
server/.eslintrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"env": {
"node": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
}

20
src/.eslintrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"env": {
"browser": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"react-hooks/rules-of-hooks": "off" // for IIFC
}
}

View file

@ -1,21 +0,0 @@
import {
DiffAddedIcon,
DiffIgnoredIcon,
DiffModifiedIcon,
DiffRemovedIcon,
DiffRenamedIcon,
} from '@primer/octicons-react'
import React from 'react'
import { Icon } from '../Icon'
const iconMap = {
added: DiffAddedIcon,
ignored: DiffIgnoredIcon,
modified: DiffModifiedIcon,
removed: DiffRemovedIcon,
renamed: DiffRenamedIcon,
}
export const DiffIcon: React.FC<{
diff: Required<TreeNode>['diff']
}> = ({ diff: { status } }) => <Icon className={status} IconComponent={iconMap[status]} />

View file

@ -1,8 +1,27 @@
import {
DiffAddedIcon,
DiffIgnoredIcon,
DiffModifiedIcon,
DiffRemovedIcon,
DiffRenamedIcon,
} from '@primer/octicons-react'
import React from 'react' import React from 'react'
import { resolveDiffGraphMeta } from 'utils/general' import { resolveDiffGraphMeta } from 'utils/general'
import { Icon } from '../Icon'
export function DiffStatGraph({ diff }: { diff: Required<TreeNode>['diff'] }) { const iconMap = {
const { changes, additions, deletions } = diff added: DiffAddedIcon,
ignored: DiffIgnoredIcon,
modified: DiffModifiedIcon,
removed: DiffRemovedIcon,
renamed: DiffRenamedIcon,
}
export function DiffStatGraph({
diff: { status, changes, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
const { g, r, w } = resolveDiffGraphMeta(additions, deletions, changes) const { g, r, w } = resolveDiffGraphMeta(additions, deletions, changes)
const children: React.ReactNode[] = [] const children: React.ReactNode[] = []
@ -13,5 +32,10 @@ export function DiffStatGraph({ diff }: { diff: Required<TreeNode>['diff'] }) {
for (let i = 0; i < w; i++) for (let i = 0; i < w; i++)
children.push(<span key={`w-${i}`} className="diff-stat-graph-no-change" />) children.push(<span key={`w-${i}`} className="diff-stat-graph-no-change" />)
return <span className={'diff-stat-graph'}>{children}</span> return (
<span className={'diff-stat-graph'}>
<Icon className={status} IconComponent={iconMap[status]} />
{children}
</span>
)
} }

View file

@ -1,9 +1,22 @@
import React from 'react' import React from 'react'
import { Icon } from '../Icon'
export function DiffStatText({ diff }: { diff: Required<TreeNode>['diff'] }) { const iconMap = {
const { additions, deletions } = diff added: 'diffAdded',
ignored: 'diffIgnored',
modified: 'diffModified',
removed: 'diffRemoved',
renamed: 'diffRenamed',
}
export function DiffStatText({
diff: { status, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
return ( return (
<span className={'diff-stat-text'}> <span className={'diff-stat-text'}>
<Icon className={status} type={iconMap[status]} />
{additions > 0 && <span className={'additions'}>{additions}</span>} {additions > 0 && <span className={'additions'}>{additions}</span>}
{additions > 0 && deletions > 0 && '/'} {additions > 0 && deletions > 0 && '/'}
{deletions > 0 && <span className={'deletions'}>{deletions}</span>} {deletions > 0 && <span className={'deletions'}>{deletions}</span>}

View file

@ -1,37 +0,0 @@
import { useCallback, useRef } from 'react'
import { useEvent } from 'react-use'
import { useAfterRedirect } from 'utils/hooks/useFastRedirect'
export function useHandleClickFileLink(ref: React.MutableRefObject<HTMLElement | null>) {
const toGoRef = useRef<string | null>(null)
const onClickCaptureSaveHrefWithHash = useCallback(
(e: Event) => {
const target = e.target
if (target instanceof HTMLElement && ref.current?.contains(target)) {
let e: HTMLElement | null = target
while (e && e !== ref.current) {
if (e instanceof HTMLAnchorElement) {
const hash = e.href.split('#')[1]
if (hash) {
toGoRef.current = e.href
}
}
e = e.parentElement
}
}
},
[ref],
)
useEvent('click', onClickCaptureSaveHrefWithHash, document, true)
const redirectToSavedHrefWithHash = useCallback(() => {
const toGo = toGoRef.current
if (toGo) {
toGoRef.current = null
if (toGo.startsWith(location.href)) {
window.location.replace(toGo)
}
}
}, [])
useAfterRedirect(redirectToSavedHrefWithHash)
}

View file

@ -1,6 +1,4 @@
import { import {
CheckCircleFillIcon,
CheckCircleIcon,
CheckIcon, CheckIcon,
CommentIcon, CommentIcon,
CrossReferenceIcon, CrossReferenceIcon,
@ -17,7 +15,6 @@ import { cancelEvent, onEnterKeyDown } from 'utils/DOMHelper'
import { is } from 'utils/is' import { is } from 'utils/is'
import { Icon } from '../../Icon' import { Icon } from '../../Icon'
import { SearchMode } from '../../searchModes' import { SearchMode } from '../../searchModes'
import { DiffIcon } from '../DiffIcon'
import { DiffStatText } from '../DiffStatText' import { DiffStatText } from '../DiffStatText'
import { DiffStatGraph } from './../DiffStatGraph' import { DiffStatGraph } from './../DiffStatGraph'
import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods' import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods'
@ -37,27 +34,16 @@ export function useNodeRenderers(allRenderers: (NodeRenderer | null | undefined)
export function useRenderFileStatus() { export function useRenderFileStatus() {
const { showDiffInText } = useConfigs().value const { showDiffInText } = useConfigs().value
return useCallback( return useCallback(
function renderFileStatus({ diff, reviewed }: TreeNode) { function renderFileStatus({ diff }: TreeNode) {
return ( return (
<> diff && (
{diff && ( <span
<span className={'node-item-diff'}
className={'node-item-diff'} title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`} >
> {showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />}
<DiffIcon diff={diff} /> </span>
{showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />} )
</span>
)}
{reviewed === undefined ? null : (
<span
className={cx('node-item-reviewed', { reviewed })}
title={reviewed ? 'Reviewed' : 'Not reviewed'}
>
<Icon IconComponent={reviewed ? CheckCircleFillIcon : CheckCircleIcon} />
</span>
)}
</>
) )
}, },
[showDiffInText], [showDiffInText],

View file

@ -41,8 +41,7 @@ export function useHandleNodeClick(
focusNode(node) focusNode(node)
if (node.url) { if (node.url) {
const isHashLink = const isHashLink = node.url.includes('#')
node.url.includes('#') && node.url.split('#')[0] === location.pathname
if (!isHashLink) { if (!isHashLink) {
event.preventDefault() event.preventDefault()
loadWithFastRedirect(node.url, event.currentTarget) loadWithFastRedirect(node.url, event.currentTarget)

View file

@ -6,7 +6,6 @@ import { useConfigs } from 'containers/ConfigsContext'
import { useInspector } from 'containers/Inspector' import { useInspector } from 'containers/Inspector'
import { PortalContext } from 'containers/PortalContext' import { PortalContext } from 'containers/PortalContext'
import { RepoContext } from 'containers/RepoContext' import { RepoContext } from 'containers/RepoContext'
import { platform } from 'platforms'
import React, { import React, {
useCallback, useCallback,
useContext, useContext,
@ -26,7 +25,6 @@ import { useOnLocationChange } from 'utils/hooks/useOnLocationChange'
import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
import { SideBarStateContext } from '../../containers/SideBarState' import { SideBarStateContext } from '../../containers/SideBarState'
import { useGetCurrentPath } from './hooks/useGetCurrentPath' import { useGetCurrentPath } from './hooks/useGetCurrentPath'
import { useHandleClickFileLink } from './hooks/useHandleClickFileLink'
import { useHandleKeyDown } from './hooks/useHandleKeyDown' import { useHandleKeyDown } from './hooks/useHandleKeyDown'
import { import {
NodeRenderer, NodeRenderer,
@ -61,8 +59,6 @@ export function FileExplorer() {
const visibleNodes = useVisibleNodes(visibleNodesGenerator) const visibleNodes = useVisibleNodes(visibleNodesGenerator)
const state = useLoadedContext(SideBarStateContext).value const state = useLoadedContext(SideBarStateContext).value
platform.usePlatformFileTreeHooks?.({ visibleNodesGenerator })
return ( return (
<> <>
{run(() => { {run(() => {
@ -200,8 +196,6 @@ function LoadedFileExplorer({
useCallback(() => ref.current?.focus(), []), useCallback(() => ref.current?.focus(), []),
) )
useHandleClickFileLink(ref)
return ( return (
<div ref={ref} className={`file-explorer`} tabIndex={-1} onKeyDown={handleKeyDown}> <div ref={ref} className={`file-explorer`} tabIndex={-1} onKeyDown={handleKeyDown}>
{visibleNodesGenerator?.defer && ( {visibleNodesGenerator?.defer && (

View file

@ -33,7 +33,7 @@ export function SearchBar({ onSearch, onFocus, value }: Props) {
({ ({
regex: isValidRegexpSource(value), regex: isValidRegexpSource(value),
fuzzy: true, fuzzy: true,
})[searchMode], }[searchMode]),
[value, searchMode], [value, searchMode],
) )
const isSupportedRegex = useMemo( const isSupportedRegex = useMemo(
@ -45,8 +45,8 @@ export function SearchBar({ onSearch, onFocus, value }: Props) {
? !isInputValid ? !isInputValid
? 'Invalid regular expression.' ? 'Invalid regular expression.'
: !isSupportedRegex : !isSupportedRegex
? `Highlight is not supported for regular expression containing '?:', '?=', '?!', '?<=', or '?<!.'` ? `Highlight is not supported for regular expression containing '?:', '?=', '?!', '?<=', or '?<!.'`
: null : null
: null : null
const [focused, setFocused] = React.useState(false) const [focused, setFocused] = React.useState(false)

1
src/global.d.ts vendored
View file

@ -19,7 +19,6 @@ type TreeNode = {
rawLink?: string rawLink?: string
sha?: string sha?: string
accessDenied?: boolean accessDenied?: boolean
reviewed?: boolean
comments?: { comments?: {
active: number active: number
resolved: number resolved: number

View file

@ -2,11 +2,7 @@ import { errors } from 'platforms'
import { isEnterprise } from '.' import { isEnterprise } from '.'
import { is } from '../../utils/is' import { is } from '../../utils/is'
import { gitakoServiceHost } from '../../utils/networkService' import { gitakoServiceHost } from '../../utils/networkService'
import { import { continuousLoadFragmentedPages, getDOM, resolveHeaderLink } from './utils'
continuousLoadFragmentedPages,
continuousLoadFragmentedPagesFromUrl,
resolveHeaderLink,
} from './utils'
function isAPIRateLimitExceeded(content: JSONValue) { function isAPIRateLimitExceeded(content: JSONValue) {
return ( return (
@ -148,24 +144,21 @@ export async function getPullPageDocuments(
userName: string, userName: string,
repoName: string, repoName: string,
pullId: string, pullId: string,
preset?: { document?: Document,
url: string ): Promise<Document[]> {
document: Document // Response of this API contains view of few files but is not complete.
}, return continuousLoadFragmentedPages(
) { document ||
if (preset) { (await getDOM(`${window.location.origin}/${userName}/${repoName}/pull/${pullId}/files`)),
return continuousLoadFragmentedPages(preset.url, preset.document) )
}
// Response of this contains view of few files but is not complete.
return continuousLoadFragmentedPagesFromUrl(`/${userName}/${repoName}/pull/${pullId}/files`)
} }
export async function getCommitPageDocuments() { export async function getCommitPageDocuments(): Promise<Document[]> {
/* userName: string, /* userName: string,
repoName: string, repoName: string,
commitId: string, */ commitId: string, */
// arguments are not used because info are collected from DOM directly // arguments are not used because info are collected from DOM directly
return continuousLoadFragmentedPages(window.location.href, document) return continuousLoadFragmentedPages(document)
} }
export async function getBlobData( export async function getBlobData(

View file

@ -2,15 +2,13 @@ import { raiseError } from 'analytics'
import { Clippy, ClippyClassName } from 'components/Clippy' import { Clippy, ClippyClassName } from 'components/Clippy'
import React from 'react' import React from 'react'
import * as s from 'superstruct' import * as s from 'superstruct'
import { $, make$ } from 'utils/$' import { $ } from 'utils/$'
import { formatClass, parseIntFromElement } from 'utils/DOMHelper' import { formatClass, parseIntFromElement } from 'utils/DOMHelper'
import { renderReact } from 'utils/general' import { renderReact } from 'utils/general'
import { embeddedDataStruct } from './embeddedDataStructures' import { embeddedDataStruct } from './embeddedDataStructures'
const selectors = { const selectors = {
normal: { normal: {
userName: '[itemprop="author"] > a[rel="author"]',
repoName: '[itemprop="name"] > a[href]',
reactApp: `react-app[app-name="react-code-view"] [data-target="react-app.reactRoot"]`, reactApp: `react-app[app-name="react-code-view"] [data-target="react-app.reactRoot"]`,
codeTab: '#code-tab', codeTab: '#code-tab',
branchSwitcher: [ branchSwitcher: [
@ -26,40 +24,30 @@ const selectors = {
globalNavigation: { globalNavigation: {
navbar: { navbar: {
repositoryOwner: [ repositoryOwner: [
'nav[role="navigation"][aria-label="GitHub Breadcrumb"] [id^="contextregion-usercrumb"][id$="-link"]',
'.AppHeader-context-item[data-hovercard-type="user"]', '.AppHeader-context-item[data-hovercard-type="user"]',
'.AppHeader-context-item[data-hovercard-type="organization"]', '.AppHeader-context-item[data-hovercard-type="organization"]',
].join(), ].join(),
// its meant to be the element visually next to the `repositoryOwner` element // its meant to be the element visually next to the `repositoryOwner` element
repositoryName: [ repositoryName:
'nav[role="navigation"][aria-label="GitHub Breadcrumb"] [id^="contextregion-repositorycrumb"][id$="-link"]',
'nav[role="navigation"] ul[role="list"] li:nth-child(2) .AppHeader-context-item', 'nav[role="navigation"] ul[role="list"] li:nth-child(2) .AppHeader-context-item',
].join(),
}, },
treeViewBranchSelector: ['#react-repos-tree-pane-ref-selector'].join(), branchSelector: 'button[id^="branch-picker-"]',
branchSelector: [
'button[id^="branch-picker-"]',
'#ref-picker-repos-header-ref-selector-wide',
].join(),
pathContext: '[data-testid="breadcrumbs"]', pathContext: '[data-testid="breadcrumbs"]',
pathContextFileName: '[data-testid="breadcrumbs-filename"]', pathContextFileName: '[data-testid="breadcrumbs-filename"]',
pathContextScreenReaderHeading: '[data-testid="screen-reader-heading"]', pathContextScreenReaderHeading: '[data-testid="screen-reader-heading"]',
embeddedData: { embeddedData: {
app: 'script[type="application/json"][data-target="react-app.embeddedData"]', app: 'script[type="application/json"][data-target="react-app.embeddedData"]',
reactAppCodeView:
'react-app[app-name="react-code-view"] script[type="application/json"][data-target="react-app.embeddedData"]',
reposOverview: reposOverview:
'[partial-name="repos-overview"] script[type="application/json"][data-target="react-partial.embeddedData"]', '[partial-name="repos-overview"] script[type="application/json"][data-target="react-partial.embeddedData"]',
pullRequest: 'script[type="application/json"][data-target="react-app.embeddedData"]',
}, },
}, },
} }
const getDOMJSON = (selector: string, _$ = $) => const getDOMJSON = (selector: string) =>
_$(selector, e => { $(selector, e => {
try { try {
return JSON.parse(e.textContent || '') return JSON.parse(e.textContent || '')
} catch { } catch (error) {
return null return null
} }
}) })
@ -85,23 +73,13 @@ function resolveEmbeddedAppData() {
if (s.is(data, embeddedDataStruct.app)) return getMetaFromPayload(data.payload) if (s.is(data, embeddedDataStruct.app)) return getMetaFromPayload(data.payload)
} }
function resolveEmbeddedCodeViewData() {
const data = getDOMJSON(selectors.globalNavigation.embeddedData.reactAppCodeView)
if (s.is(data, embeddedDataStruct.codeViewApp)) return data.payload
}
function resolveEmbeddedReposOverviewData() { function resolveEmbeddedReposOverviewData() {
const data = getDOMJSON(selectors.globalNavigation.embeddedData.reposOverview) const data = getDOMJSON(selectors.globalNavigation.embeddedData.reposOverview)
if (s.is(data, embeddedDataStruct.reposOverview)) if (s.is(data, embeddedDataStruct.reposOverview))
return getMetaFromPayload(data.props.initialPayload) return getMetaFromPayload(data.props.initialPayload)
} }
export function resolveEmbeddedPullRequestData(doc: Document) { export function resolveEmbeddedData(): {
const data = getDOMJSON(selectors.globalNavigation.embeddedData.pullRequest, make$(doc))
if (s.is(data, embeddedDataStruct.pullRequest)) return data
}
export function resolveMetaFromEmbeddedData(): {
defaultBranch: string defaultBranch: string
metaData: MetaData metaData: MetaData
} | void { } | void {
@ -109,19 +87,19 @@ export function resolveMetaFromEmbeddedData(): {
} }
export function resolveMeta(): Partial<MetaData> { export function resolveMeta(): Partial<MetaData> {
const dataFromJSON = resolveMetaFromEmbeddedData() const dataFromJSON = resolveEmbeddedData()
if (dataFromJSON) return dataFromJSON.metaData if (dataFromJSON) return dataFromJSON.metaData
const metaData = { const metaData = {
userName: userName:
$( $(
[selectors.normal.userName, selectors.globalNavigation.navbar.repositoryOwner].join(), '[itemprop="author"] > a[rel="author"]',
e => e.textContent?.trim(), e => e.textContent?.trim(),
() => $(selectors.globalNavigation.navbar.repositoryOwner, e => e.textContent?.trim()), () => $(selectors.globalNavigation.navbar.repositoryOwner, e => e.textContent?.trim()),
) || undefined, ) || undefined,
repoName: repoName:
$( $(
[selectors.normal.repoName, selectors.globalNavigation.navbar.repositoryName].join(), '[itemprop="name"] > a[href]',
e => e.textContent?.trim(), e => e.textContent?.trim(),
() => $(selectors.globalNavigation.navbar.repositoryName, e => e.textContent?.trim()), () => $(selectors.globalNavigation.navbar.repositoryName, e => e.textContent?.trim()),
) || undefined, ) || undefined,
@ -136,12 +114,6 @@ export function resolveMeta(): Partial<MetaData> {
export function isInRepoPage() { export function isInRepoPage() {
const repoHeadSelector = '.repohead' const repoHeadSelector = '.repohead'
const authorNameSelector = '.author[itemprop="author"]' const authorNameSelector = '.author[itemprop="author"]'
const repoMetaSelector = [
'meta[name="octolytics-dimension-repository_nwo"]',
'meta[name="octolytics-dimension-repository_id"]',
].join()
if (document.querySelector(repoMetaSelector)) return true
return Boolean( return Boolean(
document.querySelector( document.querySelector(
[ [
@ -167,16 +139,7 @@ export function isInPullFilesPage() {
} }
export function getIssueTitle() { export function getIssueTitle() {
const titleContainerSelectors = [ const title = $('.gh-header-title')?.textContent
'[data-component="TitleArea"] [data-component="PH_Title"]', // PR new experience title
'.gh-header-title',
]
const title = (
$(
// exclude issue ID from title
titleContainerSelectors.map(selector => `${selector} .markdown-title`).join(),
) ?? $(titleContainerSelectors.join())
)?.textContent
return title?.trim().replace(/\n/g, '') return title?.trim().replace(/\n/g, '')
} }
@ -186,22 +149,6 @@ export function getCommitTitle() {
} }
export function getCurrentBranch(passive = false) { export function getCurrentBranch(passive = false) {
const embeddedData = resolveEmbeddedCodeViewData()
if (embeddedData) {
return embeddedData.refInfo.name
}
{
const treeViewSelectedBranchButtonSelector = [
selectors.globalNavigation.treeViewBranchSelector,
].join()
const treeViewSelectedBranchButtonElement = $(treeViewSelectedBranchButtonSelector)
const branchName = treeViewSelectedBranchButtonElement?.textContent.trim()
if (branchName) {
return branchName
}
}
const selectedBranchButtonSelector = [ const selectedBranchButtonSelector = [
'main #branch-select-menu summary', 'main #branch-select-menu summary',
'main .branch-select-menu summary', 'main .branch-select-menu summary',
@ -247,9 +194,7 @@ export function getCurrentBranch(passive = false) {
}) })
if (branchNameFromCodeTab) return branchNameFromCodeTab if (branchNameFromCodeTab) return branchNameFromCodeTab
if (!passive) { if (!passive) raiseError(new Error('cannot get current branch'))
raiseError(new Error('cannot get current branch'))
}
} }
/** /**

View file

@ -1,6 +1,6 @@
import * as s from 'superstruct' import * as s from 'superstruct'
const repo = s.type({ const repo = s.object({
id: s.number(), id: s.number(),
defaultBranch: s.string(), defaultBranch: s.string(),
name: s.string(), name: s.string(),
@ -15,13 +15,13 @@ const repo = s.type({
isOrgOwned: s.boolean(), isOrgOwned: s.boolean(),
}) })
const user = s.type({ const user = s.object({
id: s.number(), id: s.number(),
login: s.string(), login: s.string(),
userEmail: s.string(), userEmail: s.string(),
}) })
const rel = s.type({ const rel = s.object({
name: s.string(), name: s.string(),
listCacheKey: s.string(), listCacheKey: s.string(),
canEdit: s.boolean(), canEdit: s.boolean(),
@ -29,13 +29,13 @@ const rel = s.type({
currentOid: s.string(), currentOid: s.string(),
}) })
const treeItem = s.type({ const treeItem = s.object({
name: s.string(), name: s.string(),
path: s.string(), path: s.string(),
contentType: s.string(), contentType: s.string(),
}) })
const tree = s.type({ const tree = s.object({
items: s.array(treeItem), items: s.array(treeItem),
templateDirectorySuggestionUrl: s.nullable(s.never()), templateDirectorySuggestionUrl: s.nullable(s.never()),
readme: s.nullable(s.never()), readme: s.nullable(s.never()),
@ -43,7 +43,7 @@ const tree = s.type({
showBranchInfobar: s.boolean(), showBranchInfobar: s.boolean(),
}) })
const repoPayload = s.type({ const repoPayload = s.object({
allShortcutsEnabled: s.boolean(), allShortcutsEnabled: s.boolean(),
path: s.string(), path: s.string(),
repo: repo, repo: repo,
@ -59,58 +59,16 @@ const repoPayload = s.type({
overview: s.unknown(), overview: s.unknown(),
}) })
const reposOverview = s.type({ const reposOverview = s.object({
props: s.type({ props: s.object({
initialPayload: repoPayload, initialPayload: repoPayload,
appPayload: s.unknown(), appPayload: s.unknown(),
}), }),
}) })
const app = s.object({
const app = s.type({
payload: repoPayload, payload: repoPayload,
}) })
const codeViewApp = s.type({
payload: s.type({
refInfo: s.type({
name: s.string(),
refType: s.string(),
}),
}),
})
const diffSummary = s.type({
changeType: s.string(),
highestAnnotationLevel: s.nullable(s.string()),
isCodeowner: s.nullable(s.boolean()),
isManifestFile: s.boolean(),
isSymlink: s.boolean(),
isVendored: s.boolean(),
linesAdded: s.number(),
linesChanged: s.number(),
linesDeleted: s.number(),
markedAsViewed: s.boolean(),
path: s.string(),
pathDigest: s.string(),
})
export type DiffSummary = s.Infer<typeof diffSummary>
const pullRequest = s.type({
payload: s.type({
pullRequestsFilesRoute: s.optional(
s.type({
diffSummaries: s.array(diffSummary),
}),
),
pullRequestsChangesRoute: s.optional(
s.type({
diffSummaries: s.array(diffSummary),
}),
),
}),
})
export const embeddedDataStruct = { export const embeddedDataStruct = {
repo, repo,
user, user,
@ -120,6 +78,4 @@ export const embeddedDataStruct = {
repoPayload, repoPayload,
reposOverview, reposOverview,
app, app,
codeViewApp,
pullRequest,
} }

View file

@ -15,7 +15,7 @@ export async function getCommitTreeData(
.map(({ files }) => files) .map(({ files }) => files)
.flat() .flat()
const [, documents] = await API.getCommitPageDocuments(/* userName, repoName, commitSHA */) const documents = await API.getCommitPageDocuments(/* userName, repoName, commitSHA */)
const getItemURL = (path: string) => { const getItemURL = (path: string) => {
for (const doc of documents) { for (const doc of documents) {

View file

@ -1,13 +1,6 @@
import { map } from 'utils/map'
import { sanitizedLocation } from 'utils/URLHelper' import { sanitizedLocation } from 'utils/URLHelper'
import * as API from './API' import * as API from './API'
import { import { getPRDiffTotalStat, getPullRequestFilesCount, isInPullFilesPage } from './DOMHelper'
getPRDiffTotalStat,
getPullRequestFilesCount,
isInPullFilesPage,
resolveEmbeddedPullRequestData,
} from './DOMHelper'
import { DiffSummary } from './embeddedDataStructures'
import { processTree } from './index' import { processTree } from './index'
import { getCommentsMap } from './utils' import { getCommentsMap } from './utils'
@ -38,27 +31,28 @@ export async function getPullRequestTreeData(
API.getPullComments(userName, repoName, pullId, accessToken), API.getPullComments(userName, repoName, pullId, accessToken),
]) ])
const [fileChangesPagePath, docs] = await API.getPullPageDocuments( const docs = await API.getPullPageDocuments(
userName, userName,
repoName, repoName,
pullId, pullId,
isInPullFilesPage() isInPullFilesPage() ? document : undefined,
? {
url: window.location.href,
document,
}
: undefined,
) )
// query all elements at once to make getFileElementHash run faster
const diffSummaryMap = resolveDiffSummaryMap(docs) const elementsHavePath = docs.map(doc => doc.querySelectorAll(`[data-path]`))
const map = new Map<string, string>()
const fileHashMap = for (const group of elementsHavePath) {
diffSummaryMap.size > 0 for (let i = 0; i < group.length; i++) {
? new Map(map(diffSummaryMap, ([, { path, pathDigest }]) => [path, `diff-${pathDigest}`])) const element = group[i]
: resolveFileHashMap(docs) const id = element.parentElement?.id
if (id) {
const path = element.getAttribute('data-path')
if (path) map.set(path, id)
}
}
}
const url = new URL(sanitizedLocation.href) const url = new URL(sanitizedLocation.href)
url.pathname = fileChangesPagePath url.pathname = `/${userName}/${repoName}/pull/${pullId}/files`
const commentsMap = getCommentsMap(commentData) const commentsMap = getCommentsMap(commentData)
const nodes: TreeNode[] = treeData.map( const nodes: TreeNode[] = treeData.map(
({ ({
@ -71,7 +65,7 @@ export async function getPullRequestTreeData(
raw_url: rawLink, raw_url: rawLink,
blob_url: permalink, blob_url: permalink,
}) => { }) => {
url.hash = fileHashMap.get(filename) || '' url.hash = map.get(filename) || ''
return { return {
path: filename || '', path: filename || '',
type: 'blob', type: 'blob',
@ -80,7 +74,6 @@ export async function getPullRequestTreeData(
permalink, permalink,
rawLink, rawLink,
sha, sha,
reviewed: diffSummaryMap.get(filename)?.markedAsViewed,
comments: commentsMap.get(filename), comments: commentsMap.get(filename),
diff: { diff: {
status, status,
@ -100,45 +93,6 @@ const GITHUB_API_RESPONSE_LENGTH_LIMIT = 3000
const GITHUB_API_RESPONSE_MAX_SIZE_PER_PAGE = 100 const GITHUB_API_RESPONSE_MAX_SIZE_PER_PAGE = 100
const MAX_PAGE = Math.ceil(GITHUB_API_RESPONSE_LENGTH_LIMIT / GITHUB_API_RESPONSE_MAX_SIZE_PER_PAGE) const MAX_PAGE = Math.ceil(GITHUB_API_RESPONSE_LENGTH_LIMIT / GITHUB_API_RESPONSE_MAX_SIZE_PER_PAGE)
function resolveFileHashMap(docs: Document[]) {
// query all elements at once to make getFileElementHash run faster
const elementsHavePath = docs.map(doc => doc.querySelectorAll(`[data-path]`))
const fileHashMap = new Map<string, string>()
for (const group of elementsHavePath) {
for (let i = 0; i < group.length; i++) {
const element = group[i]
const id = element.parentElement?.id
if (id) {
const path = element.getAttribute('data-path')
if (path) fileHashMap.set(path, id)
}
}
}
return fileHashMap
}
function resolveDiffSummaryMap(docs: Document[]) {
return docs
.map(resolveEmbeddedPullRequestData)
.map(json => {
const payload = json?.payload
if (!payload) return null
if ('pullRequestsFilesRoute' in payload) {
return payload.pullRequestsFilesRoute
} else if ('pullRequestsChangesRoute' in payload) {
return payload.pullRequestsChangesRoute
}
})
.map(pullRequests => pullRequests?.diffSummaries)
.reduce((map, curr) => {
curr?.forEach(record => {
map.set(record.path, record)
})
return map
}, new Map<DiffSummary['path'], DiffSummary>())
}
async function safeGetPullRequestTreeData( async function safeGetPullRequestTreeData(
{ userName, repoName }: Pick<MetaData, 'userName' | 'repoName' | 'branchName'>, { userName, repoName }: Pick<MetaData, 'userName' | 'repoName' | 'branchName'>,
pullId: string, pullId: string,

View file

@ -1,30 +0,0 @@
import React from 'react'
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
export function useGitHubReviewStatus(visibleNodesGenerator: VisibleNodesGenerator | null) {
React.useEffect(() => {
if (!visibleNodesGenerator) return
const clickHandler = (event: MouseEvent) => {
const target = event.target as HTMLElement
const markReviewedButton = target.closest('[class*="MarkAsViewedButton-"]')
const filePath = markReviewedButton
?.closest('[id^="diff-"]')
?.querySelector('a[href^="#diff-"]')
?.textContent?.trim()
// remove Unicode control characters that GitHub adds for RTL support
.replace(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '')
if (!markReviewedButton) return
if (!filePath) return
visibleNodesGenerator.updateNode(filePath, node => {
node.reviewed = markReviewedButton.getAttribute('aria-label') === 'Viewed'
})
}
window.addEventListener('click', clickHandler)
return () => {
window.removeEventListener('click', clickHandler)
}
}, [visibleNodesGenerator])
}

View file

@ -1,7 +1,6 @@
import { useConfigs } from 'containers/ConfigsContext' import { useConfigs } from 'containers/ConfigsContext'
import { GITHUB_OAUTH } from 'env' import { GITHUB_OAUTH } from 'env'
import { Base64 } from 'js-base64' import { Base64 } from 'js-base64'
import { Platform } from 'platforms/platform'
import { $ } from 'utils/$' import { $ } from 'utils/$'
import { configRef } from 'utils/config/helper' import { configRef } from 'utils/config/helper'
import { resolveGitModules } from 'utils/gitSubmodule' import { resolveGitModules } from 'utils/gitSubmodule'
@ -14,7 +13,6 @@ import { getPullRequestTreeData } from './getPullRequestTreeData'
import { useEnterpriseStatBarStyleFix } from './hooks/useEnterpriseStatBarStyleFix' import { useEnterpriseStatBarStyleFix } from './hooks/useEnterpriseStatBarStyleFix'
import { useGitHubAttachCopySnippetButton } from './hooks/useGitHubAttachCopySnippetButton' import { useGitHubAttachCopySnippetButton } from './hooks/useGitHubAttachCopySnippetButton'
import { useGitHubCodeFold } from './hooks/useGitHubCodeFold' import { useGitHubCodeFold } from './hooks/useGitHubCodeFold'
import { useGitHubReviewStatus } from './hooks/useGitHubReviewStatus'
export function processTree(tree: TreeNode[]): TreeNode { export function processTree(tree: TreeNode[]): TreeNode {
// nodes are created from items and put onto tree // nodes are created from items and put onto tree
@ -139,7 +137,7 @@ export const GitHub: Platform = {
return metaData return metaData
}, },
async getDefaultBranchName({ userName, repoName }, accessToken) { async getDefaultBranchName({ userName, repoName }, accessToken) {
const dataFromJSON = DOMHelper.resolveMetaFromEmbeddedData() const dataFromJSON = DOMHelper.resolveEmbeddedData()
if (dataFromJSON?.defaultBranch) return dataFromJSON.defaultBranch if (dataFromJSON?.defaultBranch) return dataFromJSON.defaultBranch
return (await API.getRepoMeta(userName, repoName, accessToken)).default_branch return (await API.getRepoMeta(userName, repoName, accessToken)).default_branch
@ -152,8 +150,8 @@ export const GitHub: Platform = {
const branchUrl = pullId const branchUrl = pullId
? `${repoUrl}/pull/${pullId}` ? `${repoUrl}/pull/${pullId}`
: commitId && URLHelper.isPossiblyCommitSHA(commitId) : commitId && URLHelper.isPossiblyCommitSHA(commitId)
? `${repoUrl}/tree/${commitId}` ? `${repoUrl}/tree/${commitId}`
: `${repoUrl}/tree/${branchName}` : `${repoUrl}/tree/${branchName}`
return { return {
repoUrl, repoUrl,
userUrl, userUrl,
@ -172,8 +170,8 @@ export const GitHub: Platform = {
shouldExpandSideBar() { shouldExpandSideBar() {
return Boolean( return Boolean(
(DOMHelper.isInCodePage() || URLHelper.isInCommitPage() || URLHelper.isInPullPage()) && (DOMHelper.isInCodePage() || URLHelper.isInCommitPage() || URLHelper.isInPullPage()) &&
!DOMHelper.isNativeFileTreeShown() && !DOMHelper.isNativeFileTreeShown() &&
!DOMHelper.isNativePRFileTreeShown(), !DOMHelper.isNativePRFileTreeShown(),
) )
}, },
shouldExpandAll() { shouldExpandAll() {
@ -209,9 +207,6 @@ export const GitHub: Platform = {
useGitHubCodeFold(codeFolding) useGitHubCodeFold(codeFolding)
useEnterpriseStatBarStyleFix() useEnterpriseStatBarStyleFix()
}, },
usePlatformFileTreeHooks({ visibleNodesGenerator = null }) {
useGitHubReviewStatus(visibleNodesGenerator)
},
delegateFastRedirectAnchorProps() { delegateFastRedirectAnchorProps() {
if (configRef.pjaxMode !== 'native') return if (configRef.pjaxMode !== 'native') return

View file

@ -1,5 +1,3 @@
import { findMapFirst } from '../../utils/findMapFirst'
/** /**
* Resolved from response header `link` * Resolved from response header `link`
* *
@ -76,21 +74,11 @@ export function resolveHeaderLink(raw: string) {
} }
} }
async function getDOM(url: string) { export async function getDOM(url: string) {
const res = await fetch(url) return new DOMParser().parseFromString(await (await fetch(url)).text(), 'text/html')
const content = await res.text()
return [res.url, new DOMParser().parseFromString(content, 'text/html')] as const
} }
export async function continuousLoadFragmentedPages( export async function continuousLoadFragmentedPages(doc: Document) {
url: string,
doc: Document,
docs: Document[] = [],
): Promise<[string, Document[]]> {
docs.push(doc)
// const data = resolveEmbeddedPullRequestData(doc)
/** /**
* <include-fragment * <include-fragment
* src="..." * src="..."
@ -104,21 +92,22 @@ export async function continuousLoadFragmentedPages(
'.js-diff-progressive-container include-fragment[src]', // legacy support '.js-diff-progressive-container include-fragment[src]', // legacy support
] ]
const fragment = findMapFirst(fragmentSelectors, selector => doc.querySelector(selector)) const documents: Document[] = [doc]
if (fragment instanceof HTMLElement) {
const src = fragment.getAttribute('src') const selector = fragmentSelectors.find(selector => doc.querySelector(selector))
if (src) { if (selector) {
// eslint-disable-next-line no-constant-condition
while (true) {
const fragment = doc.querySelector(selector)
if (!(fragment instanceof HTMLElement)) break
const src = fragment.getAttribute('src')
if (!src) break
// Using `src` without origin below would fail in Firefox if the src is an absolute path // Using `src` without origin below would fail in Firefox if the src is an absolute path
// do NOT return here because we need to preserve the first `url` for the final return value doc = await getDOM(new URL(src, window.location.origin).href)
await continuousLoadFragmentedPagesFromUrl(src, docs) documents.push(doc)
} }
} }
return [new URL(url).pathname, docs] return documents
}
export async function continuousLoadFragmentedPagesFromUrl(url: string, docs: Document[] = []) {
const [finalUrl, dom] = await getDOM(new URL(url, window.location.origin).href)
return continuousLoadFragmentedPages(finalUrl, dom, docs)
} }
export function getCommentsMap(commentData: GitHubAPI.PullComments) { export function getCommentsMap(commentData: GitHubAPI.PullComments) {

View file

@ -1,5 +1,4 @@
import { Base64 } from 'js-base64' import { Base64 } from 'js-base64'
import { Platform } from 'platforms/platform'
import { resolveGitModules } from 'utils/gitSubmodule' import { resolveGitModules } from 'utils/gitSubmodule'
import { useProgressBar } from 'utils/hooks/useProgressBar' import { useProgressBar } from 'utils/hooks/useProgressBar'
import { sortFoldersToFront } from 'utils/treeParser' import { sortFoldersToFront } from 'utils/treeParser'

View file

@ -1,7 +1,6 @@
import { GITEE_OAUTH } from 'env' import { GITEE_OAUTH } from 'env'
import { Base64 } from 'js-base64' import { Base64 } from 'js-base64'
import { errors, platform } from 'platforms' import { errors, platform } from 'platforms'
import { Platform } from 'platforms/platform'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { resolveGitModules } from 'utils/gitSubmodule' import { resolveGitModules } from 'utils/gitSubmodule'
import { useAfterRedirect } from 'utils/hooks/useFastRedirect' import { useAfterRedirect } from 'utils/hooks/useFastRedirect'
@ -184,7 +183,7 @@ export const Gitee: Platform = {
mapErrorMessage: (error: Error) => mapErrorMessage: (error: Error) =>
({ ({
['Only signed in user is allowed to call APIs.']: errors.BAD_CREDENTIALS, ['Only signed in user is allowed to call APIs.']: errors.BAD_CREDENTIALS,
})[error.message], }[error.message]),
} }
export function useGiteeAttachCopySnippetButton(copySnippetButton: boolean) { export function useGiteeAttachCopySnippetButton(copySnippetButton: boolean) {

View file

@ -1,5 +1,3 @@
import { Platform } from './platform'
export const dummyPlatformForTypeSafety: Platform = { export const dummyPlatformForTypeSafety: Platform = {
isEnterprise() { isEnterprise() {
return false return false

View file

@ -1,6 +1,4 @@
import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator' type Platform = {
export type Platform = {
shouldActivate?(): boolean shouldActivate?(): boolean
isEnterprise(): boolean isEnterprise(): boolean
// branch name might not be available when resolving from DOM and URL // branch name might not be available when resolving from DOM and URL
@ -31,8 +29,5 @@ export type Platform = {
| void | void
loadWithFastRedirect?(url: string, element: HTMLElement): boolean | void loadWithFastRedirect?(url: string, element: HTMLElement): boolean | void
usePlatformHooks?(): void usePlatformHooks?(): void
usePlatformFileTreeHooks?(fileTree: {
visibleNodesGenerator?: VisibleNodesGenerator | null
}): void
mapErrorMessage?: (error: Error) => string | void mapErrorMessage?: (error: Error) => string | void
} }

View file

@ -1,27 +1,20 @@
export interface $ { export function $<E extends HTMLElement>(selector: string): E | null
<E extends HTMLElement>(selector: string): E | null export function $<R1>(selector: string, existCallback: (element: HTMLElement) => R1): R1 | null
<R1>(selector: string, existCallback: (element: HTMLElement) => R1): R1 | null export function $<R1, R2>(
<R1, R2>( selector: string,
selector: string, existCallback: (element: HTMLElement) => R1,
existCallback: (element: HTMLElement) => R1, otherwise: () => R2,
otherwise: () => R2, ): R1 | R2
): R1 | R2 export function $<E extends HTMLElement, R2>(
<E extends HTMLElement, R2>( selector: string,
selector: string, existCallback: undefined | null,
existCallback: undefined | null, otherwise: () => R2,
otherwise: () => R2, ): E | R2
): E | R2 // eslint-disable-next-line @typescript-eslint/no-explicit-any
} export function $(selector: string, existCallback?: any, otherwise?: any) {
const element = document.querySelector(selector)
export const make$ = if (element) {
(root?: Document | HTMLElement): $ => return existCallback ? existCallback(element) : element
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(selector: string, existCallback?: any, otherwise?: any) => {
const element = (root ?? document).querySelector(selector)
if (element) {
return existCallback ? existCallback(element) : element
}
return otherwise ? otherwise() : null
} }
return otherwise ? otherwise() : null
export const $: $ = make$() }

View file

@ -2,11 +2,11 @@ import { EventHub } from '../EventHub'
import { findNode } from '../general' import { findNode } from '../general'
import { Options } from './index' import { Options } from './index'
function inPlaceMergeNodes(target: TreeNode, source: TreeNode) { function mergeNodes(target: TreeNode, source: TreeNode) {
for (const node of source.contents || []) { for (const node of source.contents || []) {
const dup = target.contents?.find($node => $node.path === node.path) const dup = target.contents?.find($node => $node.path === node.path)
if (dup) { if (dup) {
inPlaceMergeNodes(dup, node) mergeNodes(dup, node)
} else { } else {
if (!target.contents) target.contents = [] if (!target.contents) target.contents = []
target.contents.push(node) target.contents.push(node)
@ -31,14 +31,6 @@ export class BaseLayer {
this.defer = defer this.defer = defer
} }
updateNode = async (path: string, updateNode: (node: TreeNode) => void) => {
const node = await findNode(this.baseRoot, path)
if (node) {
updateNode(node)
this.baseHub.emit('emit', this.baseRoot)
}
}
loadTreeData = async (path: string) => { loadTreeData = async (path: string) => {
const node = await findNode(this.baseRoot, path) const node = await findNode(this.baseRoot, path)
if (node && node.type !== 'tree') return node if (node && node.type !== 'tree') return node
@ -47,7 +39,7 @@ export class BaseLayer {
this.loading.add(path) this.loading.add(path)
this.baseHub.emit('loadingChange', this.loading) this.baseHub.emit('loadingChange', this.loading)
inPlaceMergeNodes(this.baseRoot, await this.getTreeData(path)) mergeNodes(this.baseRoot, await this.getTreeData(path))
this.loading.delete(path) this.loading.delete(path)
this.baseHub.emit('loadingChange', this.loading) this.baseHub.emit('loadingChange', this.loading)
this.baseHub.emit('emit', this.baseRoot) this.baseHub.emit('emit', this.baseRoot)

View file

@ -1,7 +0,0 @@
export const findMapFirst = <T, R>(array: T[], map: (item: T) => R): R | null => {
for (const item of array) {
const result = map(item)
if (result) return result
}
return null
}

View file

@ -1,11 +0,0 @@
export const map = <K, R>(
iterable: Iterable<K>,
callback: (item: K, index: number, iterable: Iterable<K>) => R,
): R[] => {
const result: R[] = []
let index = 0
for (const item of iterable) {
result.push(callback(item, index++, iterable))
}
return result
}

View file

@ -72,9 +72,6 @@ function createConfig({ envTarget }: { envTarget: Target }) {
manifest.manifest_version = 3 manifest.manifest_version = 3
// Firefox does not support service worker // Firefox does not support service worker
Reflect.set(manifest.background, 'scripts', [manifest.background.service_worker]) Reflect.set(manifest.background, 'scripts', [manifest.background.service_worker])
Reflect.set(manifest, 'browser_specific_settings', {
gecko: { id: '{983bd86b-9d6f-4394-92b8-63d844c4ce4c}' },
})
Reflect.deleteProperty(manifest.background, 'service_worker') Reflect.deleteProperty(manifest.background, 'service_worker')
break break
} }
@ -145,23 +142,18 @@ function createConfig({ envTarget }: { envTarget: Target }) {
rules: [ rules: [
{ {
test: /\.tsx?$/, test: /\.tsx?$/,
loader: 'swc-loader', loader: 'babel-loader',
include: [srcPath], include: [srcPath],
exclude: /node_modules/, exclude: /node_modules/,
sideEffects: false, sideEffects: false,
}, },
{ {
test: /\.[cm]?js$/, test: /\.[cm]?js$/,
loader: 'swc-loader', loader: 'babel-loader',
// Transpile as least files under node_modules // Transpile as least files under node_modules
include: /node_modules\/(webext-.*|superstruct)\/.*\.[cm]?js$/, include: /node_modules\/(webext-.*|superstruct)\/.*\.[cm]?js$/,
options: { options: {
jsc: { cacheDirectory: true,
parser: {
syntax: 'ecmascript',
},
target: 'es2022',
},
}, },
}, },
{ {

4444
yarn.lock

File diff suppressed because it is too large Load diff