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-
- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: |
yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline

View file

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

2
.gitignore vendored
View file

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

View file

@ -1,4 +1,4 @@
*-profile/
dist/
vscode-icons/
/vscode-icons
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://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.

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)'],
// 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
// testRegex: [],
@ -162,12 +162,27 @@ module.exports = {
// timers: "real",
// 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
// transformIgnorePatterns: [
// "/node_modules/"
// ],
transformIgnorePatterns: ['/node_modules/(?!(webext-.*|superstruct)/)'],
// 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,

View file

@ -1,6 +1,6 @@
{
"name": "gitako",
"version": "3.13.1",
"version": "3.15.4",
"description": "File tree for GitHub, and more than that.",
"repository": "https://github.com/EnixCoda/Gitako",
"author": "EnixCoda",
@ -11,17 +11,18 @@
"node": ">=18"
},
"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",
"debug-firefox": "web-ext run --source-dir=dist-firefox --keep-profile-changes --start-url https://github.com/EnixCoda/Gitako",
"prepare": "husky install",
"postinstall": "patch-package",
"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:analyze": "ANALYZE= yarn run build",
"test": "NODE_ENV=test jest --config __tests__/jest.puppeteer.config.js",
"test:unit": "NODE_ENV=test jest --config jest.config.js"
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:unit": "NODE_ENV=test jest --config jest.config.cjs"
},
"dependencies": {
"@primer/css": "^20.4.3",
@ -44,15 +45,12 @@
"webextension-polyfill": "^0.11.0"
},
"devDependencies": {
"@babel/cli": "^7.17.6",
"@babel/core": "^7.17.9",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
"@playwright/test": "^1.40.0",
"@sentry/cli": "^1.64.2",
"@swc/core": "^1.15.8",
"@swc/jest": "^0.2.39",
"@testing-library/react": "^13.3.0",
"@types/dotenv": "^6",
"@types/firefox-webext-browser": "^120.0.3",
"@types/history": "^5.0.0",
"@types/ini": "^1.3.31",
@ -60,46 +58,44 @@
"@types/js-base64": "^3.3.1",
"@types/node": "^18",
"@types/nprogress": "^0.0.29",
"@types/puppeteer": "^5.4.3",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.3",
"@types/react-window": "^1.8.5",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",
"babel-loader": "^8.2.5",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^2.1.0",
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2",
"dotenv": "^6.2.0",
"dotenv-webpack": "^8.1.0",
"eslint": "^8.15.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"file-loader": "^3.0.1",
"fork-ts-checker-webpack-plugin": "^9.0.2",
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"globals": "^16.0.0",
"husky": "^8.0.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-puppeteer": "^10.0.1",
"json-loader": "^0.5.7",
"lint-staged": "^13.0.3",
"mini-css-extract-plugin": "^2.9.0",
"mini-css-extract-plugin": "^2.10.0",
"patch-package": "^8.0.0",
"prettier": "^2.8.3",
"puppeteer": "^22.12.1",
"prettier": "^3.8.0",
"raw-loader": "^4.0.0",
"sass": "^1.26.2",
"sass-loader": "^8.0.2",
"sass-loader": "^16.0.6",
"swc-loader": "^0.2.7",
"ts-node": "^10.9.2",
"typescript": "^5.5.3",
"url-loader": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.0",
"url-loader": "^4.1.1",
"web-ext": "^7.11.0",
"webpack": "^5.91.0",
"webpack": "^5.104.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4",
"webpack-cli": "^6.0.1",
"webpack-dashboard": "^3.3.8"
},
"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 { promises: fs, existsSync } = require('fs')
const puppeteer = require('puppeteer')
const { chromium } = require('@playwright/test')
const generateFileIconIndex = require('./generate-file-icon-index')
const generateFolderIconIndex = require('./generate-folder-icon-index')
const { emitDirPath, checkEmitDir } = require('./check-emit-dir')
@ -8,8 +8,9 @@ const { emitDirPath, checkEmitDir } = require('./check-emit-dir')
let browser
async function getPage() {
const headless = process.env.HEADLESS !== 'false'
browser = browser || (await puppeteer.launch({ headless }))
return await browser.newPage()
browser = browser || (await chromium.launch({ headless }))
const context = await browser.newContext()
return await context.newPage()
}
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 { resolveDiffGraphMeta } from 'utils/general'
import { Icon } from '../Icon'
const iconMap = {
added: DiffAddedIcon,
ignored: DiffIgnoredIcon,
modified: DiffModifiedIcon,
removed: DiffRemovedIcon,
renamed: DiffRenamedIcon,
}
export function DiffStatGraph({
diff: { status, changes, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
export function DiffStatGraph({ diff }: { diff: Required<TreeNode>['diff'] }) {
const { changes, additions, deletions } = diff
const { g, r, w } = resolveDiffGraphMeta(additions, deletions, changes)
const children: React.ReactNode[] = []
@ -32,10 +13,5 @@ export function DiffStatGraph({
for (let i = 0; i < w; i++)
children.push(<span key={`w-${i}`} className="diff-stat-graph-no-change" />)
return (
<span className={'diff-stat-graph'}>
<Icon className={status} IconComponent={iconMap[status]} />
{children}
</span>
)
return <span className={'diff-stat-graph'}>{children}</span>
}

View file

@ -1,22 +1,9 @@
import React from 'react'
import { Icon } from '../Icon'
const iconMap = {
added: 'diffAdded',
ignored: 'diffIgnored',
modified: 'diffModified',
removed: 'diffRemoved',
renamed: 'diffRenamed',
}
export function DiffStatText({
diff: { status, additions, deletions },
}: {
diff: Required<TreeNode>['diff']
}) {
export function DiffStatText({ diff }: { diff: Required<TreeNode>['diff'] }) {
const { additions, deletions } = diff
return (
<span className={'diff-stat-text'}>
<Icon className={status} type={iconMap[status]} />
{additions > 0 && <span className={'additions'}>{additions}</span>}
{additions > 0 && deletions > 0 && '/'}
{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 {
CheckCircleFillIcon,
CheckCircleIcon,
CheckIcon,
CommentIcon,
CrossReferenceIcon,
@ -15,6 +17,7 @@ import { cancelEvent, onEnterKeyDown } from 'utils/DOMHelper'
import { is } from 'utils/is'
import { Icon } from '../../Icon'
import { SearchMode } from '../../searchModes'
import { DiffIcon } from '../DiffIcon'
import { DiffStatText } from '../DiffStatText'
import { DiffStatGraph } from './../DiffStatGraph'
import { VisibleNodesGeneratorMethods } from './useVisibleNodesGeneratorMethods'
@ -34,16 +37,27 @@ export function useNodeRenderers(allRenderers: (NodeRenderer | null | undefined)
export function useRenderFileStatus() {
const { showDiffInText } = useConfigs().value
return useCallback(
function renderFileStatus({ diff }: TreeNode) {
function renderFileStatus({ diff, reviewed }: TreeNode) {
return (
diff && (
<span
className={'node-item-diff'}
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
>
{showDiffInText ? <DiffStatText diff={diff} /> : <DiffStatGraph diff={diff} />}
</span>
)
<>
{diff && (
<span
className={'node-item-diff'}
title={`${diff.status}, ${diff.changes} changes: +${diff.additions} & -${diff.deletions}`}
>
<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],

View file

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

View file

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

View file

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

1
src/global.d.ts vendored
View file

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

View file

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

View file

@ -2,13 +2,15 @@ import { raiseError } from 'analytics'
import { Clippy, ClippyClassName } from 'components/Clippy'
import React from 'react'
import * as s from 'superstruct'
import { $ } from 'utils/$'
import { $, make$ } from 'utils/$'
import { formatClass, parseIntFromElement } from 'utils/DOMHelper'
import { renderReact } from 'utils/general'
import { embeddedDataStruct } from './embeddedDataStructures'
const selectors = {
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"]`,
codeTab: '#code-tab',
branchSwitcher: [
@ -24,30 +26,40 @@ const selectors = {
globalNavigation: {
navbar: {
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="organization"]',
].join(),
// 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',
].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"]',
pathContextFileName: '[data-testid="breadcrumbs-filename"]',
pathContextScreenReaderHeading: '[data-testid="screen-reader-heading"]',
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:
'[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) =>
$(selector, e => {
const getDOMJSON = (selector: string, _$ = $) =>
_$(selector, e => {
try {
return JSON.parse(e.textContent || '')
} catch (error) {
} catch {
return null
}
})
@ -73,13 +85,23 @@ function resolveEmbeddedAppData() {
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() {
const data = getDOMJSON(selectors.globalNavigation.embeddedData.reposOverview)
if (s.is(data, embeddedDataStruct.reposOverview))
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
metaData: MetaData
} | void {
@ -87,19 +109,19 @@ export function resolveEmbeddedData(): {
}
export function resolveMeta(): Partial<MetaData> {
const dataFromJSON = resolveEmbeddedData()
const dataFromJSON = resolveMetaFromEmbeddedData()
if (dataFromJSON) return dataFromJSON.metaData
const metaData = {
userName:
$(
'[itemprop="author"] > a[rel="author"]',
[selectors.normal.userName, selectors.globalNavigation.navbar.repositoryOwner].join(),
e => e.textContent?.trim(),
() => $(selectors.globalNavigation.navbar.repositoryOwner, e => e.textContent?.trim()),
) || undefined,
repoName:
$(
'[itemprop="name"] > a[href]',
[selectors.normal.repoName, selectors.globalNavigation.navbar.repositoryName].join(),
e => e.textContent?.trim(),
() => $(selectors.globalNavigation.navbar.repositoryName, e => e.textContent?.trim()),
) || undefined,
@ -114,6 +136,12 @@ export function resolveMeta(): Partial<MetaData> {
export function isInRepoPage() {
const repoHeadSelector = '.repohead'
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(
document.querySelector(
[
@ -139,7 +167,16 @@ export function isInPullFilesPage() {
}
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, '')
}
@ -149,6 +186,22 @@ export function getCommitTitle() {
}
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 = [
'main #branch-select-menu summary',
'main .branch-select-menu summary',
@ -194,7 +247,9 @@ export function getCurrentBranch(passive = false) {
})
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'
const repo = s.object({
const repo = s.type({
id: s.number(),
defaultBranch: s.string(),
name: s.string(),
@ -15,13 +15,13 @@ const repo = s.object({
isOrgOwned: s.boolean(),
})
const user = s.object({
const user = s.type({
id: s.number(),
login: s.string(),
userEmail: s.string(),
})
const rel = s.object({
const rel = s.type({
name: s.string(),
listCacheKey: s.string(),
canEdit: s.boolean(),
@ -29,13 +29,13 @@ const rel = s.object({
currentOid: s.string(),
})
const treeItem = s.object({
const treeItem = s.type({
name: s.string(),
path: s.string(),
contentType: s.string(),
})
const tree = s.object({
const tree = s.type({
items: s.array(treeItem),
templateDirectorySuggestionUrl: s.nullable(s.never()),
readme: s.nullable(s.never()),
@ -43,7 +43,7 @@ const tree = s.object({
showBranchInfobar: s.boolean(),
})
const repoPayload = s.object({
const repoPayload = s.type({
allShortcutsEnabled: s.boolean(),
path: s.string(),
repo: repo,
@ -59,16 +59,58 @@ const repoPayload = s.object({
overview: s.unknown(),
})
const reposOverview = s.object({
props: s.object({
const reposOverview = s.type({
props: s.type({
initialPayload: repoPayload,
appPayload: s.unknown(),
}),
})
const app = s.object({
const app = s.type({
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 = {
repo,
user,
@ -78,4 +120,6 @@ export const embeddedDataStruct = {
repoPayload,
reposOverview,
app,
codeViewApp,
pullRequest,
}

View file

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

View file

@ -1,6 +1,13 @@
import { map } from 'utils/map'
import { sanitizedLocation } from 'utils/URLHelper'
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 { getCommentsMap } from './utils'
@ -31,28 +38,27 @@ export async function getPullRequestTreeData(
API.getPullComments(userName, repoName, pullId, accessToken),
])
const docs = await API.getPullPageDocuments(
const [fileChangesPagePath, docs] = await API.getPullPageDocuments(
userName,
repoName,
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 map = 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) map.set(path, id)
}
}
}
const diffSummaryMap = resolveDiffSummaryMap(docs)
const fileHashMap =
diffSummaryMap.size > 0
? new Map(map(diffSummaryMap, ([, { path, pathDigest }]) => [path, `diff-${pathDigest}`]))
: resolveFileHashMap(docs)
const url = new URL(sanitizedLocation.href)
url.pathname = `/${userName}/${repoName}/pull/${pullId}/files`
url.pathname = fileChangesPagePath
const commentsMap = getCommentsMap(commentData)
const nodes: TreeNode[] = treeData.map(
({
@ -65,7 +71,7 @@ export async function getPullRequestTreeData(
raw_url: rawLink,
blob_url: permalink,
}) => {
url.hash = map.get(filename) || ''
url.hash = fileHashMap.get(filename) || ''
return {
path: filename || '',
type: 'blob',
@ -74,6 +80,7 @@ export async function getPullRequestTreeData(
permalink,
rawLink,
sha,
reviewed: diffSummaryMap.get(filename)?.markedAsViewed,
comments: commentsMap.get(filename),
diff: {
status,
@ -93,6 +100,45 @@ const GITHUB_API_RESPONSE_LENGTH_LIMIT = 3000
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)
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(
{ userName, repoName }: Pick<MetaData, 'userName' | 'repoName' | 'branchName'>,
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 { GITHUB_OAUTH } from 'env'
import { Base64 } from 'js-base64'
import { Platform } from 'platforms/platform'
import { $ } from 'utils/$'
import { configRef } from 'utils/config/helper'
import { resolveGitModules } from 'utils/gitSubmodule'
@ -13,6 +14,7 @@ import { getPullRequestTreeData } from './getPullRequestTreeData'
import { useEnterpriseStatBarStyleFix } from './hooks/useEnterpriseStatBarStyleFix'
import { useGitHubAttachCopySnippetButton } from './hooks/useGitHubAttachCopySnippetButton'
import { useGitHubCodeFold } from './hooks/useGitHubCodeFold'
import { useGitHubReviewStatus } from './hooks/useGitHubReviewStatus'
export function processTree(tree: TreeNode[]): TreeNode {
// nodes are created from items and put onto tree
@ -137,7 +139,7 @@ export const GitHub: Platform = {
return metaData
},
async getDefaultBranchName({ userName, repoName }, accessToken) {
const dataFromJSON = DOMHelper.resolveEmbeddedData()
const dataFromJSON = DOMHelper.resolveMetaFromEmbeddedData()
if (dataFromJSON?.defaultBranch) return dataFromJSON.defaultBranch
return (await API.getRepoMeta(userName, repoName, accessToken)).default_branch
@ -150,8 +152,8 @@ export const GitHub: Platform = {
const branchUrl = pullId
? `${repoUrl}/pull/${pullId}`
: commitId && URLHelper.isPossiblyCommitSHA(commitId)
? `${repoUrl}/tree/${commitId}`
: `${repoUrl}/tree/${branchName}`
? `${repoUrl}/tree/${commitId}`
: `${repoUrl}/tree/${branchName}`
return {
repoUrl,
userUrl,
@ -170,8 +172,8 @@ export const GitHub: Platform = {
shouldExpandSideBar() {
return Boolean(
(DOMHelper.isInCodePage() || URLHelper.isInCommitPage() || URLHelper.isInPullPage()) &&
!DOMHelper.isNativeFileTreeShown() &&
!DOMHelper.isNativePRFileTreeShown(),
!DOMHelper.isNativeFileTreeShown() &&
!DOMHelper.isNativePRFileTreeShown(),
)
},
shouldExpandAll() {
@ -207,6 +209,9 @@ export const GitHub: Platform = {
useGitHubCodeFold(codeFolding)
useEnterpriseStatBarStyleFix()
},
usePlatformFileTreeHooks({ visibleNodesGenerator = null }) {
useGitHubReviewStatus(visibleNodesGenerator)
},
delegateFastRedirectAnchorProps() {
if (configRef.pjaxMode !== 'native') return

View file

@ -1,3 +1,5 @@
import { findMapFirst } from '../../utils/findMapFirst'
/**
* Resolved from response header `link`
*
@ -74,11 +76,21 @@ export function resolveHeaderLink(raw: string) {
}
}
export async function getDOM(url: string) {
return new DOMParser().parseFromString(await (await fetch(url)).text(), 'text/html')
async function getDOM(url: string) {
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
* src="..."
@ -92,22 +104,21 @@ export async function continuousLoadFragmentedPages(doc: Document) {
'.js-diff-progressive-container include-fragment[src]', // legacy support
]
const documents: Document[] = [doc]
const selector = fragmentSelectors.find(selector => doc.querySelector(selector))
if (selector) {
// eslint-disable-next-line no-constant-condition
while (true) {
const fragment = doc.querySelector(selector)
if (!(fragment instanceof HTMLElement)) break
const src = fragment.getAttribute('src')
if (!src) break
const fragment = findMapFirst(fragmentSelectors, selector => doc.querySelector(selector))
if (fragment instanceof HTMLElement) {
const src = fragment.getAttribute('src')
if (src) {
// 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)
documents.push(doc)
// do NOT return here because we need to preserve the first `url` for the final return value
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) {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,27 @@
export function $<E extends HTMLElement>(selector: string): E | null
export function $<R1>(selector: string, existCallback: (element: HTMLElement) => R1): R1 | null
export function $<R1, R2>(
selector: string,
existCallback: (element: HTMLElement) => R1,
otherwise: () => R2,
): R1 | R2
export function $<E extends HTMLElement, R2>(
selector: string,
existCallback: undefined | null,
otherwise: () => R2,
): E | R2
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function $(selector: string, existCallback?: any, otherwise?: any) {
const element = document.querySelector(selector)
if (element) {
return existCallback ? existCallback(element) : element
}
return otherwise ? otherwise() : null
export interface $ {
<E extends HTMLElement>(selector: string): E | null
<R1>(selector: string, existCallback: (element: HTMLElement) => R1): R1 | null
<R1, R2>(
selector: string,
existCallback: (element: HTMLElement) => R1,
otherwise: () => R2,
): R1 | R2
<E extends HTMLElement, R2>(
selector: string,
existCallback: undefined | null,
otherwise: () => R2,
): E | R2
}
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 { Options } from './index'
function mergeNodes(target: TreeNode, source: TreeNode) {
function inPlaceMergeNodes(target: TreeNode, source: TreeNode) {
for (const node of source.contents || []) {
const dup = target.contents?.find($node => $node.path === node.path)
if (dup) {
mergeNodes(dup, node)
inPlaceMergeNodes(dup, node)
} else {
if (!target.contents) target.contents = []
target.contents.push(node)
@ -31,6 +31,14 @@ export class BaseLayer {
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) => {
const node = await findNode(this.baseRoot, path)
if (node && node.type !== 'tree') return node
@ -39,7 +47,7 @@ export class BaseLayer {
this.loading.add(path)
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.baseHub.emit('loadingChange', this.loading)
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
// Firefox does not support 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')
break
}
@ -142,18 +145,23 @@ function createConfig({ envTarget }: { envTarget: Target }) {
rules: [
{
test: /\.tsx?$/,
loader: 'babel-loader',
loader: 'swc-loader',
include: [srcPath],
exclude: /node_modules/,
sideEffects: false,
},
{
test: /\.[cm]?js$/,
loader: 'babel-loader',
loader: 'swc-loader',
// Transpile as least files under node_modules
include: /node_modules\/(webext-.*|superstruct)\/.*\.[cm]?js$/,
options: {
cacheDirectory: true,
jsc: {
parser: {
syntax: 'ecmascript',
},
target: 'es2022',
},
},
},
{

4446
yarn.lock

File diff suppressed because it is too large Load diff