mirror of
https://github.com/EnixCoda/Gitako.git
synced 2026-03-11 08:54:44 +00:00
Compare commits
25 commits
release-v3
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
954a132624 | ||
|
|
b4ace1f54b | ||
|
|
6e193db9c5 | ||
|
|
750f8e4462 | ||
|
|
d149a4903d | ||
|
|
5352526d74 | ||
|
|
7560895fff | ||
|
|
5a687cb9f5 | ||
|
|
fd3476c1fa | ||
|
|
68915bfac4 | ||
|
|
08bb716ef4 | ||
|
|
6ac043a13a | ||
|
|
420e1e288e | ||
|
|
6331892329 | ||
|
|
3cf4deaa9f | ||
|
|
550b3f0d41 | ||
|
|
ecaf9087a3 | ||
|
|
f67065bcf4 | ||
|
|
4bcce483e9 | ||
|
|
a9979620f4 | ||
|
|
ba06289ed3 | ||
|
|
30668b16aa | ||
|
|
225a944685 | ||
|
|
697b6e2ad4 | ||
|
|
186e1365d0 |
77 changed files with 3051 additions and 3435 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
25
.github/workflows/tests.yml
vendored
25
.github/workflows/tests.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -7,3 +7,5 @@ dist-firefox
|
|||
yarn-error.log
|
||||
/vscode-icons
|
||||
firefox-profile
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
*-profile/
|
||||
dist/
|
||||
vscode-icons/
|
||||
/vscode-icons
|
||||
Safari
|
||||
|
|
|
|||
20
.swcrc
Normal file
20
.swcrc
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"excludedFiles": ["*.d.ts"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// TS files cannot be transformed without this babel config
|
||||
module.exports = require('../babel.config')
|
||||
|
|
@ -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()
|
||||
// })
|
||||
})
|
||||
|
|
@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
1
__tests__/global.d.ts
vendored
1
__tests__/global.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
declare var page: Puppeteer.Page
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const baseConfig = require('./jest.config')
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
testMatch: [...baseConfig.testMatch, '**/__tests__/cases/**/*.ts?(x)'],
|
||||
setupFilesAfterEnv: ['<rootDir>/setup.ts'],
|
||||
}
|
||||
4
__tests__/puppeteer.d.ts
vendored
4
__tests__/puppeteer.d.ts
vendored
|
|
@ -1,4 +0,0 @@
|
|||
import * as Puppeteer from 'puppeteer'
|
||||
|
||||
export as namespace Puppeteer
|
||||
export = Puppeteer
|
||||
|
|
@ -1 +0,0 @@
|
|||
jest.retryTimes(3)
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"exclude": ["tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"module": "CommonJS",
|
||||
"baseUrl": null
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
30
e2e/baseline.spec.ts
Normal 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
17
e2e/empty-project.spec.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
27
e2e/expand-to-target.spec.ts
Normal file
27
e2e/expand-to-target.spec.ts
Normal 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
29
e2e/fixtures.ts
Normal 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
|
||||
14
e2e/homepage.not-render.spec.ts
Normal file
14
e2e/homepage.not-render.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
31
e2e/pjax.commits-page.spec.ts
Normal file
31
e2e/pjax.commits-page.spec.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
32
e2e/pjax.files-page.spec.ts
Normal file
32
e2e/pjax.files-page.spec.ts
Normal 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
41
e2e/pjax.general.spec.ts
Normal 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
30
e2e/pjax.internal.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
46
e2e/project-page.gitako.spec.ts
Normal file
46
e2e/project-page.gitako.spec.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
19
e2e/pull-request-page.gitako.spec.ts
Normal file
19
e2e/pull-request-page.gitako.spec.ts
Normal 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
15
e2e/tsconfig.json
Normal 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
98
e2e/utils.ts
Normal 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
98
eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -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}`,
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -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,
|
||||
64
package.json
64
package.json
|
|
@ -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
45
playwright.config.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
21
src/components/FileExplorer/DiffIcon.tsx
Normal file
21
src/components/FileExplorer/DiffIcon.tsx
Normal 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]} />
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
37
src/components/FileExplorer/hooks/useHandleClickFileLink.ts
Normal file
37
src/components/FileExplorer/hooks/useHandleClickFileLink.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
1
src/global.d.ts
vendored
|
|
@ -19,6 +19,7 @@ type TreeNode = {
|
|||
rawLink?: string
|
||||
sha?: string
|
||||
accessDenied?: boolean
|
||||
reviewed?: boolean
|
||||
comments?: {
|
||||
active: number
|
||||
resolved: number
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
30
src/platforms/GitHub/hooks/useGitHubReviewStatus.ts
Normal file
30
src/platforms/GitHub/hooks/useGitHubReviewStatus.ts
Normal 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])
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Platform } from './platform'
|
||||
|
||||
export const dummyPlatformForTypeSafety: Platform = {
|
||||
isEnterprise() {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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$()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
7
src/utils/findMapFirst.ts
Normal file
7
src/utils/findMapFirst.ts
Normal 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
11
src/utils/map.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue