mirror of
https://github.com/desktop/desktop
synced 2024-08-28 12:40:55 +00:00
Merge remote-tracking branch 'upstream/development' into development
This commit is contained in:
commit
659ad53ec1
|
@ -123,7 +123,10 @@ rules:
|
|||
jsdoc/check-tag-names: error
|
||||
jsdoc/check-types: error
|
||||
jsdoc/implements-on-classes: error
|
||||
jsdoc/newline-after-description: error
|
||||
jsdoc/tag-lines:
|
||||
- error
|
||||
- any
|
||||
- startLines: 1
|
||||
jsdoc/no-undefined-types: error
|
||||
jsdoc/valid-types: error
|
||||
|
||||
|
@ -198,8 +201,7 @@ rules:
|
|||
# larger web pages, e.g. to focus a form field in the main content, entirely
|
||||
# skipping the header and often much else besides.
|
||||
jsx-a11y/no-autofocus:
|
||||
- warn
|
||||
- ignoreNonDOM: true
|
||||
- off
|
||||
|
||||
overrides:
|
||||
- files: '*.d.ts'
|
||||
|
|
101
.github/workflows/ci.yml
vendored
101
.github/workflows/ci.yml
vendored
|
@ -4,29 +4,77 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- development
|
||||
- __release-*
|
||||
pull_request:
|
||||
workflow_call:
|
||||
inputs:
|
||||
repository:
|
||||
default: desktop/desktop
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
upload-artifacts:
|
||||
default: false
|
||||
required: false
|
||||
type: boolean
|
||||
environment:
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
DESKTOP_OAUTH_CLIENT_ID:
|
||||
DESKTOP_OAUTH_CLIENT_SECRET:
|
||||
APPLE_ID:
|
||||
APPLE_ID_PASSWORD:
|
||||
APPLE_APPLICATION_CERT:
|
||||
APPLE_APPLICATION_CERT_PASSWORD:
|
||||
WINDOWS_CERT_PFX:
|
||||
WINDOWS_CERT_PASSWORD:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: ${{ inputs.repository || github.repository }}
|
||||
ref: ${{ inputs.ref }}
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.17.1
|
||||
cache: yarn
|
||||
- run: yarn
|
||||
- run: yarn validate-electron-version
|
||||
- run: yarn lint
|
||||
- run: yarn validate-changelog
|
||||
- name: Ensure a clean working directory
|
||||
run: git diff --name-status --exit-code
|
||||
build:
|
||||
name: ${{ matrix.friendlyName }} ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [16.17.1]
|
||||
os: [macos-11, windows-2019]
|
||||
node: [18.14.0]
|
||||
os: [macos-13-xl-arm64, windows-2019]
|
||||
arch: [x64, arm64]
|
||||
include:
|
||||
- os: macos-11
|
||||
- os: macos-13-xl-arm64
|
||||
friendlyName: macOS
|
||||
- os: windows-2019
|
||||
friendlyName: Windows
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
RELEASE_CHANNEL: ${{ inputs.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: ${{ inputs.repository || github.repository }}
|
||||
ref: ${{ inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Use Node.js ${{ matrix.node }}
|
||||
uses: actions/setup-node@v3
|
||||
|
@ -45,14 +93,6 @@ jobs:
|
|||
env:
|
||||
npm_config_arch: ${{ matrix.arch }}
|
||||
TARGET_ARCH: ${{ matrix.arch }}
|
||||
- name: Validate Electron version
|
||||
run: yarn run validate-electron-version
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
- name: Validate changelog
|
||||
run: yarn validate-changelog
|
||||
- name: Ensure a clean working directory
|
||||
run: git diff --name-status --exit-code
|
||||
- name: Build production app
|
||||
run: yarn build:prod
|
||||
env:
|
||||
|
@ -61,28 +101,41 @@ jobs:
|
|||
${{ secrets.DESKTOP_OAUTH_CLIENT_SECRET }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }}
|
||||
KEY_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }}
|
||||
npm_config_arch: ${{ matrix.arch }}
|
||||
TARGET_ARCH: ${{ matrix.arch }}
|
||||
- name: Prepare testing environment
|
||||
if: matrix.arch == 'x64'
|
||||
run: yarn test:setup
|
||||
env:
|
||||
npm_config_arch: ${{ matrix.arch }}
|
||||
- name: Run unit tests
|
||||
if: matrix.arch == 'x64'
|
||||
run: yarn test:unit
|
||||
- name: Run script tests
|
||||
if: matrix.arch == 'x64'
|
||||
run: yarn test:script
|
||||
- name: Publish production app
|
||||
run: yarn run publish
|
||||
- name: Install Windows code signing certificate
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
shell: bash
|
||||
env:
|
||||
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
|
||||
run: base64 -d <<<"$CERT_CONTENTS" > ./script/windows-certificate.pfx
|
||||
- name: Package production app
|
||||
run: yarn package
|
||||
env:
|
||||
npm_config_arch: ${{ matrix.arch }}
|
||||
DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }}
|
||||
WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
DEPLOYMENT_SECRET: ${{ secrets.DEPLOYMENT_SECRET }}
|
||||
AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }}
|
||||
AZURE_STORAGE_ACCESS_KEY: ${{ secrets.AZURE_STORAGE_ACCESS_KEY }}
|
||||
AZURE_BLOB_CONTAINER: ${{ secrets.AZURE_BLOB_CONTAINER }}
|
||||
AZURE_STORAGE_URL: ${{ secrets.AZURE_STORAGE_URL }}
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
if: ${{ inputs.upload-artifacts }}
|
||||
with:
|
||||
name: ${{matrix.friendlyName}}-${{matrix.arch}}
|
||||
path: |
|
||||
dist/GitHub Desktop-${{matrix.arch}}.zip
|
||||
dist/GitHubDesktop-*.nupkg
|
||||
dist/GitHubDesktopSetup-${{matrix.arch}}.exe
|
||||
dist/GitHubDesktopSetup-${{matrix.arch}}.msi
|
||||
dist/bundle-size.json
|
||||
if-no-files-found: error
|
||||
|
|
|
@ -1 +1 @@
|
|||
16.17.1
|
||||
18.14.0
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
python 3.9.5
|
||||
nodejs 16.17.1
|
||||
nodejs 18.14.0
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
runtime = electron
|
||||
disturl = https://electronjs.org/headers
|
||||
target = 22.0.0
|
||||
target = 24.4.0
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"productName": "GitHub Desktop",
|
||||
"bundleID": "com.github.GitHubClient",
|
||||
"companyName": "GitHub, Inc.",
|
||||
"version": "3.2.4",
|
||||
"version": "3.2.8-beta2",
|
||||
"main": "./main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -46,6 +46,7 @@
|
|||
"primer-support": "^4.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"quick-lru": "^3.0.0",
|
||||
"re2js": "^0.1.0",
|
||||
"react": "^16.8.4",
|
||||
"react-css-transition-replace": "^3.0.3",
|
||||
"react-dom": "^16.8.4",
|
||||
|
|
|
@ -14,6 +14,8 @@ export function openDesktop(url: string = '') {
|
|||
// https://github.com/nodejs/node/blob/b39dabefe6d/lib/child_process.js#L565-L577
|
||||
const shell = process.env.comspec || 'cmd.exe'
|
||||
return ChildProcess.spawn(shell, ['/d', '/c', 'start', url], { env })
|
||||
} else if (__LINUX__) {
|
||||
return ChildProcess.spawn('xdg-open', [url], { env })
|
||||
} else {
|
||||
throw new Error(
|
||||
`Desktop command line interface not currently supported on platform ${process.platform}`
|
||||
|
|
|
@ -485,6 +485,100 @@ export interface IAPIBranch {
|
|||
readonly protected: boolean
|
||||
}
|
||||
|
||||
/** Repository rule information returned by the GitHub API */
|
||||
export interface IAPIRepoRule {
|
||||
/**
|
||||
* The ID of the ruleset this rule is configured in.
|
||||
*/
|
||||
readonly ruleset_id: number
|
||||
|
||||
/**
|
||||
* The type of the rule.
|
||||
*/
|
||||
readonly type: APIRepoRuleType
|
||||
|
||||
/**
|
||||
* The parameters that apply to the rule if it is a metadata rule.
|
||||
* Other rule types may have parameters, but they are not used in
|
||||
* this app so they are ignored. Do not attempt to use this field
|
||||
* unless you know {@link type} matches a metadata rule type.
|
||||
*/
|
||||
readonly parameters?: IAPIRepoRuleMetadataParameters
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-exhaustive list of rules that can be configured. Only the rule
|
||||
* types used by this app are included.
|
||||
*/
|
||||
export enum APIRepoRuleType {
|
||||
Creation = 'creation',
|
||||
Update = 'update',
|
||||
RequiredDeployments = 'required_deployments',
|
||||
RequiredSignatures = 'required_signatures',
|
||||
RequiredStatusChecks = 'required_status_checks',
|
||||
PullRequest = 'pull_request',
|
||||
CommitMessagePattern = 'commit_message_pattern',
|
||||
CommitAuthorEmailPattern = 'commit_author_email_pattern',
|
||||
CommitterEmailPattern = 'committer_email_pattern',
|
||||
BranchNamePattern = 'branch_name_pattern',
|
||||
}
|
||||
|
||||
/**
|
||||
* A ruleset returned from the GitHub API's "get all rulesets for a repo" endpoint.
|
||||
* This endpoint returns a slimmed-down version of the full ruleset object, though
|
||||
* only the ID is used.
|
||||
*/
|
||||
export interface IAPISlimRepoRuleset {
|
||||
readonly id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A ruleset returned from the GitHub API's "get a ruleset for a repo" endpoint.
|
||||
*/
|
||||
export interface IAPIRepoRuleset extends IAPISlimRepoRuleset {
|
||||
/**
|
||||
* Whether the user making the API request can bypass the ruleset.
|
||||
*/
|
||||
readonly current_user_can_bypass: 'always' | 'pull_requests_only' | 'never'
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata parameters for a repo rule metadata rule.
|
||||
*/
|
||||
export interface IAPIRepoRuleMetadataParameters {
|
||||
/**
|
||||
* User-supplied name/description of the rule
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* Whether the operator is negated. For example, if `true`
|
||||
* and {@link operator} is `starts_with`, then the rule
|
||||
* will be negated to 'does not start with'.
|
||||
*/
|
||||
negate: boolean
|
||||
|
||||
/**
|
||||
* The pattern to match against. If the operator is 'regex', then
|
||||
* this is a regex string match. Otherwise, it is a raw string match
|
||||
* of the type specified by {@link operator} with no additional parsing.
|
||||
*/
|
||||
pattern: string
|
||||
|
||||
/**
|
||||
* The type of match to use for the pattern. For example, `starts_with`
|
||||
* means {@link pattern} must be at the start of the string.
|
||||
*/
|
||||
operator: APIRepoRuleMetadataOperator
|
||||
}
|
||||
|
||||
export enum APIRepoRuleMetadataOperator {
|
||||
StartsWith = 'starts_with',
|
||||
EndsWith = 'ends_with',
|
||||
Contains = 'contains',
|
||||
RegexMatch = 'regex',
|
||||
}
|
||||
|
||||
interface IAPIPullRequestRef {
|
||||
readonly ref: string
|
||||
readonly sha: string
|
||||
|
@ -1555,6 +1649,72 @@ export class API {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all repository rules that apply to the provided branch.
|
||||
*/
|
||||
public async fetchRepoRulesForBranch(
|
||||
owner: string,
|
||||
name: string,
|
||||
branch: string
|
||||
): Promise<ReadonlyArray<IAPIRepoRule>> {
|
||||
const path = `repos/${owner}/${name}/rules/branches/${encodeURIComponent(
|
||||
branch
|
||||
)}`
|
||||
try {
|
||||
const response = await this.request('GET', path)
|
||||
return await parsedResponse<IAPIRepoRule[]>(response)
|
||||
} catch (err) {
|
||||
log.info(
|
||||
`[fetchRepoRulesForBranch] unable to fetch repo rules for branch: ${branch} | ${path}`,
|
||||
err
|
||||
)
|
||||
return new Array<IAPIRepoRule>()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches slim versions of all repo rulesets for the given repository. Utilize the cache
|
||||
* in IAppState instead of querying this if possible.
|
||||
*/
|
||||
public async fetchAllRepoRulesets(
|
||||
owner: string,
|
||||
name: string
|
||||
): Promise<ReadonlyArray<IAPISlimRepoRuleset> | null> {
|
||||
const path = `repos/${owner}/${name}/rulesets`
|
||||
try {
|
||||
const response = await this.request('GET', path)
|
||||
return await parsedResponse<ReadonlyArray<IAPISlimRepoRuleset>>(response)
|
||||
} catch (err) {
|
||||
log.info(
|
||||
`[fetchAllRepoRulesets] unable to fetch all repo rulesets | ${path}`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the repo ruleset with the given ID. Utilize the cache in IAppState
|
||||
* instead of querying this if possible.
|
||||
*/
|
||||
public async fetchRepoRuleset(
|
||||
owner: string,
|
||||
name: string,
|
||||
id: number
|
||||
): Promise<IAPIRepoRuleset | null> {
|
||||
const path = `repos/${owner}/${name}/rulesets/${id}`
|
||||
try {
|
||||
const response = await this.request('GET', path)
|
||||
return await parsedResponse<IAPIRepoRuleset>(response)
|
||||
} catch (err) {
|
||||
log.info(
|
||||
`[fetchRepoRuleset] unable to fetch repo ruleset for ID: ${id} | ${path}`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated requests to a paginating resource such as issues.
|
||||
*
|
||||
|
|
|
@ -46,6 +46,8 @@ import {
|
|||
} from '../models/multi-commit-operation'
|
||||
import { IChangesetData } from './git'
|
||||
import { Popup } from '../models/popup'
|
||||
import { RepoRulesInfo } from '../models/repo-rules'
|
||||
import { IAPIRepoRuleset } from './api'
|
||||
|
||||
export enum SelectionType {
|
||||
Repository,
|
||||
|
@ -210,6 +212,9 @@ export interface IAppState {
|
|||
/** Should the app prompt the user to confirm a discard stash */
|
||||
readonly askForConfirmationOnDiscardStash: boolean
|
||||
|
||||
/** Should the app prompt the user to confirm a commit checkout? */
|
||||
readonly askForConfirmationOnCheckoutCommit: boolean
|
||||
|
||||
/** Should the app prompt the user to confirm a force push? */
|
||||
readonly askForConfirmationOnForcePush: boolean
|
||||
|
||||
|
@ -323,6 +328,12 @@ export interface IAppState {
|
|||
readonly pullRequestSuggestedNextAction:
|
||||
| PullRequestSuggestedNextAction
|
||||
| undefined
|
||||
|
||||
/**
|
||||
* Cached repo rulesets. Used to prevent repeatedly querying the same
|
||||
* rulesets to check their bypass status.
|
||||
*/
|
||||
readonly cachedRepoRulesets: ReadonlyMap<number, IAPIRepoRuleset>
|
||||
}
|
||||
|
||||
export enum FoldoutType {
|
||||
|
@ -713,6 +724,11 @@ export interface IChangesState {
|
|||
|
||||
/** `true` if the GitHub API reports that the branch is protected */
|
||||
readonly currentBranchProtected: boolean
|
||||
|
||||
/**
|
||||
* Repo rules that apply to the current branch.
|
||||
*/
|
||||
readonly currentRepoRulesInfo: RepoRulesInfo
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
export interface IFoundEditor<T> {
|
||||
readonly editor: T
|
||||
readonly path: string
|
||||
/**
|
||||
* Indicate to Desktop to launch the editor with the `shell: true` option included.
|
||||
*
|
||||
* This is available to all platforms, but is only currently used by some Windows
|
||||
* editors as their launch programs end in `.cmd`
|
||||
*/
|
||||
readonly usesShell?: boolean
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ export async function launchExternalEditor(
|
|||
const editorPath = editor.path
|
||||
const exists = await pathExists(editorPath)
|
||||
if (!exists) {
|
||||
const label = __DARWIN__ ? 'Preferences' : 'Options'
|
||||
const label = __DARWIN__ ? 'Settings' : 'Options'
|
||||
throw new ExternalEditorError(
|
||||
`Could not find executable for '${editor.editor}' at path '${editor.path}'. Please open ${label} and select an available editor.`,
|
||||
{ openPreferences: true }
|
||||
|
|
|
@ -57,7 +57,7 @@ export async function findEditorOrDefault(
|
|||
if (name) {
|
||||
const match = editors.find(p => p.editor === name) || null
|
||||
if (!match) {
|
||||
const menuItemName = __DARWIN__ ? 'Preferences' : 'Options'
|
||||
const menuItemName = __DARWIN__ ? 'Settings' : 'Options'
|
||||
const message = `The editor '${name}' could not be found. Please open ${menuItemName} and choose an available editor.`
|
||||
|
||||
throw new ExternalEditorError(message, { openPreferences: true })
|
||||
|
|
|
@ -56,7 +56,7 @@ type WindowsExternalEditor = {
|
|||
readonly registryKeys: ReadonlyArray<RegistryKey>
|
||||
|
||||
/** Prefix of the DisplayName registry key that belongs to this editor. */
|
||||
readonly displayNamePrefix: string
|
||||
readonly displayNamePrefixes: string[]
|
||||
|
||||
/** Value of the Publisher registry key that belongs to this editor. */
|
||||
readonly publishers: string[]
|
||||
|
@ -140,6 +140,15 @@ const executableShimPathsForJetBrainsIDE = (
|
|||
]
|
||||
}
|
||||
|
||||
// Function to allow for validating a string against the start of strings
|
||||
// in an array. Used for validating publisher and display name
|
||||
const validateStartsWith = (
|
||||
registryVal: string,
|
||||
definedVal: string[]
|
||||
): boolean => {
|
||||
return definedVal.some(subString => registryVal.startsWith(subString))
|
||||
}
|
||||
|
||||
/**
|
||||
* This list contains all the external editors supported on Windows. Add a new
|
||||
* entry here to add support for your favorite editor.
|
||||
|
@ -149,21 +158,21 @@ const editors: WindowsExternalEditor[] = [
|
|||
name: 'Atom',
|
||||
registryKeys: [CurrentUserUninstallKey('atom')],
|
||||
executableShimPaths: [['bin', 'atom.cmd']],
|
||||
displayNamePrefix: 'Atom',
|
||||
displayNamePrefixes: ['Atom'],
|
||||
publishers: ['GitHub Inc.'],
|
||||
},
|
||||
{
|
||||
name: 'Atom Beta',
|
||||
registryKeys: [CurrentUserUninstallKey('atom-beta')],
|
||||
executableShimPaths: [['bin', 'atom-beta.cmd']],
|
||||
displayNamePrefix: 'Atom Beta',
|
||||
displayNamePrefixes: ['Atom Beta'],
|
||||
publishers: ['GitHub Inc.'],
|
||||
},
|
||||
{
|
||||
name: 'Atom Nightly',
|
||||
registryKeys: [CurrentUserUninstallKey('atom-nightly')],
|
||||
executableShimPaths: [['bin', 'atom-nightly.cmd']],
|
||||
displayNamePrefix: 'Atom Nightly',
|
||||
displayNamePrefixes: ['Atom Nightly'],
|
||||
publishers: ['GitHub Inc.'],
|
||||
},
|
||||
{
|
||||
|
@ -185,7 +194,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
LocalMachineUninstallKey('{A5270FC5-65AD-483E-AC30-2C276B63D0AC}_is1'),
|
||||
],
|
||||
executableShimPaths: [['bin', 'code.cmd']],
|
||||
displayNamePrefix: 'Microsoft Visual Studio Code',
|
||||
displayNamePrefixes: ['Microsoft Visual Studio Code'],
|
||||
publishers: ['Microsoft Corporation'],
|
||||
},
|
||||
{
|
||||
|
@ -207,7 +216,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
LocalMachineUninstallKey('{0AEDB616-9614-463B-97D7-119DD86CCA64}_is1'),
|
||||
],
|
||||
executableShimPaths: [['bin', 'code-insiders.cmd']],
|
||||
displayNamePrefix: 'Microsoft Visual Studio Code Insiders',
|
||||
displayNamePrefixes: ['Microsoft Visual Studio Code Insiders'],
|
||||
publishers: ['Microsoft Corporation'],
|
||||
},
|
||||
{
|
||||
|
@ -241,7 +250,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
LocalMachineUninstallKey('{D1ACE434-89C5-48D1-88D3-E2991DF85475}_is1'),
|
||||
],
|
||||
executableShimPaths: [['bin', 'codium.cmd']],
|
||||
displayNamePrefix: 'VSCodium',
|
||||
displayNamePrefixes: ['VSCodium'],
|
||||
publishers: ['VSCodium', 'Microsoft Corporation'],
|
||||
},
|
||||
{
|
||||
|
@ -263,7 +272,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
LocalMachineUninstallKey('{44721278-64C6-4513-BC45-D48E07830599}_is1'),
|
||||
],
|
||||
executableShimPaths: [['bin', 'codium-insiders.cmd']],
|
||||
displayNamePrefix: 'VSCodium (Insiders)',
|
||||
displayNamePrefixes: ['VSCodium Insiders', 'VSCodium (Insiders)'],
|
||||
publishers: ['VSCodium'],
|
||||
},
|
||||
{
|
||||
|
@ -275,7 +284,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
LocalMachineUninstallKey('Sublime Text 3_is1'),
|
||||
],
|
||||
executableShimPaths: [['subl.exe']],
|
||||
displayNamePrefix: 'Sublime Text',
|
||||
displayNamePrefixes: ['Sublime Text'],
|
||||
publishers: ['Sublime HQ Pty Ltd'],
|
||||
},
|
||||
{
|
||||
|
@ -284,7 +293,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
Wow64LocalMachineUninstallKey('{4F3B6E8C-401B-4EDE-A423-6481C239D6FF}'),
|
||||
],
|
||||
executableShimPaths: [['Brackets.exe']],
|
||||
displayNamePrefix: 'Brackets',
|
||||
displayNamePrefixes: ['Brackets'],
|
||||
publishers: ['brackets.io'],
|
||||
},
|
||||
{
|
||||
|
@ -296,7 +305,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
LocalMachineUninstallKey('Adobe ColdFusion Builder 2016'),
|
||||
],
|
||||
executableShimPaths: [['CFBuilder.exe']],
|
||||
displayNamePrefix: 'Adobe ColdFusion Builder',
|
||||
displayNamePrefixes: ['Adobe ColdFusion Builder'],
|
||||
publishers: ['Adobe Systems Incorporated'],
|
||||
},
|
||||
{
|
||||
|
@ -310,7 +319,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
),
|
||||
],
|
||||
executableShimPaths: [['typora.exe']],
|
||||
displayNamePrefix: 'Typora',
|
||||
displayNamePrefixes: ['Typora'],
|
||||
publishers: ['typora.io'],
|
||||
},
|
||||
{
|
||||
|
@ -340,7 +349,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
LocalMachineUninstallKey('{7CC0E567-ACD6-41E8-95DA-154CEEDB0A18}'),
|
||||
],
|
||||
executableShimPaths: [['win', 'vs.exe']],
|
||||
displayNamePrefix: 'SlickEdit',
|
||||
displayNamePrefixes: ['SlickEdit'],
|
||||
publishers: ['SlickEdit Inc.'],
|
||||
},
|
||||
{
|
||||
|
@ -349,7 +358,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
Wow64LocalMachineUninstallKey('{2D6C1116-78C6-469C-9923-3E549218773F}'),
|
||||
],
|
||||
executableShimPaths: [['AptanaStudio3.exe']],
|
||||
displayNamePrefix: 'Aptana Studio',
|
||||
displayNamePrefixes: ['Aptana Studio'],
|
||||
publishers: ['Appcelerator'],
|
||||
},
|
||||
{
|
||||
|
@ -357,7 +366,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('WebStorm'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('webstorm'),
|
||||
jetBrainsToolboxScriptName: 'webstorm',
|
||||
displayNamePrefix: 'WebStorm',
|
||||
displayNamePrefixes: ['WebStorm'],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -365,7 +374,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('PhpStorm'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('phpstorm'),
|
||||
jetBrainsToolboxScriptName: 'phpstorm',
|
||||
displayNamePrefix: 'PhpStorm',
|
||||
displayNamePrefixes: ['PhpStorm'],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -377,7 +386,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
['..', 'bin', `studio64.exe`],
|
||||
['..', 'bin', `studio.exe`],
|
||||
],
|
||||
displayNamePrefix: 'Android Studio',
|
||||
displayNamePrefixes: ['Android Studio'],
|
||||
publishers: ['Google LLC'],
|
||||
},
|
||||
{
|
||||
|
@ -389,7 +398,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
Wow64LocalMachineUninstallKey('Notepad++'),
|
||||
],
|
||||
installLocationRegistryKey: 'DisplayIcon',
|
||||
displayNamePrefix: 'Notepad++',
|
||||
displayNamePrefixes: ['Notepad++'],
|
||||
publishers: ['Notepad++ Team'],
|
||||
},
|
||||
{
|
||||
|
@ -397,14 +406,14 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('JetBrains Rider'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('rider'),
|
||||
jetBrainsToolboxScriptName: 'rider',
|
||||
displayNamePrefix: 'JetBrains Rider',
|
||||
displayNamePrefixes: ['JetBrains Rider'],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
name: 'RStudio',
|
||||
registryKeys: [Wow64LocalMachineUninstallKey('RStudio')],
|
||||
installLocationRegistryKey: 'DisplayIcon',
|
||||
displayNamePrefix: 'RStudio',
|
||||
displayNamePrefixes: ['RStudio'],
|
||||
publishers: ['RStudio', 'Posit Software'],
|
||||
},
|
||||
{
|
||||
|
@ -412,7 +421,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('IntelliJ IDEA'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('idea'),
|
||||
jetBrainsToolboxScriptName: 'idea',
|
||||
displayNamePrefix: 'IntelliJ IDEA ',
|
||||
displayNamePrefixes: ['IntelliJ IDEA '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -421,7 +430,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
'IntelliJ IDEA Community Edition'
|
||||
),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('idea'),
|
||||
displayNamePrefix: 'IntelliJ IDEA Community Edition ',
|
||||
displayNamePrefixes: ['IntelliJ IDEA Community Edition '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -429,14 +438,14 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('PyCharm'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'),
|
||||
jetBrainsToolboxScriptName: 'pycharm',
|
||||
displayNamePrefix: 'PyCharm ',
|
||||
displayNamePrefixes: ['PyCharm '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
name: 'JetBrains PyCharm Community Edition',
|
||||
registryKeys: registryKeysForJetBrainsIDE('PyCharm Community Edition'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'),
|
||||
displayNamePrefix: 'PyCharm Community Edition',
|
||||
displayNamePrefixes: ['PyCharm Community Edition'],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -444,7 +453,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('CLion'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('clion'),
|
||||
jetBrainsToolboxScriptName: 'clion',
|
||||
displayNamePrefix: 'CLion ',
|
||||
displayNamePrefixes: ['CLion '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -452,7 +461,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('RubyMine'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('rubymine'),
|
||||
jetBrainsToolboxScriptName: 'rubymine',
|
||||
displayNamePrefix: 'RubyMine ',
|
||||
displayNamePrefixes: ['RubyMine '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -460,7 +469,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('GoLand'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('goland'),
|
||||
jetBrainsToolboxScriptName: 'goland',
|
||||
displayNamePrefix: 'GoLand ',
|
||||
displayNamePrefixes: ['GoLand '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -468,7 +477,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: [LocalMachineUninstallKey('Fleet')],
|
||||
jetBrainsToolboxScriptName: 'fleet',
|
||||
installLocationRegistryKey: 'DisplayIcon',
|
||||
displayNamePrefix: 'Fleet ',
|
||||
displayNamePrefixes: ['Fleet '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -476,7 +485,7 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('DataSpell'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('dataspell'),
|
||||
jetBrainsToolboxScriptName: 'dataspell',
|
||||
displayNamePrefix: 'DataSpell ',
|
||||
displayNamePrefixes: ['DataSpell '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
|
@ -521,7 +530,7 @@ async function findApplication(editor: WindowsExternalEditor) {
|
|||
const { displayName, publisher, installLocation } = getAppInfo(editor, keys)
|
||||
|
||||
if (
|
||||
!displayName.startsWith(editor.displayNamePrefix) ||
|
||||
!validateStartsWith(displayName, editor.displayNamePrefixes) ||
|
||||
!editor.publishers.includes(publisher)
|
||||
) {
|
||||
log.debug(`Unexpected registry entries for ${editor.name}`)
|
||||
|
|
|
@ -174,3 +174,9 @@ export const supportsAliveSessions = endpointSatisfies({
|
|||
ae: false,
|
||||
es: false,
|
||||
})
|
||||
|
||||
export const supportsRepoRules = endpointSatisfies({
|
||||
dotcom: true,
|
||||
ae: false,
|
||||
es: false,
|
||||
})
|
||||
|
|
|
@ -14,7 +14,6 @@ export function fatalError(msg: string): never {
|
|||
* in an exhaustive check.
|
||||
*
|
||||
* @param message The message to be used in the runtime exception.
|
||||
*
|
||||
*/
|
||||
export function assertNever(x: never, message: string): never {
|
||||
throw new Error(message)
|
||||
|
|
|
@ -73,6 +73,11 @@ export function enableResetToCommit(): boolean {
|
|||
return enableDevelopmentFeatures()
|
||||
}
|
||||
|
||||
/** Should we allow checking out a single commit? */
|
||||
export function enableCheckoutCommit(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
/** Should ci check runs show logs? */
|
||||
export function enableCICheckRunsLogs(): boolean {
|
||||
return false
|
||||
|
@ -87,3 +92,15 @@ export function enablePreviousTagSuggestions(): boolean {
|
|||
export function enablePullRequestQuickView(): boolean {
|
||||
return enableDevelopmentFeatures()
|
||||
}
|
||||
|
||||
export function enableMoveStash(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
export const enableCustomGitUserAgent = enableBetaFeatures
|
||||
|
||||
export function enableSectionList(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
export const enableRepoRules = enableBetaFeatures
|
||||
|
|
|
@ -38,6 +38,14 @@ export function getOS() {
|
|||
}
|
||||
}
|
||||
|
||||
/** We're currently running macOS and it is macOS Ventura. */
|
||||
export const isMacOSVentura = memoizeOne(
|
||||
() =>
|
||||
__DARWIN__ &&
|
||||
systemVersionGreaterThanOrEqualTo('13.0') &&
|
||||
systemVersionLessThan('14.0')
|
||||
)
|
||||
|
||||
/** We're currently running macOS and it is macOS Catalina or earlier. */
|
||||
export const isMacOSCatalinaOrEarlier = memoizeOne(
|
||||
() => __DARWIN__ && systemVersionLessThan('10.16')
|
||||
|
|
|
@ -15,20 +15,18 @@ import {
|
|||
} from './environment'
|
||||
import { WorkingDirectoryFileChange } from '../../models/status'
|
||||
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
|
||||
import { CommitOneLine, shortenSHA } from '../../models/commit'
|
||||
|
||||
export type ProgressCallback = (progress: ICheckoutProgress) => void
|
||||
|
||||
async function getCheckoutArgs(
|
||||
repository: Repository,
|
||||
branch: Branch,
|
||||
account: IGitAccount | null,
|
||||
progressCallback?: ProgressCallback
|
||||
) {
|
||||
const baseArgs =
|
||||
progressCallback != null
|
||||
? [...gitNetworkArguments(), 'checkout', '--progress']
|
||||
: [...gitNetworkArguments(), 'checkout']
|
||||
function getCheckoutArgs(progressCallback?: ProgressCallback) {
|
||||
return progressCallback != null
|
||||
? [...gitNetworkArguments(), 'checkout', '--progress']
|
||||
: [...gitNetworkArguments(), 'checkout']
|
||||
}
|
||||
|
||||
async function getBranchCheckoutArgs(branch: Branch) {
|
||||
const baseArgs: ReadonlyArray<string> = []
|
||||
if (enableRecurseSubmodulesFlag()) {
|
||||
return branch.type === BranchType.Remote
|
||||
? baseArgs.concat(
|
||||
|
@ -39,11 +37,62 @@ async function getCheckoutArgs(
|
|||
'--'
|
||||
)
|
||||
: baseArgs.concat(branch.name, '--recurse-submodules', '--')
|
||||
} else {
|
||||
return branch.type === BranchType.Remote
|
||||
? baseArgs.concat(branch.name, '-b', branch.nameWithoutRemote, '--')
|
||||
: baseArgs.concat(branch.name, '--')
|
||||
}
|
||||
|
||||
return branch.type === BranchType.Remote
|
||||
? baseArgs.concat(branch.name, '-b', branch.nameWithoutRemote, '--')
|
||||
: baseArgs.concat(branch.name, '--')
|
||||
}
|
||||
|
||||
async function getCheckoutOpts(
|
||||
repository: Repository,
|
||||
account: IGitAccount | null,
|
||||
title: string,
|
||||
target: string,
|
||||
progressCallback?: ProgressCallback,
|
||||
initialDescription?: string
|
||||
): Promise<IGitExecutionOptions> {
|
||||
const opts: IGitExecutionOptions = {
|
||||
env: await envForRemoteOperation(
|
||||
account,
|
||||
getFallbackUrlForProxyResolve(account, repository)
|
||||
),
|
||||
expectedErrors: AuthenticationErrors,
|
||||
}
|
||||
|
||||
if (!progressCallback) {
|
||||
return opts
|
||||
}
|
||||
|
||||
const kind = 'checkout'
|
||||
|
||||
// Initial progress
|
||||
progressCallback({
|
||||
kind,
|
||||
title,
|
||||
description: initialDescription ?? title,
|
||||
value: 0,
|
||||
target,
|
||||
})
|
||||
|
||||
return await executionOptionsWithProgress(
|
||||
{ ...opts, trackLFSProgress: true },
|
||||
new CheckoutProgressParser(),
|
||||
progress => {
|
||||
if (progress.kind === 'progress') {
|
||||
const description = progress.details.text
|
||||
const value = progress.percent
|
||||
|
||||
progressCallback({
|
||||
kind,
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
target,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -66,44 +115,59 @@ export async function checkoutBranch(
|
|||
branch: Branch,
|
||||
progressCallback?: ProgressCallback
|
||||
): Promise<true> {
|
||||
let opts: IGitExecutionOptions = {
|
||||
env: await envForRemoteOperation(
|
||||
account,
|
||||
getFallbackUrlForProxyResolve(account, repository)
|
||||
),
|
||||
expectedErrors: AuthenticationErrors,
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
const title = `Checking out branch ${branch.name}`
|
||||
const kind = 'checkout'
|
||||
const targetBranch = branch.name
|
||||
|
||||
opts = await executionOptionsWithProgress(
|
||||
{ ...opts, trackLFSProgress: true },
|
||||
new CheckoutProgressParser(),
|
||||
progress => {
|
||||
if (progress.kind === 'progress') {
|
||||
const description = progress.details.text
|
||||
const value = progress.percent
|
||||
|
||||
progressCallback({ kind, title, description, value, targetBranch })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Initial progress
|
||||
progressCallback({ kind, title, value: 0, targetBranch })
|
||||
}
|
||||
|
||||
const args = await getCheckoutArgs(
|
||||
const opts = await getCheckoutOpts(
|
||||
repository,
|
||||
branch,
|
||||
account,
|
||||
`Checking out branch ${branch.name}`,
|
||||
branch.name,
|
||||
progressCallback,
|
||||
`Switching to ${__DARWIN__ ? 'Branch' : 'branch'}`
|
||||
)
|
||||
|
||||
const baseArgs = getCheckoutArgs(progressCallback)
|
||||
const args = [...baseArgs, ...(await getBranchCheckoutArgs(branch))]
|
||||
|
||||
await git(args, repository.path, 'checkoutBranch', opts)
|
||||
|
||||
// we return `true` here so `GitStore.performFailableGitOperation`
|
||||
// will return _something_ differentiable from `undefined` if this succeeds
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out the given commit.
|
||||
* Literally invokes `git checkout <commit SHA>`.
|
||||
*
|
||||
* @param repository - The repository in which the branch checkout should
|
||||
* take place
|
||||
*
|
||||
* @param commit - The commit that should be checked out
|
||||
*
|
||||
* @param progressCallback - An optional function which will be invoked
|
||||
* with information about the current progress
|
||||
* of the checkout operation. When provided this
|
||||
* enables the '--progress' command line flag for
|
||||
* 'git checkout'.
|
||||
*/
|
||||
export async function checkoutCommit(
|
||||
repository: Repository,
|
||||
account: IGitAccount | null,
|
||||
commit: CommitOneLine,
|
||||
progressCallback?: ProgressCallback
|
||||
): Promise<true> {
|
||||
const title = `Checking out ${__DARWIN__ ? 'Commit' : 'commit'}`
|
||||
const opts = await getCheckoutOpts(
|
||||
repository,
|
||||
account,
|
||||
title,
|
||||
shortenSHA(commit.sha),
|
||||
progressCallback
|
||||
)
|
||||
|
||||
await git(args, repository.path, 'checkoutBranch', opts)
|
||||
const baseArgs = getCheckoutArgs(progressCallback)
|
||||
const args = [...baseArgs, commit.sha]
|
||||
|
||||
await git(args, repository.path, 'checkoutCommit', opts)
|
||||
|
||||
// we return `true` here so `GitStore.performFailableGitOperation`
|
||||
// will return _something_ differentiable from `undefined` if this succeeds
|
||||
|
|
|
@ -23,7 +23,6 @@ import { envForRemoteOperation } from './environment'
|
|||
* of the clone operation. When provided this enables
|
||||
* the '--progress' command line flag for
|
||||
* 'git clone'.
|
||||
*
|
||||
*/
|
||||
export async function clone(
|
||||
url: string,
|
||||
|
|
|
@ -309,7 +309,7 @@ export function parseConfigLockFilePathFromError(result: IGitResult) {
|
|||
function getDescriptionForError(error: DugiteError): string | null {
|
||||
if (isAuthFailureError(error)) {
|
||||
const menuHint = __DARWIN__
|
||||
? 'GitHub Desktop > Preferences.'
|
||||
? 'GitHub Desktop > Settings.'
|
||||
: 'File > Options.'
|
||||
return `Authentication failed. Some common reasons include:
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
* Example:
|
||||
*
|
||||
* `const { args, parse } = createLogParser({ sha: '%H' })`
|
||||
*
|
||||
*/
|
||||
export function createLogParser<T extends Record<string, string>>(fields: T) {
|
||||
const keys: Array<keyof T> = Object.keys(fields)
|
||||
|
@ -49,7 +48,6 @@ export function createLogParser<T extends Record<string, string>>(fields: T) {
|
|||
* Example:
|
||||
*
|
||||
* `const { args, parse } = createForEachRefParser({ sha: '%(objectname)' })`
|
||||
*
|
||||
*/
|
||||
export function createForEachRefParser<T extends Record<string, string>>(
|
||||
fields: T
|
||||
|
|
|
@ -25,7 +25,7 @@ export async function getRemotes(
|
|||
const output = result.stdout
|
||||
const lines = output.split('\n')
|
||||
const remotes = lines
|
||||
.filter(x => x.endsWith('(fetch)'))
|
||||
.filter(x => /\(fetch\)( \[.+\])?$/.test(x))
|
||||
.map(x => x.split(/\s+/))
|
||||
.map(x => ({ name: x[0], url: x[1] }))
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
* @param repository - The repository to update
|
||||
*
|
||||
* @param commit - The SHA of the commit to be reverted
|
||||
*
|
||||
*/
|
||||
export async function revertCommit(
|
||||
repository: Repository,
|
||||
|
|
|
@ -46,6 +46,8 @@ export async function getStashes(repository: Repository): Promise<StashResult> {
|
|||
name: '%gD',
|
||||
stashSha: '%H',
|
||||
message: '%gs',
|
||||
tree: '%T',
|
||||
parents: '%P',
|
||||
})
|
||||
|
||||
const result = await git(
|
||||
|
@ -66,17 +68,52 @@ export async function getStashes(repository: Repository): Promise<StashResult> {
|
|||
|
||||
const entries = parse(result.stdout)
|
||||
|
||||
for (const { name, message, stashSha } of entries) {
|
||||
for (const { name, message, stashSha, tree, parents } of entries) {
|
||||
const branchName = extractBranchFromMessage(message)
|
||||
|
||||
if (branchName !== null) {
|
||||
desktopEntries.push({ name, stashSha, branchName, files })
|
||||
desktopEntries.push({
|
||||
name,
|
||||
stashSha,
|
||||
branchName,
|
||||
tree,
|
||||
parents: parents.length > 0 ? parents.split(' ') : [],
|
||||
files,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { desktopEntries, stashEntryCount: entries.length - 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a stash entry to a different branch by means of creating
|
||||
* a new stash entry associated with the new branch and dropping the old
|
||||
* stash entry.
|
||||
*/
|
||||
export async function moveStashEntry(
|
||||
repository: Repository,
|
||||
{ stashSha, parents, tree }: IStashEntry,
|
||||
branchName: string
|
||||
) {
|
||||
const message = `On ${branchName}: ${createDesktopStashMessage(branchName)}`
|
||||
const parentArgs = parents.flatMap(p => ['-p', p])
|
||||
|
||||
const { stdout: commitId } = await git(
|
||||
['commit-tree', ...parentArgs, '-m', message, '--no-gpg-sign', tree],
|
||||
repository.path,
|
||||
'moveStashEntryToBranch'
|
||||
)
|
||||
|
||||
await git(
|
||||
['stash', 'store', '-m', message, commitId.trim()],
|
||||
repository.path,
|
||||
'moveStashEntryToBranch'
|
||||
)
|
||||
|
||||
await dropDesktopStashEntry(repository, stashSha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last Desktop created stash entry for the given branch
|
||||
*/
|
||||
|
|
16
app/src/lib/globals.d.ts
vendored
16
app/src/lib/globals.d.ts
vendored
|
@ -174,3 +174,19 @@ type Length<T extends any[]> = T extends { length: infer L } ? L : never
|
|||
|
||||
/** Obtain the the number of parameters of a function type */
|
||||
type ParameterCount<T extends (...args: any) => any> = Length<Parameters<T>>
|
||||
|
||||
// used for repository rules
|
||||
declare module 're2js' {
|
||||
export class RE2 {
|
||||
public matcher(toCheck: string): RE2Matcher
|
||||
}
|
||||
|
||||
export class RE2Matcher {
|
||||
public find(): boolean
|
||||
}
|
||||
|
||||
export namespace RE2JS {
|
||||
export function compile(regex: string): RE2
|
||||
export function quote(regex: string): string
|
||||
}
|
||||
}
|
||||
|
|
163
app/src/lib/helpers/repo-rules.ts
Normal file
163
app/src/lib/helpers/repo-rules.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
import { RE2, RE2JS } from 're2js'
|
||||
import {
|
||||
RepoRulesInfo,
|
||||
IRepoRulesMetadataRule,
|
||||
RepoRulesMetadataMatcher,
|
||||
RepoRuleEnforced,
|
||||
} from '../../models/repo-rules'
|
||||
import {
|
||||
APIRepoRuleMetadataOperator,
|
||||
APIRepoRuleType,
|
||||
IAPIRepoRule,
|
||||
IAPIRepoRuleMetadataParameters,
|
||||
IAPIRepoRuleset,
|
||||
} from '../api'
|
||||
|
||||
/**
|
||||
* Parses the GitHub API response for a branch's repo rules into a more useable
|
||||
* format.
|
||||
*/
|
||||
export function parseRepoRules(
|
||||
rules: ReadonlyArray<IAPIRepoRule>,
|
||||
rulesets: ReadonlyMap<number, IAPIRepoRuleset>
|
||||
): RepoRulesInfo {
|
||||
const info = new RepoRulesInfo()
|
||||
|
||||
for (const rule of rules) {
|
||||
// if a ruleset is null/undefined, then act as if the rule doesn't exist because
|
||||
// we don't know what will happen when they push
|
||||
const ruleset = rulesets.get(rule.ruleset_id)
|
||||
if (ruleset == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// a rule may be configured multiple times, and the strictest value always applies.
|
||||
// since the rule will not exist in the API response if it's not enforced, we know
|
||||
// we're always assigning either 'bypass' or true below. therefore, we only need
|
||||
// to check if the existing value is true, otherwise it can always be overridden.
|
||||
const enforced =
|
||||
ruleset.current_user_can_bypass === 'always' ? 'bypass' : true
|
||||
|
||||
switch (rule.type) {
|
||||
case APIRepoRuleType.Update:
|
||||
case APIRepoRuleType.RequiredDeployments:
|
||||
case APIRepoRuleType.RequiredSignatures:
|
||||
case APIRepoRuleType.RequiredStatusChecks:
|
||||
info.basicCommitWarning =
|
||||
info.basicCommitWarning !== true ? enforced : true
|
||||
break
|
||||
|
||||
case APIRepoRuleType.Creation:
|
||||
info.creationRestricted =
|
||||
info.creationRestricted !== true ? enforced : true
|
||||
break
|
||||
|
||||
case APIRepoRuleType.PullRequest:
|
||||
info.pullRequestRequired =
|
||||
info.pullRequestRequired !== true ? enforced : true
|
||||
break
|
||||
|
||||
case APIRepoRuleType.CommitMessagePattern:
|
||||
info.commitMessagePatterns.push(toMetadataRule(rule, enforced))
|
||||
break
|
||||
|
||||
case APIRepoRuleType.CommitAuthorEmailPattern:
|
||||
info.commitAuthorEmailPatterns.push(toMetadataRule(rule, enforced))
|
||||
break
|
||||
|
||||
case APIRepoRuleType.CommitterEmailPattern:
|
||||
info.committerEmailPatterns.push(toMetadataRule(rule, enforced))
|
||||
break
|
||||
|
||||
case APIRepoRuleType.BranchNamePattern:
|
||||
info.branchNamePatterns.push(toMetadataRule(rule, enforced))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
function toMetadataRule(
|
||||
rule: IAPIRepoRule | undefined,
|
||||
enforced: RepoRuleEnforced
|
||||
): IRepoRulesMetadataRule | undefined {
|
||||
if (!rule?.parameters) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
enforced,
|
||||
matcher: toMatcher(rule.parameters),
|
||||
humanDescription: toHumanDescription(rule.parameters),
|
||||
rulesetId: rule.ruleset_id,
|
||||
}
|
||||
}
|
||||
|
||||
function toHumanDescription(apiParams: IAPIRepoRuleMetadataParameters): string {
|
||||
let description = 'must '
|
||||
if (apiParams.negate) {
|
||||
description += 'not '
|
||||
}
|
||||
|
||||
if (apiParams.operator === APIRepoRuleMetadataOperator.RegexMatch) {
|
||||
return description + `match the regular expression "${apiParams.pattern}"`
|
||||
}
|
||||
|
||||
switch (apiParams.operator) {
|
||||
case APIRepoRuleMetadataOperator.StartsWith:
|
||||
description += 'start with '
|
||||
break
|
||||
|
||||
case APIRepoRuleMetadataOperator.EndsWith:
|
||||
description += 'end with '
|
||||
break
|
||||
|
||||
case APIRepoRuleMetadataOperator.Contains:
|
||||
description += 'contain '
|
||||
break
|
||||
}
|
||||
|
||||
return description + `"${apiParams.pattern}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given metadata rule into a matcher function that uses regex to test the rule.
|
||||
*/
|
||||
function toMatcher(
|
||||
rule: IAPIRepoRuleMetadataParameters | undefined
|
||||
): RepoRulesMetadataMatcher {
|
||||
if (!rule) {
|
||||
return () => false
|
||||
}
|
||||
|
||||
let regex: RE2
|
||||
|
||||
switch (rule.operator) {
|
||||
case APIRepoRuleMetadataOperator.StartsWith:
|
||||
regex = RE2JS.compile(`^${RE2JS.quote(rule.pattern)}`)
|
||||
break
|
||||
|
||||
case APIRepoRuleMetadataOperator.EndsWith:
|
||||
regex = RE2JS.compile(`${RE2JS.quote(rule.pattern)}$`)
|
||||
break
|
||||
|
||||
case APIRepoRuleMetadataOperator.Contains:
|
||||
regex = RE2JS.compile(`.*${RE2JS.quote(rule.pattern)}.*`)
|
||||
break
|
||||
|
||||
case APIRepoRuleMetadataOperator.RegexMatch:
|
||||
regex = RE2JS.compile(rule.pattern)
|
||||
break
|
||||
}
|
||||
|
||||
if (regex) {
|
||||
if (rule.negate) {
|
||||
return (toMatch: string) => !regex.matcher(toMatch).find()
|
||||
} else {
|
||||
return (toMatch: string) => regex.matcher(toMatch).find()
|
||||
}
|
||||
} else {
|
||||
return () => false
|
||||
}
|
||||
}
|
|
@ -33,6 +33,10 @@ const remoteRegexes: ReadonlyArray<{ protocol: GitProtocol; regex: RegExp }> = [
|
|||
protocol: 'ssh',
|
||||
regex: new RegExp('^git@(.+):([^/]+)/([^/]+?)(?:/|.git)?$'),
|
||||
},
|
||||
{
|
||||
protocol: 'ssh',
|
||||
regex: new RegExp('^(?:.+)@(.+.ghe.com):([^/]+)/([^/]+?)(?:/|.git)?$'),
|
||||
},
|
||||
{
|
||||
protocol: 'ssh',
|
||||
regex: new RegExp('^git:(.+)/([^/]+)/([^/]+?)(?:/|.git)?$'),
|
||||
|
|
|
@ -83,7 +83,7 @@ export async function launchShell(
|
|||
// platform-specific build targets.
|
||||
const exists = await pathExists(shell.path)
|
||||
if (!exists) {
|
||||
const label = __DARWIN__ ? 'Preferences' : 'Options'
|
||||
const label = __DARWIN__ ? 'Settings' : 'Options'
|
||||
throw new ShellError(
|
||||
`Could not find executable for '${shell.shell}' at path '${shell.path}'. Please open ${label} and select an available shell.`
|
||||
)
|
||||
|
|
|
@ -14,7 +14,6 @@ const squirrelTimeoutRegex =
|
|||
* friendlier message to the user.
|
||||
*
|
||||
* @param error The underlying error from Squirrel.
|
||||
*
|
||||
*/
|
||||
export function parseError(error: Error): Error | null {
|
||||
if (squirrelMissingRegex.test(error.message)) {
|
||||
|
|
|
@ -17,7 +17,12 @@ import { Branch, BranchType, IAheadBehind } from '../../models/branch'
|
|||
import { BranchesTab } from '../../models/branches-tab'
|
||||
import { CloneRepositoryTab } from '../../models/clone-repository-tab'
|
||||
import { CloningRepository } from '../../models/cloning-repository'
|
||||
import { Commit, ICommitContext, CommitOneLine } from '../../models/commit'
|
||||
import {
|
||||
Commit,
|
||||
ICommitContext,
|
||||
CommitOneLine,
|
||||
shortenSHA,
|
||||
} from '../../models/commit'
|
||||
import {
|
||||
DiffSelection,
|
||||
DiffSelectionType,
|
||||
|
@ -95,6 +100,7 @@ import {
|
|||
getEndpointForRepository,
|
||||
IAPIFullRepository,
|
||||
IAPIComment,
|
||||
IAPIRepoRuleset,
|
||||
} from '../api'
|
||||
import { shell } from '../app-shell'
|
||||
import {
|
||||
|
@ -175,6 +181,7 @@ import {
|
|||
updateRemoteHEAD,
|
||||
getBranchMergeBaseChangedFiles,
|
||||
getBranchMergeBaseDiff,
|
||||
checkoutCommit,
|
||||
} from '../git'
|
||||
import {
|
||||
installGlobalLFSFilters,
|
||||
|
@ -231,6 +238,7 @@ import {
|
|||
} from './updates/changes-state'
|
||||
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
|
||||
import { BranchPruner } from './helpers/branch-pruner'
|
||||
import { enableMoveStash, enableRepoRules } from '../feature-flag'
|
||||
import { Banner, BannerType } from '../../models/banner'
|
||||
import { ComputedAction } from '../../models/computed-action'
|
||||
import {
|
||||
|
@ -238,6 +246,7 @@ import {
|
|||
getLastDesktopStashEntryForBranch,
|
||||
popStashEntry,
|
||||
dropDesktopStashEntry,
|
||||
moveStashEntry,
|
||||
} from '../git/stash'
|
||||
import {
|
||||
UncommittedChangesStrategy,
|
||||
|
@ -315,6 +324,10 @@ import { ValidNotificationPullRequestReview } from '../valid-notification-pull-r
|
|||
import { determineMergeability } from '../git/merge-tree'
|
||||
import { PopupManager } from '../popup-manager'
|
||||
import { resizableComponentClass } from '../../ui/resizable'
|
||||
import { compare } from '../compare'
|
||||
import { parseRepoRules } from '../helpers/repo-rules'
|
||||
import { RepoRulesInfo } from '../../models/repo-rules'
|
||||
import { supportsRepoRules } from '../endpoint-capabilities'
|
||||
|
||||
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
|
||||
|
||||
|
@ -342,12 +355,14 @@ const confirmRepoRemovalDefault: boolean = true
|
|||
const confirmDiscardChangesDefault: boolean = true
|
||||
const confirmDiscardChangesPermanentlyDefault: boolean = true
|
||||
const confirmDiscardStashDefault: boolean = true
|
||||
const confirmCheckoutCommitDefault: boolean = true
|
||||
const askForConfirmationOnForcePushDefault = true
|
||||
const confirmUndoCommitDefault: boolean = true
|
||||
const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder'
|
||||
const confirmRepoRemovalKey: string = 'confirmRepoRemoval'
|
||||
const confirmDiscardChangesKey: string = 'confirmDiscardChanges'
|
||||
const confirmDiscardStashKey: string = 'confirmDiscardStash'
|
||||
const confirmCheckoutCommitKey: string = 'confirmCheckoutCommit'
|
||||
const confirmDiscardChangesPermanentlyKey: string =
|
||||
'confirmDiscardChangesPermanentlyKey'
|
||||
const confirmForcePushKey: string = 'confirmForcePush'
|
||||
|
@ -459,6 +474,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
private confirmDiscardChangesPermanently: boolean =
|
||||
confirmDiscardChangesPermanentlyDefault
|
||||
private confirmDiscardStash: boolean = confirmDiscardStashDefault
|
||||
private confirmCheckoutCommit: boolean = confirmCheckoutCommitDefault
|
||||
private askForConfirmationOnForcePush = askForConfirmationOnForcePushDefault
|
||||
private confirmUndoCommit: boolean = confirmUndoCommitDefault
|
||||
private imageDiffType: ImageDiffType = imageDiffTypeDefault
|
||||
|
@ -518,6 +534,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
| PullRequestSuggestedNextAction
|
||||
| undefined = undefined
|
||||
|
||||
private cachedRepoRulesets = new Map<number, IAPIRepoRuleset>()
|
||||
|
||||
public constructor(
|
||||
private readonly gitHubUserStore: GitHubUserStore,
|
||||
private readonly cloningRepositoriesStore: CloningRepositoriesStore,
|
||||
|
@ -958,6 +976,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
askForConfirmationOnDiscardChangesPermanently:
|
||||
this.confirmDiscardChangesPermanently,
|
||||
askForConfirmationOnDiscardStash: this.confirmDiscardStash,
|
||||
askForConfirmationOnCheckoutCommit: this.confirmCheckoutCommit,
|
||||
askForConfirmationOnForcePush: this.askForConfirmationOnForcePush,
|
||||
askForConfirmationOnUndoCommit: this.confirmUndoCommit,
|
||||
uncommittedChangesStrategy: this.uncommittedChangesStrategy,
|
||||
|
@ -986,6 +1005,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
notificationsEnabled: getNotificationsEnabled(),
|
||||
pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction,
|
||||
resizablePaneActive: this.resizablePaneActive,
|
||||
cachedRepoRulesets: this.cachedRepoRulesets,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1103,6 +1123,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
private clearBranchProtectionState(repository: Repository) {
|
||||
this.repositoryStateCache.updateChangesState(repository, () => ({
|
||||
currentBranchProtected: false,
|
||||
currentRepoRulesInfo: new RepoRulesInfo(),
|
||||
}))
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
@ -1134,6 +1155,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
if (!hasWritePermission(gitHubRepo)) {
|
||||
this.repositoryStateCache.updateChangesState(repository, () => ({
|
||||
currentBranchProtected: false,
|
||||
currentRepoRulesInfo: new RepoRulesInfo(),
|
||||
}))
|
||||
this.emitUpdate()
|
||||
return
|
||||
|
@ -1146,13 +1168,60 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
const pushControl = await api.fetchPushControl(owner, name, branchName)
|
||||
const currentBranchProtected = !isBranchPushable(pushControl)
|
||||
|
||||
let currentRepoRulesInfo = new RepoRulesInfo()
|
||||
if (enableRepoRules() && supportsRepoRules(gitHubRepo.endpoint)) {
|
||||
const slimRulesets = await api.fetchAllRepoRulesets(owner, name)
|
||||
|
||||
// ultimate goal here is to fetch all rulesets that apply to the repo
|
||||
// so they're already cached when needed later on
|
||||
if (slimRulesets?.length) {
|
||||
const rulesetIds = slimRulesets.map(r => r.id)
|
||||
|
||||
const calls: Promise<IAPIRepoRuleset | null>[] = []
|
||||
for (const id of rulesetIds) {
|
||||
// check the cache and don't re-query any that are already in there
|
||||
if (!this.cachedRepoRulesets.has(id)) {
|
||||
calls.push(api.fetchRepoRuleset(owner, name, id))
|
||||
}
|
||||
}
|
||||
|
||||
if (calls.length > 0) {
|
||||
const rulesets = await Promise.all(calls)
|
||||
this._updateCachedRepoRulesets(rulesets)
|
||||
}
|
||||
}
|
||||
|
||||
const branchRules = await api.fetchRepoRulesForBranch(
|
||||
owner,
|
||||
name,
|
||||
branchName
|
||||
)
|
||||
|
||||
if (branchRules.length > 0) {
|
||||
currentRepoRulesInfo = parseRepoRules(
|
||||
branchRules,
|
||||
this.cachedRepoRulesets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.repositoryStateCache.updateChangesState(repository, () => ({
|
||||
currentBranchProtected,
|
||||
currentRepoRulesInfo,
|
||||
}))
|
||||
this.emitUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public _updateCachedRepoRulesets(rulesets: Array<IAPIRepoRuleset | null>) {
|
||||
for (const rs of rulesets) {
|
||||
if (rs !== null) {
|
||||
this.cachedRepoRulesets.set(rs.id, rs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearSelectedCommit(repository: Repository) {
|
||||
this.repositoryStateCache.updateCommitSelection(repository, () => ({
|
||||
shas: [],
|
||||
|
@ -1251,32 +1320,28 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
isContiguous: boolean,
|
||||
commitLookup: Map<string, Commit>
|
||||
) {
|
||||
const shasInDiff = new Array<string>()
|
||||
|
||||
if (selectedShas.length <= 1 || !isContiguous) {
|
||||
return selectedShas
|
||||
}
|
||||
|
||||
const shasInDiff = new Set<string>()
|
||||
const selected = new Set(selectedShas)
|
||||
const shasToTraverse = [selectedShas.at(-1)]
|
||||
do {
|
||||
const currentSha = shasToTraverse.pop()
|
||||
if (currentSha === undefined) {
|
||||
continue
|
||||
let sha
|
||||
|
||||
while ((sha = shasToTraverse.pop()) !== undefined) {
|
||||
if (!shasInDiff.has(sha)) {
|
||||
shasInDiff.add(sha)
|
||||
|
||||
commitLookup.get(sha)?.parentSHAs?.forEach(parentSha => {
|
||||
if (selected.has(parentSha) && !shasInDiff.has(parentSha)) {
|
||||
shasToTraverse.push(parentSha)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
shasInDiff.push(currentSha)
|
||||
|
||||
// shas are selection of history -> should be in lookup -> `|| []` is for typing sake
|
||||
const parentSHAs = commitLookup.get(currentSha)?.parentSHAs || []
|
||||
|
||||
const parentsInSelection = parentSHAs.filter(parentSha =>
|
||||
selectedShas.includes(parentSha)
|
||||
)
|
||||
|
||||
shasToTraverse.push(...parentsInSelection)
|
||||
} while (shasToTraverse.length > 0)
|
||||
|
||||
return shasInDiff
|
||||
return Array.from(shasInDiff)
|
||||
}
|
||||
|
||||
private updateOrSelectFirstCommit(
|
||||
|
@ -1452,7 +1517,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
aheadBehind,
|
||||
}
|
||||
|
||||
this.repositoryStateCache.updateCompareState(repository, s => ({
|
||||
this.repositoryStateCache.updateCompareState(repository, () => ({
|
||||
formState: newState,
|
||||
filterText: comparisonBranch.name,
|
||||
commitSHAs,
|
||||
|
@ -2042,6 +2107,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
confirmDiscardStashDefault
|
||||
)
|
||||
|
||||
this.confirmCheckoutCommit = getBoolean(
|
||||
confirmCheckoutCommitKey,
|
||||
confirmCheckoutCommitDefault
|
||||
)
|
||||
|
||||
this.askForConfirmationOnForcePush = getBoolean(
|
||||
confirmForcePushKey,
|
||||
askForConfirmationOnForcePushDefault
|
||||
|
@ -3725,7 +3795,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
return this.checkoutImplementation(repository, branch, account, strategy)
|
||||
.then(() => this.onSuccessfulCheckout(repository, branch))
|
||||
.catch(e => this.emitError(new CheckoutError(e, repository, branch)))
|
||||
.then(() => this.refreshAfterCheckout(repository, branch))
|
||||
.then(() => this.refreshAfterCheckout(repository, branch.name))
|
||||
.finally(() => this.updateCheckoutProgress(repository, null))
|
||||
})
|
||||
}
|
||||
|
@ -3843,18 +3913,69 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
this.hasUserViewedStash = false
|
||||
}
|
||||
|
||||
private async refreshAfterCheckout(repository: Repository, branch: Branch) {
|
||||
/**
|
||||
* @param commitish A branch name or a commit hash
|
||||
*/
|
||||
private async refreshAfterCheckout(
|
||||
repository: Repository,
|
||||
commitish: string
|
||||
) {
|
||||
this.updateCheckoutProgress(repository, {
|
||||
kind: 'checkout',
|
||||
title: `Refreshing ${__DARWIN__ ? 'Repository' : 'repository'}`,
|
||||
description: 'Checking out',
|
||||
value: 1,
|
||||
targetBranch: branch.name,
|
||||
target: commitish,
|
||||
})
|
||||
|
||||
await this._refreshRepository(repository)
|
||||
return repository
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout the given commit, ignoring any local changes.
|
||||
*
|
||||
* Note: This shouldn't be called directly. See `Dispatcher`.
|
||||
*/
|
||||
public async _checkoutCommit(
|
||||
repository: Repository,
|
||||
commit: CommitOneLine
|
||||
): Promise<Repository> {
|
||||
const repositoryState = this.repositoryStateCache.get(repository)
|
||||
const { branchesState } = repositoryState
|
||||
const { tip } = branchesState
|
||||
|
||||
// No point in checking out the currently checked out commit.
|
||||
if (
|
||||
(tip.kind === TipState.Valid && tip.branch.tip.sha === commit.sha) ||
|
||||
(tip.kind === TipState.Detached && tip.currentSha === commit.sha)
|
||||
) {
|
||||
return repository
|
||||
}
|
||||
|
||||
return this.withAuthenticatingUser(repository, (repository, account) => {
|
||||
// We always want to end with refreshing the repository regardless of
|
||||
// whether the checkout succeeded or not in order to present the most
|
||||
// up-to-date information to the user.
|
||||
return this.checkoutCommitDefaultBehaviour(repository, commit, account)
|
||||
.catch(e => this.emitError(new Error(e)))
|
||||
.then(() =>
|
||||
this.refreshAfterCheckout(repository, shortenSHA(commit.sha))
|
||||
)
|
||||
.finally(() => this.updateCheckoutProgress(repository, null))
|
||||
})
|
||||
}
|
||||
|
||||
private async checkoutCommitDefaultBehaviour(
|
||||
repository: Repository,
|
||||
commit: CommitOneLine,
|
||||
account: IGitAccount | null
|
||||
) {
|
||||
await checkoutCommit(repository, account, commit, progress => {
|
||||
this.updateCheckoutProgress(repository, progress)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stash associated to the current checked out branch.
|
||||
*
|
||||
|
@ -4008,9 +4129,17 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
newName: string
|
||||
): Promise<void> {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
await gitStore.performFailableOperation(() =>
|
||||
renameBranch(repository, branch, newName)
|
||||
)
|
||||
await gitStore.performFailableOperation(async () => {
|
||||
await renameBranch(repository, branch, newName)
|
||||
|
||||
if (enableMoveStash()) {
|
||||
const stashEntry = gitStore.desktopStashEntries.get(branch.name)
|
||||
|
||||
if (stashEntry) {
|
||||
await moveStashEntry(repository, stashEntry, newName)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return this._refreshRepository(repository)
|
||||
}
|
||||
|
@ -5322,6 +5451,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public _setConfirmCheckoutCommitSetting(value: boolean): Promise<void> {
|
||||
this.confirmCheckoutCommit = value
|
||||
|
||||
setBoolean(confirmCheckoutCommitKey, value)
|
||||
this.emitUpdate()
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public _setConfirmForcePushSetting(value: boolean): Promise<void> {
|
||||
this.askForConfirmationOnForcePush = value
|
||||
setBoolean(confirmForcePushKey, value)
|
||||
|
@ -6443,10 +6581,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _popStashEntry(repository: Repository, stashEntry: IStashEntry) {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
await gitStore.performFailableOperation(() => {
|
||||
return popStashEntry(repository, stashEntry.stashSha)
|
||||
})
|
||||
await popStashEntry(repository, stashEntry.stashSha)
|
||||
log.info(
|
||||
`[AppStore. _popStashEntry] popped stash with commit id ${stashEntry.stashSha}`
|
||||
)
|
||||
|
@ -6648,9 +6783,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
) {
|
||||
const { compareState } = this.repositoryStateCache.get(repository)
|
||||
const { commitSHAs } = compareState
|
||||
const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i]))
|
||||
|
||||
return [...commits].sort(
|
||||
(a, b) => commitSHAs.indexOf(b.sha) - commitSHAs.indexOf(a.sha)
|
||||
return [...commits].sort((a, b) =>
|
||||
compare(commitIndexBySha.get(b.sha), commitIndexBySha.get(a.sha))
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -6667,9 +6803,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
) {
|
||||
const { compareState } = this.repositoryStateCache.get(repository)
|
||||
const { commitSHAs } = compareState
|
||||
const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i]))
|
||||
|
||||
return [...commits].sort(
|
||||
(a, b) => commitSHAs.indexOf(b) - commitSHAs.indexOf(a)
|
||||
return [...commits].sort((a, b) =>
|
||||
compare(commitIndexBySha.get(b), commitIndexBySha.get(a))
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1051,7 +1051,6 @@ export class GitStore extends BaseStore {
|
|||
* @param refspec - The association between a remote and local ref to use as
|
||||
* part of this action. Refer to git-scm for more
|
||||
* information on refspecs: https://www.git-scm.com/book/tr/v2/Git-Internals-The-Refspec
|
||||
*
|
||||
*/
|
||||
public async fetchRefspec(
|
||||
account: IGitAccount | null,
|
||||
|
@ -1171,6 +1170,10 @@ export class GitStore extends BaseStore {
|
|||
: null
|
||||
}
|
||||
|
||||
public get desktopStashEntries(): ReadonlyMap<string, IStashEntry> {
|
||||
return this._desktopStashEntries
|
||||
}
|
||||
|
||||
/** The total number of stash entries */
|
||||
public get stashEntryCount(): number {
|
||||
return this._stashEntryCount
|
||||
|
|
|
@ -80,7 +80,6 @@ export class PullRequestCoordinator {
|
|||
* the `Repository`)
|
||||
* * the parent GitHub repo, if the `Repository` has one (the
|
||||
* `upstream` remote for the `Repository`)
|
||||
*
|
||||
*/
|
||||
public onPullRequestsChanged(
|
||||
fn: (
|
||||
|
@ -119,7 +118,6 @@ export class PullRequestCoordinator {
|
|||
* the `Repository`)
|
||||
* * the parent GitHub repo, if the `Repository` has one (the
|
||||
* `upstream` remote for the `Repository`)
|
||||
*
|
||||
*/
|
||||
public onIsLoadingPullRequests(
|
||||
fn: (
|
||||
|
|
|
@ -24,6 +24,7 @@ import { merge } from '../merge'
|
|||
import { DefaultCommitMessage } from '../../models/commit-message'
|
||||
import { sendNonFatalException } from '../helpers/non-fatal-exception'
|
||||
import { StatsStore } from '../stats'
|
||||
import { RepoRulesInfo } from '../../models/repo-rules'
|
||||
|
||||
export class RepositoryStateCache {
|
||||
private readonly repositoryState = new Map<string, IRepositoryState>()
|
||||
|
@ -316,6 +317,7 @@ function getInitialRepositoryState(): IRepositoryState {
|
|||
conflictState: null,
|
||||
stashEntry: null,
|
||||
currentBranchProtected: false,
|
||||
currentRepoRulesInfo: new RepoRulesInfo(),
|
||||
},
|
||||
selectedSection: RepositorySectionTab.Changes,
|
||||
branchesState: {
|
||||
|
|
|
@ -8,6 +8,27 @@ import {
|
|||
removePendingSSHSecretToStore,
|
||||
storePendingSSHSecret,
|
||||
} from '../ssh/ssh-secret-storage'
|
||||
import { GitProcess } from 'dugite'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { enableCustomGitUserAgent } from '../feature-flag'
|
||||
|
||||
export const GitUserAgent = memoizeOne(() =>
|
||||
// Can't use git() as that will call withTrampolineEnv which calls this method
|
||||
GitProcess.exec(['--version'], process.cwd())
|
||||
// https://github.com/git/git/blob/a9e066fa63149291a55f383cfa113d8bdbdaa6b3/help.c#L733-L739
|
||||
.then(r => /git version (.*)/.exec(r.stdout)?.at(1))
|
||||
.catch(e => {
|
||||
log.warn(`Could not get git version information`, e)
|
||||
return 'unknown'
|
||||
})
|
||||
.then(v => {
|
||||
const suffix = __DEV__ ? `-${__SHA__.substring(0, 10)}` : ''
|
||||
const ghdVersion = `GitHub Desktop/${__APP_VERSION__}${suffix}`
|
||||
const { platform, arch } = process
|
||||
|
||||
return `git/${v} (${ghdVersion}; ${platform} ${arch})`
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Allows invoking a function with a set of environment variables to use when
|
||||
|
@ -42,6 +63,9 @@ export async function withTrampolineEnv<T>(
|
|||
DESKTOP_TRAMPOLINE_TOKEN: token,
|
||||
GIT_ASKPASS: getDesktopTrampolinePath(),
|
||||
DESKTOP_TRAMPOLINE_IDENTIFIER: TrampolineCommandIdentifier.AskPass,
|
||||
...(enableCustomGitUserAgent()
|
||||
? { GIT_USER_AGENT: await GitUserAgent() }
|
||||
: {}),
|
||||
|
||||
...sshEnv,
|
||||
})
|
||||
|
|
|
@ -69,7 +69,7 @@ export function buildDefaultMenu({
|
|||
},
|
||||
separator,
|
||||
{
|
||||
label: 'Preferences…',
|
||||
label: 'Settings…',
|
||||
id: 'preferences',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: emit('show-preferences'),
|
||||
|
|
12
app/src/models/accessible-message.ts
Normal file
12
app/src/models/accessible-message.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/** This is helper interface used when we have a message displayed that is a
|
||||
* JSX.Element for visual styling and that message also needs to be given to
|
||||
* screen reader users as well. Screen reader only messages should only be
|
||||
* strings to prevent tab focusable element from being rendered but not visible
|
||||
* as screen reader only messages are visually hidden */
|
||||
export interface IAccessibleMessage {
|
||||
/** A message presented to screen reader users via an aria-live component. */
|
||||
screenReaderMessage: string
|
||||
|
||||
/** A message visually displayed to the user. */
|
||||
displayedMessage: string | JSX.Element
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { RowIndexPath } from '../ui/lib/list/list-row-index-path'
|
||||
import { Commit } from './commit'
|
||||
import { GitHubRepository } from './github-repository'
|
||||
|
||||
|
@ -51,7 +52,7 @@ export type CommitTarget = {
|
|||
export type ListInsertionPointTarget = {
|
||||
type: DropTargetType.ListInsertionPoint
|
||||
data: DragData
|
||||
index: number
|
||||
index: RowIndexPath
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -60,6 +60,7 @@ export enum PopupType {
|
|||
StashAndSwitchBranch = 'StashAndSwitchBranch',
|
||||
ConfirmOverwriteStash = 'ConfirmOverwriteStash',
|
||||
ConfirmDiscardStash = 'ConfirmDiscardStash',
|
||||
ConfirmCheckoutCommit = 'ConfirmCheckoutCommit',
|
||||
CreateTutorialRepository = 'CreateTutorialRepository',
|
||||
ConfirmExitTutorial = 'ConfirmExitTutorial',
|
||||
PushRejectedDueToMissingWorkflowScope = 'PushRejectedDueToMissingWorkflowScope',
|
||||
|
@ -93,6 +94,7 @@ export enum PopupType {
|
|||
TestNotifications = 'TestNotifications',
|
||||
PullRequestComment = 'PullRequestComment',
|
||||
UnknownAuthors = 'UnknownAuthors',
|
||||
ConfirmRepoRulesBypass = 'ConfirmRepoRulesBypass',
|
||||
}
|
||||
|
||||
interface IBasePopup {
|
||||
|
@ -234,6 +236,11 @@ export type PopupDetail =
|
|||
repository: Repository
|
||||
stash: IStashEntry
|
||||
}
|
||||
| {
|
||||
type: PopupType.ConfirmCheckoutCommit
|
||||
repository: Repository
|
||||
commit: CommitOneLine
|
||||
}
|
||||
| {
|
||||
type: PopupType.CreateTutorialRepository
|
||||
account: Account
|
||||
|
@ -409,5 +416,11 @@ export type PopupDetail =
|
|||
authors: ReadonlyArray<UnknownAuthor>
|
||||
onCommit: () => void
|
||||
}
|
||||
| {
|
||||
type: PopupType.ConfirmRepoRulesBypass
|
||||
repository: GitHubRepository
|
||||
branch: string
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export type Popup = IBasePopup & PopupDetail
|
||||
|
|
|
@ -42,8 +42,13 @@ export interface IGenericProgress extends IProgress {
|
|||
export interface ICheckoutProgress extends IProgress {
|
||||
kind: 'checkout'
|
||||
|
||||
/** The branch that's currently being checked out */
|
||||
readonly targetBranch: string
|
||||
/** The branch or commit that's currently being checked out */
|
||||
readonly target: string
|
||||
|
||||
/**
|
||||
* Infotext for the user.
|
||||
*/
|
||||
readonly description: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
130
app/src/models/repo-rules.ts
Normal file
130
app/src/models/repo-rules.ts
Normal file
|
@ -0,0 +1,130 @@
|
|||
export type RepoRulesMetadataStatus = 'pass' | 'fail' | 'bypass'
|
||||
export type RepoRulesMetadataFailure = {
|
||||
description: string
|
||||
rulesetId: number
|
||||
}
|
||||
|
||||
export class RepoRulesMetadataFailures {
|
||||
public failed: RepoRulesMetadataFailure[] = []
|
||||
public bypassed: RepoRulesMetadataFailure[] = []
|
||||
|
||||
/**
|
||||
* Returns the status of the rule based on its failures.
|
||||
* 'pass' means all rules passed, 'bypass' means some rules failed
|
||||
* but the user can bypass all of the failures, and 'fail' means
|
||||
* at least one rule failed that the user cannot bypass.
|
||||
*/
|
||||
public get status(): RepoRulesMetadataStatus {
|
||||
if (this.failed.length === 0) {
|
||||
if (this.bypassed.length === 0) {
|
||||
return 'pass'
|
||||
}
|
||||
|
||||
return 'bypass'
|
||||
}
|
||||
|
||||
return 'fail'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata restrictions for a specific type of rule, as multiple can
|
||||
* be configured at once and all apply to the branch.
|
||||
*/
|
||||
export class RepoRulesMetadataRules {
|
||||
private rules: IRepoRulesMetadataRule[] = []
|
||||
|
||||
public push(rule?: IRepoRulesMetadataRule): void {
|
||||
if (rule === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
this.rules.push(rule)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any rules are configured.
|
||||
*/
|
||||
public get hasRules(): boolean {
|
||||
return this.rules.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an object containing arrays of human-readable rules that
|
||||
* fail to match the provided input string. If the returned object
|
||||
* contains only empty arrays, then all rules pass.
|
||||
*/
|
||||
public getFailedRules(toMatch: string): RepoRulesMetadataFailures {
|
||||
const failures = new RepoRulesMetadataFailures()
|
||||
for (const rule of this.rules) {
|
||||
if (!rule.matcher(toMatch)) {
|
||||
if (rule.enforced === 'bypass') {
|
||||
failures.bypassed.push({
|
||||
description: rule.humanDescription,
|
||||
rulesetId: rule.rulesetId,
|
||||
})
|
||||
} else {
|
||||
failures.failed.push({
|
||||
description: rule.humanDescription,
|
||||
rulesetId: rule.rulesetId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed repo rule info
|
||||
*/
|
||||
export class RepoRulesInfo {
|
||||
/**
|
||||
* Many rules are not handled in a special way, they
|
||||
* instead just display a warning to the user when they're
|
||||
* about to commit. They're lumped together into this flag
|
||||
* for simplicity. See the `parseRepoRules` function for
|
||||
* the full list.
|
||||
*/
|
||||
public basicCommitWarning: RepoRuleEnforced = false
|
||||
|
||||
/**
|
||||
* If true, the branch's name conflicts with a rule and
|
||||
* cannot be created.
|
||||
*/
|
||||
public creationRestricted: RepoRuleEnforced = false
|
||||
|
||||
public pullRequestRequired: RepoRuleEnforced = false
|
||||
public commitMessagePatterns = new RepoRulesMetadataRules()
|
||||
public commitAuthorEmailPatterns = new RepoRulesMetadataRules()
|
||||
public committerEmailPatterns = new RepoRulesMetadataRules()
|
||||
public branchNamePatterns = new RepoRulesMetadataRules()
|
||||
}
|
||||
|
||||
export interface IRepoRulesMetadataRule {
|
||||
/**
|
||||
* Whether this rule is enforced for the current user.
|
||||
*/
|
||||
enforced: RepoRuleEnforced
|
||||
|
||||
/**
|
||||
* Function that determines whether the provided string matches the rule.
|
||||
*/
|
||||
matcher: RepoRulesMetadataMatcher
|
||||
|
||||
/**
|
||||
* Human-readable description of the rule. For example, a 'starts_with'
|
||||
* rule with the pattern 'abc' that is negated would have a description
|
||||
* of 'must not start with "abc"'.
|
||||
*/
|
||||
humanDescription: string
|
||||
|
||||
/**
|
||||
* ID of the ruleset this rule is configured in.
|
||||
*/
|
||||
rulesetId: number
|
||||
}
|
||||
|
||||
export type RepoRulesMetadataMatcher = (toMatch: string) => boolean
|
||||
export type RepoRuleEnforced = boolean | 'bypass'
|
|
@ -12,6 +12,9 @@ export interface IStashEntry {
|
|||
|
||||
/** The list of files this stash touches */
|
||||
readonly files: StashedFileChanges
|
||||
|
||||
readonly tree: string
|
||||
readonly parents: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
/** Whether file changes for a stash entry are loaded or not */
|
||||
|
|
|
@ -2,6 +2,14 @@ import { debounce } from 'lodash'
|
|||
import React, { Component } from 'react'
|
||||
|
||||
interface IAriaLiveContainerProps {
|
||||
/** The content that will be read by the screen reader.
|
||||
*
|
||||
* Original solution used props.children, but we ran into invisible tab
|
||||
* issues when the message has a link. Thus, we are using a prop instead to
|
||||
* require the message to be a string.
|
||||
*/
|
||||
readonly message: string | null
|
||||
|
||||
/**
|
||||
* There is a common pattern that we may need to announce a message in
|
||||
* response to user input. Unfortunately, aria-live announcements are
|
||||
|
@ -45,15 +53,15 @@ export class AriaLiveContainer extends Component<
|
|||
IAriaLiveContainerState
|
||||
> {
|
||||
private suffix: string = ''
|
||||
private onTrackedInputChanged = debounce((message: JSX.Element | null) => {
|
||||
this.setState({ message })
|
||||
private onTrackedInputChanged = debounce(() => {
|
||||
this.setState({ message: this.buildMessage() })
|
||||
}, 1000)
|
||||
|
||||
public constructor(props: IAriaLiveContainerProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
message: null,
|
||||
message: this.props.message !== null ? this.buildMessage() : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,7 +70,7 @@ export class AriaLiveContainer extends Component<
|
|||
return
|
||||
}
|
||||
|
||||
this.onTrackedInputChanged(this.buildMessage())
|
||||
this.onTrackedInputChanged()
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -70,16 +78,33 @@ export class AriaLiveContainer extends Component<
|
|||
}
|
||||
|
||||
private buildMessage() {
|
||||
this.suffix = this.suffix === '' ? '\u00A0' : ''
|
||||
// We need to toggle from two non-breaking spaces to one non-breaking space
|
||||
// because VoiceOver does not detect the empty string as a change.
|
||||
this.suffix = this.suffix === '\u00A0\u00A0' ? '\u00A0' : '\u00A0\u00A0'
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.props.children}
|
||||
{this.props.message}
|
||||
{this.suffix}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
private renderMessage() {
|
||||
// We are just using this as a typical aria-live container where the message
|
||||
// changes per usage - no need to force re-reading of the same message.
|
||||
if (this.props.trackedUserInput === undefined) {
|
||||
return this.props.message
|
||||
}
|
||||
|
||||
// We are using this as a container to force re-reading of the same message,
|
||||
// so we are re-building message based on user input changes.
|
||||
// If we get a null for the children, go ahead an empty out the
|
||||
// message so we don't get an erroneous reading of a message after it is
|
||||
// gone.
|
||||
return this.props.message !== null ? this.state.message : ''
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div
|
||||
|
@ -88,7 +113,7 @@ export class AriaLiveContainer extends Component<
|
|||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{this.state.message}
|
||||
{this.renderMessage()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -6,8 +6,6 @@ import { Button } from '../lib/button'
|
|||
import { TextBox } from '../lib/text-box'
|
||||
import { Row } from '../lib/row'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Octicon } from '../octicons'
|
||||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { PopupType } from '../../models/popup'
|
||||
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
|
||||
|
@ -16,6 +14,8 @@ import { FoldoutType } from '../../lib/app-state'
|
|||
import untildify from 'untildify'
|
||||
import { showOpenDialog } from '../main-process-proxy'
|
||||
import { Ref } from '../lib/ref'
|
||||
import { InputError } from '../lib/input-description/input-error'
|
||||
import { IAccessibleMessage } from '../../models/accessible-message'
|
||||
|
||||
interface IAddExistingRepositoryProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -130,71 +130,110 @@ export class AddExistingRepository extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderWarning() {
|
||||
private buildBareRepositoryError() {
|
||||
if (
|
||||
!this.state.path.length ||
|
||||
!this.state.showNonGitRepositoryWarning ||
|
||||
!this.state.isRepositoryBare
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const msg =
|
||||
'This directory appears to be a bare repository. Bare repositories are not currently supported.'
|
||||
|
||||
return { screenReaderMessage: msg, displayedMessage: msg }
|
||||
}
|
||||
|
||||
private buildRepositoryUnsafeError() {
|
||||
const { repositoryUnsafePath, path } = this.state
|
||||
if (
|
||||
!this.state.path.length ||
|
||||
!this.state.showNonGitRepositoryWarning ||
|
||||
!this.state.isRepositoryUnsafe ||
|
||||
repositoryUnsafePath === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Git for Windows will replace backslashes with slashes in the error
|
||||
// message so we'll do the same to not show "the repo at path c:/repo"
|
||||
// when the entered path is `c:\repo`.
|
||||
const convertedPath = __WIN32__ ? path.replaceAll('\\', '/') : path
|
||||
|
||||
const displayedMessage = (
|
||||
<>
|
||||
<p>
|
||||
The Git repository
|
||||
{repositoryUnsafePath !== convertedPath && (
|
||||
<>
|
||||
{' at '}
|
||||
<Ref>{repositoryUnsafePath}</Ref>
|
||||
</>
|
||||
)}{' '}
|
||||
appears to be owned by another user on your machine. Adding untrusted
|
||||
repositories may automatically execute files in the repository.
|
||||
</p>
|
||||
<p>
|
||||
If you trust the owner of the directory you can
|
||||
<LinkButton onClick={this.onTrustDirectory}>
|
||||
{' '}
|
||||
add an exception for this directory
|
||||
</LinkButton>{' '}
|
||||
in order to continue.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
|
||||
const screenReaderMessage = `The Git repository appears to be owned by another user on your machine.
|
||||
Adding untrusted repositories may automatically execute files in the repository.
|
||||
If you trust the owner of the directory you can add an exception for this directory in order to continue.`
|
||||
|
||||
return { screenReaderMessage, displayedMessage }
|
||||
}
|
||||
|
||||
private buildNotAGitRepositoryError(): IAccessibleMessage | null {
|
||||
if (!this.state.path.length || !this.state.showNonGitRepositoryWarning) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.state.isRepositoryBare) {
|
||||
return (
|
||||
<Row className="warning-helper-text">
|
||||
<Octicon symbol={OcticonSymbol.alert} />
|
||||
<p>
|
||||
This directory appears to be a bare repository. Bare repositories
|
||||
are not currently supported.
|
||||
</p>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
const displayedMessage = (
|
||||
<>
|
||||
This directory does not appear to be a Git repository.
|
||||
<br />
|
||||
Would you like to{' '}
|
||||
<LinkButton onClick={this.onCreateRepositoryClicked}>
|
||||
create a repository
|
||||
</LinkButton>{' '}
|
||||
here instead?
|
||||
</>
|
||||
)
|
||||
|
||||
const { isRepositoryUnsafe, repositoryUnsafePath, path } = this.state
|
||||
const screenReaderMessage =
|
||||
'This directory does not appear to be a Git repository. Would you like to create a repository here instead?'
|
||||
|
||||
if (isRepositoryUnsafe && repositoryUnsafePath !== undefined) {
|
||||
// Git for Windows will replace backslashes with slashes in the error
|
||||
// message so we'll do the same to not show "the repo at path c:/repo"
|
||||
// when the entered path is `c:\repo`.
|
||||
const convertedPath = __WIN32__ ? path.replaceAll('\\', '/') : path
|
||||
return { screenReaderMessage, displayedMessage }
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="warning-helper-text">
|
||||
<Octicon symbol={OcticonSymbol.alert} />
|
||||
<div>
|
||||
<p>
|
||||
The Git repository
|
||||
{repositoryUnsafePath !== convertedPath && (
|
||||
<>
|
||||
{' at '}
|
||||
<Ref>{repositoryUnsafePath}</Ref>
|
||||
</>
|
||||
)}{' '}
|
||||
appears to be owned by another user on your machine. Adding
|
||||
untrusted repositories may automatically execute files in the
|
||||
repository.
|
||||
</p>
|
||||
<p>
|
||||
If you trust the owner of the directory you can
|
||||
<LinkButton onClick={this.onTrustDirectory}>
|
||||
add an exception for this directory
|
||||
</LinkButton>{' '}
|
||||
in order to continue.
|
||||
</p>
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
private renderErrors() {
|
||||
const msg: IAccessibleMessage | null =
|
||||
this.buildBareRepositoryError() ??
|
||||
this.buildRepositoryUnsafeError() ??
|
||||
this.buildNotAGitRepositoryError()
|
||||
|
||||
if (msg === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="warning-helper-text">
|
||||
<Octicon symbol={OcticonSymbol.alert} />
|
||||
<p>
|
||||
This directory does not appear to be a Git repository.
|
||||
<br />
|
||||
Would you like to{' '}
|
||||
<LinkButton onClick={this.onCreateRepositoryClicked}>
|
||||
create a repository
|
||||
</LinkButton>{' '}
|
||||
here instead?
|
||||
</p>
|
||||
<Row>
|
||||
<InputError
|
||||
id="add-existing-repository-path-error"
|
||||
trackedUserInput={this.state.path}
|
||||
ariaLiveMessage={msg.screenReaderMessage}
|
||||
>
|
||||
{msg.displayedMessage}
|
||||
</InputError>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -220,10 +259,11 @@ export class AddExistingRepository extends React.Component<
|
|||
label={__DARWIN__ ? 'Local Path' : 'Local path'}
|
||||
placeholder="repository path"
|
||||
onValueChanged={this.onPathChanged}
|
||||
ariaDescribedBy="add-existing-repository-path-error"
|
||||
/>
|
||||
<Button onClick={this.showFilePicker}>Choose…</Button>
|
||||
</Row>
|
||||
{this.renderWarning()}
|
||||
{this.renderErrors()}
|
||||
</DialogContent>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
@ -37,6 +37,8 @@ import { directoryExists } from '../../lib/directory-exists'
|
|||
import { FoldoutType } from '../../lib/app-state'
|
||||
import { join } from 'path'
|
||||
import { isTopMostDialog } from '../dialog/is-top-most'
|
||||
import { InputError } from '../lib/input-description/input-error'
|
||||
import { InputWarning } from '../lib/input-description/input-warning'
|
||||
|
||||
/** The sentinel value used to indicate no gitignore should be used. */
|
||||
const NoGitIgnoreValue = 'None'
|
||||
|
@ -525,7 +527,7 @@ export class CreateRepository extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderGitRepositoryWarning() {
|
||||
private renderGitRepositoryError() {
|
||||
const isRepo = this.state.isRepository
|
||||
|
||||
if (!this.state.path || this.state.path.length === 0 || !isRepo) {
|
||||
|
@ -533,15 +535,20 @@ export class CreateRepository extends React.Component<
|
|||
}
|
||||
|
||||
return (
|
||||
<Row className="warning-helper-text">
|
||||
<Octicon symbol={OcticonSymbol.alert} />
|
||||
<p>
|
||||
<Row>
|
||||
<InputError
|
||||
id="existing-repository-path-error"
|
||||
trackedUserInput={this.state.path + this.state.name}
|
||||
ariaLiveMessage={
|
||||
'This directory appears to be a Git repository. Would you like to add this repository instead?'
|
||||
}
|
||||
>
|
||||
This directory appears to be a Git repository. Would you like to{' '}
|
||||
<LinkButton onClick={this.onAddRepositoryClicked}>
|
||||
add this repository
|
||||
</LinkButton>{' '}
|
||||
instead?
|
||||
</p>
|
||||
</InputError>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -559,12 +566,16 @@ export class CreateRepository extends React.Component<
|
|||
}
|
||||
|
||||
return (
|
||||
<Row className="warning-helper-text">
|
||||
<Octicon symbol={OcticonSymbol.alert} />
|
||||
<p>
|
||||
<Row>
|
||||
<InputWarning
|
||||
id="readme-overwrite-warning"
|
||||
trackedUserInput={this.state.createWithReadme}
|
||||
ariaLiveMessage="This directory contains a README.md file already. Checking
|
||||
this box will result in the existing file being overwritten."
|
||||
>
|
||||
This directory contains a <Ref>README.md</Ref> file already. Checking
|
||||
this box will result in the existing file being overwritten.
|
||||
</p>
|
||||
</InputWarning>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -611,6 +622,7 @@ export class CreateRepository extends React.Component<
|
|||
label="Name"
|
||||
placeholder="repository name"
|
||||
onValueChanged={this.onNameChanged}
|
||||
ariaDescribedBy="existing-repository-path-error"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
|
@ -631,6 +643,7 @@ export class CreateRepository extends React.Component<
|
|||
placeholder="repository path"
|
||||
onValueChanged={this.onPathChanged}
|
||||
disabled={readOnlyPath || loadingDefaultDir}
|
||||
ariaDescribedBy="existing-repository-path-error"
|
||||
/>
|
||||
<Button
|
||||
onClick={this.showFilePicker}
|
||||
|
@ -640,7 +653,7 @@ export class CreateRepository extends React.Component<
|
|||
</Button>
|
||||
</Row>
|
||||
|
||||
{this.renderGitRepositoryWarning()}
|
||||
{this.renderGitRepositoryError()}
|
||||
|
||||
<Row>
|
||||
<Checkbox
|
||||
|
@ -651,6 +664,7 @@ export class CreateRepository extends React.Component<
|
|||
: CheckboxValue.Off
|
||||
}
|
||||
onChange={this.onCreateWithReadmeChange}
|
||||
ariaDescribedBy="readme-overwrite-warning"
|
||||
/>
|
||||
</Row>
|
||||
{this.renderReadmeOverwriteWarning()}
|
||||
|
|
|
@ -100,6 +100,7 @@ import { Banner, BannerType } from '../models/banner'
|
|||
import { StashAndSwitchBranch } from './stash-changes/stash-and-switch-branch-dialog'
|
||||
import { OverwriteStash } from './stash-changes/overwrite-stashed-changes-dialog'
|
||||
import { ConfirmDiscardStashDialog } from './stashing/confirm-discard-stash'
|
||||
import { ConfirmCheckoutCommitDialog } from './checkout/confirm-checkout-commit'
|
||||
import { CreateTutorialRepositoryDialog } from './no-repositories/create-tutorial-repository-dialog'
|
||||
import { ConfirmExitTutorial } from './tutorial'
|
||||
import { TutorialStep, isValidTutorialStep } from '../models/tutorial-step'
|
||||
|
@ -171,6 +172,7 @@ import { UnknownAuthors } from './unknown-authors/unknown-authors-dialog'
|
|||
import { UnsupportedOSBannerDismissedAtKey } from './banners/windows-version-no-longer-supported-banner'
|
||||
import { offsetFromNow } from '../lib/offset-from'
|
||||
import { getNumber } from '../lib/local-storage'
|
||||
import { RepoRulesBypassConfirmation } from './repository-rules/repo-rules-bypass-confirmation'
|
||||
|
||||
const MinuteInMilliseconds = 1000 * 60
|
||||
const HourInMilliseconds = MinuteInMilliseconds * 60
|
||||
|
@ -1606,6 +1608,9 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
this.state.askForConfirmationOnDiscardChangesPermanently
|
||||
}
|
||||
confirmDiscardStash={this.state.askForConfirmationOnDiscardStash}
|
||||
confirmCheckoutCommit={
|
||||
this.state.askForConfirmationOnCheckoutCommit
|
||||
}
|
||||
confirmForcePush={this.state.askForConfirmationOnForcePush}
|
||||
confirmUndoCommit={this.state.askForConfirmationOnUndoCommit}
|
||||
uncommittedChangesStrategy={this.state.uncommittedChangesStrategy}
|
||||
|
@ -1713,6 +1718,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
repository={repository}
|
||||
targetCommit={popup.targetCommit}
|
||||
upstreamGitHubRepository={upstreamGhRepo}
|
||||
accounts={this.state.accounts}
|
||||
cachedRepoRulesets={this.state.cachedRepoRulesets}
|
||||
onBranchCreatedFromCommit={this.onBranchCreatedFromCommit}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
dispatcher={this.props.dispatcher}
|
||||
|
@ -1985,6 +1992,22 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.ConfirmCheckoutCommit: {
|
||||
const { repository, commit } = popup
|
||||
|
||||
return (
|
||||
<ConfirmCheckoutCommitDialog
|
||||
key="confirm-checkout-commit-dialog"
|
||||
dispatcher={this.props.dispatcher}
|
||||
askForConfirmationOnCheckoutCommit={
|
||||
this.state.askForConfirmationOnDiscardStash
|
||||
}
|
||||
repository={repository}
|
||||
commit={commit}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.CreateTutorialRepository: {
|
||||
return (
|
||||
<CreateTutorialRepositoryDialog
|
||||
|
@ -2157,6 +2180,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
showBranchProtected={
|
||||
repositoryState.changesState.currentBranchProtected
|
||||
}
|
||||
repoRulesInfo={repositoryState.changesState.currentRepoRulesInfo}
|
||||
aheadBehind={repositoryState.aheadBehind}
|
||||
showCoAuthoredBy={popup.showCoAuthoredBy}
|
||||
showNoWriteAccess={!hasWritePermissionForRepository}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
|
@ -2195,6 +2220,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
askForConfirmationOnForcePush={
|
||||
this.state.askForConfirmationOnForcePush
|
||||
}
|
||||
accounts={this.state.accounts}
|
||||
cachedRepoRulesets={this.state.cachedRepoRulesets}
|
||||
openFileInExternalEditor={this.openFileInExternalEditor}
|
||||
resolvedExternalEditor={this.state.resolvedExternalEditor}
|
||||
openRepositoryInShell={this.openCurrentRepositoryInShell}
|
||||
|
@ -2482,6 +2509,17 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.ConfirmRepoRulesBypass: {
|
||||
return (
|
||||
<RepoRulesBypassConfirmation
|
||||
key="repo-rules-bypass-confirmation"
|
||||
repository={popup.repository}
|
||||
branch={popup.branch}
|
||||
onConfirm={popup.onConfirm}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return assertNever(popup, `Unknown popup type: ${popup}`)
|
||||
}
|
||||
|
@ -2839,6 +2877,11 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
top: 0,
|
||||
}
|
||||
|
||||
/** The dropdown focus trap will stop focus event propagation we made need
|
||||
* in some of our dialogs (noticed with Lists). Disabled this when dialogs
|
||||
* are open */
|
||||
const enableFocusTrap = this.state.currentPopup === null
|
||||
|
||||
return (
|
||||
<ToolbarDropdown
|
||||
icon={icon}
|
||||
|
@ -2850,6 +2893,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
onDropdownStateChanged={this.onRepositoryDropdownStateChanged}
|
||||
dropdownContentRenderer={this.renderRepositoryList}
|
||||
dropdownState={currentState}
|
||||
enableFocusTrap={enableFocusTrap}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -2932,6 +2976,11 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
aheadBehind
|
||||
)
|
||||
|
||||
/** The dropdown focus trap will stop focus event propagation we made need
|
||||
* in some of our dialogs (noticed with Lists). Disabled this when dialogs
|
||||
* are open */
|
||||
const enableFocusTrap = this.state.currentPopup === null
|
||||
|
||||
return (
|
||||
<PushPullButton
|
||||
dispatcher={this.props.dispatcher}
|
||||
|
@ -2952,6 +3001,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
isDropdownOpen={isDropdownOpen}
|
||||
askForConfirmationOnForcePush={this.state.askForConfirmationOnForcePush}
|
||||
onDropdownStateChanged={this.onPushPullDropdownStateChanged}
|
||||
enableFocusTrap={enableFocusTrap}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -3046,6 +3096,11 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
const repository = selection.repository
|
||||
const { branchesState } = selection.state
|
||||
|
||||
/** The dropdown focus trap will stop focus event propagation we made need
|
||||
* in some of our dialogs (noticed with Lists). Disabled this when dialogs
|
||||
* are open */
|
||||
const enableFocusTrap = this.state.currentPopup === null
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
dispatcher={this.props.dispatcher}
|
||||
|
@ -3062,6 +3117,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
showCIStatusPopover={this.state.showCIStatusPopover}
|
||||
emoji={this.state.emoji}
|
||||
enableFocusTrap={enableFocusTrap}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -3197,6 +3253,9 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
askForConfirmationOnDiscardStash={
|
||||
state.askForConfirmationOnDiscardStash
|
||||
}
|
||||
askForConfirmationOnCheckoutCommit={
|
||||
state.askForConfirmationOnCheckoutCommit
|
||||
}
|
||||
accounts={state.accounts}
|
||||
externalEditorLabel={externalEditorLabel}
|
||||
resolvedExternalEditor={state.resolvedExternalEditor}
|
||||
|
|
|
@ -527,10 +527,9 @@ export abstract class AutocompletingTextInput<
|
|||
{this.renderTextInput()}
|
||||
{this.renderInvisibleCaret()}
|
||||
<AriaLiveContainer
|
||||
message={autoCompleteItems.length > 0 ? suggestionsMessage : null}
|
||||
trackedUserInput={this.state.autocompletionState?.rangeText}
|
||||
>
|
||||
{autoCompleteItems.length > 0 ? suggestionsMessage : ''}
|
||||
</AriaLiveContainer>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ import { NoBranches } from './no-branches'
|
|||
import { SelectionDirection, ClickSource } from '../lib/list'
|
||||
import { generateBranchContextMenuItems } from './branch-list-item-context-menu'
|
||||
import { showContextualMenu } from '../../lib/menu-item'
|
||||
import { enableSectionList } from '../../lib/feature-flag'
|
||||
import { SectionFilterList } from '../lib/section-filter-list'
|
||||
|
||||
const RowHeight = 30
|
||||
|
||||
|
@ -168,7 +170,10 @@ export class BranchList extends React.Component<
|
|||
IBranchListProps,
|
||||
IBranchListState
|
||||
> {
|
||||
private branchFilterList: FilterList<IBranchListItem> | null = null
|
||||
private branchFilterList:
|
||||
| FilterList<IBranchListItem>
|
||||
| SectionFilterList<IBranchListItem>
|
||||
| null = null
|
||||
|
||||
public constructor(props: IBranchListProps) {
|
||||
super(props)
|
||||
|
@ -186,7 +191,32 @@ export class BranchList extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
return enableSectionList() ? (
|
||||
<SectionFilterList<IBranchListItem>
|
||||
ref={this.onBranchesFilterListRef}
|
||||
className="branches-list"
|
||||
rowHeight={RowHeight}
|
||||
filterText={this.props.filterText}
|
||||
onFilterTextChanged={this.props.onFilterTextChanged}
|
||||
onFilterKeyDown={this.props.onFilterKeyDown}
|
||||
selectedItem={this.state.selectedItem}
|
||||
renderItem={this.renderItem}
|
||||
renderGroupHeader={this.renderGroupHeader}
|
||||
onItemClick={this.onItemClick}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
onEnterPressedWithoutFilteredItems={this.onCreateNewBranch}
|
||||
groups={this.state.groups}
|
||||
invalidationProps={this.props.allBranches}
|
||||
renderPostFilter={this.onRenderNewButton}
|
||||
renderNoItems={this.onRenderNoItems}
|
||||
filterTextBox={this.props.textbox}
|
||||
hideFilterRow={this.props.hideFilterRow}
|
||||
onFilterListResultsChanged={this.props.onFilterListResultsChanged}
|
||||
renderPreList={this.props.renderPreList}
|
||||
onItemContextMenu={this.onBranchContextMenu}
|
||||
getGroupAriaLabel={this.getGroupAriaLabel}
|
||||
/>
|
||||
) : (
|
||||
<FilterList<IBranchListItem>
|
||||
ref={this.onBranchesFilterListRef}
|
||||
className="branches-list"
|
||||
|
@ -237,7 +267,10 @@ export class BranchList extends React.Component<
|
|||
}
|
||||
|
||||
private onBranchesFilterListRef = (
|
||||
filterList: FilterList<IBranchListItem> | null
|
||||
filterList:
|
||||
| FilterList<IBranchListItem>
|
||||
| SectionFilterList<IBranchListItem>
|
||||
| null
|
||||
) => {
|
||||
this.branchFilterList = filterList
|
||||
}
|
||||
|
@ -257,6 +290,12 @@ export class BranchList extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private getGroupAriaLabel = (group: number) => {
|
||||
const identifier = this.state.groups[group]
|
||||
.identifier as BranchGroupIdentifier
|
||||
return this.getGroupLabel(identifier)
|
||||
}
|
||||
|
||||
private renderGroupHeader = (label: string) => {
|
||||
const identifier = this.parseHeader(label)
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ import { startTimer } from '../lib/timing'
|
|||
import { DragType } from '../../models/drag-drop'
|
||||
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
|
||||
import { formatRelative } from '../../lib/format-relative'
|
||||
import { AriaLiveContainer } from '../accessibility/aria-live-container'
|
||||
|
||||
interface IPullRequestListItem extends IFilterListItem {
|
||||
readonly id: string
|
||||
|
@ -82,6 +83,7 @@ interface IPullRequestListState {
|
|||
readonly filterText: string
|
||||
readonly groupedItems: ReadonlyArray<IFilterListGroup<IPullRequestListItem>>
|
||||
readonly selectedItem: IPullRequestListItem | null
|
||||
readonly screenReaderStateMessage: string | null
|
||||
}
|
||||
|
||||
function resolveSelectedItem(
|
||||
|
@ -120,6 +122,7 @@ export class PullRequestList extends React.Component<
|
|||
filterText: '',
|
||||
groupedItems: [group],
|
||||
selectedItem,
|
||||
screenReaderStateMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -130,27 +133,47 @@ export class PullRequestList extends React.Component<
|
|||
nextProps,
|
||||
this.state.selectedItem
|
||||
)
|
||||
this.setState({ groupedItems: [group], selectedItem })
|
||||
|
||||
const loadingStarted =
|
||||
!this.props.isLoadingPullRequests && nextProps.isLoadingPullRequests
|
||||
const loadingComplete =
|
||||
this.props.isLoadingPullRequests && !nextProps.isLoadingPullRequests
|
||||
const numPullRequests = this.props.pullRequests.length
|
||||
const plural = numPullRequests === 1 ? '' : 's'
|
||||
const screenReaderStateMessage = loadingStarted
|
||||
? 'Hang Tight. Loading pull requests as fast as I can!'
|
||||
: loadingComplete
|
||||
? `${numPullRequests} pull request${plural} found`
|
||||
: null
|
||||
|
||||
this.setState({
|
||||
groupedItems: [group],
|
||||
selectedItem,
|
||||
screenReaderStateMessage,
|
||||
})
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<FilterList<IPullRequestListItem>
|
||||
className="pull-request-list"
|
||||
rowHeight={RowHeight}
|
||||
groups={this.state.groupedItems}
|
||||
selectedItem={this.state.selectedItem}
|
||||
renderItem={this.renderPullRequest}
|
||||
filterText={this.state.filterText}
|
||||
onFilterTextChanged={this.onFilterTextChanged}
|
||||
invalidationProps={this.props.pullRequests}
|
||||
onItemClick={this.onItemClick}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
onFilterKeyDown={this.props.onFilterKeyDown}
|
||||
renderGroupHeader={this.renderListHeader}
|
||||
renderNoItems={this.renderNoItems}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
/>
|
||||
<>
|
||||
<FilterList<IPullRequestListItem>
|
||||
className="pull-request-list"
|
||||
rowHeight={RowHeight}
|
||||
groups={this.state.groupedItems}
|
||||
selectedItem={this.state.selectedItem}
|
||||
renderItem={this.renderPullRequest}
|
||||
filterText={this.state.filterText}
|
||||
onFilterTextChanged={this.onFilterTextChanged}
|
||||
invalidationProps={this.props.pullRequests}
|
||||
onItemClick={this.onItemClick}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
onFilterKeyDown={this.props.onFilterKeyDown}
|
||||
renderGroupHeader={this.renderListHeader}
|
||||
renderNoItems={this.renderNoItems}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
/>
|
||||
<AriaLiveContainer message={this.state.screenReaderStateMessage} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -289,11 +312,14 @@ export class PullRequestList extends React.Component<
|
|||
}
|
||||
|
||||
private renderPostFilter = () => {
|
||||
const tooltip = 'Refresh the list of pull requests'
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={this.props.isLoadingPullRequests}
|
||||
onClick={this.onRefreshPullRequests}
|
||||
tooltip="Refresh the list of pull requests"
|
||||
ariaLabel={tooltip}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
<Octicon
|
||||
symbol={syncClockwise}
|
||||
|
|
|
@ -60,6 +60,10 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
? 'partially included'
|
||||
: 'not included'
|
||||
|
||||
const pathScreenReaderMessage = `${path} ${mapStatus(
|
||||
status
|
||||
)} ${includedText}`
|
||||
|
||||
return (
|
||||
<div className="file">
|
||||
<TooltippedContent
|
||||
|
@ -85,9 +89,7 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
ariaHidden={true}
|
||||
/>
|
||||
|
||||
<AriaLiveContainer>
|
||||
{path} {mapStatus(status)} {includedText}
|
||||
</AriaLiveContainer>
|
||||
<AriaLiveContainer message={pathScreenReaderMessage} />
|
||||
|
||||
<Octicon
|
||||
symbol={iconForStatus(status)}
|
||||
|
|
|
@ -55,6 +55,8 @@ import { TooltipDirection } from '../lib/tooltip'
|
|||
import { Popup } from '../../models/popup'
|
||||
import { EOL } from 'os'
|
||||
import { TooltippedContent } from '../lib/tooltipped-content'
|
||||
import { RepoRulesInfo } from '../../models/repo-rules'
|
||||
import { IAheadBehind } from '../../models/branch'
|
||||
|
||||
const RowHeight = 29
|
||||
const StashIcon: OcticonSymbol.OcticonSymbolType = {
|
||||
|
@ -169,6 +171,8 @@ interface IChangesListProps {
|
|||
readonly isCommitting: boolean
|
||||
readonly commitToAmend: Commit | null
|
||||
readonly currentBranchProtected: boolean
|
||||
readonly currentRepoRulesInfo: RepoRulesInfo
|
||||
readonly aheadBehind: IAheadBehind | null
|
||||
|
||||
/**
|
||||
* Click event handler passed directly to the onRowClick prop of List, see
|
||||
|
@ -732,6 +736,7 @@ export class ChangesList extends React.Component<
|
|||
isCommitting,
|
||||
commitToAmend,
|
||||
currentBranchProtected,
|
||||
currentRepoRulesInfo: currentRepoRulesInfo,
|
||||
} = this.props
|
||||
|
||||
if (rebaseConflictState !== null) {
|
||||
|
@ -784,6 +789,7 @@ export class ChangesList extends React.Component<
|
|||
branch={this.props.branch}
|
||||
mostRecentLocalCommit={this.props.mostRecentLocalCommit}
|
||||
commitAuthor={this.props.commitAuthor}
|
||||
dispatcher={this.props.dispatcher}
|
||||
isShowingModal={this.props.isShowingModal}
|
||||
isShowingFoldout={this.props.isShowingFoldout}
|
||||
anyFilesSelected={anyFilesSelected}
|
||||
|
@ -804,6 +810,8 @@ export class ChangesList extends React.Component<
|
|||
prepopulateCommitSummary={prepopulateCommitSummary}
|
||||
key={repository.id}
|
||||
showBranchProtected={fileCount > 0 && currentBranchProtected}
|
||||
repoRulesInfo={currentRepoRulesInfo}
|
||||
aheadBehind={this.props.aheadBehind}
|
||||
showNoWriteAccess={fileCount > 0 && !hasWritePermissionForRepository}
|
||||
shouldNudge={this.props.shouldNudgeToCommit}
|
||||
commitSpellcheckEnabled={this.props.commitSpellcheckEnabled}
|
||||
|
|
|
@ -16,6 +16,13 @@ import { OkCancelButtonGroup } from '../dialog'
|
|||
import { getConfigValue } from '../../lib/git/config'
|
||||
import { Repository } from '../../models/repository'
|
||||
import classNames from 'classnames'
|
||||
import { RepoRulesMetadataFailures } from '../../models/repo-rules'
|
||||
import { RepoRulesMetadataFailureList } from '../repository-rules/repo-rules-failure-list'
|
||||
|
||||
export type CommitMessageAvatarWarningType =
|
||||
| 'none'
|
||||
| 'misattribution'
|
||||
| 'disallowedEmail'
|
||||
|
||||
interface ICommitMessageAvatarState {
|
||||
readonly isPopoverOpen: boolean
|
||||
|
@ -34,8 +41,26 @@ interface ICommitMessageAvatarProps {
|
|||
/** Current email address configured by the user. */
|
||||
readonly email?: string
|
||||
|
||||
/** Whether or not the warning badge on the avatar should be visible. */
|
||||
readonly warningBadgeVisible: boolean
|
||||
/**
|
||||
* Controls whether a warning should be displayed.
|
||||
* - 'none': No error is displayed, the field is valid.
|
||||
* - 'misattribution': The user's Git config emails don't match and the
|
||||
* commit may not be attributed to the user.
|
||||
* - 'disallowedEmail': A repository rule may prevent the user from
|
||||
* committing with the selected email address.
|
||||
*/
|
||||
readonly warningType: CommitMessageAvatarWarningType
|
||||
|
||||
/**
|
||||
* List of validations that failed for repo rules. Only used if
|
||||
* {@link warningType} is 'disallowedEmail'.
|
||||
*/
|
||||
readonly emailRuleFailures?: RepoRulesMetadataFailures
|
||||
|
||||
/**
|
||||
* Name of the current branch
|
||||
*/
|
||||
readonly branch: string | null
|
||||
|
||||
/** Whether or not the user's account is a GHE account. */
|
||||
readonly isEnterpriseAccount: boolean
|
||||
|
@ -114,14 +139,25 @@ export class CommitMessageAvatar extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
const { warningBadgeVisible, user } = this.props
|
||||
const { warningType, user } = this.props
|
||||
|
||||
const ariaLabel = warningBadgeVisible
|
||||
? 'Commit may be misattributed. View warning.'
|
||||
: 'View commit author information'
|
||||
let ariaLabel = ''
|
||||
switch (warningType) {
|
||||
case 'none':
|
||||
ariaLabel = 'View commit author information'
|
||||
break
|
||||
|
||||
case 'misattribution':
|
||||
ariaLabel = 'Commit may be misattributed. View warning.'
|
||||
break
|
||||
|
||||
case 'disallowedEmail':
|
||||
ariaLabel = 'Email address is disallowed. View warning.'
|
||||
break
|
||||
}
|
||||
|
||||
const classes = classNames('commit-message-avatar-component', {
|
||||
misattributed: warningBadgeVisible,
|
||||
misattributed: warningType !== 'none',
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -132,7 +168,7 @@ export class CommitMessageAvatar extends React.Component<
|
|||
onButtonRef={this.onButtonRef}
|
||||
onClick={this.onAvatarClick}
|
||||
>
|
||||
{warningBadgeVisible && this.renderWarningBadge()}
|
||||
{warningType !== 'none' && this.renderWarningBadge()}
|
||||
<Avatar user={user} title={null} />
|
||||
</Button>
|
||||
{this.state.isPopoverOpen && this.renderPopover()}
|
||||
|
@ -181,7 +217,7 @@ export class CommitMessageAvatar extends React.Component<
|
|||
|
||||
const location = isGitConfigLocal ? 'local' : 'global'
|
||||
const locationDesc = isGitConfigLocal ? 'for your repository' : ''
|
||||
const settingsName = __DARWIN__ ? 'preferences' : 'options'
|
||||
const settingsName = __DARWIN__ ? 'settings' : 'options'
|
||||
const settings = isGitConfigLocal
|
||||
? 'repository settings'
|
||||
: `git ${settingsName}`
|
||||
|
@ -216,33 +252,20 @@ export class CommitMessageAvatar extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderMisattributedCommitPopover() {
|
||||
const accountTypeSuffix = this.props.isEnterpriseAccount
|
||||
? ' Enterprise'
|
||||
: ''
|
||||
private renderWarningPopover() {
|
||||
const { warningType, emailRuleFailures } = this.props
|
||||
|
||||
const updateEmailTitle = __DARWIN__ ? 'Update Email' : 'Update email'
|
||||
|
||||
const userName =
|
||||
this.props.user && this.props.user.name
|
||||
? ` for ${this.props.user.name}`
|
||||
: ''
|
||||
|
||||
return (
|
||||
const sharedHeader = (
|
||||
<>
|
||||
The email in your global Git config (
|
||||
<span className="git-email">{this.props.email}</span>)
|
||||
</>
|
||||
)
|
||||
|
||||
const sharedFooter = (
|
||||
<>
|
||||
<Row>
|
||||
<div>
|
||||
The email in your global Git config (
|
||||
<span className="git-email">{this.props.email}</span>) doesn't match
|
||||
your GitHub{accountTypeSuffix} account{userName}.{' '}
|
||||
<LinkButton
|
||||
ariaLabel="Learn more about commit attribution"
|
||||
uri="https://docs.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user"
|
||||
>
|
||||
Learn more
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Row>
|
||||
<Row>
|
||||
<Select
|
||||
label="Your Account Emails"
|
||||
|
@ -275,6 +298,54 @@ export class CommitMessageAvatar extends React.Component<
|
|||
</Row>
|
||||
</>
|
||||
)
|
||||
|
||||
if (warningType === 'misattribution') {
|
||||
const accountTypeSuffix = this.props.isEnterpriseAccount
|
||||
? ' Enterprise'
|
||||
: ''
|
||||
|
||||
const userName =
|
||||
this.props.user && this.props.user.name
|
||||
? ` for ${this.props.user.name}`
|
||||
: ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<div>
|
||||
{sharedHeader} doesn't match your GitHub{accountTypeSuffix}{' '}
|
||||
account{userName}.{' '}
|
||||
<LinkButton
|
||||
ariaLabel="Learn more about commit attribution"
|
||||
uri="https://docs.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user"
|
||||
>
|
||||
Learn more
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Row>
|
||||
{sharedFooter}
|
||||
</>
|
||||
)
|
||||
} else if (
|
||||
warningType === 'disallowedEmail' &&
|
||||
emailRuleFailures &&
|
||||
this.props.branch &&
|
||||
this.props.repository.gitHubRepository
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<RepoRulesMetadataFailureList
|
||||
repository={this.props.repository.gitHubRepository}
|
||||
branch={this.props.branch}
|
||||
failures={emailRuleFailures}
|
||||
leadingText={sharedHeader}
|
||||
/>
|
||||
{sharedFooter}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
private getCommittingAsTitle(): string | JSX.Element | undefined {
|
||||
|
@ -298,12 +369,27 @@ export class CommitMessageAvatar extends React.Component<
|
|||
}
|
||||
|
||||
private renderPopover() {
|
||||
const { warningBadgeVisible } = this.props
|
||||
const { warningType } = this.props
|
||||
|
||||
let header: string | JSX.Element | undefined = ''
|
||||
switch (this.props.warningType) {
|
||||
case 'misattribution':
|
||||
header = 'This commit will be misattributed'
|
||||
break
|
||||
|
||||
case 'disallowedEmail':
|
||||
header = 'This email address is disallowed'
|
||||
break
|
||||
|
||||
default:
|
||||
header = this.getCommittingAsTitle()
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchor={
|
||||
warningBadgeVisible
|
||||
warningType !== 'none'
|
||||
? this.warningBadgeRef.current
|
||||
: this.avatarButtonRef
|
||||
}
|
||||
|
@ -312,14 +398,10 @@ export class CommitMessageAvatar extends React.Component<
|
|||
onClickOutside={this.closePopover}
|
||||
ariaLabelledby="commit-avatar-popover-header"
|
||||
>
|
||||
<h3 id="commit-avatar-popover-header">
|
||||
{warningBadgeVisible
|
||||
? 'This commit will be misattributed'
|
||||
: this.getCommittingAsTitle()}
|
||||
</h3>
|
||||
<h3 id="commit-avatar-popover-header">{header}</h3>
|
||||
|
||||
{warningBadgeVisible
|
||||
? this.renderMisattributedCommitPopover()
|
||||
{warningType !== 'none'
|
||||
? this.renderWarningPopover()
|
||||
: this.renderGitConfigPopover()}
|
||||
</Popover>
|
||||
)
|
||||
|
|
|
@ -25,7 +25,10 @@ import { Foldout, FoldoutType } from '../../lib/app-state'
|
|||
import { IAvatarUser, getAvatarUserFromAuthor } from '../../models/avatar'
|
||||
import { showContextualMenu } from '../../lib/menu-item'
|
||||
import { Account } from '../../models/account'
|
||||
import { CommitMessageAvatar } from './commit-message-avatar'
|
||||
import {
|
||||
CommitMessageAvatar,
|
||||
CommitMessageAvatarWarningType,
|
||||
} from './commit-message-avatar'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { isAttributableEmailFor, lookupPreferredEmail } from '../../lib/email'
|
||||
import { setGlobalConfigValue } from '../../lib/git/config'
|
||||
|
@ -37,6 +40,22 @@ import { TooltipDirection } from '../lib/tooltip'
|
|||
import { pick } from '../../lib/pick'
|
||||
import { ToggledtippedContent } from '../lib/toggletipped-content'
|
||||
import { PreferencesTab } from '../../models/preferences'
|
||||
import {
|
||||
RepoRuleEnforced,
|
||||
RepoRulesInfo,
|
||||
RepoRulesMetadataFailures,
|
||||
} from '../../models/repo-rules'
|
||||
import { IAheadBehind } from '../../models/branch'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchorPosition,
|
||||
PopoverDecoration,
|
||||
} from '../lib/popover'
|
||||
import { RepoRulesetsForBranchLink } from '../repository-rules/repo-rulesets-for-branch-link'
|
||||
import { RepoRulesMetadataFailureList } from '../repository-rules/repo-rules-failure-list'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { enableRepoRules } from '../../lib/feature-flag'
|
||||
import { formatCommitMessage } from '../../lib/format-commit-message'
|
||||
|
||||
const addAuthorIcon = {
|
||||
w: 18,
|
||||
|
@ -54,6 +73,7 @@ interface ICommitMessageProps {
|
|||
readonly onCreateCommit: (context: ICommitContext) => Promise<boolean>
|
||||
readonly branch: string | null
|
||||
readonly commitAuthor: CommitIdentity | null
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly anyFilesSelected: boolean
|
||||
readonly isShowingModal: boolean
|
||||
readonly isShowingFoldout: boolean
|
||||
|
@ -73,6 +93,8 @@ interface ICommitMessageProps {
|
|||
readonly placeholder: string
|
||||
readonly prepopulateCommitSummary: boolean
|
||||
readonly showBranchProtected: boolean
|
||||
readonly repoRulesInfo: RepoRulesInfo
|
||||
readonly aheadBehind: IAheadBehind | null
|
||||
readonly showNoWriteAccess: boolean
|
||||
|
||||
/**
|
||||
|
@ -152,6 +174,12 @@ interface ICommitMessageState {
|
|||
readonly descriptionObscured: boolean
|
||||
|
||||
readonly isCommittingStatusMessage: string
|
||||
|
||||
readonly isRuleFailurePopoverOpen: boolean
|
||||
|
||||
readonly repoRuleCommitMessageFailures: RepoRulesMetadataFailures
|
||||
readonly repoRuleCommitAuthorFailures: RepoRulesMetadataFailures
|
||||
readonly repoRuleBranchNameFailures: RepoRulesMetadataFailures
|
||||
}
|
||||
|
||||
function findCommitMessageAutoCompleteProvider(
|
||||
|
@ -187,6 +215,8 @@ export class CommitMessage extends React.Component<
|
|||
|
||||
private coAuthorInputRef = React.createRef<AuthorInput>()
|
||||
|
||||
private readonly COMMIT_MSG_ERROR_BTN_ID = 'commit-message-failure-hint'
|
||||
|
||||
public constructor(props: ICommitMessageProps) {
|
||||
super(props)
|
||||
const { commitMessage } = this.props
|
||||
|
@ -201,6 +231,10 @@ export class CommitMessage extends React.Component<
|
|||
),
|
||||
descriptionObscured: false,
|
||||
isCommittingStatusMessage: '',
|
||||
isRuleFailurePopoverOpen: false,
|
||||
repoRuleCommitMessageFailures: new RepoRulesMetadataFailures(),
|
||||
repoRuleCommitAuthorFailures: new RepoRulesMetadataFailures(),
|
||||
repoRuleBranchNameFailures: new RepoRulesMetadataFailures(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -211,8 +245,9 @@ export class CommitMessage extends React.Component<
|
|||
window.removeEventListener('keydown', this.onKeyDown)
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
public async componentDidMount() {
|
||||
window.addEventListener('keydown', this.onKeyDown)
|
||||
await this.updateRepoRuleFailures(undefined, undefined, true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -258,7 +293,10 @@ export class CommitMessage extends React.Component<
|
|||
})
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: ICommitMessageProps) {
|
||||
public async componentDidUpdate(
|
||||
prevProps: ICommitMessageProps,
|
||||
prevState: ICommitMessageState
|
||||
) {
|
||||
if (
|
||||
this.props.autocompletionProviders !== prevProps.autocompletionProviders
|
||||
) {
|
||||
|
@ -305,6 +343,111 @@ export class CommitMessage extends React.Component<
|
|||
isCommittingStatusMessage: `Committed Just now - ${this.props.mostRecentLocalCommit.summary} (Sha: ${this.props.mostRecentLocalCommit.shortSha})`,
|
||||
})
|
||||
}
|
||||
|
||||
await this.updateRepoRuleFailures(prevProps, prevState)
|
||||
}
|
||||
|
||||
private async updateRepoRuleFailures(
|
||||
prevProps?: ICommitMessageProps,
|
||||
prevState?: ICommitMessageState,
|
||||
forceUpdate: boolean = false
|
||||
) {
|
||||
if (!enableRepoRules()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.updateRepoRulesCommitMessageFailures(
|
||||
prevProps,
|
||||
prevState,
|
||||
forceUpdate
|
||||
)
|
||||
this.updateRepoRulesCommitAuthorFailures(prevProps, forceUpdate)
|
||||
this.updateRepoRulesBranchNameFailures(prevProps, forceUpdate)
|
||||
}
|
||||
|
||||
private async updateRepoRulesCommitMessageFailures(
|
||||
prevProps?: ICommitMessageProps,
|
||||
prevState?: ICommitMessageState,
|
||||
forceUpdate?: boolean
|
||||
) {
|
||||
if (
|
||||
forceUpdate ||
|
||||
prevState?.summary !== this.state.summary ||
|
||||
prevState?.description !== this.state.description ||
|
||||
prevProps?.coAuthors !== this.props.coAuthors ||
|
||||
prevProps?.commitToAmend !== this.props.commitToAmend ||
|
||||
prevProps?.repository !== this.props.repository ||
|
||||
prevProps?.repoRulesInfo.commitMessagePatterns !==
|
||||
this.props.repoRulesInfo.commitMessagePatterns
|
||||
) {
|
||||
let summary = this.state.summary
|
||||
if (!summary && !this.state.description) {
|
||||
summary = this.summaryOrPlaceholder
|
||||
}
|
||||
|
||||
const context: ICommitContext = {
|
||||
summary,
|
||||
description: this.state.description,
|
||||
trailers: this.getCoAuthorTrailers(),
|
||||
amend: this.props.commitToAmend !== null,
|
||||
}
|
||||
|
||||
const msg = await formatCommitMessage(this.props.repository, context)
|
||||
const failures =
|
||||
this.props.repoRulesInfo.commitMessagePatterns.getFailedRules(msg)
|
||||
|
||||
this.setState({ repoRuleCommitMessageFailures: failures })
|
||||
}
|
||||
}
|
||||
|
||||
private updateRepoRulesCommitAuthorFailures(
|
||||
prevProps?: ICommitMessageProps,
|
||||
forceUpdate?: boolean
|
||||
) {
|
||||
if (
|
||||
forceUpdate ||
|
||||
prevProps?.commitAuthor?.email !== this.props.commitAuthor?.email ||
|
||||
prevProps?.repoRulesInfo.commitAuthorEmailPatterns !==
|
||||
this.props.repoRulesInfo.commitAuthorEmailPatterns
|
||||
) {
|
||||
const email = this.props.commitAuthor?.email
|
||||
let failures: RepoRulesMetadataFailures
|
||||
|
||||
if (!email) {
|
||||
failures = new RepoRulesMetadataFailures()
|
||||
} else {
|
||||
failures =
|
||||
this.props.repoRulesInfo.commitAuthorEmailPatterns.getFailedRules(
|
||||
email
|
||||
)
|
||||
}
|
||||
|
||||
this.setState({ repoRuleCommitAuthorFailures: failures })
|
||||
}
|
||||
}
|
||||
|
||||
private updateRepoRulesBranchNameFailures(
|
||||
prevProps?: ICommitMessageProps,
|
||||
forceUpdate?: boolean
|
||||
) {
|
||||
if (
|
||||
forceUpdate ||
|
||||
prevProps?.branch !== this.props.branch ||
|
||||
prevProps?.repoRulesInfo.branchNamePatterns !==
|
||||
this.props.repoRulesInfo.branchNamePatterns
|
||||
) {
|
||||
const branch = this.props.branch
|
||||
let failures: RepoRulesMetadataFailures
|
||||
|
||||
if (!branch) {
|
||||
failures = new RepoRulesMetadataFailures()
|
||||
} else {
|
||||
failures =
|
||||
this.props.repoRulesInfo.branchNamePatterns.getFailedRules(branch)
|
||||
}
|
||||
|
||||
this.setState({ repoRuleBranchNameFailures: failures })
|
||||
}
|
||||
}
|
||||
|
||||
private clearCommitMessage() {
|
||||
|
@ -327,7 +470,19 @@ export class CommitMessage extends React.Component<
|
|||
}
|
||||
|
||||
private onSubmit = () => {
|
||||
this.createCommit()
|
||||
if (
|
||||
this.shouldWarnForRepoRuleBypass() &&
|
||||
this.props.repository.gitHubRepository &&
|
||||
this.props.branch
|
||||
) {
|
||||
this.props.dispatcher.showRepoRulesCommitBypassWarning(
|
||||
this.props.repository.gitHubRepository,
|
||||
this.props.branch,
|
||||
() => this.createCommit()
|
||||
)
|
||||
} else {
|
||||
this.createCommit()
|
||||
}
|
||||
}
|
||||
|
||||
private getCoAuthorTrailers() {
|
||||
|
@ -391,15 +546,72 @@ export class CommitMessage extends React.Component<
|
|||
|
||||
private canCommit(): boolean {
|
||||
return (
|
||||
(this.props.anyFilesSelected === true && this.state.summary.length > 0) ||
|
||||
this.props.prepopulateCommitSummary
|
||||
((this.props.anyFilesSelected === true &&
|
||||
this.state.summary.length > 0) ||
|
||||
this.props.prepopulateCommitSummary) &&
|
||||
!this.hasRepoRuleFailure()
|
||||
)
|
||||
}
|
||||
|
||||
private canAmend(): boolean {
|
||||
return (
|
||||
this.props.commitToAmend !== null &&
|
||||
(this.state.summary.length > 0 || this.props.prepopulateCommitSummary)
|
||||
(this.state.summary.length > 0 || this.props.prepopulateCommitSummary) &&
|
||||
!this.hasRepoRuleFailure()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user will be prevented from pushing this commit due to a repo rule failure.
|
||||
*/
|
||||
private hasRepoRuleFailure(): boolean {
|
||||
if (!enableRepoRules()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
this.state.repoRuleCommitMessageFailures.status === 'fail' ||
|
||||
this.state.repoRuleCommitAuthorFailures.status === 'fail' ||
|
||||
(this.props.aheadBehind === null &&
|
||||
(this.props.repoRulesInfo.creationRestricted === true ||
|
||||
this.state.repoRuleBranchNameFailures.status === 'fail'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, then rules exist for the branch but the user is bypassing all of them.
|
||||
* Used to display a confirmation prompt.
|
||||
*/
|
||||
private shouldWarnForRepoRuleBypass(): boolean {
|
||||
const { aheadBehind, branch, repoRulesInfo } = this.props
|
||||
|
||||
if (!enableRepoRules()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if all rules pass, then nothing to warn about. if at least one rule fails, then the user won't hit this
|
||||
// in the first place because the button will be disabled. therefore, only need to check if any single
|
||||
// value is 'bypass'.
|
||||
|
||||
if (
|
||||
repoRulesInfo.basicCommitWarning === 'bypass' ||
|
||||
repoRulesInfo.pullRequestRequired === 'bypass'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.repoRuleCommitMessageFailures.status === 'bypass' ||
|
||||
this.state.repoRuleCommitAuthorFailures.status === 'bypass'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
aheadBehind === null &&
|
||||
branch !== null &&
|
||||
(repoRulesInfo.creationRestricted === 'bypass' ||
|
||||
this.state.repoRuleBranchNameFailures.status === 'bypass')
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -436,11 +648,21 @@ export class CommitMessage extends React.Component<
|
|||
const accountEmails = repositoryAccount?.emails.map(e => e.email) ?? []
|
||||
const email = commitAuthor?.email
|
||||
|
||||
const warningBadgeVisible =
|
||||
email !== undefined &&
|
||||
repositoryAccount !== null &&
|
||||
repositoryAccount !== undefined &&
|
||||
isAttributableEmailFor(repositoryAccount, email) === false
|
||||
let warningType: CommitMessageAvatarWarningType = 'none'
|
||||
if (email !== undefined) {
|
||||
if (
|
||||
enableRepoRules() &&
|
||||
this.state.repoRuleCommitAuthorFailures.status !== 'pass'
|
||||
) {
|
||||
warningType = 'disallowedEmail'
|
||||
} else if (
|
||||
repositoryAccount !== null &&
|
||||
repositoryAccount !== undefined &&
|
||||
isAttributableEmailFor(repositoryAccount, email) === false
|
||||
) {
|
||||
warningType = 'misattribution'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CommitMessageAvatar
|
||||
|
@ -449,7 +671,9 @@ export class CommitMessage extends React.Component<
|
|||
isEnterpriseAccount={
|
||||
repositoryAccount?.endpoint !== getDotComAPIEndpoint()
|
||||
}
|
||||
warningBadgeVisible={warningBadgeVisible}
|
||||
warningType={warningType}
|
||||
emailRuleFailures={this.state.repoRuleCommitAuthorFailures}
|
||||
branch={this.props.branch}
|
||||
accountEmails={accountEmails}
|
||||
preferredAccountEmail={
|
||||
repositoryAccount !== null && repositoryAccount !== undefined
|
||||
|
@ -671,14 +895,8 @@ export class CommitMessage extends React.Component<
|
|||
return <div className={className}>{this.renderCoAuthorToggleButton()}</div>
|
||||
}
|
||||
|
||||
private renderPermissionsCommitWarning() {
|
||||
const {
|
||||
commitToAmend,
|
||||
showBranchProtected,
|
||||
showNoWriteAccess,
|
||||
repository,
|
||||
branch,
|
||||
} = this.props
|
||||
private renderAmendCommitNotice() {
|
||||
const { commitToAmend } = this.props
|
||||
|
||||
if (commitToAmend !== null) {
|
||||
return (
|
||||
|
@ -690,7 +908,59 @@ export class CommitMessage extends React.Component<
|
|||
to make these changes as a new commit.
|
||||
</CommitWarning>
|
||||
)
|
||||
} else if (showNoWriteAccess) {
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private renderBranchProtectionsRepoRulesCommitWarning() {
|
||||
const {
|
||||
showNoWriteAccess,
|
||||
showBranchProtected,
|
||||
repoRulesInfo,
|
||||
aheadBehind,
|
||||
repository,
|
||||
branch,
|
||||
} = this.props
|
||||
|
||||
const { repoRuleBranchNameFailures } = this.state
|
||||
|
||||
// if one of these is not bypassable, then a failure message needs to be shown rather than just displaying
|
||||
// the first one in the if statement.
|
||||
let repoRuleWarningToDisplay: 'publish' | 'basic' | null = null
|
||||
|
||||
if (enableRepoRules()) {
|
||||
let publishStatus: RepoRuleEnforced = false
|
||||
const basicStatus = repoRulesInfo.basicCommitWarning
|
||||
|
||||
if (aheadBehind === null && branch !== null) {
|
||||
if (
|
||||
repoRulesInfo.creationRestricted === true ||
|
||||
repoRuleBranchNameFailures.status === 'fail'
|
||||
) {
|
||||
publishStatus = true
|
||||
} else if (
|
||||
repoRulesInfo.creationRestricted === 'bypass' ||
|
||||
repoRuleBranchNameFailures.status === 'bypass'
|
||||
) {
|
||||
publishStatus = 'bypass'
|
||||
} else {
|
||||
publishStatus = false
|
||||
}
|
||||
}
|
||||
|
||||
if (publishStatus === true && basicStatus) {
|
||||
repoRuleWarningToDisplay = 'publish'
|
||||
} else if (basicStatus === true) {
|
||||
repoRuleWarningToDisplay = 'basic'
|
||||
} else if (publishStatus) {
|
||||
repoRuleWarningToDisplay = 'publish'
|
||||
} else if (basicStatus) {
|
||||
repoRuleWarningToDisplay = 'basic'
|
||||
}
|
||||
}
|
||||
|
||||
if (showNoWriteAccess) {
|
||||
return (
|
||||
<CommitWarning icon={CommitWarningIcon.Warning}>
|
||||
You don't have write access to <strong>{repository.name}</strong>.
|
||||
|
@ -706,7 +976,7 @@ export class CommitMessage extends React.Component<
|
|||
// If the branch is null that means we haven't loaded the tip yet or
|
||||
// we're on a detached head. We shouldn't ever end up here with
|
||||
// showBranchProtected being true without a branch but who knows
|
||||
// what fun and exiting edge cases the future might hold
|
||||
// what fun and exciting edge cases the future might hold
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -717,11 +987,119 @@ export class CommitMessage extends React.Component<
|
|||
?
|
||||
</CommitWarning>
|
||||
)
|
||||
} else if (repoRuleWarningToDisplay === 'publish') {
|
||||
const canBypass = !(
|
||||
repoRulesInfo.creationRestricted === true ||
|
||||
this.state.repoRuleBranchNameFailures.status === 'fail'
|
||||
)
|
||||
|
||||
return (
|
||||
<CommitWarning
|
||||
icon={canBypass ? CommitWarningIcon.Warning : CommitWarningIcon.Error}
|
||||
>
|
||||
The branch name <strong>{branch}</strong> fails{' '}
|
||||
<RepoRulesetsForBranchLink
|
||||
repository={repository.gitHubRepository}
|
||||
branch={branch}
|
||||
>
|
||||
one or more rules
|
||||
</RepoRulesetsForBranchLink>{' '}
|
||||
that {canBypass ? 'would' : 'will'} prevent it from being published
|
||||
{canBypass && ', but you can bypass them. Proceed with caution!'}
|
||||
{!canBypass && (
|
||||
<>
|
||||
. Want to{' '}
|
||||
<LinkButton onClick={this.onSwitchBranch}>
|
||||
switch branches
|
||||
</LinkButton>
|
||||
?
|
||||
</>
|
||||
)}
|
||||
</CommitWarning>
|
||||
)
|
||||
} else if (repoRuleWarningToDisplay === 'basic') {
|
||||
const canBypass = repoRulesInfo.basicCommitWarning === 'bypass'
|
||||
|
||||
return (
|
||||
<CommitWarning
|
||||
icon={canBypass ? CommitWarningIcon.Warning : CommitWarningIcon.Error}
|
||||
>
|
||||
<RepoRulesetsForBranchLink
|
||||
repository={repository.gitHubRepository}
|
||||
branch={branch}
|
||||
>
|
||||
One or more rules
|
||||
</RepoRulesetsForBranchLink>{' '}
|
||||
apply to the branch <strong>{branch}</strong> that{' '}
|
||||
{canBypass ? 'would' : 'will'} prevent pushing
|
||||
{canBypass && ', but you can bypass them. Proceed with caution!'}
|
||||
{!canBypass && (
|
||||
<>
|
||||
. Want to{' '}
|
||||
<LinkButton onClick={this.onSwitchBranch}>
|
||||
switch branches
|
||||
</LinkButton>
|
||||
?
|
||||
</>
|
||||
)}
|
||||
</CommitWarning>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private renderRuleFailurePopover() {
|
||||
const { branch, repository } = this.props
|
||||
|
||||
// the failure status is checked here separately from whether the popover is open. if the
|
||||
// user has it open but rules pass as they're typing, then keep the popover logic open
|
||||
// but just don't render it. as they keep typing, if the message fails again, then the
|
||||
// popover will open back up.
|
||||
if (
|
||||
!branch ||
|
||||
!repository.gitHubRepository ||
|
||||
!enableRepoRules() ||
|
||||
this.state.repoRuleCommitMessageFailures.status === 'pass'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const header = __DARWIN__
|
||||
? 'Commit Message Rule Failures'
|
||||
: 'Commit message rule failures'
|
||||
return (
|
||||
<Popover
|
||||
anchor={this.summaryTextInput}
|
||||
anchorPosition={PopoverAnchorPosition.Right}
|
||||
decoration={PopoverDecoration.Balloon}
|
||||
minHeight={200}
|
||||
trapFocus={false}
|
||||
ariaLabelledby="commit-message-rule-failure-popover-header"
|
||||
onClickOutside={this.closeRuleFailurePopover}
|
||||
>
|
||||
<h3 id="commit-message-rule-failure-popover-header">{header}</h3>
|
||||
|
||||
<RepoRulesMetadataFailureList
|
||||
repository={repository.gitHubRepository}
|
||||
branch={branch}
|
||||
failures={this.state.repoRuleCommitMessageFailures}
|
||||
leadingText="This commit message"
|
||||
/>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
private toggleRuleFailurePopover = () => {
|
||||
this.setState({
|
||||
isRuleFailurePopoverOpen: !this.state.isRuleFailurePopoverOpen,
|
||||
})
|
||||
}
|
||||
|
||||
public closeRuleFailurePopover = () => {
|
||||
this.setState({ isRuleFailurePopoverOpen: false })
|
||||
}
|
||||
|
||||
private onSwitchBranch = () => {
|
||||
this.props.onShowFoldout({ type: FoldoutType.Branch })
|
||||
}
|
||||
|
@ -843,6 +1221,9 @@ export class CommitMessage extends React.Component<
|
|||
</div>
|
||||
</>
|
||||
}
|
||||
ariaLiveMessage={
|
||||
'Great commit summaries contain fewer than 50 characters. Place extra information in the description field.'
|
||||
}
|
||||
direction={TooltipDirection.NORTH}
|
||||
className="length-hint"
|
||||
tooltipClassName="length-hint-tooltip"
|
||||
|
@ -853,6 +1234,42 @@ export class CommitMessage extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderRepoRuleCommitMessageFailureHint(): JSX.Element | null {
|
||||
// enableRepoRules FF is checked before this method
|
||||
|
||||
if (this.state.repoRuleCommitMessageFailures.status === 'pass') {
|
||||
return null
|
||||
}
|
||||
|
||||
const canBypass =
|
||||
this.state.repoRuleCommitMessageFailures.status === 'bypass'
|
||||
|
||||
let ariaLabelPrefix: string
|
||||
let bypassMessage = ''
|
||||
if (canBypass) {
|
||||
ariaLabelPrefix = 'Warning'
|
||||
bypassMessage = ', but you can bypass them'
|
||||
} else {
|
||||
ariaLabelPrefix = 'Error'
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
id="commit-message-failure-hint"
|
||||
className="commit-message-failure-hint button-component"
|
||||
aria-label={`${ariaLabelPrefix}: Commit message fails repository rules${bypassMessage}. View details.`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={this.state.isRuleFailurePopoverOpen}
|
||||
onClick={this.toggleRuleFailurePopover}
|
||||
>
|
||||
<Octicon
|
||||
symbol={canBypass ? OcticonSymbol.alert : OcticonSymbol.stop}
|
||||
className={canBypass ? 'warning-icon' : 'error-icon'}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const className = classNames('commit-message-component', {
|
||||
'with-action-bar': this.isActionBarEnabled,
|
||||
|
@ -863,14 +1280,27 @@ export class CommitMessage extends React.Component<
|
|||
'with-overflow': this.state.descriptionObscured,
|
||||
})
|
||||
|
||||
const showSummaryLengthHint = this.state.summary.length > IdealSummaryLength
|
||||
// both of these are calculated, but only the repo rule icon is displayed if both are true, see below
|
||||
const showRepoRuleCommitMessageFailureHint =
|
||||
enableRepoRules() &&
|
||||
this.state.repoRuleCommitMessageFailures.status !== 'pass'
|
||||
|
||||
const showSummaryLengthHint =
|
||||
!showRepoRuleCommitMessageFailureHint &&
|
||||
this.state.summary.length > IdealSummaryLength
|
||||
|
||||
const summaryClassName = classNames('summary', {
|
||||
'with-length-hint': showSummaryLengthHint,
|
||||
'with-trailing-icon':
|
||||
showRepoRuleCommitMessageFailureHint || showSummaryLengthHint,
|
||||
})
|
||||
const summaryInputClassName = classNames('summary-field', 'nudge-arrow', {
|
||||
'nudge-arrow-left': this.props.shouldNudge === true,
|
||||
})
|
||||
|
||||
const ariaDescribedBy = showRepoRuleCommitMessageFailureHint
|
||||
? this.COMMIT_MSG_ERROR_BTN_ID
|
||||
: undefined
|
||||
|
||||
const { placeholder, isCommitting, commitSpellcheckEnabled } = this.props
|
||||
|
||||
return (
|
||||
|
@ -896,13 +1326,18 @@ export class CommitMessage extends React.Component<
|
|||
autocompletionProviders={
|
||||
this.state.commitMessageAutocompletionProviders
|
||||
}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
onContextMenu={this.onAutocompletingInputContextMenu}
|
||||
disabled={isCommitting === true}
|
||||
spellcheck={commitSpellcheckEnabled}
|
||||
/>
|
||||
{showRepoRuleCommitMessageFailureHint &&
|
||||
this.renderRepoRuleCommitMessageFailureHint()}
|
||||
{showSummaryLengthHint && this.renderSummaryLengthHint()}
|
||||
</div>
|
||||
|
||||
{this.state.isRuleFailurePopoverOpen && this.renderRuleFailurePopover()}
|
||||
|
||||
<FocusContainer
|
||||
className="description-focus-container"
|
||||
onClick={this.onFocusContainerClick}
|
||||
|
@ -916,6 +1351,7 @@ export class CommitMessage extends React.Component<
|
|||
autocompletionProviders={
|
||||
this.state.commitMessageAutocompletionProviders
|
||||
}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
ref={this.onDescriptionFieldRef}
|
||||
onElementRef={this.onDescriptionTextAreaRef}
|
||||
onContextMenu={this.onAutocompletingInputContextMenu}
|
||||
|
@ -927,7 +1363,8 @@ export class CommitMessage extends React.Component<
|
|||
|
||||
{this.renderCoAuthorInput()}
|
||||
|
||||
{this.renderPermissionsCommitWarning()}
|
||||
{this.renderAmendCommitNotice()}
|
||||
{this.renderBranchProtectionsRepoRulesCommitWarning()}
|
||||
|
||||
{this.renderSubmitButton()}
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
|
|
|
@ -6,6 +6,7 @@ import * as OcticonSymbol from '../octicons/octicons.generated'
|
|||
export enum CommitWarningIcon {
|
||||
Warning,
|
||||
Information,
|
||||
Error,
|
||||
}
|
||||
|
||||
const renderIcon = (icon: CommitWarningIcon) => {
|
||||
|
@ -21,6 +22,10 @@ const renderIcon = (icon: CommitWarningIcon) => {
|
|||
className = 'information-icon'
|
||||
symbol = OcticonSymbol.info
|
||||
break
|
||||
case CommitWarningIcon.Error:
|
||||
className = 'error-icon'
|
||||
symbol = OcticonSymbol.stop
|
||||
break
|
||||
default:
|
||||
assertNever(icon, `Unexpected icon value ${icon}`)
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import { filesNotTrackedByLFS } from '../../lib/git/lfs'
|
|||
import { getLargeFilePaths } from '../../lib/large-files'
|
||||
import { isConflictedFile, hasUnresolvedConflicts } from '../../lib/status'
|
||||
import { getAccountForRepository } from '../../lib/get-account-for-repository'
|
||||
import { IAheadBehind } from '../../models/branch'
|
||||
|
||||
/**
|
||||
* The timeout for the animation of the enter/leave animation for Undo.
|
||||
|
@ -41,6 +42,7 @@ const UndoCommitAnimationTimeout = 500
|
|||
interface IChangesSidebarProps {
|
||||
readonly repository: Repository
|
||||
readonly changes: IChangesState
|
||||
readonly aheadBehind: IAheadBehind | null
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly commitAuthor: CommitIdentity | null
|
||||
readonly branch: string | null
|
||||
|
@ -364,6 +366,7 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
|
|||
conflictState,
|
||||
selection,
|
||||
currentBranchProtected,
|
||||
currentRepoRulesInfo,
|
||||
} = this.props.changes
|
||||
let rebaseConflictState: RebaseConflictState | null = null
|
||||
if (conflictState !== null) {
|
||||
|
@ -429,6 +432,8 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
|
|||
currentBranchProtected={currentBranchProtected}
|
||||
shouldNudgeToCommit={this.props.shouldNudgeToCommit}
|
||||
commitSpellcheckEnabled={this.props.commitSpellcheckEnabled}
|
||||
currentRepoRulesInfo={currentRepoRulesInfo}
|
||||
aheadBehind={this.props.aheadBehind}
|
||||
/>
|
||||
{this.renderUndoCommit(rebaseConflictState)}
|
||||
</div>
|
||||
|
|
106
app/src/ui/checkout/confirm-checkout-commit.tsx
Normal file
106
app/src/ui/checkout/confirm-checkout-commit.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import * as React from 'react'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { Row } from '../lib/row'
|
||||
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
|
||||
import { Checkbox, CheckboxValue } from '../lib/checkbox'
|
||||
import { CommitOneLine } from '../../models/commit'
|
||||
|
||||
interface IConfirmCheckoutCommitProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly repository: Repository
|
||||
readonly commit: CommitOneLine
|
||||
readonly askForConfirmationOnCheckoutCommit: boolean
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
interface IConfirmCheckoutCommitState {
|
||||
readonly isCheckingOut: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
}
|
||||
/**
|
||||
* Dialog to confirm checking out a commit
|
||||
*/
|
||||
export class ConfirmCheckoutCommitDialog extends React.Component<
|
||||
IConfirmCheckoutCommitProps,
|
||||
IConfirmCheckoutCommitState
|
||||
> {
|
||||
public constructor(props: IConfirmCheckoutCommitProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
isCheckingOut: false,
|
||||
confirmCheckoutCommit: props.askForConfirmationOnCheckoutCommit,
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const title = __DARWIN__ ? 'Checkout Commit?' : 'Checkout commit?'
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id="checkout-commit"
|
||||
type="warning"
|
||||
title={title}
|
||||
loading={this.state.isCheckingOut}
|
||||
disabled={this.state.isCheckingOut}
|
||||
onSubmit={this.onSubmit}
|
||||
onDismissed={this.props.onDismissed}
|
||||
ariaDescribedBy="checking-out-commit-confirmation"
|
||||
role="alertdialog"
|
||||
>
|
||||
<DialogContent>
|
||||
<Row id="checking-out-commit-confirmation">
|
||||
Checking out a commit will create a detached HEAD, and you will no
|
||||
longer be on any branch. Are you sure you want to checkout this
|
||||
commit?
|
||||
</Row>
|
||||
<Row>
|
||||
<Checkbox
|
||||
label="Do not show this message again"
|
||||
value={
|
||||
this.state.confirmCheckoutCommit
|
||||
? CheckboxValue.Off
|
||||
: CheckboxValue.On
|
||||
}
|
||||
onChange={this.onaskForConfirmationOnCheckoutCommitChanged}
|
||||
/>
|
||||
</Row>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup destructive={true} okButtonText="Checkout" />
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private onaskForConfirmationOnCheckoutCommitChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = !event.currentTarget.checked
|
||||
|
||||
this.setState({ confirmCheckoutCommit: value })
|
||||
}
|
||||
|
||||
private onSubmit = async () => {
|
||||
const { dispatcher, repository, commit, onDismissed } = this.props
|
||||
|
||||
this.setState({
|
||||
isCheckingOut: true,
|
||||
})
|
||||
|
||||
try {
|
||||
dispatcher.setConfirmCheckoutCommitSetting(
|
||||
this.state.confirmCheckoutCommit
|
||||
)
|
||||
await dispatcher.checkoutCommit(repository, commit)
|
||||
} finally {
|
||||
this.setState({
|
||||
isCheckingOut: false,
|
||||
})
|
||||
}
|
||||
|
||||
onDismissed()
|
||||
}
|
||||
}
|
|
@ -15,6 +15,8 @@ import { HighlightText } from '../lib/highlight-text'
|
|||
import { ClickSource } from '../lib/list'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { Ref } from '../lib/ref'
|
||||
import { enableSectionList } from '../../lib/feature-flag'
|
||||
import { SectionFilterList } from '../lib/section-filter-list'
|
||||
|
||||
interface ICloneableRepositoryFilterListProps {
|
||||
/** The account to clone from. */
|
||||
|
@ -157,26 +159,34 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
|
|||
const { repositories, account, selectedItem } = this.props
|
||||
|
||||
const groups = this.getRepositoryGroups(repositories, account.login)
|
||||
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
|
||||
const getGroupAriaLabel = (group: number) => {
|
||||
const groupIdentifier = groups[group].identifier
|
||||
return groupIdentifier === YourRepositoriesIdentifier
|
||||
? this.getYourRepositoriesLabel()
|
||||
: groupIdentifier
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterList<ICloneableRepositoryListItem>
|
||||
className="clone-github-repo"
|
||||
rowHeight={RowHeight}
|
||||
selectedItem={selectedListItem}
|
||||
renderItem={this.renderItem}
|
||||
renderGroupHeader={this.renderGroupHeader}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
invalidationProps={groups}
|
||||
groups={groups}
|
||||
filterText={this.props.filterText}
|
||||
onFilterTextChanged={this.props.onFilterTextChanged}
|
||||
renderNoItems={this.renderNoItems}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
onItemClick={this.props.onItemClicked ? this.onItemClick : undefined}
|
||||
placeholderText="Filter your repositories"
|
||||
/>
|
||||
)
|
||||
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
|
||||
const ListComponent = enableSectionList() ? SectionFilterList : FilterList
|
||||
const filterListProps: typeof ListComponent['prototype']['props'] = {
|
||||
className: 'clone-github-repo',
|
||||
rowHeight: RowHeight,
|
||||
selectedItem: selectedListItem,
|
||||
renderItem: this.renderItem,
|
||||
renderGroupHeader: this.renderGroupHeader,
|
||||
onSelectionChanged: this.onSelectionChanged,
|
||||
invalidationProps: groups,
|
||||
groups: groups,
|
||||
filterText: this.props.filterText,
|
||||
onFilterTextChanged: this.props.onFilterTextChanged,
|
||||
renderNoItems: this.renderNoItems,
|
||||
renderPostFilter: this.renderPostFilter,
|
||||
onItemClick: this.props.onItemClicked ? this.onItemClick : undefined,
|
||||
placeholderText: 'Filter your repositories',
|
||||
getGroupAriaLabel,
|
||||
}
|
||||
|
||||
return <ListComponent {...filterListProps} />
|
||||
}
|
||||
|
||||
private onItemClick = (
|
||||
|
@ -206,10 +216,14 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
|
|||
}
|
||||
}
|
||||
|
||||
private getYourRepositoriesLabel = () => {
|
||||
return __DARWIN__ ? 'Your Repositories' : 'Your repositories'
|
||||
}
|
||||
|
||||
private renderGroupHeader = (identifier: string) => {
|
||||
let header = identifier
|
||||
if (identifier === YourRepositoriesIdentifier) {
|
||||
header = __DARWIN__ ? 'Your Repositories' : 'Your repositories'
|
||||
header = this.getYourRepositoriesLabel()
|
||||
}
|
||||
return (
|
||||
<div className="clone-repository-list-content clone-repository-list-group-header">
|
||||
|
@ -228,16 +242,20 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
|
|||
<div className="name" title={item.text[0]}>
|
||||
<HighlightText text={item.text[0]} highlight={matches.title} />
|
||||
</div>
|
||||
{item.archived && <div className="archived">Archived</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderPostFilter = () => {
|
||||
const tooltip = 'Refresh the list of repositories'
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={this.props.loading}
|
||||
onClick={this.refreshRepositories}
|
||||
tooltip="Refresh the list of repositories"
|
||||
ariaLabel={tooltip}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
<Octicon
|
||||
symbol={syncClockwise}
|
||||
|
|
|
@ -23,6 +23,9 @@ export interface ICloneableRepositoryListItem extends IFilterListItem {
|
|||
|
||||
/** The clone URL. */
|
||||
readonly url: string
|
||||
|
||||
/** Whether or not the repository is archived */
|
||||
readonly archived?: boolean
|
||||
}
|
||||
|
||||
function getIcon(gitHubRepo: IAPIRepository): OcticonSymbolType {
|
||||
|
@ -44,6 +47,7 @@ const toListItems = (repositories: ReadonlyArray<IAPIRepository>) =>
|
|||
url: repo.clone_url,
|
||||
name: repo.name,
|
||||
icon: getIcon(repo),
|
||||
archived: repo.archived,
|
||||
}))
|
||||
.sort((x, y) => compare(x.name, y.name))
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@ import { Popup } from '../../models/popup'
|
|||
import { Foldout } from '../../lib/app-state'
|
||||
import { Account } from '../../models/account'
|
||||
import { pick } from '../../lib/pick'
|
||||
import { RepoRulesInfo } from '../../models/repo-rules'
|
||||
import { IAheadBehind } from '../../models/branch'
|
||||
|
||||
interface ICommitMessageDialogProps {
|
||||
/**
|
||||
|
@ -70,6 +72,11 @@ interface ICommitMessageDialogProps {
|
|||
/** Whether to warn the user that they are on a protected branch. */
|
||||
readonly showBranchProtected: boolean
|
||||
|
||||
/** Repository rules that apply to the branch. */
|
||||
readonly repoRulesInfo: RepoRulesInfo
|
||||
|
||||
readonly aheadBehind: IAheadBehind | null
|
||||
|
||||
/**
|
||||
* Whether or not to show a field for adding co-authors to a commit
|
||||
* (currently only supported for GH/GHE repositories)
|
||||
|
@ -114,6 +121,7 @@ export class CommitMessageDialog extends React.Component<
|
|||
branch={this.props.branch}
|
||||
mostRecentLocalCommit={null}
|
||||
commitAuthor={this.props.commitAuthor}
|
||||
dispatcher={this.props.dispatcher}
|
||||
isShowingModal={true}
|
||||
isShowingFoldout={false}
|
||||
commitButtonText={this.props.dialogButtonText}
|
||||
|
@ -128,6 +136,8 @@ export class CommitMessageDialog extends React.Component<
|
|||
prepopulateCommitSummary={this.props.prepopulateCommitSummary}
|
||||
key={this.props.repository.id}
|
||||
showBranchProtected={this.props.showBranchProtected}
|
||||
repoRulesInfo={this.props.repoRulesInfo}
|
||||
aheadBehind={this.props.aheadBehind}
|
||||
showNoWriteAccess={this.props.showNoWriteAccess}
|
||||
commitSpellcheckEnabled={this.props.commitSpellcheckEnabled}
|
||||
onCoAuthorsUpdated={this.onCoAuthorsUpdated}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Branch, StartPoint } from '../../models/branch'
|
|||
import { Row } from '../lib/row'
|
||||
import { Ref } from '../lib/ref'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { Dialog, DialogError, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import {
|
||||
VerticalSegmentedControl,
|
||||
ISegmentedItem,
|
||||
|
@ -28,11 +28,21 @@ import { CommitOneLine } from '../../models/commit'
|
|||
import { PopupType } from '../../models/popup'
|
||||
import { RepositorySettingsTab } from '../repository-settings/repository-settings'
|
||||
import { isRepositoryWithForkedGitHubRepository } from '../../models/repository'
|
||||
import { debounce } from 'lodash'
|
||||
import { API, APIRepoRuleType, IAPIRepoRuleset } from '../../lib/api'
|
||||
import { Account } from '../../models/account'
|
||||
import { getAccountForRepository } from '../../lib/get-account-for-repository'
|
||||
import { supportsRepoRules } from '../../lib/endpoint-capabilities'
|
||||
import { enableRepoRules } from '../../lib/feature-flag'
|
||||
import { InputError } from '../lib/input-description/input-error'
|
||||
import { InputWarning } from '../lib/input-description/input-warning'
|
||||
|
||||
interface ICreateBranchProps {
|
||||
readonly repository: Repository
|
||||
readonly targetCommit?: CommitOneLine
|
||||
readonly upstreamGitHubRepository: GitHubRepository | null
|
||||
readonly accounts: ReadonlyArray<Account>
|
||||
readonly cachedRepoRulesets: ReadonlyMap<number, IAPIRepoRuleset>
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly onBranchCreatedFromCommit?: () => void
|
||||
readonly onDismissed: () => void
|
||||
|
@ -63,7 +73,7 @@ interface ICreateBranchProps {
|
|||
}
|
||||
|
||||
interface ICreateBranchState {
|
||||
readonly currentError: Error | null
|
||||
readonly currentError: { error: Error; isWarning: boolean } | null
|
||||
readonly branchName: string
|
||||
readonly startPoint: StartPoint
|
||||
|
||||
|
@ -101,6 +111,92 @@ export class CreateBranch extends React.Component<
|
|||
ICreateBranchProps,
|
||||
ICreateBranchState
|
||||
> {
|
||||
/**
|
||||
* Checks repo rules to see if the provided branch name is valid for the
|
||||
* current user and repository. The "get all rules for a branch" endpoint
|
||||
* is called first, and if a "creation" or "branch name" rule is found,
|
||||
* then those rulesets are checked to see if the current user can bypass
|
||||
* them.
|
||||
*/
|
||||
private checkBranchRules = debounce(async (branchName: string) => {
|
||||
if (
|
||||
this.props.accounts.length === 0 ||
|
||||
this.props.upstreamGitHubRepository === null ||
|
||||
branchName === '' ||
|
||||
this.state.currentError !== null ||
|
||||
!enableRepoRules() ||
|
||||
!supportsRepoRules(this.props.upstreamGitHubRepository.endpoint)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const account = getAccountForRepository(
|
||||
this.props.accounts,
|
||||
this.props.repository
|
||||
)
|
||||
|
||||
if (account === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const api = API.fromAccount(account)
|
||||
const branchRules = await api.fetchRepoRulesForBranch(
|
||||
this.props.upstreamGitHubRepository.owner.login,
|
||||
this.props.upstreamGitHubRepository.name,
|
||||
branchName
|
||||
)
|
||||
|
||||
// filter the rules to only the relevant ones and get their IDs. use a Set to dedupe.
|
||||
const toCheckForBypass = new Set(
|
||||
branchRules
|
||||
.filter(
|
||||
r =>
|
||||
r.type === APIRepoRuleType.Creation ||
|
||||
r.type === APIRepoRuleType.BranchNamePattern
|
||||
)
|
||||
.map(r => r.ruleset_id)
|
||||
)
|
||||
|
||||
// there are no relevant rules for this branch name, so return
|
||||
if (toCheckForBypass.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// check cached rulesets to see which ones the user can bypass
|
||||
let cannotBypass = false
|
||||
for (const id of toCheckForBypass) {
|
||||
const rs = this.props.cachedRepoRulesets.get(id)
|
||||
|
||||
if (!rs?.current_user_can_bypass) {
|
||||
// the user cannot bypass, so stop checking
|
||||
cannotBypass = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (cannotBypass) {
|
||||
this.setState({
|
||||
currentError: {
|
||||
error: new Error(
|
||||
`Branch name '${branchName}' is restricted by repo rules.`
|
||||
),
|
||||
isWarning: false,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
currentError: {
|
||||
error: new Error(
|
||||
`Branch name '${branchName}' is restricted by repo rules, but you can bypass them. Proceed with caution!`
|
||||
),
|
||||
isWarning: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, 500)
|
||||
|
||||
private readonly ERRORS_ID = 'branch-name-errors'
|
||||
|
||||
public constructor(props: ICreateBranchProps) {
|
||||
super(props)
|
||||
|
||||
|
@ -190,6 +286,37 @@ export class CreateBranch extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private renderBranchNameErrors() {
|
||||
const { currentError } = this.state
|
||||
if (!currentError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentError.isWarning) {
|
||||
return (
|
||||
<Row>
|
||||
<InputWarning
|
||||
id={this.ERRORS_ID}
|
||||
trackedUserInput={this.state.branchName}
|
||||
>
|
||||
{currentError.error.message}
|
||||
</InputWarning>
|
||||
</Row>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Row>
|
||||
<InputError
|
||||
id={this.ERRORS_ID}
|
||||
trackedUserInput={this.state.branchName}
|
||||
>
|
||||
{currentError.error.message}
|
||||
</InputError>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private onBaseBranchChanged = (startPoint: StartPoint) => {
|
||||
this.setState({
|
||||
startPoint,
|
||||
|
@ -199,9 +326,9 @@ export class CreateBranch extends React.Component<
|
|||
public render() {
|
||||
const disabled =
|
||||
this.state.branchName.length <= 0 ||
|
||||
!!this.state.currentError ||
|
||||
(!!this.state.currentError && !this.state.currentError.isWarning) ||
|
||||
/^\s*$/.test(this.state.branchName)
|
||||
const error = this.state.currentError
|
||||
const hasError = !!this.state.currentError
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
@ -212,15 +339,16 @@ export class CreateBranch extends React.Component<
|
|||
loading={this.state.isCreatingBranch}
|
||||
disabled={this.state.isCreatingBranch}
|
||||
>
|
||||
{error ? <DialogError>{error.message}</DialogError> : null}
|
||||
|
||||
<DialogContent>
|
||||
<RefNameTextBox
|
||||
label="Name"
|
||||
ariaDescribedBy={hasError ? this.ERRORS_ID : undefined}
|
||||
initialValue={this.props.initialName}
|
||||
onValueChange={this.onBranchNameChange}
|
||||
/>
|
||||
|
||||
{this.renderBranchNameErrors()}
|
||||
|
||||
{renderBranchNameExistsOnRemoteWarning(
|
||||
this.state.branchName,
|
||||
this.props.allBranches
|
||||
|
@ -259,14 +387,21 @@ export class CreateBranch extends React.Component<
|
|||
this.updateBranchName(name)
|
||||
}
|
||||
|
||||
private updateBranchName(branchName: string) {
|
||||
private async updateBranchName(branchName: string) {
|
||||
const alreadyExists =
|
||||
this.props.allBranches.findIndex(b => b.name === branchName) > -1
|
||||
|
||||
const currentError = alreadyExists
|
||||
? new Error(`A branch named ${branchName} already exists`)
|
||||
? {
|
||||
error: new Error(`A branch named ${branchName} already exists.`),
|
||||
isWarning: false,
|
||||
}
|
||||
: null
|
||||
|
||||
if (!currentError) {
|
||||
await this.checkBranchRules(branchName)
|
||||
}
|
||||
|
||||
this.setState({
|
||||
branchName,
|
||||
currentError,
|
||||
|
@ -288,7 +423,10 @@ export class CreateBranch extends React.Component<
|
|||
// to make sure the startPoint state is valid given the current props.
|
||||
if (!defaultBranch) {
|
||||
this.setState({
|
||||
currentError: new Error('Could not determine the default branch'),
|
||||
currentError: {
|
||||
error: new Error('Could not determine the default branch.'),
|
||||
isWarning: false,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -299,7 +437,10 @@ export class CreateBranch extends React.Component<
|
|||
// to make sure the startPoint state is valid given the current props.
|
||||
if (!upstreamDefaultBranch) {
|
||||
this.setState({
|
||||
currentError: new Error('Could not determine the default branch'),
|
||||
currentError: {
|
||||
error: new Error('Could not determine the default branch.'),
|
||||
isWarning: false,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
|
@ -39,9 +39,11 @@ export class DeleteTag extends React.Component<
|
|||
onDismissed={this.props.onDismissed}
|
||||
disabled={this.state.isDeleting}
|
||||
loading={this.state.isDeleting}
|
||||
role="alertdialog"
|
||||
ariaDescribedBy="delete-tag-confirmation"
|
||||
>
|
||||
<DialogContent>
|
||||
<p>
|
||||
<p id="delete-tag-confirmation">
|
||||
Are you sure you want to delete the tag{' '}
|
||||
<Ref>{this.props.tagName}</Ref>?
|
||||
</p>
|
||||
|
|
|
@ -4,6 +4,7 @@ import { DialogHeader } from './header'
|
|||
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
|
||||
import { getTitleBarHeight } from '../window/title-bar'
|
||||
import { isTopMostDialog } from './is-top-most'
|
||||
import { isMacOSVentura } from '../../lib/get-os'
|
||||
|
||||
export interface IDialogStackContext {
|
||||
/** Whether or not this dialog is the top most one in the stack to be
|
||||
|
@ -130,6 +131,9 @@ interface IDialogProps {
|
|||
* of the loading operation.
|
||||
*/
|
||||
readonly loading?: boolean
|
||||
|
||||
/** Whether or not to override focus of first element with close button */
|
||||
readonly focusCloseButtonOnOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -466,14 +470,23 @@ export class Dialog extends React.Component<DialogProps, IDialogState> {
|
|||
// anchor tag masquerading as a button)
|
||||
let firstTabbable: HTMLElement | null = null
|
||||
|
||||
const closeButton = dialog.querySelector(':scope > header button.close')
|
||||
const closeButton = dialog.querySelector(
|
||||
':scope > div.dialog-header button.close'
|
||||
)
|
||||
|
||||
if (
|
||||
closeButton instanceof HTMLElement &&
|
||||
this.props.focusCloseButtonOnOpen
|
||||
) {
|
||||
closeButton.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const excludedInputTypes = [
|
||||
':not([type=button])',
|
||||
':not([type=submit])',
|
||||
':not([type=reset])',
|
||||
':not([type=hidden])',
|
||||
':not([type=checkbox])',
|
||||
':not([type=radio])',
|
||||
]
|
||||
|
||||
|
@ -710,6 +723,58 @@ export class Dialog extends React.Component<DialogProps, IDialogState> {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the aria-labelledby and aria-describedby attributes for the dialog
|
||||
* element.
|
||||
*
|
||||
* The correct semantics are that the dialog element should have the
|
||||
* aria-labelledby and the aria-describedby is optional unless the dialog has
|
||||
* a role of alertdialog, in which case both are required.
|
||||
*
|
||||
* However, macOs Ventura introduced a regression in that:
|
||||
*
|
||||
* For role of 'dialog' (default), the aria-labelledby is not announced and
|
||||
* if provided prevents the aria-describedby from being announced. Thus,
|
||||
* this method will add the aria-labelledby to the aria-describedby in this
|
||||
* case.
|
||||
*
|
||||
* For role of 'alertdialog', the aria-labelledby is announced but not the
|
||||
* aria-describedby. Thus, this method will add both to the
|
||||
* aria-labelledby.
|
||||
*
|
||||
* Neither of the above is semantically correct tho, hopefully, macOs will be
|
||||
* fixed in a future release. The issue is known for macOS versions 13.0 to
|
||||
* the current version of 13.5 as of 2023-07-31.
|
||||
*
|
||||
* A known macOS behavior is that if two ids are provided to the
|
||||
* aria-describedby only the first one is announced with a note about the
|
||||
* second one existing. This currently does not impact us as we only provide
|
||||
* one id for non-alert dialogs and the alert dialogs are handled with the
|
||||
* `aria-labelledby` where both ids are announced.
|
||||
*
|
||||
*/
|
||||
private getAriaAttributes() {
|
||||
if (!isMacOSVentura()) {
|
||||
// correct semantics for all other os
|
||||
return {
|
||||
'aria-labelledby': this.state.titleId,
|
||||
'aria-describedby': this.props.ariaDescribedBy,
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.role === 'alertdialog') {
|
||||
return {
|
||||
'aria-labelledby': `${this.state.titleId} ${this.props.ariaDescribedBy}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'aria-describedby': `${this.state.titleId} ${
|
||||
this.props.ariaDescribedBy ?? ''
|
||||
}`,
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const className = classNames(
|
||||
{
|
||||
|
@ -729,8 +794,7 @@ export class Dialog extends React.Component<DialogProps, IDialogState> {
|
|||
onMouseDown={this.onDialogMouseDown}
|
||||
onKeyDown={this.onKeyDown}
|
||||
className={className}
|
||||
aria-labelledby={this.state.titleId}
|
||||
aria-describedby={this.props.ariaDescribedBy}
|
||||
{...this.getAriaAttributes()}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{this.renderHeader()}
|
||||
|
|
|
@ -8,6 +8,8 @@ import {
|
|||
PopoverAnchorPosition,
|
||||
PopoverDecoration,
|
||||
} from '../lib/popover'
|
||||
import { Tooltip, TooltipDirection } from '../lib/tooltip'
|
||||
import { createObservableRef } from '../lib/observable-ref'
|
||||
|
||||
interface IDiffOptionsProps {
|
||||
readonly isInteractiveDiff: boolean
|
||||
|
@ -31,6 +33,7 @@ export class DiffOptions extends React.Component<
|
|||
IDiffOptionsProps,
|
||||
IDiffOptionsState
|
||||
> {
|
||||
private innerButtonRef = createObservableRef<HTMLButtonElement>()
|
||||
private diffOptionsRef = React.createRef<HTMLDivElement>()
|
||||
private gearIconRef = React.createRef<HTMLSpanElement>()
|
||||
|
||||
|
@ -79,9 +82,21 @@ export class DiffOptions extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
const buttonLabel = `Diff ${__DARWIN__ ? 'Settings' : 'Options'}`
|
||||
return (
|
||||
<div className="diff-options-component" ref={this.diffOptionsRef}>
|
||||
<button onClick={this.onButtonClick}>
|
||||
<button
|
||||
aria-label={buttonLabel}
|
||||
onClick={this.onButtonClick}
|
||||
aria-expanded={this.state.isPopoverOpen}
|
||||
ref={this.innerButtonRef}
|
||||
>
|
||||
<Tooltip
|
||||
target={this.innerButtonRef}
|
||||
direction={TooltipDirection.NORTH}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Tooltip>
|
||||
<span ref={this.gearIconRef}>
|
||||
<Octicon symbol={OcticonSymbol.gear} />
|
||||
</span>
|
||||
|
@ -102,7 +117,7 @@ export class DiffOptions extends React.Component<
|
|||
onClickOutside={this.closePopover}
|
||||
>
|
||||
<h3 id="diff-options-popover-header">
|
||||
Diff {__DARWIN__ ? 'Preferences' : 'Options'}
|
||||
Diff {__DARWIN__ ? 'Settings' : 'Options'}
|
||||
</h3>
|
||||
{this.renderHideWhitespaceChanges()}
|
||||
{this.renderShowSideBySide()}
|
||||
|
@ -119,8 +134,8 @@ export class DiffOptions extends React.Component<
|
|||
|
||||
private renderShowSideBySide() {
|
||||
return (
|
||||
<section>
|
||||
<h4>Diff display</h4>
|
||||
<fieldset role="radiogroup">
|
||||
<legend>Diff display</legend>
|
||||
<RadioButton
|
||||
value="Unified"
|
||||
checked={!this.props.showSideBySideDiff}
|
||||
|
@ -137,14 +152,14 @@ export class DiffOptions extends React.Component<
|
|||
}
|
||||
onSelected={this.onSideBySideSelected}
|
||||
/>
|
||||
</section>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
private renderHideWhitespaceChanges() {
|
||||
return (
|
||||
<section>
|
||||
<h4>Whitespace</h4>
|
||||
<fieldset>
|
||||
<legend>Whitespace</legend>
|
||||
<Checkbox
|
||||
value={
|
||||
this.props.hideWhitespaceChanges
|
||||
|
@ -162,7 +177,7 @@ export class DiffOptions extends React.Component<
|
|||
hiding whitespace.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
IAPIPullRequest,
|
||||
IAPIFullRepository,
|
||||
IAPICheckSuite,
|
||||
IAPIRepoRuleset,
|
||||
} from '../../lib/api'
|
||||
import { shell } from '../../lib/app-shell'
|
||||
import {
|
||||
|
@ -694,6 +695,14 @@ export class Dispatcher {
|
|||
return this.appStore._checkoutBranch(repository, branch, strategy)
|
||||
}
|
||||
|
||||
/** Check out the given commit. */
|
||||
public checkoutCommit(
|
||||
repository: Repository,
|
||||
commit: CommitOneLine
|
||||
): Promise<Repository> {
|
||||
return this.appStore._checkoutCommit(repository, commit)
|
||||
}
|
||||
|
||||
/** Push the current branch. */
|
||||
public push(repository: Repository): Promise<void> {
|
||||
return this.appStore._push(repository)
|
||||
|
@ -1561,6 +1570,19 @@ export class Dispatcher {
|
|||
})
|
||||
}
|
||||
|
||||
public async showRepoRulesCommitBypassWarning(
|
||||
repository: GitHubRepository,
|
||||
branch: string,
|
||||
onConfirm: () => void
|
||||
) {
|
||||
return this.appStore._showPopup({
|
||||
type: PopupType.ConfirmRepoRulesBypass,
|
||||
repository,
|
||||
branch,
|
||||
onConfirm,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new error handler.
|
||||
*
|
||||
|
@ -2374,6 +2396,10 @@ export class Dispatcher {
|
|||
return this.appStore._setConfirmDiscardStashSetting(value)
|
||||
}
|
||||
|
||||
public setConfirmCheckoutCommitSetting(value: boolean) {
|
||||
return this.appStore._setConfirmCheckoutCommitSetting(value)
|
||||
}
|
||||
|
||||
public setConfirmForcePushSetting(value: boolean) {
|
||||
return this.appStore._setConfirmForcePushSetting(value)
|
||||
}
|
||||
|
@ -4064,4 +4090,8 @@ export class Dispatcher {
|
|||
public appFocusedElementChanged() {
|
||||
this.appStore._appFocusedElementChanged()
|
||||
}
|
||||
|
||||
public updateCachedRepoRulesets(rulesets: Array<IAPIRepoRuleset | null>) {
|
||||
this.appStore._updateCachedRepoRulesets(rulesets)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,10 +178,6 @@ export class CommitDragElement extends React.Component<
|
|||
commit={commit}
|
||||
selectedCommits={selectedCommits}
|
||||
emoji={emoji}
|
||||
canBeUndone={false}
|
||||
canBeAmended={false}
|
||||
canBeResetTo={false}
|
||||
isLocal={false}
|
||||
showUnpushedIndicator={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,20 +1,15 @@
|
|||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import * as React from 'react'
|
||||
import { Commit, CommitOneLine } from '../../models/commit'
|
||||
import { Commit } from '../../models/commit'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { IAvatarUser, getAvatarUsersForCommit } from '../../models/avatar'
|
||||
import { RichText } from '../lib/rich-text'
|
||||
import { RelativeTime } from '../relative-time'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { clipboard } from 'electron'
|
||||
import { showContextualMenu } from '../../lib/menu-item'
|
||||
import { CommitAttribution } from '../lib/commit-attribution'
|
||||
import { AvatarStack } from '../lib/avatar-stack'
|
||||
import { IMenuItem } from '../../lib/menu-item'
|
||||
import { Octicon } from '../octicons'
|
||||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { Draggable } from '../lib/draggable'
|
||||
import { enableResetToCommit } from '../../lib/feature-flag'
|
||||
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
|
||||
import {
|
||||
DragType,
|
||||
|
@ -28,19 +23,6 @@ interface ICommitProps {
|
|||
readonly commit: Commit
|
||||
readonly selectedCommits: ReadonlyArray<Commit>
|
||||
readonly emoji: Map<string, string>
|
||||
readonly isLocal: boolean
|
||||
readonly canBeUndone: boolean
|
||||
readonly canBeAmended: boolean
|
||||
readonly canBeResetTo: boolean
|
||||
readonly onResetToCommit?: (commit: Commit) => void
|
||||
readonly onUndoCommit?: (commit: Commit) => void
|
||||
readonly onRevertCommit?: (commit: Commit) => void
|
||||
readonly onViewCommitOnGitHub?: (sha: string) => void
|
||||
readonly onCreateBranch?: (commit: CommitOneLine) => void
|
||||
readonly onCreateTag?: (targetCommitSha: string) => void
|
||||
readonly onDeleteTag?: (tagName: string) => void
|
||||
readonly onAmendCommit?: (commit: Commit, isLocalCommit: boolean) => void
|
||||
readonly onCherryPick?: (commits: ReadonlyArray<CommitOneLine>) => void
|
||||
readonly onRenderCommitDragElement?: (commit: Commit) => void
|
||||
readonly onRemoveDragElement?: () => void
|
||||
readonly onSquash?: (
|
||||
|
@ -48,9 +30,13 @@ interface ICommitProps {
|
|||
squashOnto: Commit,
|
||||
isInvokedByContextMenu: boolean
|
||||
) => void
|
||||
/**
|
||||
* Whether or not the commit can be dragged for certain operations like squash,
|
||||
* cherry-pick, reorder, etc. Defaults to false.
|
||||
*/
|
||||
readonly isDraggable?: boolean
|
||||
readonly showUnpushedIndicator: boolean
|
||||
readonly unpushedIndicatorTitle?: string
|
||||
readonly unpushedTags?: ReadonlyArray<string>
|
||||
readonly disableSquashing?: boolean
|
||||
readonly isMultiCommitOperationInProgress?: boolean
|
||||
}
|
||||
|
@ -126,7 +112,7 @@ export class CommitListItem extends React.PureComponent<
|
|||
author: { date },
|
||||
} = commit
|
||||
|
||||
const isDraggable = this.isDraggable()
|
||||
const isDraggable = this.props.isDraggable || false
|
||||
const hasEmptySummary = commit.summary.length === 0
|
||||
const commitSummary = hasEmptySummary
|
||||
? 'Empty commit message'
|
||||
|
@ -151,7 +137,6 @@ export class CommitListItem extends React.PureComponent<
|
|||
>
|
||||
<div
|
||||
className="commit"
|
||||
onContextMenu={this.onContextMenu}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onMouseUp={this.onMouseUp}
|
||||
|
@ -211,253 +196,6 @@ export class CommitListItem extends React.PureComponent<
|
|||
)
|
||||
}
|
||||
|
||||
private onAmendCommit = () => {
|
||||
this.props.onAmendCommit?.(this.props.commit, this.props.isLocal)
|
||||
}
|
||||
|
||||
private onCopySHA = () => {
|
||||
clipboard.writeText(this.props.commit.sha)
|
||||
}
|
||||
|
||||
private onCopyTags = () => {
|
||||
clipboard.writeText(this.props.commit.tags.join(' '))
|
||||
}
|
||||
|
||||
private onViewOnGitHub = () => {
|
||||
if (this.props.onViewCommitOnGitHub) {
|
||||
this.props.onViewCommitOnGitHub(this.props.commit.sha)
|
||||
}
|
||||
}
|
||||
|
||||
private onCreateTag = () => {
|
||||
if (this.props.onCreateTag) {
|
||||
this.props.onCreateTag(this.props.commit.sha)
|
||||
}
|
||||
}
|
||||
|
||||
private onCherryPick = () => {
|
||||
if (this.props.onCherryPick !== undefined) {
|
||||
this.props.onCherryPick(this.props.selectedCommits)
|
||||
}
|
||||
}
|
||||
|
||||
private onSquash = () => {
|
||||
if (this.props.onSquash !== undefined) {
|
||||
this.props.onSquash(this.props.selectedCommits, this.props.commit, true)
|
||||
}
|
||||
}
|
||||
|
||||
private onContextMenu = (event: React.MouseEvent<any>) => {
|
||||
event.preventDefault()
|
||||
|
||||
let items: IMenuItem[] = []
|
||||
if (this.props.selectedCommits.length > 1) {
|
||||
items = this.getContextMenuMultipleCommits()
|
||||
} else {
|
||||
items = this.getContextMenuForSingleCommit()
|
||||
}
|
||||
|
||||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private getContextMenuForSingleCommit(): IMenuItem[] {
|
||||
let viewOnGitHubLabel = 'View on GitHub'
|
||||
const gitHubRepository = this.props.gitHubRepository
|
||||
|
||||
if (
|
||||
gitHubRepository &&
|
||||
gitHubRepository.endpoint !== getDotComAPIEndpoint()
|
||||
) {
|
||||
viewOnGitHubLabel = 'View on GitHub Enterprise'
|
||||
}
|
||||
|
||||
const items: IMenuItem[] = []
|
||||
|
||||
if (this.props.canBeAmended) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Amend Commit…' : 'Amend commit…',
|
||||
action: this.onAmendCommit,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.props.canBeUndone) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Undo Commit…' : 'Undo commit…',
|
||||
action: () => {
|
||||
if (this.props.onUndoCommit) {
|
||||
this.props.onUndoCommit(this.props.commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onUndoCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (enableResetToCommit()) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Reset to Commit…' : 'Reset to commit…',
|
||||
action: () => {
|
||||
if (this.props.onResetToCommit) {
|
||||
this.props.onResetToCommit(this.props.commit)
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
this.props.canBeResetTo && this.props.onResetToCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Revert Changes in Commit'
|
||||
: 'Revert changes in commit',
|
||||
action: () => {
|
||||
if (this.props.onRevertCommit) {
|
||||
this.props.onRevertCommit(this.props.commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onRevertCommit !== undefined,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Create Branch from Commit'
|
||||
: 'Create branch from commit',
|
||||
action: () => {
|
||||
if (this.props.onCreateBranch) {
|
||||
this.props.onCreateBranch(this.props.commit)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Create Tag…',
|
||||
action: this.onCreateTag,
|
||||
enabled: this.props.onCreateTag !== undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const deleteTagsMenuItem = this.getDeleteTagsMenuItem()
|
||||
|
||||
if (deleteTagsMenuItem !== null) {
|
||||
items.push(
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
deleteTagsMenuItem
|
||||
)
|
||||
}
|
||||
const darwinTagsLabel =
|
||||
this.props.commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag'
|
||||
const windowTagsLabel =
|
||||
this.props.commit.tags.length > 1 ? 'Copy tags' : 'Copy tag'
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
|
||||
action: this.onCherryPick,
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Copy SHA',
|
||||
action: this.onCopySHA,
|
||||
},
|
||||
{
|
||||
label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel,
|
||||
action: this.onCopyTags,
|
||||
enabled: this.props.commit.tags.length > 0,
|
||||
},
|
||||
{
|
||||
label: viewOnGitHubLabel,
|
||||
action: this.onViewOnGitHub,
|
||||
enabled: !this.props.isLocal && !!gitHubRepository,
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private getContextMenuMultipleCommits(): IMenuItem[] {
|
||||
const count = this.props.selectedCommits.length
|
||||
|
||||
return [
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Cherry-pick ${count} Commits…`
|
||||
: `Cherry-pick ${count} commits…`,
|
||||
action: this.onCherryPick,
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Squash ${count} Commits…`
|
||||
: `Squash ${count} commits…`,
|
||||
action: this.onSquash,
|
||||
enabled: this.canSquash(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private isDraggable(): boolean {
|
||||
const { onCherryPick, onSquash, isMultiCommitOperationInProgress } =
|
||||
this.props
|
||||
return (
|
||||
(onCherryPick !== undefined || onSquash !== undefined) &&
|
||||
isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private canCherryPick(): boolean {
|
||||
const { onCherryPick, isMultiCommitOperationInProgress } = this.props
|
||||
return (
|
||||
onCherryPick !== undefined && isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private canSquash(): boolean {
|
||||
const { onSquash, disableSquashing, isMultiCommitOperationInProgress } =
|
||||
this.props
|
||||
return (
|
||||
onSquash !== undefined &&
|
||||
disableSquashing === false &&
|
||||
isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private getDeleteTagsMenuItem(): IMenuItem | null {
|
||||
const { unpushedTags, onDeleteTag, commit } = this.props
|
||||
|
||||
if (
|
||||
onDeleteTag === undefined ||
|
||||
unpushedTags === undefined ||
|
||||
commit.tags.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (commit.tags.length === 1) {
|
||||
const tagName = commit.tags[0]
|
||||
|
||||
return {
|
||||
label: `Delete tag ${tagName}`,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTags.includes(tagName),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tags to a Set to avoid O(n^2)
|
||||
const unpushedTagsSet = new Set(unpushedTags)
|
||||
|
||||
return {
|
||||
label: 'Delete tag…',
|
||||
submenu: commit.tags.map(tagName => {
|
||||
return {
|
||||
label: tagName,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTagsSet.has(tagName),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private onDragStart = () => {
|
||||
// Removes active status from commit selection so they do not appear
|
||||
// highlighted in commit list.
|
||||
|
|
|
@ -7,6 +7,14 @@ import { List } from '../lib/list'
|
|||
import { arrayEquals } from '../../lib/equality'
|
||||
import { DragData, DragType } from '../../models/drag-drop'
|
||||
import classNames from 'classnames'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { IMenuItem, showContextualMenu } from '../../lib/menu-item'
|
||||
import {
|
||||
enableCheckoutCommit,
|
||||
enableResetToCommit,
|
||||
} from '../../lib/feature-flag'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { clipboard } from 'electron'
|
||||
|
||||
const RowHeight = 50
|
||||
|
||||
|
@ -70,6 +78,12 @@ interface ICommitListProps {
|
|||
*/
|
||||
readonly onCreateBranch?: (commit: CommitOneLine) => void
|
||||
|
||||
/**
|
||||
* Callback to fire to checkout the selected commit in the current
|
||||
* repository
|
||||
*/
|
||||
readonly onCheckoutCommit?: (commit: CommitOneLine) => void
|
||||
|
||||
/** Callback to fire to open the dialog to create a new tag on the given commit */
|
||||
readonly onCreateTag?: (targetCommitSha: string) => void
|
||||
|
||||
|
@ -142,6 +156,11 @@ interface ICommitListProps {
|
|||
/** A component which displays the list of commits. */
|
||||
export class CommitList extends React.Component<ICommitListProps, {}> {
|
||||
private commitsHash = memoize(makeCommitsHash, arrayEquals)
|
||||
private commitIndexBySha = memoizeOne(
|
||||
(commitSHAs: ReadonlyArray<string>) =>
|
||||
new Map(commitSHAs.map((sha, index) => [sha, index]))
|
||||
)
|
||||
|
||||
private listRef = React.createRef<List>()
|
||||
|
||||
private getVisibleCommits(): ReadonlyArray<Commit> {
|
||||
|
@ -156,6 +175,9 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return commits
|
||||
}
|
||||
|
||||
private isLocalCommit = (sha: string) =>
|
||||
this.props.localCommitSHAs.includes(sha)
|
||||
|
||||
private renderCommit = (row: number) => {
|
||||
const sha = this.props.commitSHAs[row]
|
||||
const commit = this.props.commitLookup.get(sha)
|
||||
|
@ -169,52 +191,27 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return null
|
||||
}
|
||||
|
||||
const tagsToPushSet = new Set(this.props.tagsToPush ?? [])
|
||||
|
||||
const isLocal = this.props.localCommitSHAs.includes(commit.sha)
|
||||
const unpushedTags = commit.tags.filter(tagName =>
|
||||
tagsToPushSet.has(tagName)
|
||||
)
|
||||
const isLocal = this.isLocalCommit(commit.sha)
|
||||
const unpushedTags = this.getUnpushedTags(commit)
|
||||
|
||||
const showUnpushedIndicator =
|
||||
(isLocal || unpushedTags.length > 0) &&
|
||||
this.props.isLocalRepository === false
|
||||
|
||||
// The user can reset to any commit up to the first non-local one (included).
|
||||
// They cannot reset to the most recent commit... because they're already
|
||||
// in it.
|
||||
const isResettableCommit =
|
||||
row > 0 && row <= this.props.localCommitSHAs.length
|
||||
|
||||
return (
|
||||
<CommitListItem
|
||||
key={commit.sha}
|
||||
gitHubRepository={this.props.gitHubRepository}
|
||||
isLocal={isLocal}
|
||||
canBeUndone={this.props.canUndoCommits === true && isLocal && row === 0}
|
||||
canBeAmended={this.props.canAmendCommits === true && row === 0}
|
||||
canBeResetTo={
|
||||
this.props.canResetToCommits === true && isResettableCommit
|
||||
}
|
||||
showUnpushedIndicator={showUnpushedIndicator}
|
||||
unpushedIndicatorTitle={this.getUnpushedIndicatorTitle(
|
||||
isLocal,
|
||||
unpushedTags.length
|
||||
)}
|
||||
unpushedTags={unpushedTags}
|
||||
commit={commit}
|
||||
emoji={this.props.emoji}
|
||||
onCreateBranch={this.props.onCreateBranch}
|
||||
onCreateTag={this.props.onCreateTag}
|
||||
onDeleteTag={this.props.onDeleteTag}
|
||||
onCherryPick={this.props.onCherryPick}
|
||||
isDraggable={this.props.isMultiCommitOperationInProgress === false}
|
||||
onSquash={this.onSquash}
|
||||
onResetToCommit={this.props.onResetToCommit}
|
||||
onUndoCommit={this.props.onUndoCommit}
|
||||
onRevertCommit={this.props.onRevertCommit}
|
||||
onAmendCommit={this.props.onAmendCommit}
|
||||
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
|
||||
selectedCommits={this.lookupCommits(this.props.selectedSHAs)}
|
||||
selectedCommits={this.selectedCommits}
|
||||
onRenderCommitDragElement={this.onRenderCommitDragElement}
|
||||
onRemoveDragElement={this.props.onRemoveCommitDragElement}
|
||||
disableSquashing={this.props.disableSquashing}
|
||||
|
@ -252,10 +249,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
}
|
||||
|
||||
private onRenderCommitDragElement = (commit: Commit) => {
|
||||
this.props.onRenderCommitDragElement?.(
|
||||
commit,
|
||||
this.lookupCommits(this.props.selectedSHAs)
|
||||
)
|
||||
this.props.onRenderCommitDragElement?.(commit, this.selectedCommits)
|
||||
}
|
||||
|
||||
private getUnpushedIndicatorTitle(
|
||||
|
@ -275,6 +269,15 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return undefined
|
||||
}
|
||||
|
||||
private get selectedCommits() {
|
||||
return this.lookupCommits(this.props.selectedSHAs)
|
||||
}
|
||||
|
||||
private getUnpushedTags(commit: Commit) {
|
||||
const tagsToPushSet = new Set(this.props.tagsToPush ?? [])
|
||||
return commit.tags.filter(tagName => tagsToPushSet.has(tagName))
|
||||
}
|
||||
|
||||
private onSelectionChanged = (rows: ReadonlyArray<number>) => {
|
||||
const selectedShas = rows.map(r => this.props.commitSHAs[r])
|
||||
const selectedCommits = this.lookupCommits(selectedShas)
|
||||
|
@ -345,13 +348,8 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
this.props.onCompareListScrolled?.(scrollTop)
|
||||
}
|
||||
|
||||
private rowForSHA(sha_: string | null): number {
|
||||
const sha = sha_
|
||||
if (!sha) {
|
||||
return -1
|
||||
}
|
||||
|
||||
return this.props.commitSHAs.findIndex(s => s === sha)
|
||||
private rowForSHA(sha: string) {
|
||||
return this.commitIndexBySha(this.props.commitSHAs).get(sha) ?? -1
|
||||
}
|
||||
|
||||
private getRowCustomClassMap = () => {
|
||||
|
@ -410,6 +408,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
onDropDataInsertion={this.onDropDataInsertion}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
onRowContextMenu={this.onRowContextMenu}
|
||||
selectionMode="multi"
|
||||
onScroll={this.onScroll}
|
||||
insertionDragType={
|
||||
|
@ -432,6 +431,247 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
)
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
row: number,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
event.preventDefault()
|
||||
|
||||
const sha = this.props.commitSHAs[row]
|
||||
const commit = this.props.commitLookup.get(sha)
|
||||
if (commit === undefined) {
|
||||
if (__DEV__) {
|
||||
log.warn(
|
||||
`[CommitList]: the commit '${sha}' does not exist in the cache`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let items: IMenuItem[] = []
|
||||
if (this.props.selectedSHAs.length > 1) {
|
||||
items = this.getContextMenuMultipleCommits(commit)
|
||||
} else {
|
||||
items = this.getContextMenuForSingleCommit(row, commit)
|
||||
}
|
||||
|
||||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private getContextMenuForSingleCommit(
|
||||
row: number,
|
||||
commit: Commit
|
||||
): IMenuItem[] {
|
||||
const isLocal = this.isLocalCommit(commit.sha)
|
||||
|
||||
const canBeUndone =
|
||||
this.props.canUndoCommits === true && isLocal && row === 0
|
||||
const canBeAmended = this.props.canAmendCommits === true && row === 0
|
||||
// The user can reset to any commit up to the first non-local one (included).
|
||||
// They cannot reset to the most recent commit... because they're already
|
||||
// in it.
|
||||
const isResettableCommit =
|
||||
row > 0 && row <= this.props.localCommitSHAs.length
|
||||
const canBeResetTo =
|
||||
this.props.canResetToCommits === true && isResettableCommit
|
||||
const canBeCheckedOut = row > 0 //Cannot checkout the current commit
|
||||
|
||||
let viewOnGitHubLabel = 'View on GitHub'
|
||||
const gitHubRepository = this.props.gitHubRepository
|
||||
|
||||
if (
|
||||
gitHubRepository &&
|
||||
gitHubRepository.endpoint !== getDotComAPIEndpoint()
|
||||
) {
|
||||
viewOnGitHubLabel = 'View on GitHub Enterprise'
|
||||
}
|
||||
|
||||
const items: IMenuItem[] = []
|
||||
|
||||
if (canBeAmended) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Amend Commit…' : 'Amend commit…',
|
||||
action: () => this.props.onAmendCommit?.(commit, isLocal),
|
||||
})
|
||||
}
|
||||
|
||||
if (canBeUndone) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Undo Commit…' : 'Undo commit…',
|
||||
action: () => {
|
||||
if (this.props.onUndoCommit) {
|
||||
this.props.onUndoCommit(commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onUndoCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (enableResetToCommit()) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Reset to Commit…' : 'Reset to commit…',
|
||||
action: () => {
|
||||
if (this.props.onResetToCommit) {
|
||||
this.props.onResetToCommit(commit)
|
||||
}
|
||||
},
|
||||
enabled: canBeResetTo && this.props.onResetToCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (enableCheckoutCommit()) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Checkout Commit' : 'Checkout commit',
|
||||
action: () => {
|
||||
this.props.onCheckoutCommit?.(commit)
|
||||
},
|
||||
enabled: canBeCheckedOut && this.props.onCheckoutCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Revert Changes in Commit'
|
||||
: 'Revert changes in commit',
|
||||
action: () => {
|
||||
if (this.props.onRevertCommit) {
|
||||
this.props.onRevertCommit(commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onRevertCommit !== undefined,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Create Branch from Commit'
|
||||
: 'Create branch from commit',
|
||||
action: () => {
|
||||
if (this.props.onCreateBranch) {
|
||||
this.props.onCreateBranch(commit)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Create Tag…',
|
||||
action: () => this.props.onCreateTag?.(commit.sha),
|
||||
enabled: this.props.onCreateTag !== undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const deleteTagsMenuItem = this.getDeleteTagsMenuItem(commit)
|
||||
|
||||
if (deleteTagsMenuItem !== null) {
|
||||
items.push(
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
deleteTagsMenuItem
|
||||
)
|
||||
}
|
||||
const darwinTagsLabel = commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag'
|
||||
const windowTagsLabel = commit.tags.length > 1 ? 'Copy tags' : 'Copy tag'
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
|
||||
action: () => this.props.onCherryPick?.(this.selectedCommits),
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Copy SHA',
|
||||
action: () => clipboard.writeText(commit.sha),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel,
|
||||
action: () => clipboard.writeText(commit.tags.join(' ')),
|
||||
enabled: commit.tags.length > 0,
|
||||
},
|
||||
{
|
||||
label: viewOnGitHubLabel,
|
||||
action: () => this.props.onViewCommitOnGitHub?.(commit.sha),
|
||||
enabled: !isLocal && !!gitHubRepository,
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private canCherryPick(): boolean {
|
||||
const { onCherryPick, isMultiCommitOperationInProgress } = this.props
|
||||
return (
|
||||
onCherryPick !== undefined && isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private canSquash(): boolean {
|
||||
const { onSquash, disableSquashing, isMultiCommitOperationInProgress } =
|
||||
this.props
|
||||
return (
|
||||
onSquash !== undefined &&
|
||||
disableSquashing === false &&
|
||||
isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private getDeleteTagsMenuItem(commit: Commit): IMenuItem | null {
|
||||
const { onDeleteTag } = this.props
|
||||
const unpushedTags = this.getUnpushedTags(commit)
|
||||
|
||||
if (
|
||||
onDeleteTag === undefined ||
|
||||
unpushedTags === undefined ||
|
||||
commit.tags.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (commit.tags.length === 1) {
|
||||
const tagName = commit.tags[0]
|
||||
|
||||
return {
|
||||
label: `Delete tag ${tagName}`,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTags.includes(tagName),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tags to a Set to avoid O(n^2)
|
||||
const unpushedTagsSet = new Set(unpushedTags)
|
||||
|
||||
return {
|
||||
label: 'Delete tag…',
|
||||
submenu: commit.tags.map(tagName => {
|
||||
return {
|
||||
label: tagName,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTagsSet.has(tagName),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private getContextMenuMultipleCommits(commit: Commit): IMenuItem[] {
|
||||
const count = this.props.selectedSHAs.length
|
||||
|
||||
return [
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Cherry-pick ${count} Commits…`
|
||||
: `Cherry-pick ${count} commits…`,
|
||||
action: () => this.props.onCherryPick?.(this.selectedCommits),
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Squash ${count} Commits…`
|
||||
: `Squash ${count} commits…`,
|
||||
action: () => this.onSquash(this.selectedCommits, commit, true),
|
||||
enabled: this.canSquash(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private onDropDataInsertion = (row: number, data: DragData) => {
|
||||
if (
|
||||
this.props.onDropCommitInsertion === undefined ||
|
||||
|
|
|
@ -19,6 +19,7 @@ import _ from 'lodash'
|
|||
import { LinkButton } from '../lib/link-button'
|
||||
import { UnreachableCommitsTab } from './unreachable-commits-dialog'
|
||||
import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha'
|
||||
import memoizeOne from 'memoize-one'
|
||||
|
||||
interface ICommitSummaryProps {
|
||||
readonly repository: Repository
|
||||
|
@ -38,7 +39,7 @@ interface ICommitSummaryProps {
|
|||
|
||||
readonly onExpandChanged: (isExpanded: boolean) => void
|
||||
|
||||
readonly onDescriptionBottomChanged: (descriptionBottom: Number) => void
|
||||
readonly onDescriptionBottomChanged: (descriptionBottom: number) => void
|
||||
|
||||
readonly hideDescriptionBorder: boolean
|
||||
|
||||
|
@ -141,21 +142,6 @@ function getCommitSummary(selectedCommits: ReadonlyArray<Commit>) {
|
|||
: selectedCommits[0].summary
|
||||
}
|
||||
|
||||
function getCountCommitsNotInDiff(
|
||||
selectedCommits: ReadonlyArray<Commit>,
|
||||
shasInDiff: ReadonlyArray<string>
|
||||
) {
|
||||
if (selectedCommits.length === 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const excludedCommits = selectedCommits.filter(
|
||||
({ sha }) => !shasInDiff.includes(sha)
|
||||
)
|
||||
|
||||
return excludedCommits.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function which determines if two commit objects
|
||||
* have the same commit summary and body.
|
||||
|
@ -173,6 +159,23 @@ export class CommitSummary extends React.Component<
|
|||
private updateOverflowTimeoutId: NodeJS.Immediate | null = null
|
||||
private descriptionRef: HTMLDivElement | null = null
|
||||
|
||||
private getCountCommitsNotInDiff = memoizeOne(
|
||||
(
|
||||
selectedCommits: ReadonlyArray<Commit>,
|
||||
shasInDiff: ReadonlyArray<string>
|
||||
) => {
|
||||
if (selectedCommits.length === 1) {
|
||||
return 0
|
||||
} else {
|
||||
const shas = new Set(shasInDiff)
|
||||
return selectedCommits.reduce(
|
||||
(acc, c) => acc + (shas.has(c.sha) ? 0 : 1),
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
public constructor(props: ICommitSummaryProps) {
|
||||
super(props)
|
||||
|
||||
|
@ -369,7 +372,7 @@ export class CommitSummary extends React.Component<
|
|||
return
|
||||
}
|
||||
|
||||
const excludedCommitsCount = getCountCommitsNotInDiff(
|
||||
const excludedCommitsCount = this.getCountCommitsNotInDiff(
|
||||
selectedCommits,
|
||||
shasInDiff
|
||||
)
|
||||
|
@ -455,7 +458,7 @@ export class CommitSummary extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
const commitsNotInDiff = getCountCommitsNotInDiff(
|
||||
const commitsNotInDiff = this.getCountCommitsNotInDiff(
|
||||
selectedCommits,
|
||||
shasInDiff
|
||||
)
|
||||
|
@ -501,10 +504,7 @@ export class CommitSummary extends React.Component<
|
|||
{this.renderLinesChanged()}
|
||||
{this.renderTags()}
|
||||
|
||||
<li
|
||||
className="commit-summary-meta-item without-truncation"
|
||||
title="Diff Options"
|
||||
>
|
||||
<li className="commit-summary-meta-item without-truncation">
|
||||
<DiffOptions
|
||||
isInteractiveDiff={false}
|
||||
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
|
||||
|
|
|
@ -8,22 +8,12 @@ import { Octicon, iconForStatus } from '../octicons'
|
|||
interface ICommittedFileItemProps {
|
||||
readonly availableWidth: number
|
||||
readonly file: CommittedFileChange
|
||||
readonly onContextMenu?: (
|
||||
file: CommittedFileChange,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => void
|
||||
}
|
||||
|
||||
export class CommittedFileItem extends React.Component<ICommittedFileItemProps> {
|
||||
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (this.props.onContextMenu !== undefined) {
|
||||
this.props.onContextMenu(this.props.file, event)
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { file } = this.props
|
||||
const status = file.status
|
||||
const { status } = file
|
||||
const fileStatus = mapStatus(status)
|
||||
|
||||
const listItemPadding = 10 * 2
|
||||
|
@ -36,11 +26,12 @@ export class CommittedFileItem extends React.Component<ICommittedFileItemProps>
|
|||
statusWidth
|
||||
|
||||
return (
|
||||
<div className="file" onContextMenu={this.onContextMenu}>
|
||||
<div className="file">
|
||||
<PathLabel
|
||||
path={file.path}
|
||||
status={file.status}
|
||||
availableWidth={availablePathWidth}
|
||||
ariaHidden={true}
|
||||
/>
|
||||
|
||||
<Octicon
|
||||
|
|
|
@ -38,6 +38,7 @@ interface ICompareSidebarProps {
|
|||
readonly emoji: Map<string, string>
|
||||
readonly commitLookup: Map<string, Commit>
|
||||
readonly localCommitSHAs: ReadonlyArray<string>
|
||||
readonly askForConfirmationOnCheckoutCommit: boolean
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly currentBranch: Branch | null
|
||||
readonly selectedCommitShas: ReadonlyArray<string>
|
||||
|
@ -255,6 +256,7 @@ export class CompareSidebar extends React.Component<
|
|||
onCommitsSelected={this.onCommitsSelected}
|
||||
onScroll={this.onScroll}
|
||||
onCreateBranch={this.onCreateBranch}
|
||||
onCheckoutCommit={this.onCheckoutCommit}
|
||||
onCreateTag={this.onCreateTag}
|
||||
onDeleteTag={this.onDeleteTag}
|
||||
onCherryPick={this.onCherryPick}
|
||||
|
@ -599,6 +601,20 @@ export class CompareSidebar extends React.Component<
|
|||
})
|
||||
}
|
||||
|
||||
private onCheckoutCommit = (commit: CommitOneLine) => {
|
||||
const { repository, dispatcher, askForConfirmationOnCheckoutCommit } =
|
||||
this.props
|
||||
if (!askForConfirmationOnCheckoutCommit) {
|
||||
dispatcher.checkoutCommit(repository, commit)
|
||||
} else {
|
||||
dispatcher.showPopup({
|
||||
type: PopupType.ConfirmCheckoutCommit,
|
||||
commit: commit,
|
||||
repository,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private onDeleteTag = (tagName: string) => {
|
||||
this.props.dispatcher.showDeleteTagDialog(this.props.repository, tagName)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from 'react'
|
||||
import { mapStatus } from '../../lib/status'
|
||||
|
||||
import { CommittedFileChange } from '../../models/status'
|
||||
import { ClickSource, List } from '../lib/list'
|
||||
|
@ -30,7 +31,6 @@ export class FileList extends React.Component<IFileListProps> {
|
|||
<CommittedFileItem
|
||||
file={this.props.files[row]}
|
||||
availableWidth={this.props.availableWidth}
|
||||
onContextMenu={this.props.onContextMenu}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -39,6 +39,20 @@ export class FileList extends React.Component<IFileListProps> {
|
|||
return file ? this.props.files.findIndex(f => f.path === file.path) : -1
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
row: number,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.props.onContextMenu?.(this.props.files[row], event)
|
||||
}
|
||||
|
||||
private getFileAriaLabel = (row: number) => {
|
||||
const file = this.props.files[row]
|
||||
const { path, status } = file
|
||||
const fileStatus = mapStatus(status)
|
||||
return `${path} ${fileStatus}`
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className="file-list">
|
||||
|
@ -49,6 +63,8 @@ export class FileList extends React.Component<IFileListProps> {
|
|||
selectedRows={[this.rowForFile(this.props.selectedFile)]}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
onRowDoubleClick={this.props.onRowDoubleClick}
|
||||
onRowContextMenu={this.onRowContextMenu}
|
||||
getRowAriaLabel={this.getFileAriaLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -213,7 +213,7 @@ export class SelectedCommits extends React.Component<
|
|||
this.setState({ isExpanded })
|
||||
}
|
||||
|
||||
private onDescriptionBottomChanged = (descriptionBottom: Number) => {
|
||||
private onDescriptionBottomChanged = (descriptionBottom: number) => {
|
||||
if (this.historyRef) {
|
||||
const historyBottom = this.historyRef.getBoundingClientRect().bottom
|
||||
this.setState({
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Octicon } from '../octicons'
|
|||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { Ref } from './ref'
|
||||
import { IStashEntry } from '../../models/stash-entry'
|
||||
import { enableMoveStash } from '../../lib/feature-flag'
|
||||
|
||||
export function renderBranchHasRemoteWarning(branch: Branch) {
|
||||
if (branch.upstream != null) {
|
||||
|
@ -47,7 +48,7 @@ export function renderBranchNameExistsOnRemoteWarning(
|
|||
}
|
||||
|
||||
export function renderStashWillBeLostWarning(stash: IStashEntry | null) {
|
||||
if (stash === null) {
|
||||
if (stash === null || enableMoveStash()) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
|
|
|
@ -253,10 +253,6 @@ export class ConfigureGitUser extends React.Component<
|
|||
commit={dummyCommit}
|
||||
emoji={emoji}
|
||||
gitHubRepository={null}
|
||||
canBeUndone={false}
|
||||
canBeAmended={false}
|
||||
canBeResetTo={false}
|
||||
isLocal={false}
|
||||
showUnpushedIndicator={false}
|
||||
selectedCommits={[dummyCommit]}
|
||||
/>
|
||||
|
|
|
@ -292,12 +292,14 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
|
|||
public render() {
|
||||
const itemRows = this.state.rows.filter(row => row.kind === 'item')
|
||||
const resultsPluralized = itemRows.length === 1 ? 'result' : 'results'
|
||||
const screenReaderMessage = `${itemRows.length} ${resultsPluralized}`
|
||||
|
||||
return (
|
||||
<div className={classnames('filter-list', this.props.className)}>
|
||||
<AriaLiveContainer trackedUserInput={this.state.filterValue}>
|
||||
{itemRows.length} {resultsPluralized}
|
||||
</AriaLiveContainer>
|
||||
<AriaLiveContainer
|
||||
message={screenReaderMessage}
|
||||
trackedUserInput={this.state.filterValue}
|
||||
/>
|
||||
{this.props.renderPreList ? this.props.renderPreList() : null}
|
||||
|
||||
{this.renderFilterRow()}
|
||||
|
@ -358,7 +360,9 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
|
|||
rowCount={this.state.rows.length}
|
||||
rowRenderer={this.renderRow}
|
||||
rowHeight={this.props.rowHeight}
|
||||
selectedRows={[this.state.selectedRow]}
|
||||
selectedRows={
|
||||
this.state.selectedRow === -1 ? [] : [this.state.selectedRow]
|
||||
}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
onRowClick={this.onRowClick}
|
||||
onRowKeyDown={this.onRowKeyDown}
|
||||
|
|
|
@ -20,7 +20,42 @@ interface IGitEmailNotFoundWarningProps {
|
|||
* email doesn't match any of the emails in their GitHub (Enterprise) account.
|
||||
*/
|
||||
export class GitEmailNotFoundWarning extends React.Component<IGitEmailNotFoundWarningProps> {
|
||||
private buildMessage() {
|
||||
private buildMessage(isAttributableEmail: boolean) {
|
||||
const indicatorIcon = !isAttributableEmail ? (
|
||||
<span className="warning-icon">⚠️</span>
|
||||
) : (
|
||||
<span className="green-circle">
|
||||
<Octicon className="check-icon" symbol={OcticonSymbol.check} />
|
||||
</span>
|
||||
)
|
||||
|
||||
const learnMore = !isAttributableEmail ? (
|
||||
<LinkButton
|
||||
ariaLabel="Learn more about commit attribution"
|
||||
uri="https://docs.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user"
|
||||
>
|
||||
Learn more.
|
||||
</LinkButton>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
{indicatorIcon}
|
||||
{this.buildScreenReaderMessage(isAttributableEmail)}
|
||||
{learnMore}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
private buildScreenReaderMessage(isAttributableEmail: boolean) {
|
||||
const verb = !isAttributableEmail ? 'does not match' : 'matches'
|
||||
const info = !isAttributableEmail
|
||||
? 'Your commits will be wrongly attributed. '
|
||||
: ''
|
||||
return `This email address ${verb} ${this.getAccountTypeDescription()}. ${info}`
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { accounts, email } = this.props
|
||||
|
||||
if (accounts.length === 0 || email.trim().length === 0) {
|
||||
|
@ -31,43 +66,6 @@ export class GitEmailNotFoundWarning extends React.Component<IGitEmailNotFoundWa
|
|||
isAttributableEmailFor(account, email)
|
||||
)
|
||||
|
||||
const verb = !isAttributableEmail ? 'does not match' : 'matches'
|
||||
|
||||
const indicatorIcon = !isAttributableEmail ? (
|
||||
<span className="warning-icon">⚠️</span>
|
||||
) : (
|
||||
<span className="green-circle">
|
||||
<Octicon className="check-icon" symbol={OcticonSymbol.check} />
|
||||
</span>
|
||||
)
|
||||
|
||||
const info = !isAttributableEmail ? (
|
||||
<>
|
||||
Your commits will be wrongly attributed.{' '}
|
||||
<LinkButton
|
||||
ariaLabel="Learn more about commit attribution"
|
||||
uri="https://docs.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user"
|
||||
>
|
||||
Learn more.
|
||||
</LinkButton>
|
||||
</>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
{indicatorIcon}
|
||||
This email address {verb} {this.getAccountTypeDescription()}. {info}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { accounts, email } = this.props
|
||||
|
||||
if (accounts.length === 0 || email.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we put the message in the top div for visual users immediately and
|
||||
* in the bottom div for screen readers. The screen reader content is
|
||||
|
@ -75,14 +73,15 @@ export class GitEmailNotFoundWarning extends React.Component<IGitEmailNotFoundWa
|
|||
*/
|
||||
return (
|
||||
<>
|
||||
<div className="git-email-not-found-warning">{this.buildMessage()}</div>
|
||||
<div className="git-email-not-found-warning">
|
||||
{this.buildMessage(isAttributableEmail)}
|
||||
</div>
|
||||
|
||||
<AriaLiveContainer
|
||||
id="git-email-not-found-warning-for-screen-readers"
|
||||
trackedUserInput={this.props.email}
|
||||
>
|
||||
{this.buildMessage()}
|
||||
</AriaLiveContainer>
|
||||
message={this.buildScreenReaderMessage(isAttributableEmail)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
24
app/src/ui/lib/input-description/input-caption.tsx
Normal file
24
app/src/ui/lib/input-description/input-caption.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
IBaseInputDescriptionProps,
|
||||
InputDescription,
|
||||
InputDescriptionType,
|
||||
} from './input-description'
|
||||
|
||||
/**
|
||||
* An caption element with app-standard styles for captions to be used with inputs.
|
||||
*
|
||||
* Provide `children` elements to render as the content for the error element.
|
||||
*/
|
||||
export class Caption extends React.Component<IBaseInputDescriptionProps> {
|
||||
public render() {
|
||||
return (
|
||||
<InputDescription
|
||||
inputDescriptionType={InputDescriptionType.Caption}
|
||||
{...this.props}
|
||||
>
|
||||
{this.props.children}
|
||||
</InputDescription>
|
||||
)
|
||||
}
|
||||
}
|
131
app/src/ui/lib/input-description/input-description.tsx
Normal file
131
app/src/ui/lib/input-description/input-description.tsx
Normal file
|
@ -0,0 +1,131 @@
|
|||
import * as React from 'react'
|
||||
import { Octicon } from '../../octicons'
|
||||
import * as OcticonSymbol from '../../octicons/octicons.generated'
|
||||
import classNames from 'classnames'
|
||||
import { AriaLiveContainer } from '../../accessibility/aria-live-container'
|
||||
import { assertNever } from '../../../lib/fatal-error'
|
||||
|
||||
export enum InputDescriptionType {
|
||||
Caption,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
export interface IBaseInputDescriptionProps {
|
||||
/** The ID for description. This ID needs be linked to the associated input
|
||||
* using the `aria-describedby` attribute for screen reader users. */
|
||||
readonly id: string
|
||||
|
||||
/**
|
||||
* There is a common pattern that we may need to announce a message in
|
||||
* response to user input. Unfortunately, aria-live announcements are
|
||||
* interrupted by continued user input. We can force a rereading of a message
|
||||
* by appending an invisible character when the user finishes their input.
|
||||
*
|
||||
* This prop allows us to pass in when the user input changes. We can append
|
||||
* the invisible character to force the screen reader to read the message
|
||||
* again after each input. To prevent the message from being read too much, we
|
||||
* debounce the message.
|
||||
*/
|
||||
readonly trackedUserInput?: string | boolean
|
||||
|
||||
readonly ariaLiveMessage?: string
|
||||
}
|
||||
|
||||
export interface IInputDescriptionProps extends IBaseInputDescriptionProps {
|
||||
/** Whether the description is a caption, a warning, or an error.
|
||||
*
|
||||
* Captions are styled with a muted color and are used to provide additional information about the input.
|
||||
* Warnings are styled with a orange color with warning icon and are used to communicate that the input is valid but may have unintended consequences.
|
||||
* Errors are styled with a red color with error icon and are used to communicate that the input is invalid.
|
||||
*/
|
||||
readonly inputDescriptionType: InputDescriptionType
|
||||
}
|
||||
|
||||
/**
|
||||
* An Input description element with app-standard styles for captions, warnings,
|
||||
* and errors of inputs.
|
||||
*
|
||||
* Provide `children` elements to render as the content for the error element.
|
||||
*/
|
||||
export class InputDescription extends React.Component<IInputDescriptionProps> {
|
||||
private getClassName() {
|
||||
const { inputDescriptionType: type } = this.props
|
||||
|
||||
switch (type) {
|
||||
case InputDescriptionType.Caption:
|
||||
return classNames('input-description', 'input-description-caption')
|
||||
case InputDescriptionType.Warning:
|
||||
return classNames('input-description', 'input-description-warning')
|
||||
case InputDescriptionType.Error:
|
||||
return classNames('input-description', 'input-description-error')
|
||||
default:
|
||||
return assertNever(type, `Unknown input type ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
const { inputDescriptionType: type } = this.props
|
||||
|
||||
switch (type) {
|
||||
case InputDescriptionType.Caption:
|
||||
return null
|
||||
case InputDescriptionType.Warning:
|
||||
return <Octicon symbol={OcticonSymbol.alert} />
|
||||
case InputDescriptionType.Error:
|
||||
return <Octicon symbol={OcticonSymbol.stop} />
|
||||
default:
|
||||
return assertNever(type, `Unknown input type ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** If a input is a warning or an error that is displayed in response to
|
||||
* tracked user input. We want it announced on user input debounce. */
|
||||
private renderAriaLiveContainer() {
|
||||
if (
|
||||
this.props.inputDescriptionType === InputDescriptionType.Caption ||
|
||||
this.props.trackedUserInput === undefined ||
|
||||
this.props.ariaLiveMessage === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AriaLiveContainer
|
||||
message={this.props.ariaLiveMessage}
|
||||
trackedUserInput={this.props.trackedUserInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** If the input is an error, and we are not announcing it based on user
|
||||
* input. We should have a role of alert so that it at least announced once.
|
||||
* This may be true if the error is displayed in response to a form submission.
|
||||
* */
|
||||
private getRole() {
|
||||
if (
|
||||
this.props.inputDescriptionType === InputDescriptionType.Error &&
|
||||
this.props.trackedUserInput === undefined
|
||||
) {
|
||||
return 'alert'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id={this.props.id}
|
||||
className={this.getClassName()}
|
||||
role={this.getRole()}
|
||||
>
|
||||
{this.renderIcon()}
|
||||
<div>{this.props.children}</div>
|
||||
</div>
|
||||
{this.renderAriaLiveContainer()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
24
app/src/ui/lib/input-description/input-error.tsx
Normal file
24
app/src/ui/lib/input-description/input-error.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
IBaseInputDescriptionProps,
|
||||
InputDescription,
|
||||
InputDescriptionType,
|
||||
} from './input-description'
|
||||
|
||||
/**
|
||||
* An Error component with app-standard styles for errors to be used with inputs.
|
||||
*
|
||||
* Provide `children` elements to render as the content for the error element.
|
||||
*/
|
||||
export class InputError extends React.Component<IBaseInputDescriptionProps> {
|
||||
public render() {
|
||||
return (
|
||||
<InputDescription
|
||||
inputDescriptionType={InputDescriptionType.Error}
|
||||
{...this.props}
|
||||
>
|
||||
{this.props.children}
|
||||
</InputDescription>
|
||||
)
|
||||
}
|
||||
}
|
24
app/src/ui/lib/input-description/input-warning.tsx
Normal file
24
app/src/ui/lib/input-description/input-warning.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
IBaseInputDescriptionProps,
|
||||
InputDescription,
|
||||
InputDescriptionType,
|
||||
} from './input-description'
|
||||
|
||||
/**
|
||||
* An Warning component with app-standard styles for warnings to be used with inputs.
|
||||
*
|
||||
* Provide `children` elements to render as the content for the error element.
|
||||
*/
|
||||
export class InputWarning extends React.Component<IBaseInputDescriptionProps> {
|
||||
public render() {
|
||||
return (
|
||||
<InputDescription
|
||||
inputDescriptionType={InputDescriptionType.Warning}
|
||||
{...this.props}
|
||||
>
|
||||
{this.props.children}
|
||||
</InputDescription>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import { Disposable } from 'event-kit'
|
|||
import * as React from 'react'
|
||||
import { dragAndDropManager } from '../../../lib/drag-and-drop-manager'
|
||||
import { DragData, DragType, DropTargetType } from '../../../models/drag-drop'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
|
||||
enum InsertionFeedbackType {
|
||||
None,
|
||||
|
@ -13,11 +14,11 @@ enum InsertionFeedbackType {
|
|||
|
||||
interface IListItemInsertionOverlayProps {
|
||||
readonly onDropDataInsertion?: (
|
||||
insertionIndex: number,
|
||||
insertionIndex: RowIndexPath,
|
||||
data: DragData
|
||||
) => void
|
||||
|
||||
readonly itemIndex: number
|
||||
readonly itemIndex: RowIndexPath
|
||||
readonly dragType: DragType
|
||||
}
|
||||
|
||||
|
@ -188,7 +189,10 @@ export class ListItemInsertionOverlay extends React.PureComponent<
|
|||
let index = this.props.itemIndex
|
||||
|
||||
if (this.state.feedbackType === InsertionFeedbackType.Bottom) {
|
||||
index++
|
||||
index = {
|
||||
...index,
|
||||
row: index.row + 1,
|
||||
}
|
||||
}
|
||||
this.props.onDropDataInsertion(index, dragAndDropManager.dragData)
|
||||
}
|
||||
|
|
92
app/src/ui/lib/list/list-row-index-path.ts
Normal file
92
app/src/ui/lib/list/list-row-index-path.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
export type RowIndexPath = {
|
||||
readonly row: number
|
||||
readonly section: number
|
||||
}
|
||||
|
||||
export const InvalidRowIndexPath: RowIndexPath = { section: -1, row: -1 }
|
||||
|
||||
export function rowIndexPathEquals(a: RowIndexPath, b: RowIndexPath): boolean {
|
||||
return a.section === b.section && a.row === b.row
|
||||
}
|
||||
|
||||
export function getTotalRowCount(rowCount: ReadonlyArray<number>) {
|
||||
return rowCount.reduce((sum, count) => sum + count, 0)
|
||||
}
|
||||
|
||||
export function rowIndexPathToGlobalIndex(
|
||||
indexPath: RowIndexPath,
|
||||
rowCount: ReadonlyArray<number>
|
||||
): number | null {
|
||||
if (!isValidRow(indexPath, rowCount)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let index = 0
|
||||
|
||||
for (let section = 0; section < indexPath.section; section++) {
|
||||
index += rowCount[section]
|
||||
}
|
||||
|
||||
index += indexPath.row
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
export function globalIndexToRowIndexPath(
|
||||
index: number,
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (index < 0 || index >= getTotalRowCount(rowCount)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let section = 0
|
||||
let row = index
|
||||
|
||||
while (row >= rowCount[section]) {
|
||||
row -= rowCount[section]
|
||||
section++
|
||||
}
|
||||
|
||||
return { section, row }
|
||||
}
|
||||
|
||||
export function isValidRow(
|
||||
indexPath: RowIndexPath,
|
||||
rowCount: ReadonlyArray<number>
|
||||
) {
|
||||
return (
|
||||
indexPath.section >= 0 &&
|
||||
indexPath.section < rowCount.length &&
|
||||
indexPath.row >= 0 &&
|
||||
indexPath.row < rowCount[indexPath.section]
|
||||
)
|
||||
}
|
||||
|
||||
export function getFirstRowIndexPath(
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (rowCount.length > 0) {
|
||||
for (let section = 0; section < rowCount.length; section++) {
|
||||
if (rowCount[section] > 0) {
|
||||
return { section, row: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getLastRowIndexPath(
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (rowCount.length > 0) {
|
||||
for (let section = rowCount.length - 1; section >= 0; section--) {
|
||||
if (rowCount[section] > 0) {
|
||||
return { section, row: rowCount[section] - 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
|
@ -1,12 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
|
||||
interface IListRowProps {
|
||||
/** whether or not the section to which this row belongs has a header */
|
||||
readonly sectionHasHeader: boolean
|
||||
|
||||
/** the total number of row in this list */
|
||||
readonly rowCount: number
|
||||
|
||||
/** the index of the row in the list */
|
||||
readonly rowIndex: number
|
||||
readonly rowIndex: RowIndexPath
|
||||
|
||||
/** custom styles to provide to the row */
|
||||
readonly style?: React.CSSProperties
|
||||
|
@ -21,39 +25,51 @@ interface IListRowProps {
|
|||
readonly selected?: boolean
|
||||
|
||||
/** callback to fire when the DOM element is created */
|
||||
readonly onRowRef?: (index: number, element: HTMLDivElement | null) => void
|
||||
readonly onRowRef?: (
|
||||
index: RowIndexPath,
|
||||
element: HTMLDivElement | null
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a mousedown event */
|
||||
readonly onRowMouseDown: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowMouseDown: (
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<any>
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a mouseup event */
|
||||
readonly onRowMouseUp: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowMouseUp: (index: RowIndexPath, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row is clicked */
|
||||
readonly onRowClick: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowClick: (index: RowIndexPath, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row is double clicked */
|
||||
readonly onRowDoubleClick: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowDoubleClick: (
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<any>
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a keyboard event */
|
||||
readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void
|
||||
readonly onRowKeyDown: (
|
||||
index: RowIndexPath,
|
||||
e: React.KeyboardEvent<any>
|
||||
) => void
|
||||
|
||||
/** called when the row (or any of its descendants) receives focus */
|
||||
readonly onRowFocus?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** called when the row (and all of its descendants) loses focus */
|
||||
readonly onRowBlur?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** Called back for when the context menu is invoked (user right clicks of
|
||||
* uses keyboard shortcuts) */
|
||||
readonly onContextMenu?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
|
@ -66,6 +82,14 @@ interface IListRowProps {
|
|||
|
||||
/** a custom css class to apply to the row */
|
||||
readonly className?: string
|
||||
|
||||
/**
|
||||
* aria label value for screen readers
|
||||
*
|
||||
* Note: you may need to apply an aria-hidden attribute to any child text
|
||||
* elements for this to take precedence.
|
||||
*/
|
||||
readonly ariaLabel?: string
|
||||
}
|
||||
|
||||
export class ListRow extends React.Component<IListRowProps, {}> {
|
||||
|
@ -106,12 +130,23 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const selected = this.props.selected
|
||||
const className = classNames(
|
||||
const {
|
||||
selected,
|
||||
selectable,
|
||||
className,
|
||||
style,
|
||||
rowCount,
|
||||
id,
|
||||
tabIndex,
|
||||
rowIndex,
|
||||
children,
|
||||
sectionHasHeader,
|
||||
} = this.props
|
||||
const rowClassName = classNames(
|
||||
'list-item',
|
||||
{ selected },
|
||||
{ 'not-selectable': this.props.selectable === false },
|
||||
this.props.className
|
||||
{ 'not-selectable': selectable === false },
|
||||
className
|
||||
)
|
||||
// react-virtualized gives us an explicit pixel width for rows, but that
|
||||
// width doesn't take into account whether or not the scroll bar needs
|
||||
|
@ -120,29 +155,44 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
// *But* the parent Grid uses `autoContainerWidth` which means its width
|
||||
// *does* reflect any width needed by the scroll bar. So we should just use
|
||||
// that width.
|
||||
const style = { ...this.props.style, width: '100%' }
|
||||
const fullWidthStyle = { ...style, width: '100%' }
|
||||
|
||||
let ariaSetSize: number | undefined = rowCount
|
||||
let ariaPosInSet: number | undefined = rowIndex.row + 1
|
||||
if (sectionHasHeader) {
|
||||
if (rowIndex.row === 0) {
|
||||
ariaSetSize = undefined
|
||||
ariaPosInSet = undefined
|
||||
} else {
|
||||
ariaSetSize -= 1
|
||||
ariaPosInSet -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={this.props.id}
|
||||
role="option"
|
||||
aria-setsize={this.props.rowCount}
|
||||
aria-posinset={this.props.rowIndex + 1}
|
||||
aria-selected={this.props.selectable ? this.props.selected : undefined}
|
||||
className={className}
|
||||
tabIndex={this.props.tabIndex}
|
||||
id={id}
|
||||
role={
|
||||
sectionHasHeader && rowIndex.row === 0 ? 'presentation' : 'option'
|
||||
}
|
||||
aria-setsize={ariaSetSize}
|
||||
aria-posinset={ariaPosInSet}
|
||||
aria-selected={selectable ? selected : undefined}
|
||||
aria-label={this.props.ariaLabel}
|
||||
className={rowClassName}
|
||||
tabIndex={tabIndex}
|
||||
ref={this.onRef}
|
||||
onMouseDown={this.onRowMouseDown}
|
||||
onMouseUp={this.onRowMouseUp}
|
||||
onClick={this.onRowClick}
|
||||
onDoubleClick={this.onRowDoubleClick}
|
||||
onKeyDown={this.onRowKeyDown}
|
||||
style={style}
|
||||
style={fullWidthStyle}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
{this.props.children}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ import { range } from '../../../lib/range'
|
|||
import { ListItemInsertionOverlay } from './list-item-insertion-overlay'
|
||||
import { DragData, DragType } from '../../../models/drag-drop'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
import { sendNonFatalException } from '../../../lib/helpers/non-fatal-exception'
|
||||
|
||||
/**
|
||||
* Describe the first argument given to the cellRenderer,
|
||||
|
@ -254,6 +256,15 @@ interface IListProps {
|
|||
|
||||
/** The aria-label attribute for the list component. */
|
||||
readonly ariaLabel?: string
|
||||
|
||||
/**
|
||||
* Optional callback for providing an aria label for screen readers for each
|
||||
* row.
|
||||
*
|
||||
* Note: you may need to apply an aria-hidden attribute to any child text
|
||||
* elements for this to take precedence.
|
||||
*/
|
||||
readonly getRowAriaLabel?: (row: number) => string | undefined
|
||||
}
|
||||
|
||||
interface IListState {
|
||||
|
@ -579,11 +590,11 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
|
||||
private onRowKeyDown = (
|
||||
rowIndex: number,
|
||||
indexPath: RowIndexPath,
|
||||
event: React.KeyboardEvent<any>
|
||||
) => {
|
||||
if (this.props.onRowKeyDown) {
|
||||
this.props.onRowKeyDown(rowIndex, event)
|
||||
this.props.onRowKeyDown(indexPath.row, event)
|
||||
}
|
||||
|
||||
const hasModifier =
|
||||
|
@ -644,21 +655,27 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
})
|
||||
}
|
||||
|
||||
private onRowFocus = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
|
||||
this.focusRow = index
|
||||
private onRowFocus = (
|
||||
indexPath: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.focusRow = indexPath.row
|
||||
}
|
||||
|
||||
private onRowBlur = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (this.focusRow === index) {
|
||||
private onRowBlur = (
|
||||
indexPath: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (this.focusRow === indexPath.row) {
|
||||
this.focusRow = -1
|
||||
}
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
row: number,
|
||||
indexPath: RowIndexPath,
|
||||
e: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.props.onRowContextMenu?.(row, e)
|
||||
this.props.onRowContextMenu?.(indexPath.row, e)
|
||||
}
|
||||
|
||||
/** Convenience method for invoking canSelectRow callback when it exists */
|
||||
|
@ -866,14 +883,17 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowRef = (rowIndex: number, element: HTMLDivElement | null) => {
|
||||
private onRowRef = (
|
||||
indexPath: RowIndexPath,
|
||||
element: HTMLDivElement | null
|
||||
) => {
|
||||
if (element === null) {
|
||||
this.rowRefs.delete(rowIndex)
|
||||
this.rowRefs.delete(indexPath.row)
|
||||
} else {
|
||||
this.rowRefs.set(rowIndex, element)
|
||||
this.rowRefs.set(indexPath.row, element)
|
||||
}
|
||||
|
||||
if (rowIndex === this.focusRow) {
|
||||
if (indexPath.row === this.focusRow) {
|
||||
// The currently focused row is going being unmounted so we'll move focus
|
||||
// programmatically to the grid so that keyboard navigation still works
|
||||
if (element === null) {
|
||||
|
@ -924,8 +944,8 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
const element =
|
||||
this.props.insertionDragType !== undefined ? (
|
||||
<ListItemInsertionOverlay
|
||||
onDropDataInsertion={this.props.onDropDataInsertion}
|
||||
itemIndex={rowIndex}
|
||||
onDropDataInsertion={this.onDropDataInsertion}
|
||||
itemIndex={{ section: 0, row: rowIndex }}
|
||||
dragType={this.props.insertionDragType}
|
||||
>
|
||||
{row}
|
||||
|
@ -936,14 +956,21 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
|
||||
const id = this.getRowId(rowIndex)
|
||||
|
||||
const ariaLabel =
|
||||
this.props.getRowAriaLabel !== undefined
|
||||
? this.props.getRowAriaLabel(rowIndex)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
key={params.key}
|
||||
id={id}
|
||||
onRowRef={this.onRowRef}
|
||||
rowCount={this.props.rowCount}
|
||||
rowIndex={rowIndex}
|
||||
rowIndex={{ section: 0, row: rowIndex }}
|
||||
sectionHasHeader={false}
|
||||
selected={selected}
|
||||
ariaLabel={ariaLabel}
|
||||
onRowClick={this.onRowClick}
|
||||
onRowDoubleClick={this.onRowDoubleClick}
|
||||
onRowKeyDown={this.onRowKeyDown}
|
||||
|
@ -998,7 +1025,6 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
*
|
||||
* @param width - The width of the Grid as given by AutoSizer
|
||||
* @param height - The height of the Grid as given by AutoSizer
|
||||
*
|
||||
*/
|
||||
private renderContents(width: number, height: number) {
|
||||
if (__WIN32__) {
|
||||
|
@ -1028,6 +1054,18 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
* @param height - The height of the Grid as given by AutoSizer
|
||||
*/
|
||||
private renderGrid(width: number, height: number) {
|
||||
// It is possible to send an invalid array such as [-1] to this component,
|
||||
// if you do, you get weird focus problems. We shouldn't be doing this.. but
|
||||
// if we do, send a non fatal exception to tell us about it.
|
||||
if (this.props.selectedRows[0] < 0) {
|
||||
sendNonFatalException(
|
||||
'The selected rows of the List.tsx contained a negative number.',
|
||||
new Error(
|
||||
`Invalid selected rows that contained a negative number passed to List component. This will cause keyboard navigation and focus problems.`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// The currently selected list item is focusable but if there's no focused
|
||||
// item the list itself needs to be focusable so that you can reach it with
|
||||
// keyboard navigation and select an item.
|
||||
|
@ -1060,6 +1098,9 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
height={height}
|
||||
columnWidth={width}
|
||||
columnCount={1}
|
||||
aria-multiselectable={
|
||||
this.props.selectionMode !== 'single' ? true : undefined
|
||||
}
|
||||
rowCount={this.props.rowCount}
|
||||
rowHeight={this.props.rowHeight}
|
||||
cellRenderer={this.renderRow}
|
||||
|
@ -1085,7 +1126,6 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
* and accurately positions the fake scroll bar.
|
||||
*
|
||||
* @param height The height of the Grid as given by AutoSizer
|
||||
*
|
||||
*/
|
||||
private renderFakeScroll(height: number) {
|
||||
let totalHeight: number = 0
|
||||
|
@ -1133,7 +1173,12 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowMouseDown = (row: number, event: React.MouseEvent<any>) => {
|
||||
private onRowMouseDown = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
const { row } = indexPath
|
||||
|
||||
if (this.canSelectRow(row)) {
|
||||
if (this.props.onRowMouseDown) {
|
||||
this.props.onRowMouseDown(row, event)
|
||||
|
@ -1226,7 +1271,12 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowMouseUp = (row: number, event: React.MouseEvent<any>) => {
|
||||
private onRowMouseUp = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
const { row } = indexPath
|
||||
|
||||
if (!this.canSelectRow(row)) {
|
||||
return
|
||||
}
|
||||
|
@ -1288,27 +1338,37 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowClick = (row: number, event: React.MouseEvent<any>) => {
|
||||
if (this.canSelectRow(row) && this.props.onRowClick) {
|
||||
private onDropDataInsertion = (indexPath: RowIndexPath, data: DragData) => {
|
||||
this.props.onDropDataInsertion?.(indexPath.row, data)
|
||||
}
|
||||
|
||||
private onRowClick = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
if (this.canSelectRow(indexPath.row) && this.props.onRowClick) {
|
||||
const rowCount = this.props.rowCount
|
||||
|
||||
if (row < 0 || row >= rowCount) {
|
||||
if (indexPath.row < 0 || indexPath.row >= rowCount) {
|
||||
log.debug(
|
||||
`[List.onRowClick] unable to onRowClick for row ${row} as it is outside the bounds of the array [0, ${rowCount}]`
|
||||
`[List.onRowClick] unable to onRowClick for row ${indexPath.row} as it is outside the bounds of the array [0, ${rowCount}]`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onRowClick(row, { kind: 'mouseclick', event })
|
||||
this.props.onRowClick(indexPath.row, { kind: 'mouseclick', event })
|
||||
}
|
||||
}
|
||||
|
||||
private onRowDoubleClick = (row: number, event: React.MouseEvent<any>) => {
|
||||
private onRowDoubleClick = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
if (!this.props.onRowDoubleClick) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onRowDoubleClick(row, { kind: 'mouseclick', event })
|
||||
this.props.onRowDoubleClick(indexPath.row, { kind: 'mouseclick', event })
|
||||
}
|
||||
|
||||
private onScroll = ({
|
||||
|
|
191
app/src/ui/lib/list/section-list-selection.ts
Normal file
191
app/src/ui/lib/list/section-list-selection.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
getTotalRowCount,
|
||||
globalIndexToRowIndexPath,
|
||||
InvalidRowIndexPath,
|
||||
isValidRow,
|
||||
RowIndexPath,
|
||||
rowIndexPathEquals,
|
||||
rowIndexPathToGlobalIndex,
|
||||
} from './list-row-index-path'
|
||||
|
||||
export type SelectionDirection = 'up' | 'down'
|
||||
|
||||
interface ISelectRowAction {
|
||||
/**
|
||||
* The vertical direction use when searching for a selectable row.
|
||||
*/
|
||||
readonly direction: SelectionDirection
|
||||
|
||||
/**
|
||||
* The starting row index to search from.
|
||||
*/
|
||||
readonly row: RowIndexPath
|
||||
|
||||
/**
|
||||
* A flag to indicate or not to look beyond the last or first
|
||||
* row (depending on direction) such that given the last row and
|
||||
* a downward direction will consider the first row as a
|
||||
* candidate or given the first row and an upward direction
|
||||
* will consider the last row as a candidate.
|
||||
*
|
||||
* Defaults to true if not set.
|
||||
*/
|
||||
readonly wrap?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a pointer device clicking or pressing on an item.
|
||||
*/
|
||||
export interface IMouseClickSource {
|
||||
readonly kind: 'mouseclick'
|
||||
readonly event: React.MouseEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a pointer device hovering over an item.
|
||||
* Only applicable when selectedOnHover is set.
|
||||
*/
|
||||
export interface IHoverSource {
|
||||
readonly kind: 'hover'
|
||||
readonly event: React.MouseEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a keyboard
|
||||
*/
|
||||
export interface IKeyboardSource {
|
||||
readonly kind: 'keyboard'
|
||||
readonly event: React.KeyboardEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection of all list
|
||||
* items (usually by clicking the Edit > Select all menu item in
|
||||
* the application window). This is highly specific to GitHub Desktop
|
||||
*/
|
||||
export interface ISelectAllSource {
|
||||
readonly kind: 'select-all'
|
||||
}
|
||||
|
||||
/** A type union of possible sources of a selection changed event */
|
||||
export type SelectionSource =
|
||||
| IMouseClickSource
|
||||
| IHoverSource
|
||||
| IKeyboardSource
|
||||
| ISelectAllSource
|
||||
|
||||
/**
|
||||
* Determine the next selectable row, given the direction and a starting
|
||||
* row index. Whether a row is selectable or not is determined using
|
||||
* the `canSelectRow` function, which defaults to true if not provided.
|
||||
*
|
||||
* Returns null if no row can be selected or if the only selectable row is
|
||||
* identical to the given row parameter.
|
||||
*/
|
||||
export function findNextSelectableRow(
|
||||
rowCount: ReadonlyArray<number>,
|
||||
action: ISelectRowAction,
|
||||
canSelectRow: (indexPath: RowIndexPath) => boolean = row => true
|
||||
): RowIndexPath | null {
|
||||
const totalRowCount = getTotalRowCount(rowCount)
|
||||
if (totalRowCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { direction, row } = action
|
||||
const wrap = action.wrap === undefined ? true : action.wrap
|
||||
const rowIndex = rowIndexPathEquals(InvalidRowIndexPath, row)
|
||||
? -1
|
||||
: rowIndexPathToGlobalIndex(row, rowCount)
|
||||
|
||||
if (rowIndex === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Ensure the row value is in the range between 0 and rowCount - 1
|
||||
//
|
||||
// If the row falls outside this range, use the direction
|
||||
// given to choose a suitable value:
|
||||
//
|
||||
// - move in an upward direction -> select last row
|
||||
// - move in a downward direction -> select first row
|
||||
//
|
||||
let currentRow = isValidRow(row, rowCount)
|
||||
? rowIndex
|
||||
: direction === 'up'
|
||||
? totalRowCount - 1
|
||||
: 0
|
||||
|
||||
// handle specific case from switching from filter text to list
|
||||
//
|
||||
// locking currentRow to [0,rowCount) above means that the below loops
|
||||
// will skip over the first entry
|
||||
if (direction === 'down' && rowIndexPathEquals(row, InvalidRowIndexPath)) {
|
||||
currentRow = -1
|
||||
}
|
||||
|
||||
const delta = direction === 'up' ? -1 : 1
|
||||
|
||||
// Iterate through all rows (starting offset from the
|
||||
// given row and ending on and including the given row)
|
||||
for (let i = 0; i < totalRowCount; i++) {
|
||||
currentRow += delta
|
||||
|
||||
if (currentRow >= totalRowCount) {
|
||||
// We've hit rock bottom, wrap around to the top
|
||||
// if we're allowed to or give up.
|
||||
if (wrap) {
|
||||
currentRow = 0
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else if (currentRow < 0) {
|
||||
// We've reached the top, wrap around to the bottom
|
||||
// if we're allowed to or give up
|
||||
if (wrap) {
|
||||
currentRow = totalRowCount - 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const currentRowIndexPath = globalIndexToRowIndexPath(currentRow, rowCount)
|
||||
if (
|
||||
currentRowIndexPath !== null &&
|
||||
!rowIndexPathEquals(row, currentRowIndexPath) &&
|
||||
canSelectRow(currentRowIndexPath)
|
||||
) {
|
||||
return currentRowIndexPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last selectable row in either direction, used
|
||||
* for moving to the first or last selectable row in a list,
|
||||
* i.e. Home/End key navigation.
|
||||
*/
|
||||
export function findLastSelectableRow(
|
||||
direction: SelectionDirection,
|
||||
rowCount: ReadonlyArray<number>,
|
||||
canSelectRow: (indexPath: RowIndexPath) => boolean
|
||||
): RowIndexPath | null {
|
||||
const totalRowCount = getTotalRowCount(rowCount)
|
||||
let i = direction === 'up' ? 0 : totalRowCount - 1
|
||||
const delta = direction === 'up' ? 1 : -1
|
||||
|
||||
for (; i >= 0 && i < totalRowCount; i += delta) {
|
||||
const indexPath = globalIndexToRowIndexPath(i, rowCount)
|
||||
if (indexPath !== null && canSelectRow(indexPath)) {
|
||||
return indexPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
1684
app/src/ui/lib/list/section-list.tsx
Normal file
1684
app/src/ui/lib/list/section-list.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
@ -87,7 +87,7 @@ export function findNextSelectableRow(
|
|||
}
|
||||
|
||||
const { direction, row } = action
|
||||
const wrap = action.wrap === undefined ? true : action.wrap
|
||||
const wrap = action.wrap ?? true
|
||||
|
||||
// Ensure the row value is in the range between 0 and rowCount - 1
|
||||
//
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
size,
|
||||
} from '@floating-ui/core'
|
||||
import { assertNever } from '../../lib/fatal-error'
|
||||
import { isMacOSVentura } from '../../lib/get-os'
|
||||
|
||||
/**
|
||||
* Position of the popover relative to its anchor element. It's composed by 2
|
||||
|
@ -218,12 +219,36 @@ export class Popover extends React.Component<IPopoverProps, IPopoverState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the aria-labelledby or aria-describedby attribute
|
||||
*
|
||||
* The correct semantics are that a dialog element (which this is) should have
|
||||
* an aria-labelledby for it's title.
|
||||
*
|
||||
* However, macOs Ventura introduced a regression in that the aria-labelledby
|
||||
* is not announced and if provided prevents the aria-describedby from being
|
||||
* announced. Thus, this method will use aria-describedby instead of the
|
||||
* aria-labelledby for macOs Ventura. This is not semantically correct tho,
|
||||
* hopefully, macOs will be fixed in a future release. The issue is known for
|
||||
* macOS versions 13.0 to the current version of 13.5 as of 2023-07-31.
|
||||
*/
|
||||
private getAriaAttributes() {
|
||||
if (!isMacOSVentura()) {
|
||||
return {
|
||||
'aria-labelledby': this.props.ariaLabelledby,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'aria-describedby': this.props.ariaLabelledby,
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
trapFocus,
|
||||
className,
|
||||
appearEffect,
|
||||
ariaLabelledby,
|
||||
children,
|
||||
decoration,
|
||||
maxHeight,
|
||||
|
@ -292,7 +317,7 @@ export class Popover extends React.Component<IPopoverProps, IPopoverState> {
|
|||
className={cn}
|
||||
style={style}
|
||||
ref={this.containerDivRef}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
{...this.getAriaAttributes()}
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
|
|
|
@ -19,6 +19,11 @@ interface IRefNameProps {
|
|||
*/
|
||||
readonly label?: string | JSX.Element
|
||||
|
||||
/**
|
||||
* The aria-describedby attribute for the text box.
|
||||
*/
|
||||
readonly ariaDescribedBy?: string
|
||||
|
||||
/**
|
||||
* Called when the user changes the ref name.
|
||||
*
|
||||
|
@ -84,6 +89,7 @@ export class RefNameTextBox extends React.Component<
|
|||
label={this.props.label}
|
||||
value={this.state.proposedValue}
|
||||
ref={this.textBoxRef}
|
||||
ariaDescribedBy={this.props.ariaDescribedBy}
|
||||
onValueChanged={this.onValueChange}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
|
|
698
app/src/ui/lib/section-filter-list.tsx
Normal file
698
app/src/ui/lib/section-filter-list.tsx
Normal file
|
@ -0,0 +1,698 @@
|
|||
import * as React from 'react'
|
||||
import classnames from 'classnames'
|
||||
|
||||
import { SectionList, ClickSource } from '../lib/list/section-list'
|
||||
import {
|
||||
findNextSelectableRow,
|
||||
SelectionDirection,
|
||||
} from '../lib/list/section-list-selection'
|
||||
import { TextBox } from '../lib/text-box'
|
||||
import { Row } from '../lib/row'
|
||||
|
||||
import { match, IMatch, IMatches } from '../../lib/fuzzy-find'
|
||||
import { AriaLiveContainer } from '../accessibility/aria-live-container'
|
||||
import {
|
||||
InvalidRowIndexPath,
|
||||
RowIndexPath,
|
||||
rowIndexPathEquals,
|
||||
} from './list/list-row-index-path'
|
||||
import {
|
||||
IFilterListGroup,
|
||||
IFilterListItem,
|
||||
SelectionSource,
|
||||
} from './filter-list'
|
||||
|
||||
interface IFlattenedGroup {
|
||||
readonly kind: 'group'
|
||||
readonly identifier: string
|
||||
}
|
||||
|
||||
interface IFlattenedItem<T extends IFilterListItem> {
|
||||
readonly kind: 'item'
|
||||
readonly item: T
|
||||
/** Array of indexes in `item.text` that should be highlighted */
|
||||
readonly matches: IMatches
|
||||
}
|
||||
|
||||
/**
|
||||
* A row in the list. This is used internally after the user-provided groups are
|
||||
* flattened.
|
||||
*/
|
||||
type IFilterListRow<T extends IFilterListItem> =
|
||||
| IFlattenedGroup
|
||||
| IFlattenedItem<T>
|
||||
|
||||
interface ISectionFilterListProps<T extends IFilterListItem> {
|
||||
/** A class name for the wrapping element. */
|
||||
readonly className?: string
|
||||
|
||||
/** The height of the rows. */
|
||||
readonly rowHeight: number
|
||||
|
||||
/** The ordered groups to display in the list. */
|
||||
readonly groups: ReadonlyArray<IFilterListGroup<T>>
|
||||
|
||||
/** The selected item. */
|
||||
readonly selectedItem: T | null
|
||||
|
||||
/** Called to render each visible item. */
|
||||
readonly renderItem: (item: T, matches: IMatches) => JSX.Element | null
|
||||
|
||||
/** Called to render header for the group with the given identifier. */
|
||||
readonly renderGroupHeader?: (identifier: string) => JSX.Element | null
|
||||
|
||||
/** Called to render content before/above the filter and list. */
|
||||
readonly renderPreList?: () => JSX.Element | null
|
||||
|
||||
/**
|
||||
* This function will be called when a pointer device is pressed and then
|
||||
* released on a selectable row. Note that this follows the conventions
|
||||
* of button elements such that pressing Enter or Space on a keyboard
|
||||
* while focused on a particular row will also trigger this event. Consumers
|
||||
* can differentiate between the two using the source parameter.
|
||||
*
|
||||
* Note that this event handler will not be called for keyboard events
|
||||
* if `event.preventDefault()` was called in the onRowKeyDown event handler.
|
||||
*
|
||||
* Consumers of this event do _not_ have to call event.preventDefault,
|
||||
* when this event is subscribed to the list will automatically call it.
|
||||
*/
|
||||
readonly onItemClick?: (item: T, source: ClickSource) => void
|
||||
|
||||
/**
|
||||
* This function will be called when the selection changes as a result of a
|
||||
* user keyboard or mouse action (i.e. not when props change). This function
|
||||
* will not be invoked when an already selected row is clicked on.
|
||||
*
|
||||
* @param selectedItem - The item that was just selected
|
||||
* @param source - The kind of user action that provoked the change,
|
||||
* either a pointer device press, or a keyboard event
|
||||
* (arrow up/down)
|
||||
*/
|
||||
readonly onSelectionChanged?: (
|
||||
selectedItem: T | null,
|
||||
source: SelectionSource
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Called when a key down happens in the filter text input. Users have a
|
||||
* chance to respond or cancel the default behavior by calling
|
||||
* `preventDefault()`.
|
||||
*/
|
||||
readonly onFilterKeyDown?: (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => void
|
||||
|
||||
/** Called when the Enter key is pressed in field of type search */
|
||||
readonly onEnterPressedWithoutFilteredItems?: (text: string) => void
|
||||
|
||||
/** Aria label for a specific group */
|
||||
readonly getGroupAriaLabel?: (group: number) => string | undefined
|
||||
|
||||
/** The current filter text to use in the form */
|
||||
readonly filterText?: string
|
||||
|
||||
/** Called when the filter text is changed by the user */
|
||||
readonly onFilterTextChanged?: (text: string) => void
|
||||
|
||||
/**
|
||||
* Whether or not the filter list should allow selection
|
||||
* and filtering. Defaults to false.
|
||||
*/
|
||||
readonly disabled?: boolean
|
||||
|
||||
/** Any props which should cause a re-render if they change. */
|
||||
readonly invalidationProps: any
|
||||
|
||||
/** Called to render content after the filter. */
|
||||
readonly renderPostFilter?: () => JSX.Element | null
|
||||
|
||||
/** Called when there are no items to render. */
|
||||
readonly renderNoItems?: () => JSX.Element | null
|
||||
|
||||
/**
|
||||
* A reference to a TextBox that will be used to control this component.
|
||||
*
|
||||
* See https://github.com/desktop/desktop/issues/4317 for refactoring work to
|
||||
* make this more composable which should make this unnecessary.
|
||||
*/
|
||||
readonly filterTextBox?: TextBox
|
||||
|
||||
/**
|
||||
* Callback to fire when the items in the filter list are updated
|
||||
*/
|
||||
readonly onFilterListResultsChanged?: (resultCount: number) => void
|
||||
|
||||
/** Placeholder text for text box. Default is "Filter". */
|
||||
readonly placeholderText?: string
|
||||
|
||||
/** If true, we do not render the filter. */
|
||||
readonly hideFilterRow?: boolean
|
||||
|
||||
/**
|
||||
* A handler called whenever a context menu event is received on the
|
||||
* row container element.
|
||||
*
|
||||
* The context menu is invoked when a user right clicks the row or
|
||||
* uses keyboard shortcut.s
|
||||
*/
|
||||
readonly onItemContextMenu?: (
|
||||
item: T,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => void
|
||||
}
|
||||
|
||||
interface IFilterListState<T extends IFilterListItem> {
|
||||
readonly rows: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>
|
||||
readonly selectedRow: RowIndexPath
|
||||
readonly filterValue: string
|
||||
// Indices of groups in the filtered list
|
||||
readonly groups: ReadonlyArray<number>
|
||||
}
|
||||
|
||||
/** A List which includes the ability to filter based on its contents. */
|
||||
export class SectionFilterList<
|
||||
T extends IFilterListItem
|
||||
> extends React.Component<ISectionFilterListProps<T>, IFilterListState<T>> {
|
||||
private list: SectionList | null = null
|
||||
private filterTextBox: TextBox | null = null
|
||||
|
||||
public constructor(props: ISectionFilterListProps<T>) {
|
||||
super(props)
|
||||
|
||||
this.state = createStateUpdate(props)
|
||||
}
|
||||
|
||||
public componentWillMount() {
|
||||
if (this.props.filterTextBox !== undefined) {
|
||||
this.filterTextBox = this.props.filterTextBox
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: ISectionFilterListProps<T>) {
|
||||
this.setState(createStateUpdate(nextProps))
|
||||
}
|
||||
|
||||
public componentDidUpdate(
|
||||
prevProps: ISectionFilterListProps<T>,
|
||||
prevState: IFilterListState<T>
|
||||
) {
|
||||
if (this.props.onSelectionChanged) {
|
||||
const oldSelectedItemId = getItemIdFromRowIndex(
|
||||
prevState.rows,
|
||||
prevState.selectedRow
|
||||
)
|
||||
const newSelectedItemId = getItemIdFromRowIndex(
|
||||
this.state.rows,
|
||||
this.state.selectedRow
|
||||
)
|
||||
|
||||
if (oldSelectedItemId !== newSelectedItemId) {
|
||||
const propSelectionId = this.props.selectedItem
|
||||
? this.props.selectedItem.id
|
||||
: null
|
||||
|
||||
if (propSelectionId !== newSelectedItemId) {
|
||||
const newSelectedItem = getItemFromRowIndex(
|
||||
this.state.rows,
|
||||
this.state.selectedRow
|
||||
)
|
||||
this.props.onSelectionChanged(newSelectedItem, {
|
||||
kind: 'filter',
|
||||
filterText: this.props.filterText || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.onFilterListResultsChanged !== undefined) {
|
||||
const itemCount = this.state.rows
|
||||
.flat()
|
||||
.filter(row => row.kind === 'item').length
|
||||
|
||||
this.props.onFilterListResultsChanged(itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.filterTextBox !== null) {
|
||||
this.filterTextBox.selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
public renderTextBox() {
|
||||
return (
|
||||
<TextBox
|
||||
ref={this.onTextBoxRef}
|
||||
type="search"
|
||||
autoFocus={true}
|
||||
placeholder={this.props.placeholderText || 'Filter'}
|
||||
className="filter-list-filter-field"
|
||||
onValueChanged={this.onFilterValueChanged}
|
||||
onEnterPressed={this.onEnterPressed}
|
||||
onKeyDown={this.onKeyDown}
|
||||
value={this.props.filterText}
|
||||
disabled={this.props.disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
public renderFilterRow() {
|
||||
if (this.props.hideFilterRow === true) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="filter-field-row">
|
||||
{this.props.filterTextBox === undefined ? this.renderTextBox() : null}
|
||||
{this.props.renderPostFilter ? this.props.renderPostFilter() : null}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const itemRows = this.state.rows.flat().filter(row => row.kind === 'item')
|
||||
const resultsPluralized = itemRows.length === 1 ? 'result' : 'results'
|
||||
const screenReaderMessage = `${itemRows.length} ${resultsPluralized}`
|
||||
|
||||
return (
|
||||
<div className={classnames('filter-list', this.props.className)}>
|
||||
<AriaLiveContainer
|
||||
trackedUserInput={this.state.filterValue}
|
||||
message={screenReaderMessage}
|
||||
/>
|
||||
{this.props.renderPreList ? this.props.renderPreList() : null}
|
||||
|
||||
{this.renderFilterRow()}
|
||||
|
||||
<div className="filter-list-container">{this.renderContent()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public selectNextItem(
|
||||
focus: boolean = false,
|
||||
inDirection: SelectionDirection = 'down'
|
||||
) {
|
||||
if (this.list === null) {
|
||||
return
|
||||
}
|
||||
let next: RowIndexPath | null = null
|
||||
|
||||
const rowCount = this.state.rows.map(r => r.length)
|
||||
if (
|
||||
this.state.selectedRow.row === -1 ||
|
||||
this.state.selectedRow.row === this.state.rows.length
|
||||
) {
|
||||
next = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: inDirection,
|
||||
row: InvalidRowIndexPath,
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
} else {
|
||||
next = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: inDirection,
|
||||
row: this.state.selectedRow,
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
}
|
||||
|
||||
if (next !== null) {
|
||||
this.setState({ selectedRow: next }, () => {
|
||||
if (focus && this.list !== null) {
|
||||
this.list.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
if (this.state.rows.length === 0 && this.props.renderNoItems) {
|
||||
return this.props.renderNoItems()
|
||||
} else {
|
||||
return (
|
||||
<SectionList
|
||||
ref={this.onListRef}
|
||||
rowCount={this.state.rows.map(r => r.length)}
|
||||
rowRenderer={this.renderRow}
|
||||
sectionHasHeader={this.sectionHasHeader}
|
||||
getSectionAriaLabel={this.getGroupAriaLabel}
|
||||
rowHeight={this.props.rowHeight}
|
||||
selectedRows={
|
||||
rowIndexPathEquals(this.state.selectedRow, InvalidRowIndexPath)
|
||||
? []
|
||||
: [this.state.selectedRow]
|
||||
}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
onRowClick={this.onRowClick}
|
||||
onRowKeyDown={this.onRowKeyDown}
|
||||
onRowContextMenu={this.onRowContextMenu}
|
||||
canSelectRow={this.canSelectRow}
|
||||
invalidationProps={{
|
||||
...this.props,
|
||||
...this.props.invalidationProps,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sectionHasHeader = (section: number) => {
|
||||
const rows = this.state.rows[section]
|
||||
return rows.length > 0 && rows[0].kind === 'group'
|
||||
}
|
||||
|
||||
private getGroupAriaLabel = (group: number) => {
|
||||
return this.props.getGroupAriaLabel?.(this.state.groups[group])
|
||||
}
|
||||
|
||||
private renderRow = (index: RowIndexPath) => {
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
if (row.kind === 'item') {
|
||||
return this.props.renderItem(row.item, row.matches)
|
||||
} else if (this.props.renderGroupHeader) {
|
||||
return this.props.renderGroupHeader(row.identifier)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private onTextBoxRef = (component: TextBox | null) => {
|
||||
this.filterTextBox = component
|
||||
}
|
||||
|
||||
private onListRef = (instance: SectionList | null) => {
|
||||
this.list = instance
|
||||
}
|
||||
|
||||
private onFilterValueChanged = (text: string) => {
|
||||
if (this.props.onFilterTextChanged) {
|
||||
this.props.onFilterTextChanged(text)
|
||||
}
|
||||
}
|
||||
|
||||
private onEnterPressed = (text: string) => {
|
||||
const rows = this.state.rows.length
|
||||
if (
|
||||
rows === 0 &&
|
||||
text.trim().length > 0 &&
|
||||
this.props.onEnterPressedWithoutFilteredItems !== undefined
|
||||
) {
|
||||
this.props.onEnterPressedWithoutFilteredItems(text)
|
||||
}
|
||||
}
|
||||
|
||||
private onSelectedRowChanged = (
|
||||
index: RowIndexPath,
|
||||
source: SelectionSource
|
||||
) => {
|
||||
this.setState({ selectedRow: index })
|
||||
|
||||
if (this.props.onSelectionChanged) {
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
if (row.kind === 'item') {
|
||||
this.props.onSelectionChanged(row.item, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private canSelectRow = (index: RowIndexPath) => {
|
||||
if (this.props.disabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
return row.kind === 'item'
|
||||
}
|
||||
|
||||
private onRowClick = (index: RowIndexPath, source: ClickSource) => {
|
||||
if (this.props.onItemClick) {
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
|
||||
if (row.kind === 'item') {
|
||||
this.props.onItemClick(row.item, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
index: RowIndexPath,
|
||||
source: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (!this.props.onItemContextMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
|
||||
if (row.kind !== 'item') {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onItemContextMenu(row.item, source)
|
||||
}
|
||||
|
||||
private onRowKeyDown = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.KeyboardEvent<any>
|
||||
) => {
|
||||
const list = this.list
|
||||
if (!list) {
|
||||
return
|
||||
}
|
||||
|
||||
const rowCount = this.state.rows.map(r => r.length)
|
||||
|
||||
const firstSelectableRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{ direction: 'down', row: InvalidRowIndexPath },
|
||||
this.canSelectRow
|
||||
)
|
||||
const lastSelectableRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: 'up',
|
||||
row: {
|
||||
section: 0,
|
||||
row: 0,
|
||||
},
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
|
||||
let shouldFocus = false
|
||||
|
||||
if (
|
||||
event.key === 'ArrowUp' &&
|
||||
firstSelectableRow &&
|
||||
rowIndexPathEquals(indexPath, firstSelectableRow)
|
||||
) {
|
||||
shouldFocus = true
|
||||
} else if (
|
||||
event.key === 'ArrowDown' &&
|
||||
lastSelectableRow &&
|
||||
rowIndexPathEquals(indexPath, lastSelectableRow)
|
||||
) {
|
||||
shouldFocus = true
|
||||
}
|
||||
|
||||
if (shouldFocus) {
|
||||
const textBox = this.filterTextBox
|
||||
|
||||
if (textBox) {
|
||||
event.preventDefault()
|
||||
textBox.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const list = this.list
|
||||
const key = event.key
|
||||
|
||||
if (!list) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.props.onFilterKeyDown) {
|
||||
this.props.onFilterKeyDown(event)
|
||||
}
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
const rowCount = this.state.rows.map(r => r.length)
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
if (rowCount.length > 0) {
|
||||
const selectedRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{ direction: 'down', row: InvalidRowIndexPath },
|
||||
this.canSelectRow
|
||||
)
|
||||
if (selectedRow != null) {
|
||||
this.setState({ selectedRow }, () => {
|
||||
list.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
} else if (key === 'ArrowUp') {
|
||||
if (rowCount.length > 0) {
|
||||
const selectedRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: 'up',
|
||||
row: {
|
||||
section: 0,
|
||||
row: 0,
|
||||
},
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
if (selectedRow != null) {
|
||||
this.setState({ selectedRow }, () => {
|
||||
list.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
} else if (key === 'Enter') {
|
||||
// no repositories currently displayed, bail out
|
||||
if (rowCount.length === 0) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
const filterText = this.props.filterText
|
||||
|
||||
if (filterText !== undefined && !/\S/.test(filterText)) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
const row = findNextSelectableRow(
|
||||
rowCount,
|
||||
{ direction: 'down', row: InvalidRowIndexPath },
|
||||
this.canSelectRow
|
||||
)
|
||||
|
||||
if (row != null) {
|
||||
this.onRowClick(row, { kind: 'keyboard', event })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getText<T extends IFilterListItem>(
|
||||
item: T
|
||||
): ReadonlyArray<string> {
|
||||
return item['text']
|
||||
}
|
||||
|
||||
function getFirstVisibleRow<T extends IFilterListItem>(
|
||||
rows: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>
|
||||
): RowIndexPath {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const groupRows = rows[i]
|
||||
for (let j = 0; j < groupRows.length; j++) {
|
||||
const row = groupRows[j]
|
||||
if (row.kind === 'item') {
|
||||
return { section: i, row: j }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return InvalidRowIndexPath
|
||||
}
|
||||
|
||||
function createStateUpdate<T extends IFilterListItem>(
|
||||
props: ISectionFilterListProps<T>
|
||||
) {
|
||||
const rows = new Array<Array<IFilterListRow<T>>>()
|
||||
const filter = (props.filterText || '').toLowerCase()
|
||||
let selectedRow = InvalidRowIndexPath
|
||||
let section = 0
|
||||
const selectedItem = props.selectedItem
|
||||
const groupIndices = []
|
||||
|
||||
for (const [idx, group] of props.groups.entries()) {
|
||||
const groupRows = new Array<IFilterListRow<T>>()
|
||||
const items: ReadonlyArray<IMatch<T>> = filter
|
||||
? match(filter, group.items, getText)
|
||||
: group.items.map(item => ({
|
||||
score: 1,
|
||||
matches: { title: [], subtitle: [] },
|
||||
item,
|
||||
}))
|
||||
|
||||
if (!items.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
groupIndices.push(idx)
|
||||
|
||||
if (props.renderGroupHeader) {
|
||||
groupRows.push({ kind: 'group', identifier: group.identifier })
|
||||
}
|
||||
|
||||
for (const { item, matches } of items) {
|
||||
if (selectedItem && item.id === selectedItem.id) {
|
||||
selectedRow = {
|
||||
section,
|
||||
row: groupRows.length,
|
||||
}
|
||||
}
|
||||
|
||||
groupRows.push({ kind: 'item', item, matches })
|
||||
}
|
||||
|
||||
rows.push(groupRows)
|
||||
section++
|
||||
}
|
||||
|
||||
if (selectedRow.row < 0 && filter.length) {
|
||||
// If the selected item isn't in the list (e.g., filtered out), then
|
||||
// select the first visible item.
|
||||
selectedRow = getFirstVisibleRow(rows)
|
||||
}
|
||||
|
||||
return { rows: rows, selectedRow, filterValue: filter, groups: groupIndices }
|
||||
}
|
||||
|
||||
function getItemFromRowIndex<T extends IFilterListItem>(
|
||||
items: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>,
|
||||
index: RowIndexPath
|
||||
): T | null {
|
||||
if (index.section < 0 || index.section >= items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const group = items[index.section]
|
||||
if (index.row < 0 || index.row >= group.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = group[index.row]
|
||||
|
||||
if (row.kind === 'item') {
|
||||
return row.item
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getItemIdFromRowIndex<T extends IFilterListItem>(
|
||||
items: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>,
|
||||
index: RowIndexPath
|
||||
): string | null {
|
||||
const item = getItemFromRowIndex(items, index)
|
||||
return item ? item.id : null
|
||||
}
|
|
@ -84,6 +84,10 @@ export interface ITextBoxProps {
|
|||
/** Optional aria-label attribute */
|
||||
readonly ariaLabel?: string
|
||||
|
||||
/** Optional aria-describedby attribute - usually for associating a descriptive
|
||||
* message to the input such as a validation error, warning, or caption */
|
||||
readonly ariaDescribedBy?: string
|
||||
|
||||
readonly ariaControls?: string
|
||||
}
|
||||
|
||||
|
@ -277,6 +281,7 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
|
|||
spellCheck={this.props.spellcheck === true}
|
||||
aria-label={this.props.ariaLabel}
|
||||
aria-controls={this.props.ariaControls}
|
||||
aria-describedby={this.props.ariaDescribedBy}
|
||||
required={this.props.required}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -13,6 +13,11 @@ interface IToggledtippedContentProps
|
|||
/** The tooltip contents */
|
||||
readonly tooltip: JSX.Element | string | undefined
|
||||
|
||||
/** Likely the tooltips content as a string - whatever needs to be
|
||||
* communicated to a screen reader user that is communicated through the
|
||||
* tooltip */
|
||||
readonly ariaLiveMessage: string
|
||||
|
||||
/**
|
||||
* An optional additional class name to set on the tooltip in order to be able
|
||||
* to apply specific styles to the tooltip
|
||||
|
@ -86,6 +91,7 @@ export class ToggledtippedContent extends React.Component<
|
|||
className,
|
||||
tooltipClassName,
|
||||
ariaLabel,
|
||||
ariaLiveMessage,
|
||||
...rest
|
||||
} = this.props
|
||||
|
||||
|
@ -113,10 +119,9 @@ export class ToggledtippedContent extends React.Component<
|
|||
{children}
|
||||
{this.state.tooltipVisible && (
|
||||
<AriaLiveContainer
|
||||
message={ariaLiveMessage}
|
||||
trackedUserInput={this.shouldForceAriaLiveMessage}
|
||||
>
|
||||
{tooltip}
|
||||
</AriaLiveContainer>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</button>
|
||||
|
|
|
@ -12,6 +12,8 @@ import { ConfirmAbortDialog } from './dialog/confirm-abort-dialog'
|
|||
import { ProgressDialog } from './dialog/progress-dialog'
|
||||
import { WarnForcePushDialog } from './dialog/warn-force-push-dialog'
|
||||
import { PopupType } from '../../models/popup'
|
||||
import { Account } from '../../models/account'
|
||||
import { IAPIRepoRuleset } from '../../lib/api'
|
||||
|
||||
export interface IMultiCommitOperationProps {
|
||||
readonly repository: Repository
|
||||
|
@ -32,6 +34,9 @@ export interface IMultiCommitOperationProps {
|
|||
/** Whether user should be warned about force pushing */
|
||||
readonly askForConfirmationOnForcePush: boolean
|
||||
|
||||
readonly accounts: ReadonlyArray<Account>
|
||||
readonly cachedRepoRulesets: ReadonlyMap<number, IAPIRepoRuleset>
|
||||
|
||||
/**
|
||||
* Callbacks for the conflict selection components to let the user jump out
|
||||
* to their preferred editor.
|
||||
|
|
|
@ -130,6 +130,8 @@ export abstract class CherryPick extends BaseMultiCommitOperation {
|
|||
defaultBranch={defaultBranch}
|
||||
upstreamDefaultBranch={upstreamDefaultBranch}
|
||||
upstreamGitHubRepository={upstreamGhRepo}
|
||||
accounts={this.props.accounts}
|
||||
cachedRepoRulesets={this.props.cachedRepoRulesets}
|
||||
allBranches={allBranches}
|
||||
repository={repository}
|
||||
onDismissed={this.onFlowEnded}
|
||||
|
|
|
@ -29,6 +29,8 @@ export class MultiCommitOperation extends React.Component<IMultiCommitOperationP
|
|||
askForConfirmationOnForcePush={
|
||||
this.props.askForConfirmationOnForcePush
|
||||
}
|
||||
accounts={this.props.accounts}
|
||||
cachedRepoRulesets={this.props.cachedRepoRulesets}
|
||||
openFileInExternalEditor={this.props.openFileInExternalEditor}
|
||||
resolvedExternalEditor={this.props.resolvedExternalEditor}
|
||||
openRepositoryInShell={this.props.openRepositoryInShell}
|
||||
|
@ -46,6 +48,8 @@ export class MultiCommitOperation extends React.Component<IMultiCommitOperationP
|
|||
askForConfirmationOnForcePush={
|
||||
this.props.askForConfirmationOnForcePush
|
||||
}
|
||||
accounts={this.props.accounts}
|
||||
cachedRepoRulesets={this.props.cachedRepoRulesets}
|
||||
openFileInExternalEditor={this.props.openFileInExternalEditor}
|
||||
resolvedExternalEditor={this.props.resolvedExternalEditor}
|
||||
openRepositoryInShell={this.props.openRepositoryInShell}
|
||||
|
@ -63,6 +67,8 @@ export class MultiCommitOperation extends React.Component<IMultiCommitOperationP
|
|||
askForConfirmationOnForcePush={
|
||||
this.props.askForConfirmationOnForcePush
|
||||
}
|
||||
accounts={this.props.accounts}
|
||||
cachedRepoRulesets={this.props.cachedRepoRulesets}
|
||||
openFileInExternalEditor={this.props.openFileInExternalEditor}
|
||||
resolvedExternalEditor={this.props.resolvedExternalEditor}
|
||||
openRepositoryInShell={this.props.openRepositoryInShell}
|
||||
|
|
|
@ -56,6 +56,7 @@ interface IPreferencesProps {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
|
@ -81,6 +82,7 @@ interface IPreferencesState {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
|
@ -128,6 +130,7 @@ export class Preferences extends React.Component<
|
|||
confirmDiscardChanges: false,
|
||||
confirmDiscardChangesPermanently: false,
|
||||
confirmDiscardStash: false,
|
||||
confirmCheckoutCommit: false,
|
||||
confirmForcePush: false,
|
||||
confirmUndoCommit: false,
|
||||
uncommittedChangesStrategy: defaultUncommittedChangesStrategy,
|
||||
|
@ -188,6 +191,7 @@ export class Preferences extends React.Component<
|
|||
confirmDiscardChangesPermanently:
|
||||
this.props.confirmDiscardChangesPermanently,
|
||||
confirmDiscardStash: this.props.confirmDiscardStash,
|
||||
confirmCheckoutCommit: this.props.confirmCheckoutCommit,
|
||||
confirmForcePush: this.props.confirmForcePush,
|
||||
confirmUndoCommit: this.props.confirmUndoCommit,
|
||||
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
|
||||
|
@ -209,7 +213,7 @@ export class Preferences extends React.Component<
|
|||
return (
|
||||
<Dialog
|
||||
id="preferences"
|
||||
title={__DARWIN__ ? 'Preferences' : 'Options'}
|
||||
title={__DARWIN__ ? 'Settings' : 'Options'}
|
||||
onDismissed={this.onCancel}
|
||||
onSubmit={this.onSave}
|
||||
>
|
||||
|
@ -364,6 +368,7 @@ export class Preferences extends React.Component<
|
|||
this.state.confirmDiscardChangesPermanently
|
||||
}
|
||||
confirmDiscardStash={this.state.confirmDiscardStash}
|
||||
confirmCheckoutCommit={this.state.confirmCheckoutCommit}
|
||||
confirmForcePush={this.state.confirmForcePush}
|
||||
confirmUndoCommit={this.state.confirmUndoCommit}
|
||||
onConfirmRepositoryRemovalChanged={
|
||||
|
@ -371,6 +376,7 @@ export class Preferences extends React.Component<
|
|||
}
|
||||
onConfirmDiscardChangesChanged={this.onConfirmDiscardChangesChanged}
|
||||
onConfirmDiscardStashChanged={this.onConfirmDiscardStashChanged}
|
||||
onConfirmCheckoutCommitChanged={this.onConfirmCheckoutCommitChanged}
|
||||
onConfirmForcePushChanged={this.onConfirmForcePushChanged}
|
||||
onConfirmDiscardChangesPermanentlyChanged={
|
||||
this.onConfirmDiscardChangesPermanentlyChanged
|
||||
|
@ -444,6 +450,10 @@ export class Preferences extends React.Component<
|
|||
this.setState({ confirmDiscardStash: value })
|
||||
}
|
||||
|
||||
private onConfirmCheckoutCommitChanged = (value: boolean) => {
|
||||
this.setState({ confirmCheckoutCommit: value })
|
||||
}
|
||||
|
||||
private onConfirmDiscardChangesPermanentlyChanged = (value: boolean) => {
|
||||
this.setState({ confirmDiscardChangesPermanently: value })
|
||||
}
|
||||
|
@ -583,6 +593,10 @@ export class Preferences extends React.Component<
|
|||
this.state.confirmDiscardStash
|
||||
)
|
||||
|
||||
await this.props.dispatcher.setConfirmCheckoutCommitSetting(
|
||||
this.state.confirmCheckoutCommit
|
||||
)
|
||||
|
||||
await this.props.dispatcher.setConfirmUndoCommitSetting(
|
||||
this.state.confirmUndoCommit
|
||||
)
|
||||
|
|
|
@ -9,12 +9,14 @@ interface IPromptsPreferencesProps {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
readonly onConfirmDiscardChangesChanged: (checked: boolean) => void
|
||||
readonly onConfirmDiscardChangesPermanentlyChanged: (checked: boolean) => void
|
||||
readonly onConfirmDiscardStashChanged: (checked: boolean) => void
|
||||
readonly onConfirmCheckoutCommitChanged: (checked: boolean) => void
|
||||
readonly onConfirmRepositoryRemovalChanged: (checked: boolean) => void
|
||||
readonly onConfirmForcePushChanged: (checked: boolean) => void
|
||||
readonly onConfirmUndoCommitChanged: (checked: boolean) => void
|
||||
|
@ -28,6 +30,7 @@ interface IPromptsPreferencesState {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
|
@ -46,6 +49,7 @@ export class Prompts extends React.Component<
|
|||
confirmDiscardChangesPermanently:
|
||||
this.props.confirmDiscardChangesPermanently,
|
||||
confirmDiscardStash: this.props.confirmDiscardStash,
|
||||
confirmCheckoutCommit: this.props.confirmCheckoutCommit,
|
||||
confirmForcePush: this.props.confirmForcePush,
|
||||
confirmUndoCommit: this.props.confirmUndoCommit,
|
||||
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
|
||||
|
@ -79,6 +83,15 @@ export class Prompts extends React.Component<
|
|||
this.props.onConfirmDiscardStashChanged(value)
|
||||
}
|
||||
|
||||
private onConfirmCheckoutCommitChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = event.currentTarget.checked
|
||||
|
||||
this.setState({ confirmCheckoutCommit: value })
|
||||
this.props.onConfirmCheckoutCommitChanged(value)
|
||||
}
|
||||
|
||||
private onConfirmForcePushChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
|
@ -154,6 +167,15 @@ export class Prompts extends React.Component<
|
|||
}
|
||||
onChange={this.onConfirmDiscardStashChanged}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Checking out a commit"
|
||||
value={
|
||||
this.state.confirmCheckoutCommit
|
||||
? CheckboxValue.On
|
||||
: CheckboxValue.Off
|
||||
}
|
||||
onChange={this.onConfirmCheckoutCommitChanged}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Force pushing"
|
||||
value={
|
||||
|
|
|
@ -41,15 +41,16 @@ export class RenameBranch extends React.Component<
|
|||
title={__DARWIN__ ? 'Rename Branch' : 'Rename branch'}
|
||||
onDismissed={this.props.onDismissed}
|
||||
onSubmit={this.renameBranch}
|
||||
focusCloseButtonOnOpen={true}
|
||||
>
|
||||
<DialogContent>
|
||||
{renderBranchHasRemoteWarning(this.props.branch)}
|
||||
{renderStashWillBeLostWarning(this.props.stash)}
|
||||
<RefNameTextBox
|
||||
label="Name"
|
||||
initialValue={this.props.branch.name}
|
||||
onValueChange={this.onNameChange}
|
||||
/>
|
||||
{renderBranchHasRemoteWarning(this.props.branch)}
|
||||
{renderStashWillBeLostWarning(this.props.stash)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
@ -24,6 +24,8 @@ import { TooltippedContent } from '../lib/tooltipped-content'
|
|||
import memoizeOne from 'memoize-one'
|
||||
import { KeyboardShortcut } from '../keyboard-shortcut/keyboard-shortcut'
|
||||
import { generateRepositoryListContextMenu } from '../repositories-list/repository-list-item-context-menu'
|
||||
import { SectionFilterList } from '../lib/section-filter-list'
|
||||
import { enableSectionList } from '../../lib/feature-flag'
|
||||
|
||||
const BlankSlateImage = encodePathAsUrl(__dirname, 'static/empty-no-repo.svg')
|
||||
|
||||
|
@ -241,25 +243,31 @@ export class RepositoriesList extends React.Component<
|
|||
]
|
||||
: baseGroups
|
||||
|
||||
const getGroupAriaLabel = (group: number) => groups[group].identifier
|
||||
|
||||
const ListComponent = enableSectionList() ? SectionFilterList : FilterList
|
||||
const filterListProps: typeof ListComponent['prototype']['props'] = {
|
||||
rowHeight: RowHeight,
|
||||
selectedItem: selectedItem,
|
||||
filterText: this.props.filterText,
|
||||
onFilterTextChanged: this.props.onFilterTextChanged,
|
||||
renderItem: this.renderItem,
|
||||
renderGroupHeader: this.renderGroupHeader,
|
||||
onItemClick: this.onItemClick,
|
||||
renderPostFilter: this.renderPostFilter,
|
||||
renderNoItems: this.renderNoItems,
|
||||
groups: groups,
|
||||
invalidationProps: {
|
||||
repositories: this.props.repositories,
|
||||
filterText: this.props.filterText,
|
||||
},
|
||||
onItemContextMenu: this.onItemContextMenu,
|
||||
getGroupAriaLabel,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="repository-list">
|
||||
<FilterList<IRepositoryListItem>
|
||||
rowHeight={RowHeight}
|
||||
selectedItem={selectedItem}
|
||||
filterText={this.props.filterText}
|
||||
onFilterTextChanged={this.props.onFilterTextChanged}
|
||||
renderItem={this.renderItem}
|
||||
renderGroupHeader={this.renderGroupHeader}
|
||||
onItemClick={this.onItemClick}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
renderNoItems={this.renderNoItems}
|
||||
groups={groups}
|
||||
invalidationProps={{
|
||||
repositories: this.props.repositories,
|
||||
filterText: this.props.filterText,
|
||||
}}
|
||||
onItemContextMenu={this.onItemContextMenu}
|
||||
/>
|
||||
<ListComponent {...filterListProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import * as React from 'react'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
OkCancelButtonGroup,
|
||||
} from '../dialog'
|
||||
import { RepoRulesetsForBranchLink } from './repo-rulesets-for-branch-link'
|
||||
|
||||
interface IRepoRulesBypassConfirmationProps {
|
||||
readonly repository: GitHubRepository
|
||||
readonly branch: string
|
||||
readonly onConfirm: () => void
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a LinkButton to the webpage for the ruleset with the
|
||||
* provided ID within the provided repo.
|
||||
*/
|
||||
export class RepoRulesBypassConfirmation extends React.Component<
|
||||
IRepoRulesBypassConfirmationProps,
|
||||
{}
|
||||
> {
|
||||
public render() {
|
||||
return (
|
||||
<Dialog
|
||||
id="repo-rules-bypass-confirmation"
|
||||
title={
|
||||
__DARWIN__ ? 'Bypass Repository Rules' : 'Bypass repository rules'
|
||||
}
|
||||
onSubmit={this.submit}
|
||||
onDismissed={this.props.onDismissed}
|
||||
type="warning"
|
||||
>
|
||||
<DialogContent>
|
||||
This commit will bypass{' '}
|
||||
<RepoRulesetsForBranchLink
|
||||
repository={this.props.repository}
|
||||
branch={this.props.branch}
|
||||
>
|
||||
one or more repository rules
|
||||
</RepoRulesetsForBranchLink>
|
||||
. Are you sure you want to continue?
|
||||
</DialogContent>
|
||||
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
destructive={true}
|
||||
okButtonText={
|
||||
__DARWIN__ ? 'Bypass Rules and Commit' : 'Bypass rules and commit'
|
||||
}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private submit = () => {
|
||||
this.props.onConfirm()
|
||||
this.props.onDismissed()
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue