Compare commits

...

25 commits

Author SHA1 Message Date
EnixCoda
954a132624 3.15.4
Some checks failed
Tests / build (push) Has been cancelled
Tests / e2e-test (push) Has been cancelled
Tests / unit-test (push) Has been cancelled
2026-01-18 20:39:21 +08:00
EnixCoda
b4ace1f54b fix: resolve branch name 2026-01-18 12:32:19 +08:00
EnixCoda
6e193db9c5 fix: resolve repo meta from DOM 2026-01-18 11:53:21 +08:00
EnixCoda
750f8e4462 build: babel -> swc 2026-01-18 11:53:21 +08:00
EnixCoda
d149a4903d build: upgrade eslint 2026-01-18 11:52:18 +08:00
EnixCoda
5352526d74 build: upgrade prettier 2026-01-18 11:52:18 +08:00
EnixCoda
7560895fff test: puppeteer -> playwright 2026-01-17 22:39:40 +08:00
EnixCoda
5a687cb9f5 fix: resolve pull request meta (title without id) 2026-01-17 21:22:22 +08:00
EnixCoda
fd3476c1fa 3.15.3 2026-01-16 22:16:05 +08:00
EnixCoda
68915bfac4 fix: handle paginated pull request files page 2026-01-16 22:13:48 +08:00
EnixCoda
08bb716ef4 build: upgrade typescript 2026-01-16 22:05:53 +08:00
EnixCoda
6ac043a13a fix: handle redirect to GitHub pull request file 2026-01-16 22:05:45 +08:00
EnixCoda
420e1e288e 3.15.2 2026-01-15 22:45:12 +08:00
EnixCoda
6331892329 fix: handle file changes path properly 2026-01-15 22:38:19 +08:00
Lushichen
3cf4deaa9f fix(GitHub): 优化仓库页面识别逻辑
- 增加通过 meta 标签判断是否为仓库页面的逻辑
- 使用 octolytics-dimension-repository_nwo 和 repository_id 作为判断条件
- 保持原有的 DOM 查询方式作为辅助判断
- 提升仓库页面识别的准确性和鲁棒性
2026-01-15 22:37:40 +08:00
EnixCoda
550b3f0d41 3.15.1 2026-01-14 21:33:52 +08:00
EnixCoda
ecaf9087a3 fix: support new pull request experience new data structure 2026-01-14 21:33:38 +08:00
EnixCoda
f67065bcf4 build: FireFox ID 2025-12-10 22:40:03 +08:00
EnixCoda
4bcce483e9 refactor: move pull request embedded data type 2025-12-08 21:06:29 +08:00
EnixCoda
a9979620f4 refactor: make$ 2025-12-08 21:05:26 +08:00
EnixCoda
ba06289ed3 3.15.0 2025-12-07 20:49:19 +08:00
EnixCoda
30668b16aa feat: show file review status 2025-12-07 20:48:51 +08:00
EnixCoda
225a944685 3.14.0 2025-12-03 22:07:52 +08:00
EnixCoda
697b6e2ad4 feat: support GitHub new pull request files page 2025-12-03 22:07:52 +08:00
Gitoffthelawn
186e1365d0 i18n for AMO link 2025-08-18 20:59:19 +08:00
77 changed files with 3051 additions and 3435 deletions

View file

@ -1,18 +0,0 @@
{
"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,8 +22,6 @@ 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,17 +47,32 @@ 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:
args: yarn test name: playwright-report
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
@ -86,8 +101,6 @@ 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,3 +7,5 @@ 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 Normal file
View file

@ -0,0 +1,20 @@
{
"$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/en-US/firefox/addon/gitako-github-file-tree/"><img width="64" alt="Firefox" src="./assets/Firefox.svg" /></a> <a href="https://addons.mozilla.org/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.

View file

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

View file

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

View file

@ -1,49 +0,0 @@
/**
* 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

@ -1,15 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,15 +0,0 @@
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)
})
})

View file

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

View file

@ -1,185 +0,0 @@
// 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

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

View file

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

View file

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

View file

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

View file

@ -1,137 +0,0 @@
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)
}

View file

@ -1,26 +0,0 @@
// `.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'],
}

30
e2e/baseline.spec.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* 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()
})
})

17
e2e/empty-project.spec.ts Normal file
View file

@ -0,0 +1,17 @@
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

@ -0,0 +1,27 @@
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 })
})
})

29
e2e/fixtures.ts Normal file
View file

@ -0,0 +1,29 @@
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

@ -0,0 +1,14 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,32 @@
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,
})
}
})
})

41
e2e/pjax.general.spec.ts Normal file
View file

@ -0,0 +1,41 @@
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')
})
})

30
e2e/pjax.internal.spec.ts Normal file
View file

@ -0,0 +1,30 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,19 @@
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,
})
})
})

15
e2e/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"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"]
}

98
e2e/utils.ts Normal file
View file

@ -0,0 +1,98 @@
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)
}

98
eslint.config.mjs Normal file
View file

@ -0,0 +1,98 @@
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',
},
},
)

View file

@ -1,21 +0,0 @@
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/'], testPathIgnorePatterns: ['/node_modules/', '.d.ts$', '<rootDir>/vscode-icons/', '<rootDir>/e2e/'],
// 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,12 +162,27 @@ 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: null, transform: {
'^.+\\.(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: [ transformIgnorePatterns: ['/node_modules/(?!(webext-.*|superstruct)/)'],
// "/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.13.1", "version": "3.15.4",
"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,17 +11,18 @@
"node": ">=18" "node": ">=18"
}, },
"scripts": { "scripts": {
"dev": "VERSION=dev-v$(node scripts/get-version.js) NODE_OPTIONS=--openssl-legacy-provider webpack-dashboard -- webpack --watch", "dev": "VERSION=dev-v$(node scripts/get-version.js) 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_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack", "build": "VERSION=v$(node scripts/get-version.js) 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": "NODE_ENV=test jest --config __tests__/jest.puppeteer.config.js", "test": "playwright test",
"test:unit": "NODE_ENV=test jest --config jest.config.js" "test:ui": "playwright test --ui",
"test:unit": "NODE_ENV=test jest --config jest.config.cjs"
}, },
"dependencies": { "dependencies": {
"@primer/css": "^20.4.3", "@primer/css": "^20.4.3",
@ -44,15 +45,12 @@
"webextension-polyfill": "^0.11.0" "webextension-polyfill": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.17.6", "@playwright/test": "^1.40.0",
"@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",
@ -60,46 +58,44 @@
"@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": "^5.33.1", "@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^5.33.1", "@typescript-eslint/parser": "^8.28.0",
"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": "^12.0.2", "copy-webpack-plugin": "^13.0.1",
"css-loader": "^2.1.0", "css-loader": "^7.1.2",
"dotenv": "^6.2.0", "dotenv": "^6.2.0",
"dotenv-webpack": "^8.1.0", "dotenv-webpack": "^8.1.0",
"eslint": "^8.15.0", "eslint": "^9.39.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^10.1.0",
"eslint-plugin-react": "^7.29.4", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.5.0", "eslint-plugin-react-hooks": "^5.2.0",
"file-loader": "^3.0.1", "file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^9.0.2", "fork-ts-checker-webpack-plugin": "^9.1.0",
"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.9.0", "mini-css-extract-plugin": "^2.10.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prettier": "^2.8.3", "prettier": "^3.8.0",
"puppeteer": "^22.12.1",
"raw-loader": "^4.0.0", "raw-loader": "^4.0.0",
"sass": "^1.26.2", "sass": "^1.26.2",
"sass-loader": "^8.0.2", "sass-loader": "^16.0.6",
"swc-loader": "^0.2.7",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.5.3", "typescript": "^5.9.3",
"url-loader": "^1.1.2", "typescript-eslint": "^8.53.0",
"url-loader": "^4.1.1",
"web-ext": "^7.11.0", "web-ext": "^7.11.0",
"webpack": "^5.91.0", "webpack": "^5.104.1",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4", "webpack-cli": "^6.0.1",
"webpack-dashboard": "^3.3.8" "webpack-dashboard": "^3.3.8"
}, },
"prettier": { "prettier": {

45
playwright.config.mts Normal file
View file

@ -0,0 +1,45 @@
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 puppeteer = require('puppeteer') const { chromium } = require('@playwright/test')
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,8 +8,9 @@ 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 puppeteer.launch({ headless })) browser = browser || (await chromium.launch({ headless }))
return await browser.newPage() const context = await browser.newContext()
return await context.newPage()
} }
async function generateCSV() { async function generateCSV() {

View file

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

View file

@ -1,20 +0,0 @@
{
"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

@ -0,0 +1,21 @@
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,27 +1,8 @@
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'
const iconMap = { export function DiffStatGraph({ diff }: { diff: Required<TreeNode>['diff'] }) {
added: DiffAddedIcon, const { changes, additions, deletions } = diff
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[] = []
@ -32,10 +13,5 @@ export function DiffStatGraph({
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 ( return <span className={'diff-stat-graph'}>{children}</span>
<span className={'diff-stat-graph'}>
<Icon className={status} IconComponent={iconMap[status]} />
{children}
</span>
)
} }

View file

@ -1,22 +1,9 @@
import React from 'react' import React from 'react'
import { Icon } from '../Icon'
const iconMap = { export function DiffStatText({ diff }: { diff: Required<TreeNode>['diff'] }) {
added: 'diffAdded', const { additions, deletions } = diff
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

@ -0,0 +1,37 @@
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,4 +1,6 @@
import { import {
CheckCircleFillIcon,
CheckCircleIcon,
CheckIcon, CheckIcon,
CommentIcon, CommentIcon,
CrossReferenceIcon, CrossReferenceIcon,
@ -15,6 +17,7 @@ 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'
@ -34,16 +37,27 @@ 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 }: TreeNode) { function renderFileStatus({ diff, reviewed }: TreeNode) {
return ( return (
diff && ( <>
<span {diff && (
className={'node-item-diff'} <span
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`} className={'node-item-diff'}
> title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
{showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />} >
</span> <DiffIcon diff={diff} />
) {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,7 +41,8 @@ export function useHandleNodeClick(
focusNode(node) focusNode(node)
if (node.url) { if (node.url) {
const isHashLink = node.url.includes('#') const isHashLink =
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,6 +6,7 @@ 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,
@ -25,6 +26,7 @@ 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,
@ -59,6 +61,8 @@ 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(() => {
@ -196,6 +200,8 @@ 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,6 +19,7 @@ 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,7 +2,11 @@ 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 { continuousLoadFragmentedPages, getDOM, resolveHeaderLink } from './utils' import {
continuousLoadFragmentedPages,
continuousLoadFragmentedPagesFromUrl,
resolveHeaderLink,
} from './utils'
function isAPIRateLimitExceeded(content: JSONValue) { function isAPIRateLimitExceeded(content: JSONValue) {
return ( return (
@ -144,21 +148,24 @@ export async function getPullPageDocuments(
userName: string, userName: string,
repoName: string, repoName: string,
pullId: string, pullId: string,
document?: Document, preset?: {
): Promise<Document[]> { url: string
// Response of this API contains view of few files but is not complete. document: Document
return continuousLoadFragmentedPages( },
document || ) {
(await getDOM(`${window.location.origin}/${userName}/${repoName}/pull/${pullId}/files`)), if (preset) {
) 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(): Promise<Document[]> { export async function getCommitPageDocuments() {
/* 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(document) return continuousLoadFragmentedPages(window.location.href, document)
} }
export async function getBlobData( export async function getBlobData(

View file

@ -2,13 +2,15 @@ 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 { $ } from 'utils/$' import { $, make$ } 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: [
@ -24,30 +26,40 @@ 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(),
}, },
branchSelector: 'button[id^="branch-picker-"]', treeViewBranchSelector: ['#react-repos-tree-pane-ref-selector'].join(),
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 (error) { } catch {
return null return null
} }
}) })
@ -73,13 +85,23 @@ 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 resolveEmbeddedData(): { export function resolveEmbeddedPullRequestData(doc: Document) {
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 {
@ -87,19 +109,19 @@ export function resolveEmbeddedData(): {
} }
export function resolveMeta(): Partial<MetaData> { export function resolveMeta(): Partial<MetaData> {
const dataFromJSON = resolveEmbeddedData() const dataFromJSON = resolveMetaFromEmbeddedData()
if (dataFromJSON) return dataFromJSON.metaData if (dataFromJSON) return dataFromJSON.metaData
const metaData = { const metaData = {
userName: userName:
$( $(
'[itemprop="author"] > a[rel="author"]', [selectors.normal.userName, selectors.globalNavigation.navbar.repositoryOwner].join(),
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:
$( $(
'[itemprop="name"] > a[href]', [selectors.normal.repoName, selectors.globalNavigation.navbar.repositoryName].join(),
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,
@ -114,6 +136,12 @@ 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(
[ [
@ -139,7 +167,16 @@ export function isInPullFilesPage() {
} }
export function getIssueTitle() { export function getIssueTitle() {
const title = $('.gh-header-title')?.textContent const titleContainerSelectors = [
'[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, '')
} }
@ -149,6 +186,22 @@ 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',
@ -194,7 +247,9 @@ export function getCurrentBranch(passive = false) {
}) })
if (branchNameFromCodeTab) return branchNameFromCodeTab if (branchNameFromCodeTab) return branchNameFromCodeTab
if (!passive) raiseError(new Error('cannot get current branch')) if (!passive) {
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.object({ const repo = s.type({
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.object({
isOrgOwned: s.boolean(), isOrgOwned: s.boolean(),
}) })
const user = s.object({ const user = s.type({
id: s.number(), id: s.number(),
login: s.string(), login: s.string(),
userEmail: s.string(), userEmail: s.string(),
}) })
const rel = s.object({ const rel = s.type({
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.object({
currentOid: s.string(), currentOid: s.string(),
}) })
const treeItem = s.object({ const treeItem = s.type({
name: s.string(), name: s.string(),
path: s.string(), path: s.string(),
contentType: s.string(), contentType: s.string(),
}) })
const tree = s.object({ const tree = s.type({
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.object({
showBranchInfobar: s.boolean(), showBranchInfobar: s.boolean(),
}) })
const repoPayload = s.object({ const repoPayload = s.type({
allShortcutsEnabled: s.boolean(), allShortcutsEnabled: s.boolean(),
path: s.string(), path: s.string(),
repo: repo, repo: repo,
@ -59,16 +59,58 @@ const repoPayload = s.object({
overview: s.unknown(), overview: s.unknown(),
}) })
const reposOverview = s.object({ const reposOverview = s.type({
props: s.object({ props: s.type({
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,
@ -78,4 +120,6 @@ 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,6 +1,13 @@
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 { getPRDiffTotalStat, getPullRequestFilesCount, isInPullFilesPage } from './DOMHelper' import {
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'
@ -31,28 +38,27 @@ export async function getPullRequestTreeData(
API.getPullComments(userName, repoName, pullId, accessToken), API.getPullComments(userName, repoName, pullId, accessToken),
]) ])
const docs = await API.getPullPageDocuments( const [fileChangesPagePath, docs] = await API.getPullPageDocuments(
userName, userName,
repoName, repoName,
pullId, pullId,
isInPullFilesPage() ? document : undefined, isInPullFilesPage()
? {
url: window.location.href,
document,
}
: undefined,
) )
// query all elements at once to make getFileElementHash run faster
const elementsHavePath = docs.map(doc => doc.querySelectorAll(`[data-path]`)) const diffSummaryMap = resolveDiffSummaryMap(docs)
const map = new Map<string, string>()
for (const group of elementsHavePath) { const fileHashMap =
for (let i = 0; i < group.length; i++) { diffSummaryMap.size > 0
const element = group[i] ? new Map(map(diffSummaryMap, ([, { path, pathDigest }]) => [path, `diff-${pathDigest}`]))
const id = element.parentElement?.id : resolveFileHashMap(docs)
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 = `/${userName}/${repoName}/pull/${pullId}/files` url.pathname = fileChangesPagePath
const commentsMap = getCommentsMap(commentData) const commentsMap = getCommentsMap(commentData)
const nodes: TreeNode[] = treeData.map( const nodes: TreeNode[] = treeData.map(
({ ({
@ -65,7 +71,7 @@ export async function getPullRequestTreeData(
raw_url: rawLink, raw_url: rawLink,
blob_url: permalink, blob_url: permalink,
}) => { }) => {
url.hash = map.get(filename) || '' url.hash = fileHashMap.get(filename) || ''
return { return {
path: filename || '', path: filename || '',
type: 'blob', type: 'blob',
@ -74,6 +80,7 @@ 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,
@ -93,6 +100,45 @@ 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

@ -0,0 +1,30 @@
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,6 +1,7 @@
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'
@ -13,6 +14,7 @@ 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
@ -137,7 +139,7 @@ export const GitHub: Platform = {
return metaData return metaData
}, },
async getDefaultBranchName({ userName, repoName }, accessToken) { async getDefaultBranchName({ userName, repoName }, accessToken) {
const dataFromJSON = DOMHelper.resolveEmbeddedData() const dataFromJSON = DOMHelper.resolveMetaFromEmbeddedData()
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
@ -150,8 +152,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,
@ -170,8 +172,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() {
@ -207,6 +209,9 @@ 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,3 +1,5 @@
import { findMapFirst } from '../../utils/findMapFirst'
/** /**
* Resolved from response header `link` * Resolved from response header `link`
* *
@ -74,11 +76,21 @@ export function resolveHeaderLink(raw: string) {
} }
} }
export async function getDOM(url: string) { async function getDOM(url: string) {
return new DOMParser().parseFromString(await (await fetch(url)).text(), 'text/html') const res = await fetch(url)
const content = await res.text()
return [res.url, new DOMParser().parseFromString(content, 'text/html')] as const
} }
export async function continuousLoadFragmentedPages(doc: Document) { export async function continuousLoadFragmentedPages(
url: string,
doc: Document,
docs: Document[] = [],
): Promise<[string, Document[]]> {
docs.push(doc)
// const data = resolveEmbeddedPullRequestData(doc)
/** /**
* <include-fragment * <include-fragment
* src="..." * src="..."
@ -92,22 +104,21 @@ export async function continuousLoadFragmentedPages(doc: Document) {
'.js-diff-progressive-container include-fragment[src]', // legacy support '.js-diff-progressive-container include-fragment[src]', // legacy support
] ]
const documents: Document[] = [doc] const fragment = findMapFirst(fragmentSelectors, selector => doc.querySelector(selector))
if (fragment instanceof HTMLElement) {
const selector = fragmentSelectors.find(selector => doc.querySelector(selector)) const src = fragment.getAttribute('src')
if (selector) { if (src) {
// 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
doc = await getDOM(new URL(src, window.location.origin).href) // do NOT return here because we need to preserve the first `url` for the final return value
documents.push(doc) await continuousLoadFragmentedPagesFromUrl(src, docs)
} }
} }
return documents return [new URL(url).pathname, docs]
}
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,4 +1,5 @@
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,6 +1,7 @@
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'
@ -183,7 +184,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,3 +1,5 @@
import { Platform } from './platform'
export const dummyPlatformForTypeSafety: Platform = { export const dummyPlatformForTypeSafety: Platform = {
isEnterprise() { isEnterprise() {
return false return false

View file

@ -1,4 +1,6 @@
type Platform = { import { VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
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
@ -29,5 +31,8 @@ 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,20 +1,27 @@
export function $<E extends HTMLElement>(selector: string): E | null export interface $ {
export function $<R1>(selector: string, existCallback: (element: HTMLElement) => R1): R1 | null <E extends HTMLElement>(selector: string): E | null
export function $<R1, R2>( <R1>(selector: string, existCallback: (element: HTMLElement) => R1): R1 | null
selector: string, <R1, R2>(
existCallback: (element: HTMLElement) => R1, selector: string,
otherwise: () => R2, existCallback: (element: HTMLElement) => R1,
): R1 | R2 otherwise: () => R2,
export function $<E extends HTMLElement, R2>( ): R1 | R2
selector: string, <E extends HTMLElement, R2>(
existCallback: undefined | null, selector: string,
otherwise: () => R2, existCallback: undefined | null,
): E | R2 otherwise: () => R2,
// eslint-disable-next-line @typescript-eslint/no-explicit-any ): E | R2
export function $(selector: string, existCallback?: any, otherwise?: any) {
const element = document.querySelector(selector)
if (element) {
return existCallback ? existCallback(element) : element
}
return otherwise ? otherwise() : null
} }
export const make$ =
(root?: Document | HTMLElement): $ =>
// 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
}
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 mergeNodes(target: TreeNode, source: TreeNode) { function inPlaceMergeNodes(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) {
mergeNodes(dup, node) inPlaceMergeNodes(dup, node)
} else { } else {
if (!target.contents) target.contents = [] if (!target.contents) target.contents = []
target.contents.push(node) target.contents.push(node)
@ -31,6 +31,14 @@ 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
@ -39,7 +47,7 @@ export class BaseLayer {
this.loading.add(path) this.loading.add(path)
this.baseHub.emit('loadingChange', this.loading) this.baseHub.emit('loadingChange', this.loading)
mergeNodes(this.baseRoot, await this.getTreeData(path)) inPlaceMergeNodes(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

@ -0,0 +1,7 @@
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
}

11
src/utils/map.ts Normal file
View file

@ -0,0 +1,11 @@
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,6 +72,9 @@ 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
} }
@ -142,18 +145,23 @@ function createConfig({ envTarget }: { envTarget: Target }) {
rules: [ rules: [
{ {
test: /\.tsx?$/, test: /\.tsx?$/,
loader: 'babel-loader', loader: 'swc-loader',
include: [srcPath], include: [srcPath],
exclude: /node_modules/, exclude: /node_modules/,
sideEffects: false, sideEffects: false,
}, },
{ {
test: /\.[cm]?js$/, test: /\.[cm]?js$/,
loader: 'babel-loader', loader: 'swc-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: {
cacheDirectory: true, jsc: {
parser: {
syntax: 'ecmascript',
},
target: 'es2022',
},
}, },
}, },
{ {

4446
yarn.lock

File diff suppressed because it is too large Load diff