mirror of
https://github.com/desktop/desktop
synced 2024-08-28 12:40:55 +00:00
Merge branch 'development' into ft/zed-preview-editor
This commit is contained in:
commit
b2e1716e69
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
|
@ -60,10 +60,10 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
node: [18.14.0]
|
||||
os: [macos-11, windows-2019]
|
||||
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
|
||||
|
@ -87,9 +87,7 @@ jobs:
|
|||
- name: Get NodeJS node-gyp lib for Windows arm64
|
||||
if: ${{ matrix.os == 'windows-2019' && matrix.arch == 'arm64' }}
|
||||
run: .\script\download-nodejs-win-arm64.ps1 ${{ matrix.node }}
|
||||
- name: Get app version
|
||||
id: version
|
||||
run: echo version=$(jq -r ".version" app/package.json) >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install and build dependencies
|
||||
run: yarn
|
||||
env:
|
||||
|
@ -110,6 +108,8 @@ jobs:
|
|||
- 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
|
||||
|
@ -134,8 +134,7 @@ jobs:
|
|||
name: ${{matrix.friendlyName}}-${{matrix.arch}}
|
||||
path: |
|
||||
dist/GitHub Desktop-${{matrix.arch}}.zip
|
||||
dist/GitHubDesktop-${{ steps.version.outputs.version }}-${{matrix.arch}}-full.nupkg
|
||||
dist/GitHubDesktop-${{ steps.version.outputs.version }}-${{matrix.arch}}-delta.nupkg
|
||||
dist/GitHubDesktop-*.nupkg
|
||||
dist/GitHubDesktopSetup-${{matrix.arch}}.exe
|
||||
dist/GitHubDesktopSetup-${{matrix.arch}}.msi
|
||||
dist/bundle-size.json
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"productName": "GitHub Desktop",
|
||||
"bundleID": "com.github.GitHubClient",
|
||||
"companyName": "GitHub, Inc.",
|
||||
"version": "3.2.7-beta2",
|
||||
"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",
|
||||
|
|
|
@ -241,6 +241,9 @@ interface IAPIFullIdentity {
|
|||
*/
|
||||
readonly email: string | null
|
||||
readonly type: GitHubAccountType
|
||||
readonly plan: {
|
||||
readonly name: string
|
||||
}
|
||||
}
|
||||
|
||||
/** The users we get from the mentionables endpoint. */
|
||||
|
@ -485,6 +488,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 +1652,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.
|
||||
*
|
||||
|
@ -1866,7 +2029,8 @@ export async function fetchUser(
|
|||
emails,
|
||||
user.avatar_url,
|
||||
user.id,
|
||||
user.name || user.login
|
||||
user.name || user.login,
|
||||
user.plan.name
|
||||
)
|
||||
} catch (e) {
|
||||
log.warn(`fetchUser: failed with endpoint ${endpoint}`, e)
|
||||
|
|
|
@ -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,
|
||||
|
@ -326,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 {
|
||||
|
@ -716,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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,9 +485,18 @@ const editors: WindowsExternalEditor[] = [
|
|||
registryKeys: registryKeysForJetBrainsIDE('DataSpell'),
|
||||
executableShimPaths: executableShimPathsForJetBrainsIDE('dataspell'),
|
||||
jetBrainsToolboxScriptName: 'dataspell',
|
||||
displayNamePrefix: 'DataSpell ',
|
||||
displayNamePrefixes: ['DataSpell '],
|
||||
publishers: ['JetBrains s.r.o.'],
|
||||
},
|
||||
{
|
||||
name: 'Pulsar',
|
||||
registryKeys: [
|
||||
CurrentUserUninstallKey('0949b555-c22c-56b7-873a-a960bdefa81f'),
|
||||
],
|
||||
executableShimPaths: [['..', 'pulsar', 'Pulsar.exe']],
|
||||
displayNamePrefixes: ['Pulsar'],
|
||||
publishers: ['Pulsar-Edit'],
|
||||
},
|
||||
]
|
||||
|
||||
function getKeyOrEmpty(
|
||||
|
@ -512,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,
|
||||
})
|
||||
|
|
|
@ -102,3 +102,5 @@ export const enableCustomGitUserAgent = enableBetaFeatures
|
|||
export function enableSectionList(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
export const enableRepoRulesBeta = 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')
|
||||
|
|
|
@ -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] }))
|
||||
|
||||
|
|
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
|
||||
}
|
||||
}
|
||||
|
|
206
app/src/lib/helpers/repo-rules.ts
Normal file
206
app/src/lib/helpers/repo-rules.ts
Normal file
|
@ -0,0 +1,206 @@
|
|||
import { RE2, RE2JS } from 're2js'
|
||||
import {
|
||||
RepoRulesInfo,
|
||||
IRepoRulesMetadataRule,
|
||||
RepoRulesMetadataMatcher,
|
||||
RepoRuleEnforced,
|
||||
} from '../../models/repo-rules'
|
||||
import {
|
||||
APIRepoRuleMetadataOperator,
|
||||
APIRepoRuleType,
|
||||
IAPIRepoRule,
|
||||
IAPIRepoRuleMetadataParameters,
|
||||
IAPIRepoRuleset,
|
||||
} from '../api'
|
||||
import { enableRepoRulesBeta } from '../feature-flag'
|
||||
import { supportsRepoRules } from '../endpoint-capabilities'
|
||||
import { Account } from '../../models/account'
|
||||
import {
|
||||
Repository,
|
||||
isRepositoryWithGitHubRepository,
|
||||
} from '../../models/repository'
|
||||
|
||||
/**
|
||||
* Returns whether repo rules could potentially exist for the provided account and repository.
|
||||
* This only performs client-side checks, such as whether the user is on a free plan
|
||||
* and the repo is public.
|
||||
*/
|
||||
export function useRepoRulesLogic(
|
||||
account: Account | null,
|
||||
repository: Repository
|
||||
): boolean {
|
||||
if (
|
||||
!account ||
|
||||
!repository ||
|
||||
!enableRepoRulesBeta() ||
|
||||
!isRepositoryWithGitHubRepository(repository)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { endpoint, owner, isPrivate } = repository.gitHubRepository
|
||||
|
||||
if (!supportsRepoRules(endpoint)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// repo owner's plan can't be checked, only the current user's. purposely return true
|
||||
// if the repo owner is someone else, because if the current user is a collaborator on
|
||||
// the free plan but the owner is a pro member, then repo rules could still be enabled.
|
||||
// errors will be thrown by the API in this case, but there's no way to preemptively
|
||||
// check for that.
|
||||
if (account.login === owner.login && account.plan === 'free' && isPrivate) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
|
@ -43,6 +43,7 @@ interface IAccount {
|
|||
readonly avatarURL: string
|
||||
readonly id: number
|
||||
readonly name: string
|
||||
readonly plan: string
|
||||
}
|
||||
|
||||
/** The store for logged in accounts. */
|
||||
|
@ -181,7 +182,8 @@ export class AccountsStore extends TypedBaseStore<ReadonlyArray<Account>> {
|
|||
account.emails,
|
||||
account.avatarURL,
|
||||
account.id,
|
||||
account.name
|
||||
account.name,
|
||||
account.plan
|
||||
)
|
||||
|
||||
const key = getKeyForAccount(accountWithoutToken)
|
||||
|
|
|
@ -100,6 +100,7 @@ import {
|
|||
getEndpointForRepository,
|
||||
IAPIFullRepository,
|
||||
IAPIComment,
|
||||
IAPIRepoRuleset,
|
||||
} from '../api'
|
||||
import { shell } from '../app-shell'
|
||||
import {
|
||||
|
@ -324,6 +325,8 @@ import { determineMergeability } from '../git/merge-tree'
|
|||
import { PopupManager } from '../popup-manager'
|
||||
import { resizableComponentClass } from '../../ui/resizable'
|
||||
import { compare } from '../compare'
|
||||
import { parseRepoRules, useRepoRulesLogic } from '../helpers/repo-rules'
|
||||
import { RepoRulesInfo } from '../../models/repo-rules'
|
||||
|
||||
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
|
||||
|
||||
|
@ -530,6 +533,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,
|
||||
|
@ -999,6 +1004,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
notificationsEnabled: getNotificationsEnabled(),
|
||||
pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction,
|
||||
resizablePaneActive: this.resizablePaneActive,
|
||||
cachedRepoRulesets: this.cachedRepoRulesets,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1116,6 +1122,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
private clearBranchProtectionState(repository: Repository) {
|
||||
this.repositoryStateCache.updateChangesState(repository, () => ({
|
||||
currentBranchProtected: false,
|
||||
currentRepoRulesInfo: new RepoRulesInfo(),
|
||||
}))
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
@ -1147,6 +1154,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
if (!hasWritePermission(gitHubRepo)) {
|
||||
this.repositoryStateCache.updateChangesState(repository, () => ({
|
||||
currentBranchProtected: false,
|
||||
currentRepoRulesInfo: new RepoRulesInfo(),
|
||||
}))
|
||||
this.emitUpdate()
|
||||
return
|
||||
|
@ -1159,13 +1167,60 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
const pushControl = await api.fetchPushControl(owner, name, branchName)
|
||||
const currentBranchProtected = !isBranchPushable(pushControl)
|
||||
|
||||
let currentRepoRulesInfo = new RepoRulesInfo()
|
||||
if (useRepoRulesLogic(account, repository)) {
|
||||
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: [],
|
||||
|
@ -6525,10 +6580,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}`
|
||||
)
|
||||
|
|
|
@ -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: {
|
||||
|
|
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
|
||||
}
|
|
@ -20,7 +20,7 @@ export function accountEquals(x: Account, y: Account) {
|
|||
export class Account {
|
||||
/** Create an account which can be used to perform unauthenticated API actions */
|
||||
public static anonymous(): Account {
|
||||
return new Account('', getDotComAPIEndpoint(), '', [], '', -1, '')
|
||||
return new Account('', getDotComAPIEndpoint(), '', [], '', -1, '', 'free')
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -41,7 +41,8 @@ export class Account {
|
|||
public readonly emails: ReadonlyArray<IAPIEmail>,
|
||||
public readonly avatarURL: string,
|
||||
public readonly id: number,
|
||||
public readonly name: string
|
||||
public readonly name: string,
|
||||
public readonly plan: string
|
||||
) {}
|
||||
|
||||
public withToken(token: string): Account {
|
||||
|
@ -52,7 +53,8 @@ export class Account {
|
|||
this.emails,
|
||||
this.avatarURL,
|
||||
this.id,
|
||||
this.name
|
||||
this.name,
|
||||
this.plan
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -94,6 +94,7 @@ export enum PopupType {
|
|||
TestNotifications = 'TestNotifications',
|
||||
PullRequestComment = 'PullRequestComment',
|
||||
UnknownAuthors = 'UnknownAuthors',
|
||||
ConfirmRepoRulesBypass = 'ConfirmRepoRulesBypass',
|
||||
}
|
||||
|
||||
interface IBasePopup {
|
||||
|
@ -415,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
|
||||
|
|
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'
|
|
@ -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
|
||||
|
@ -53,7 +61,7 @@ export class AriaLiveContainer extends Component<
|
|||
super(props)
|
||||
|
||||
this.state = {
|
||||
message: this.props.children !== null ? this.buildMessage() : null,
|
||||
message: this.props.message !== null ? this.buildMessage() : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -76,7 +84,7 @@ export class AriaLiveContainer extends Component<
|
|||
|
||||
return (
|
||||
<>
|
||||
{this.props.children}
|
||||
{this.props.message}
|
||||
{this.suffix}
|
||||
</>
|
||||
)
|
||||
|
@ -86,7 +94,7 @@ export class AriaLiveContainer extends Component<
|
|||
// 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.children
|
||||
return this.props.message
|
||||
}
|
||||
|
||||
// We are using this as a container to force re-reading of the same message,
|
||||
|
@ -94,7 +102,7 @@ export class AriaLiveContainer extends Component<
|
|||
// 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.children !== null ? this.state.message : ''
|
||||
return this.props.message !== null ? this.state.message : ''
|
||||
}
|
||||
|
||||
public render() {
|
||||
|
|
|
@ -15,6 +15,7 @@ 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
|
||||
|
@ -138,7 +139,10 @@ export class AddExistingRepository extends React.Component<
|
|||
return null
|
||||
}
|
||||
|
||||
return 'This directory appears to be a bare repository. Bare repositories are not currently supported.'
|
||||
const msg =
|
||||
'This directory appears to be a bare repository. Bare repositories are not currently supported.'
|
||||
|
||||
return { screenReaderMessage: msg, displayedMessage: msg }
|
||||
}
|
||||
|
||||
private buildRepositoryUnsafeError() {
|
||||
|
@ -157,7 +161,7 @@ export class AddExistingRepository extends React.Component<
|
|||
// when the entered path is `c:\repo`.
|
||||
const convertedPath = __WIN32__ ? path.replaceAll('\\', '/') : path
|
||||
|
||||
return (
|
||||
const displayedMessage = (
|
||||
<>
|
||||
<p>
|
||||
The Git repository
|
||||
|
@ -180,14 +184,20 @@ export class AddExistingRepository extends React.Component<
|
|||
</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() {
|
||||
private buildNotAGitRepositoryError(): IAccessibleMessage | null {
|
||||
if (!this.state.path.length || !this.state.showNonGitRepositoryWarning) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
const displayedMessage = (
|
||||
<>
|
||||
This directory does not appear to be a Git repository.
|
||||
<br />
|
||||
|
@ -198,10 +208,15 @@ export class AddExistingRepository extends React.Component<
|
|||
here instead?
|
||||
</>
|
||||
)
|
||||
|
||||
const screenReaderMessage =
|
||||
'This directory does not appear to be a Git repository. Would you like to create a repository here instead?'
|
||||
|
||||
return { screenReaderMessage, displayedMessage }
|
||||
}
|
||||
|
||||
private renderErrors() {
|
||||
const msg =
|
||||
const msg: IAccessibleMessage | null =
|
||||
this.buildBareRepositoryError() ??
|
||||
this.buildRepositoryUnsafeError() ??
|
||||
this.buildNotAGitRepositoryError()
|
||||
|
@ -215,8 +230,9 @@ export class AddExistingRepository extends React.Component<
|
|||
<InputError
|
||||
id="add-existing-repository-path-error"
|
||||
trackedUserInput={this.state.path}
|
||||
ariaLiveMessage={msg.screenReaderMessage}
|
||||
>
|
||||
{msg}
|
||||
{msg.displayedMessage}
|
||||
</InputError>
|
||||
</Row>
|
||||
)
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -172,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
|
||||
|
@ -1717,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}
|
||||
|
@ -2177,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}
|
||||
|
@ -2215,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}
|
||||
|
@ -2502,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}`)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -291,12 +291,9 @@ export class BranchList extends React.Component<
|
|||
}
|
||||
|
||||
private getGroupAriaLabel = (group: number) => {
|
||||
const GroupIdentifiers: ReadonlyArray<BranchGroupIdentifier> = [
|
||||
'default',
|
||||
'recent',
|
||||
'other',
|
||||
]
|
||||
return this.getGroupLabel(GroupIdentifiers[group])
|
||||
const identifier = this.state.groups[group]
|
||||
.identifier as BranchGroupIdentifier
|
||||
return this.getGroupLabel(identifier)
|
||||
}
|
||||
|
||||
private renderGroupHeader = (label: string) => {
|
||||
|
|
|
@ -192,8 +192,8 @@ export class BranchesContainer extends React.Component<
|
|||
selectedIndex={this.props.selectedTab}
|
||||
allowDragOverSwitching={true}
|
||||
>
|
||||
<span>Branches</span>
|
||||
<span className="pull-request-tab">
|
||||
<span id="branches-tab">Branches</span>
|
||||
<span id="pull-requests-tab" className="pull-request-tab">
|
||||
{__DARWIN__ ? 'Pull Requests' : 'Pull requests'}
|
||||
{this.renderOpenPullRequestsBubble()}
|
||||
</span>
|
||||
|
@ -212,7 +212,27 @@ export class BranchesContainer extends React.Component<
|
|||
}
|
||||
|
||||
private renderSelectedTab() {
|
||||
const { selectedTab, repository } = this.props
|
||||
|
||||
const ariaLabelledBy =
|
||||
selectedTab === BranchesTab.Branches || !repository.gitHubRepository
|
||||
? 'branches-tab'
|
||||
: 'pull-requests-tab'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className="branches-container-panel"
|
||||
>
|
||||
{this.renderSelectedTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderSelectedTabContent() {
|
||||
let tab = this.props.selectedTab
|
||||
|
||||
if (!this.props.repository.gitHubRepository) {
|
||||
tab = BranchesTab.Branches
|
||||
}
|
||||
|
@ -241,7 +261,6 @@ export class BranchesContainer extends React.Component<
|
|||
onDeleteBranch={this.props.onDeleteBranch}
|
||||
/>
|
||||
)
|
||||
|
||||
case BranchesTab.PullRequests: {
|
||||
return this.renderPullRequests()
|
||||
}
|
||||
|
|
|
@ -172,9 +172,7 @@ export class PullRequestList extends React.Component<
|
|||
renderNoItems={this.renderNoItems}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
/>
|
||||
<AriaLiveContainer>
|
||||
{this.state.screenReaderStateMessage}
|
||||
</AriaLiveContainer>
|
||||
<AriaLiveContainer message={this.state.screenReaderStateMessage} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ interface IChangedFileProps {
|
|||
readonly availableWidth: number
|
||||
readonly disableSelection: boolean
|
||||
readonly checkboxTooltip?: string
|
||||
readonly focused: boolean
|
||||
readonly onIncludeChanged: (path: string, include: boolean) => void
|
||||
}
|
||||
|
||||
|
@ -36,7 +37,7 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const { file, availableWidth, disableSelection, checkboxTooltip } =
|
||||
const { file, availableWidth, disableSelection, checkboxTooltip, focused } =
|
||||
this.props
|
||||
const { status, path } = file
|
||||
const fileStatus = mapStatus(status)
|
||||
|
@ -60,6 +61,10 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
? 'partially included'
|
||||
: 'not included'
|
||||
|
||||
const pathScreenReaderMessage = `${path} ${mapStatus(
|
||||
status
|
||||
)} ${includedText}`
|
||||
|
||||
return (
|
||||
<div className="file">
|
||||
<TooltippedContent
|
||||
|
@ -85,16 +90,18 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
ariaHidden={true}
|
||||
/>
|
||||
|
||||
<AriaLiveContainer>
|
||||
{path} {mapStatus(status)} {includedText}
|
||||
</AriaLiveContainer>
|
||||
|
||||
<Octicon
|
||||
symbol={iconForStatus(status)}
|
||||
className={'status status-' + fileStatus.toLowerCase()}
|
||||
title={fileStatus}
|
||||
tooltipDirection={TooltipDirection.EAST}
|
||||
/>
|
||||
<AriaLiveContainer message={pathScreenReaderMessage} />
|
||||
<TooltippedContent
|
||||
ancestorFocused={focused}
|
||||
openOnFocus={true}
|
||||
tooltip={fileStatus}
|
||||
direction={TooltipDirection.EAST}
|
||||
>
|
||||
<Octicon
|
||||
symbol={iconForStatus(status)}
|
||||
className={'status status-' + fileStatus.toLowerCase()}
|
||||
/>
|
||||
</TooltippedContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -219,6 +223,7 @@ interface IChangesListProps {
|
|||
|
||||
interface IChangesState {
|
||||
readonly selectedRows: ReadonlyArray<number>
|
||||
readonly focusedRow: number | null
|
||||
}
|
||||
|
||||
function getSelectedRowsFromProps(
|
||||
|
@ -248,6 +253,7 @@ export class ChangesList extends React.Component<
|
|||
super(props)
|
||||
this.state = {
|
||||
selectedRows: getSelectedRowsFromProps(props),
|
||||
focusedRow: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -325,6 +331,7 @@ export class ChangesList extends React.Component<
|
|||
availableWidth={availableWidth}
|
||||
disableSelection={disableSelection}
|
||||
checkboxTooltip={checkboxTooltip}
|
||||
focused={this.state.focusedRow === row}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -732,6 +739,7 @@ export class ChangesList extends React.Component<
|
|||
isCommitting,
|
||||
commitToAmend,
|
||||
currentBranchProtected,
|
||||
currentRepoRulesInfo: currentRepoRulesInfo,
|
||||
} = this.props
|
||||
|
||||
if (rebaseConflictState !== null) {
|
||||
|
@ -784,6 +792,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 +813,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}
|
||||
|
@ -985,9 +996,12 @@ export class ChangesList extends React.Component<
|
|||
invalidationProps={{
|
||||
workingDirectory: workingDirectory,
|
||||
isCommitting: isCommitting,
|
||||
focusedRow: this.state.focusedRow,
|
||||
}}
|
||||
onRowClick={this.props.onRowClick}
|
||||
onRowDoubleClick={this.onRowDoubleClick}
|
||||
onRowKeyboardFocus={this.onRowFocus}
|
||||
onRowBlur={this.onRowBlur}
|
||||
onScroll={this.onScroll}
|
||||
setScrollTop={this.props.changesListScrollTop}
|
||||
onRowKeyDown={this.onRowKeyDown}
|
||||
|
@ -1000,4 +1014,14 @@ export class ChangesList extends React.Component<
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
private onRowFocus = (row: number) => {
|
||||
this.setState({ focusedRow: row })
|
||||
}
|
||||
|
||||
private onRowBlur = (row: number) => {
|
||||
if (this.state.focusedRow === row) {
|
||||
this.setState({ focusedRow: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()}
|
||||
|
@ -141,9 +177,21 @@ export class CommitMessageAvatar extends React.Component<
|
|||
}
|
||||
|
||||
private renderWarningBadge() {
|
||||
const { warningType, emailRuleFailures } = this.props
|
||||
|
||||
// the parent component only renders this one if an error/warning is present, so we
|
||||
// only need to check which of the two it is here
|
||||
const isError =
|
||||
warningType === 'disallowedEmail' && emailRuleFailures?.status === 'fail'
|
||||
const classes = classNames('warning-badge', {
|
||||
error: isError,
|
||||
warning: !isError,
|
||||
})
|
||||
const symbol = isError ? OcticonSymbol.stop : OcticonSymbol.alert
|
||||
|
||||
return (
|
||||
<div className="warning-badge" ref={this.warningBadgeRef}>
|
||||
<Octicon symbol={OcticonSymbol.alert} />
|
||||
<div className={classes} ref={this.warningBadgeRef}>
|
||||
<Octicon symbol={symbol} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -216,33 +264,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 +310,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 +381,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 +410,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 { formatCommitMessage } from '../../lib/format-commit-message'
|
||||
import { useRepoRulesLogic } from '../../lib/helpers/repo-rules'
|
||||
|
||||
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,14 @@ interface ICommitMessageState {
|
|||
readonly descriptionObscured: boolean
|
||||
|
||||
readonly isCommittingStatusMessage: string
|
||||
|
||||
readonly repoRulesEnabled: boolean
|
||||
|
||||
readonly isRuleFailurePopoverOpen: boolean
|
||||
|
||||
readonly repoRuleCommitMessageFailures: RepoRulesMetadataFailures
|
||||
readonly repoRuleCommitAuthorFailures: RepoRulesMetadataFailures
|
||||
readonly repoRuleBranchNameFailures: RepoRulesMetadataFailures
|
||||
}
|
||||
|
||||
function findCommitMessageAutoCompleteProvider(
|
||||
|
@ -187,6 +217,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 +233,11 @@ export class CommitMessage extends React.Component<
|
|||
),
|
||||
descriptionObscured: false,
|
||||
isCommittingStatusMessage: '',
|
||||
repoRulesEnabled: false,
|
||||
isRuleFailurePopoverOpen: false,
|
||||
repoRuleCommitMessageFailures: new RepoRulesMetadataFailures(),
|
||||
repoRuleCommitAuthorFailures: new RepoRulesMetadataFailures(),
|
||||
repoRuleBranchNameFailures: new RepoRulesMetadataFailures(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -211,8 +248,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 +296,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 +346,124 @@ 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
|
||||
) {
|
||||
let repoRulesEnabled = this.state.repoRulesEnabled
|
||||
if (
|
||||
forceUpdate ||
|
||||
prevProps?.repository !== this.props.repository ||
|
||||
prevProps?.repositoryAccount !== this.props.repositoryAccount
|
||||
) {
|
||||
repoRulesEnabled = useRepoRulesLogic(
|
||||
this.props.repositoryAccount,
|
||||
this.props.repository
|
||||
)
|
||||
this.setState({ repoRulesEnabled })
|
||||
}
|
||||
|
||||
if (!repoRulesEnabled) {
|
||||
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 +486,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 +562,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 (!this.state.repoRulesEnabled) {
|
||||
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 (!this.state.repoRulesEnabled) {
|
||||
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 +664,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 (
|
||||
this.state.repoRulesEnabled &&
|
||||
this.state.repoRuleCommitAuthorFailures.status !== 'pass'
|
||||
) {
|
||||
warningType = 'disallowedEmail'
|
||||
} else if (
|
||||
repositoryAccount !== null &&
|
||||
repositoryAccount !== undefined &&
|
||||
isAttributableEmailFor(repositoryAccount, email) === false
|
||||
) {
|
||||
warningType = 'misattribution'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CommitMessageAvatar
|
||||
|
@ -449,7 +687,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 +911,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 +924,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, repoRulesEnabled } = 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 (repoRulesEnabled) {
|
||||
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 +992,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 +1003,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 ||
|
||||
!this.state.repoRulesEnabled ||
|
||||
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 +1237,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 +1250,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 +1296,26 @@ export class CommitMessage extends React.Component<
|
|||
'with-overflow': this.state.descriptionObscured,
|
||||
})
|
||||
|
||||
const showSummaryLengthHint = this.state.summary.length > IdealSummaryLength
|
||||
const showRepoRuleCommitMessageFailureHint =
|
||||
this.state.repoRulesEnabled &&
|
||||
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 +1341,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 +1366,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 +1378,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>
|
||||
|
|
|
@ -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,20 @@ 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 { InputError } from '../lib/input-description/input-error'
|
||||
import { InputWarning } from '../lib/input-description/input-warning'
|
||||
import { useRepoRulesLogic } from '../../lib/helpers/repo-rules'
|
||||
|
||||
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 +72,7 @@ interface ICreateBranchProps {
|
|||
}
|
||||
|
||||
interface ICreateBranchState {
|
||||
readonly currentError: Error | null
|
||||
readonly currentError: { error: Error; isWarning: boolean } | null
|
||||
readonly branchName: string
|
||||
readonly startPoint: StartPoint
|
||||
|
||||
|
@ -101,6 +110,93 @@ 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
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const account = getAccountForRepository(
|
||||
this.props.accounts,
|
||||
this.props.repository
|
||||
)
|
||||
|
||||
if (
|
||||
account === null ||
|
||||
!useRepoRulesLogic(account, this.props.repository)
|
||||
) {
|
||||
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,7 +470,17 @@ 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])',
|
||||
|
@ -709,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(
|
||||
{
|
||||
|
@ -728,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>
|
||||
|
@ -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 {
|
||||
|
@ -1569,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.
|
||||
*
|
||||
|
@ -4076,4 +4090,8 @@ export class Dispatcher {
|
|||
public appFocusedElementChanged() {
|
||||
this.appStore._appFocusedElementChanged()
|
||||
}
|
||||
|
||||
public updateCachedRepoRulesets(rulesets: Array<IAPIRepoRuleset | null>) {
|
||||
this.appStore._updateCachedRepoRulesets(rulesets)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -504,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}
|
||||
|
|
|
@ -4,26 +4,19 @@ import { CommittedFileChange } from '../../models/status'
|
|||
import { mapStatus } from '../../lib/status'
|
||||
import { PathLabel } from '../lib/path-label'
|
||||
import { Octicon, iconForStatus } from '../octicons'
|
||||
import { TooltippedContent } from '../lib/tooltipped-content'
|
||||
import { TooltipDirection } from '../lib/tooltip'
|
||||
|
||||
interface ICommittedFileItemProps {
|
||||
readonly availableWidth: number
|
||||
readonly file: CommittedFileChange
|
||||
readonly onContextMenu?: (
|
||||
file: CommittedFileChange,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => void
|
||||
readonly focused: boolean
|
||||
}
|
||||
|
||||
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 { file, focused } = this.props
|
||||
const { status } = file
|
||||
const fileStatus = mapStatus(status)
|
||||
|
||||
const listItemPadding = 10 * 2
|
||||
|
@ -36,18 +29,24 @@ 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
|
||||
symbol={iconForStatus(status)}
|
||||
className={'status status-' + fileStatus.toLowerCase()}
|
||||
title={fileStatus}
|
||||
/>
|
||||
<TooltippedContent
|
||||
ancestorFocused={focused}
|
||||
openOnFocus={true}
|
||||
tooltip={fileStatus}
|
||||
direction={TooltipDirection.NORTH}
|
||||
>
|
||||
<Octicon
|
||||
symbol={iconForStatus(status)}
|
||||
className={'status status-' + fileStatus.toLowerCase()}
|
||||
/>
|
||||
</TooltippedContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
@ -16,10 +17,22 @@ interface IFileListProps {
|
|||
) => void
|
||||
}
|
||||
|
||||
interface IFileListState {
|
||||
readonly focusedRow: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a list of changed files as part of a commit or stash
|
||||
*/
|
||||
export class FileList extends React.Component<IFileListProps> {
|
||||
export class FileList extends React.Component<IFileListProps, IFileListState> {
|
||||
public constructor(props: IFileListProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
focusedRow: null,
|
||||
}
|
||||
}
|
||||
|
||||
private onSelectedRowChanged = (row: number) => {
|
||||
const file = this.props.files[row]
|
||||
this.props.onSelectedFileChanged(file)
|
||||
|
@ -30,7 +43,7 @@ export class FileList extends React.Component<IFileListProps> {
|
|||
<CommittedFileItem
|
||||
file={this.props.files[row]}
|
||||
availableWidth={this.props.availableWidth}
|
||||
onContextMenu={this.props.onContextMenu}
|
||||
focused={this.state.focusedRow === row}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -39,6 +52,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,8 +76,23 @@ export class FileList extends React.Component<IFileListProps> {
|
|||
selectedRows={[this.rowForFile(this.props.selectedFile)]}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
onRowDoubleClick={this.props.onRowDoubleClick}
|
||||
onRowContextMenu={this.onRowContextMenu}
|
||||
onRowKeyboardFocus={this.onRowFocus}
|
||||
onRowBlur={this.onRowBlur}
|
||||
getRowAriaLabel={this.getFileAriaLabel}
|
||||
invalidationProps={{ focusedRow: this.state.focusedRow }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onRowFocus = (row: number) => {
|
||||
this.setState({ focusedRow: row })
|
||||
}
|
||||
|
||||
private onRowBlur = (row: number) => {
|
||||
if (this.state.focusedRow === row) {
|
||||
this.setState({ focusedRow: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ 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,
|
||||
|
@ -27,6 +28,8 @@ export interface IBaseInputDescriptionProps {
|
|||
* debounce the message.
|
||||
*/
|
||||
readonly trackedUserInput?: string | boolean
|
||||
|
||||
readonly ariaLiveMessage?: string
|
||||
}
|
||||
|
||||
export interface IInputDescriptionProps extends IBaseInputDescriptionProps {
|
||||
|
@ -47,45 +50,51 @@ export interface IInputDescriptionProps extends IBaseInputDescriptionProps {
|
|||
*/
|
||||
export class InputDescription extends React.Component<IInputDescriptionProps> {
|
||||
private getClassName() {
|
||||
let typeClassName = 'input-description-caption'
|
||||
const { inputDescriptionType: type } = this.props
|
||||
|
||||
if (InputDescriptionType.Warning) {
|
||||
typeClassName = 'input-description-warning'
|
||||
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}`)
|
||||
}
|
||||
|
||||
if (InputDescriptionType.Error) {
|
||||
typeClassName = 'input-description-error'
|
||||
}
|
||||
|
||||
return classNames('input-description', typeClassName)
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
if (InputDescriptionType.Error) {
|
||||
return <Octicon symbol={OcticonSymbol.stop} />
|
||||
}
|
||||
const { inputDescriptionType: type } = this.props
|
||||
|
||||
if (InputDescriptionType.Warning) {
|
||||
return <Octicon symbol={OcticonSymbol.alert} />
|
||||
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}`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
InputDescriptionType.Caption ||
|
||||
this.props.trackedUserInput === undefined
|
||||
this.props.inputDescriptionType === InputDescriptionType.Caption ||
|
||||
this.props.trackedUserInput === undefined ||
|
||||
this.props.ariaLiveMessage === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AriaLiveContainer trackedUserInput={this.props.trackedUserInput}>
|
||||
{this.props.children}
|
||||
</AriaLiveContainer>
|
||||
<AriaLiveContainer
|
||||
message={this.props.ariaLiveMessage}
|
||||
trackedUserInput={this.props.trackedUserInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -95,7 +104,7 @@ export class InputDescription extends React.Component<IInputDescriptionProps> {
|
|||
* */
|
||||
private getRole() {
|
||||
if (
|
||||
InputDescriptionType.Error &&
|
||||
this.props.inputDescriptionType === InputDescriptionType.Error &&
|
||||
this.props.trackedUserInput === undefined
|
||||
) {
|
||||
return 'alert'
|
||||
|
|
|
@ -54,6 +54,14 @@ interface IListRowProps {
|
|||
e: React.KeyboardEvent<any>
|
||||
) => void
|
||||
|
||||
/** called when the row (or any of its descendants) receives focus due to a
|
||||
* keyboard event
|
||||
*/
|
||||
readonly onRowKeyboardFocus?: (
|
||||
index: RowIndexPath,
|
||||
e: React.KeyboardEvent<any>
|
||||
) => void
|
||||
|
||||
/** called when the row (or any of its descendants) receives focus */
|
||||
readonly onRowFocus?: (
|
||||
index: RowIndexPath,
|
||||
|
@ -82,9 +90,25 @@ 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, {}> {
|
||||
// Since there is no way of knowing when a row has been focused via keyboard
|
||||
// or mouse interaction, we will use the keyDown and keyUp events to track
|
||||
// what the user did to get the row in a focused state.
|
||||
// The heuristic is that we should receive a focus event followed by a keyUp
|
||||
// event, with no keyDown events (since that keyDown event should've happened
|
||||
// in the component that previously had focus).
|
||||
private keyboardFocusDetectionState: 'ready' | 'failed' | 'focused' = 'ready'
|
||||
|
||||
private onRef = (elem: HTMLDivElement | null) => {
|
||||
this.props.onRowRef?.(this.props.rowIndex, elem)
|
||||
}
|
||||
|
@ -107,13 +131,27 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
|
||||
private onRowKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
this.props.onRowKeyDown(this.props.rowIndex, e)
|
||||
this.keyboardFocusDetectionState = 'failed'
|
||||
}
|
||||
|
||||
private onRowKeyUp = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
this.props.onRowKeyDown(this.props.rowIndex, e)
|
||||
|
||||
if (this.keyboardFocusDetectionState === 'focused') {
|
||||
this.props.onRowKeyboardFocus?.(this.props.rowIndex, e)
|
||||
}
|
||||
this.keyboardFocusDetectionState = 'ready'
|
||||
}
|
||||
|
||||
private onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
this.props.onRowFocus?.(this.props.rowIndex, e)
|
||||
if (this.keyboardFocusDetectionState === 'ready') {
|
||||
this.keyboardFocusDetectionState = 'focused'
|
||||
}
|
||||
}
|
||||
|
||||
private onBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
this.keyboardFocusDetectionState = 'ready'
|
||||
this.props.onRowBlur?.(this.props.rowIndex, e)
|
||||
}
|
||||
|
||||
|
@ -170,6 +208,7 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
aria-setsize={ariaSetSize}
|
||||
aria-posinset={ariaPosInSet}
|
||||
aria-selected={selectable ? selected : undefined}
|
||||
aria-label={this.props.ariaLabel}
|
||||
className={rowClassName}
|
||||
tabIndex={tabIndex}
|
||||
ref={this.onRef}
|
||||
|
@ -178,6 +217,7 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
onClick={this.onRowClick}
|
||||
onDoubleClick={this.onRowDoubleClick}
|
||||
onKeyDown={this.onRowKeyDown}
|
||||
onKeyUp={this.onRowKeyUp}
|
||||
style={fullWidthStyle}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
|
|
|
@ -117,6 +117,24 @@ interface IListProps {
|
|||
|
||||
readonly onRowDoubleClick?: (row: number, source: IMouseClickSource) => void
|
||||
|
||||
/** This function will be called when a row obtains focus, no matter how */
|
||||
readonly onRowFocus?: (
|
||||
row: number,
|
||||
event: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** This function will be called only when a row obtains focus via keyboard */
|
||||
readonly onRowKeyboardFocus?: (
|
||||
row: number,
|
||||
e: React.KeyboardEvent<any>
|
||||
) => void
|
||||
|
||||
/** This function will be called when a row loses focus */
|
||||
readonly onRowBlur?: (
|
||||
row: number,
|
||||
event: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/**
|
||||
* This prop defines the behaviour of the selection of items within this list.
|
||||
* - 'single' : (default) single list-item selection. [shift] and [ctrl] have
|
||||
|
@ -256,6 +274,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 {
|
||||
|
@ -651,6 +678,15 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.focusRow = indexPath.row
|
||||
this.props.onRowFocus?.(indexPath.row, e)
|
||||
}
|
||||
|
||||
private onRowKeyboardFocus = (
|
||||
indexPath: RowIndexPath,
|
||||
e: React.KeyboardEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.focusRow = indexPath.row
|
||||
this.props.onRowKeyboardFocus?.(indexPath.row, e)
|
||||
}
|
||||
|
||||
private onRowBlur = (
|
||||
|
@ -660,6 +696,7 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
if (this.focusRow === indexPath.row) {
|
||||
this.focusRow = -1
|
||||
}
|
||||
this.props.onRowBlur?.(indexPath.row, e)
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
|
@ -947,6 +984,11 @@ 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}
|
||||
|
@ -956,12 +998,14 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
rowIndex={{ section: 0, row: rowIndex }}
|
||||
sectionHasHeader={false}
|
||||
selected={selected}
|
||||
ariaLabel={ariaLabel}
|
||||
onRowClick={this.onRowClick}
|
||||
onRowDoubleClick={this.onRowDoubleClick}
|
||||
onRowKeyDown={this.onRowKeyDown}
|
||||
onRowMouseDown={this.onRowMouseDown}
|
||||
onRowMouseUp={this.onRowMouseUp}
|
||||
onRowFocus={this.onRowFocus}
|
||||
onRowKeyboardFocus={this.onRowKeyboardFocus}
|
||||
onRowBlur={this.onRowBlur}
|
||||
onContextMenu={this.onRowContextMenu}
|
||||
style={params.style}
|
||||
|
@ -1083,6 +1127,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}
|
||||
|
|
|
@ -137,6 +137,24 @@ interface ISectionListProps {
|
|||
source: IMouseClickSource
|
||||
) => void
|
||||
|
||||
/** This function will be called when a row obtains focus, no matter how */
|
||||
readonly onRowFocus?: (
|
||||
indexPath: RowIndexPath,
|
||||
source: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** This function will be called only when a row obtains focus via keyboard */
|
||||
readonly onRowKeyboardFocus?: (
|
||||
indexPath: RowIndexPath,
|
||||
e: React.KeyboardEvent<any>
|
||||
) => void
|
||||
|
||||
/** This function will be called when a row loses focus */
|
||||
readonly onRowBlur?: (
|
||||
indexPath: RowIndexPath,
|
||||
source: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/**
|
||||
* This prop defines the behaviour of the selection of items within this list.
|
||||
* - 'single' : (default) single list-item selection. [shift] and [ctrl] have
|
||||
|
@ -749,6 +767,14 @@ export class SectionList extends React.Component<
|
|||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.focusRow = index
|
||||
this.props.onRowFocus?.(index, e)
|
||||
}
|
||||
|
||||
private onRowKeyboardFocus = (
|
||||
index: RowIndexPath,
|
||||
e: React.KeyboardEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.props.onRowKeyboardFocus?.(index, e)
|
||||
}
|
||||
|
||||
private onRowBlur = (
|
||||
|
@ -758,6 +784,7 @@ export class SectionList extends React.Component<
|
|||
if (rowIndexPathEquals(this.focusRow, index)) {
|
||||
this.focusRow = InvalidRowIndexPath
|
||||
}
|
||||
this.props.onRowBlur?.(index, e)
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
|
@ -1140,6 +1167,7 @@ export class SectionList extends React.Component<
|
|||
onRowMouseDown={this.onRowMouseDown}
|
||||
onRowMouseUp={this.onRowMouseUp}
|
||||
onRowFocus={this.onRowFocus}
|
||||
onRowKeyboardFocus={this.onRowKeyboardFocus}
|
||||
onRowBlur={this.onRowBlur}
|
||||
onContextMenu={this.onRowContextMenu}
|
||||
style={params.style}
|
||||
|
@ -1265,6 +1293,9 @@ export class SectionList extends React.Component<
|
|||
autoContainerWidth={true}
|
||||
containerRole="presentation"
|
||||
aria-label={this.props.getSectionAriaLabel?.(section)}
|
||||
aria-multiselectable={
|
||||
this.props.selectionMode !== 'single' ? true : undefined
|
||||
}
|
||||
// Set the width and columnWidth to a hardcoded large value to prevent
|
||||
columnWidth={10000}
|
||||
width={10000}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -273,12 +273,14 @@ export class SectionFilterList<
|
|||
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}>
|
||||
{itemRows.length} {resultsPluralized}
|
||||
</AriaLiveContainer>
|
||||
<AriaLiveContainer
|
||||
trackedUserInput={this.state.filterValue}
|
||||
message={screenReaderMessage}
|
||||
/>
|
||||
{this.props.renderPreList ? this.props.renderPreList() : null}
|
||||
|
||||
{this.renderFilterRow()}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -117,6 +117,13 @@ export interface ITooltipProps<T> {
|
|||
* ":focus-visible open on focus. This means any time the target it focused it
|
||||
* opens." */
|
||||
readonly openOnFocus?: boolean
|
||||
|
||||
/** Whether or not an ancestor component is focused, used in case we want
|
||||
* the tooltip to be shown when it's focused. Examples of this are how we
|
||||
* want to show the tooltip for file status icons when files in the file
|
||||
* list are focused.
|
||||
*/
|
||||
readonly ancestorFocused?: boolean
|
||||
}
|
||||
|
||||
interface ITooltipState {
|
||||
|
@ -280,6 +287,24 @@ export class Tooltip<T extends TooltipTarget> extends React.Component<
|
|||
target?.removeAttribute('aria-describedby')
|
||||
}
|
||||
}
|
||||
|
||||
if (prevProps.ancestorFocused !== this.props.ancestorFocused) {
|
||||
this.updateBasedOnAncestorFocused()
|
||||
}
|
||||
}
|
||||
|
||||
private updateBasedOnAncestorFocused() {
|
||||
const { target } = this.state
|
||||
if (target === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { ancestorFocused } = this.props
|
||||
if (ancestorFocused === true) {
|
||||
this.beginShowTooltip()
|
||||
} else if (ancestorFocused === false) {
|
||||
this.beginHideTooltip()
|
||||
}
|
||||
}
|
||||
|
||||
private installTooltip(elem: TooltipTarget) {
|
||||
|
|
|
@ -25,6 +25,13 @@ interface ITooltippedContentProps
|
|||
|
||||
/** Open on target focus */
|
||||
readonly openOnFocus?: boolean
|
||||
|
||||
/** Whether or not an ancestor component is focused, used in case we want
|
||||
* the tooltip to be shown when it's focused. Examples of this are how we
|
||||
* want to show the tooltip for file status icons when files in the file
|
||||
* list are focused.
|
||||
*/
|
||||
readonly ancestorFocused?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -48,7 +55,6 @@ export class TooltippedContent extends React.Component<ITooltippedContentProps>
|
|||
<Tooltip
|
||||
target={this.wrapperRef}
|
||||
className={tooltipClassName}
|
||||
openOnFocus={this.props.openOnFocus}
|
||||
{...rest}
|
||||
>
|
||||
{tooltip}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
83
app/src/ui/repository-rules/repo-rules-failure-list.tsx
Normal file
83
app/src/ui/repository-rules/repo-rules-failure-list.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import * as React from 'react'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import {
|
||||
RepoRulesMetadataFailure,
|
||||
RepoRulesMetadataFailures,
|
||||
} from '../../models/repo-rules'
|
||||
import { RepoRulesetsForBranchLink } from './repo-rulesets-for-branch-link'
|
||||
import { RepoRulesetLink } from './repo-ruleset-link'
|
||||
|
||||
interface IRepoRulesMetadataFailureListProps {
|
||||
readonly repository: GitHubRepository
|
||||
readonly branch: string
|
||||
readonly failures: RepoRulesMetadataFailures
|
||||
|
||||
/**
|
||||
* Text that will come before the standard text, should be the name of the rule
|
||||
* that's being checked. For example, "The email in your global Git config" or
|
||||
* "This commit message".
|
||||
*/
|
||||
readonly leadingText: string | JSX.Element
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a standard message for failed repo metadata rules.
|
||||
*/
|
||||
export class RepoRulesMetadataFailureList extends React.Component<IRepoRulesMetadataFailureListProps> {
|
||||
public render() {
|
||||
const { repository, branch, failures, leadingText } = this.props
|
||||
|
||||
const totalFails = failures.failed.length + failures.bypassed.length
|
||||
let endText: string
|
||||
if (failures.status === 'bypass') {
|
||||
endText = `, but you can bypass ${
|
||||
totalFails === 1 ? 'it' : 'them'
|
||||
}. Proceed with caution!`
|
||||
} else {
|
||||
endText = '.'
|
||||
}
|
||||
|
||||
const rulesText = __DARWIN__ ? 'Rules' : 'rules'
|
||||
|
||||
return (
|
||||
<div className="repo-rules-failure-list-component">
|
||||
<p>
|
||||
{leadingText} fails {totalFails} rule{totalFails > 1 ? 's' : ''}
|
||||
{endText}{' '}
|
||||
<RepoRulesetsForBranchLink repository={repository} branch={branch}>
|
||||
View all rulesets for this branch.
|
||||
</RepoRulesetsForBranchLink>
|
||||
</p>
|
||||
{failures.failed.length > 0 && (
|
||||
<div className="repo-rule-list">
|
||||
<strong>Failed {rulesText}:</strong>
|
||||
{this.renderRuleFailureList(failures.failed)}
|
||||
</div>
|
||||
)}
|
||||
{failures.bypassed.length > 0 && (
|
||||
<div className="repo-rule-list">
|
||||
<strong>Bypassed {rulesText}:</strong>
|
||||
{this.renderRuleFailureList(failures.bypassed)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderRuleFailureList(failures: RepoRulesMetadataFailure[]) {
|
||||
return (
|
||||
<ul>
|
||||
{failures.map(f => (
|
||||
<li key={`${f.description}-${f.rulesetId}`}>
|
||||
<RepoRulesetLink
|
||||
repository={this.props.repository}
|
||||
rulesetId={f.rulesetId}
|
||||
>
|
||||
{f.description}
|
||||
</RepoRulesetLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
29
app/src/ui/repository-rules/repo-ruleset-link.tsx
Normal file
29
app/src/ui/repository-rules/repo-ruleset-link.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import * as React from 'react'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
|
||||
interface IRepoRulesetLinkProps {
|
||||
readonly repository: GitHubRepository
|
||||
readonly rulesetId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a LinkButton to the webpage for the ruleset with the
|
||||
* provided ID within the provided repo.
|
||||
*/
|
||||
export class RepoRulesetLink extends React.Component<
|
||||
IRepoRulesetLinkProps,
|
||||
{}
|
||||
> {
|
||||
public render() {
|
||||
const { repository, rulesetId, children } = this.props
|
||||
|
||||
const link = `${repository.htmlURL}/rules/${rulesetId}`
|
||||
|
||||
return (
|
||||
<LinkButton uri={link} className="repo-ruleset-link">
|
||||
{children}
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import * as React from 'react'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
|
||||
interface IRepoRulesetsForBranchLinkProps {
|
||||
readonly repository: GitHubRepository | null
|
||||
readonly branch: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a LinkButton to the rulesets page for the given repository and branch. Returns
|
||||
* the raw children with no link if the repository or branch are null.
|
||||
*/
|
||||
export class RepoRulesetsForBranchLink extends React.Component<
|
||||
IRepoRulesetsForBranchLinkProps,
|
||||
{}
|
||||
> {
|
||||
public render() {
|
||||
const { repository, branch, children } = this.props
|
||||
|
||||
if (!repository || !branch) {
|
||||
return children
|
||||
}
|
||||
|
||||
const link = `${repository.htmlURL}/rules/?ref=${encodeURIComponent(
|
||||
'refs/heads/' + branch
|
||||
)}`
|
||||
|
||||
return (
|
||||
<LinkButton uri={link} className="repo-rulesets-for-branch-link">
|
||||
{children}
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -224,6 +224,7 @@ export class RepositoryView extends React.Component<
|
|||
repository={this.props.repository}
|
||||
dispatcher={this.props.dispatcher}
|
||||
changes={this.props.state.changesState}
|
||||
aheadBehind={this.props.state.aheadBehind}
|
||||
branch={branchName}
|
||||
commitAuthor={this.props.state.commitAuthor}
|
||||
emoji={this.props.emoji}
|
||||
|
@ -358,8 +359,7 @@ export class RepositoryView extends React.Component<
|
|||
|
||||
private renderStashedChangesContent(): JSX.Element | null {
|
||||
const { changesState } = this.props.state
|
||||
const { selection, stashEntry, workingDirectory } = changesState
|
||||
const isWorkingTreeClean = workingDirectory.files.length === 0
|
||||
const { selection, stashEntry } = changesState
|
||||
|
||||
if (selection.kind !== ChangesSelectionKind.Stash || stashEntry === null) {
|
||||
return null
|
||||
|
@ -378,7 +378,6 @@ export class RepositoryView extends React.Component<
|
|||
askForConfirmationOnDiscardStash={
|
||||
this.props.askForConfirmationOnDiscardStash
|
||||
}
|
||||
isWorkingTreeClean={isWorkingTreeClean}
|
||||
showSideBySideDiff={this.props.showSideBySideDiff}
|
||||
onOpenBinaryFile={this.onOpenBinaryFile}
|
||||
onOpenSubmodule={this.onOpenSubmodule}
|
||||
|
|
|
@ -3,16 +3,14 @@ import { IStashEntry } from '../../models/stash-entry'
|
|||
import { Dispatcher } from '../dispatcher'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { PopupType } from '../../models/popup'
|
||||
import { Octicon } from '../octicons'
|
||||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
|
||||
import { ErrorWithMetadata } from '../../lib/error-with-metadata'
|
||||
|
||||
interface IStashDiffHeaderProps {
|
||||
readonly stashEntry: IStashEntry
|
||||
readonly repository: Repository
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly askForConfirmationOnDiscardStash: boolean
|
||||
readonly isWorkingTreeClean: boolean
|
||||
}
|
||||
|
||||
interface IStashDiffHeaderState {
|
||||
|
@ -38,7 +36,6 @@ export class StashDiffHeader extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
const { isWorkingTreeClean } = this.props
|
||||
const { isRestoring, isDiscarding } = this.state
|
||||
|
||||
return (
|
||||
|
@ -47,44 +44,23 @@ export class StashDiffHeader extends React.Component<
|
|||
<div className="row">
|
||||
<OkCancelButtonGroup
|
||||
okButtonText="Restore"
|
||||
okButtonDisabled={
|
||||
isRestoring || !isWorkingTreeClean || isDiscarding
|
||||
}
|
||||
okButtonDisabled={isRestoring || isDiscarding}
|
||||
onOkButtonClick={this.onRestoreClick}
|
||||
cancelButtonText="Discard"
|
||||
cancelButtonDisabled={isRestoring || isDiscarding}
|
||||
onCancelButtonClick={this.onDiscardClick}
|
||||
/>
|
||||
{this.renderExplanatoryText()}
|
||||
<div className="explanatory-text">
|
||||
<span className="text">
|
||||
<strong>Restore</strong> will move your stashed files to the
|
||||
Changes list.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderExplanatoryText() {
|
||||
const { isWorkingTreeClean } = this.props
|
||||
|
||||
if (isWorkingTreeClean || this.state.isRestoring) {
|
||||
return (
|
||||
<div className="explanatory-text">
|
||||
<span className="text">
|
||||
<strong>Restore</strong> will move your stashed files to the Changes
|
||||
list.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explanatory-text">
|
||||
<Octicon symbol={OcticonSymbol.alert} />
|
||||
<span className="text">
|
||||
Unable to restore stash when changes are present on your branch.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onDiscardClick = async () => {
|
||||
const {
|
||||
dispatcher,
|
||||
|
@ -117,8 +93,16 @@ export class StashDiffHeader extends React.Component<
|
|||
private onRestoreClick = async () => {
|
||||
const { dispatcher, repository, stashEntry } = this.props
|
||||
|
||||
this.setState({ isRestoring: true }, () => {
|
||||
dispatcher.popStash(repository, stashEntry)
|
||||
})
|
||||
try {
|
||||
this.setState({ isRestoring: true })
|
||||
await dispatcher.popStash(repository, stashEntry)
|
||||
} catch (err) {
|
||||
const errorWithMetadata = new ErrorWithMetadata(err, {
|
||||
repository: repository,
|
||||
})
|
||||
dispatcher.postError(errorWithMetadata)
|
||||
} finally {
|
||||
this.setState({ isRestoring: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,9 +33,6 @@ interface IStashDiffViewerProps {
|
|||
/** Whether we should display side by side diffs. */
|
||||
readonly showSideBySideDiff: boolean
|
||||
|
||||
/** Are there any uncommitted changes */
|
||||
readonly isWorkingTreeClean: boolean
|
||||
|
||||
/**
|
||||
* Called when the user requests to open a binary file in an the
|
||||
* system-assigned application for said file type.
|
||||
|
@ -96,7 +93,6 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
|
|||
repository,
|
||||
dispatcher,
|
||||
imageDiffType,
|
||||
isWorkingTreeClean,
|
||||
fileListWidth,
|
||||
onOpenBinaryFile,
|
||||
onChangeImageDiffType,
|
||||
|
@ -131,7 +127,6 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
|
|||
stashEntry={stashEntry}
|
||||
repository={repository}
|
||||
dispatcher={dispatcher}
|
||||
isWorkingTreeClean={isWorkingTreeClean}
|
||||
askForConfirmationOnDiscardStash={
|
||||
this.props.askForConfirmationOnDiscardStash
|
||||
}
|
||||
|
|
|
@ -208,6 +208,12 @@ export interface IToolbarDropdownProps {
|
|||
* Such as when a button wraps an image and there is no text.
|
||||
*/
|
||||
readonly ariaLabel?: string
|
||||
|
||||
/** Whether or not the focus trap should return focus to the activating button */
|
||||
readonly returnFocusOnDeactivate?: boolean
|
||||
|
||||
/** Callback fro when the focus trap deactivates */
|
||||
readonly onDropdownFocusTrapDeactivate?: () => void
|
||||
}
|
||||
|
||||
interface IToolbarDropdownState {
|
||||
|
@ -236,6 +242,8 @@ export class ToolbarDropdown extends React.Component<
|
|||
// we would lose the "source" of the event (keyboard vs pointer).
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
returnFocusOnDeactivate: this.props.returnFocusOnDeactivate,
|
||||
onDeactivate: this.props.onDropdownFocusTrapDeactivate,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -265,9 +265,78 @@ export class PushPullButton extends React.Component<
|
|||
this.props.dispatcher.push(this.props.repository)
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropdown focus trap has logic to set the document.ActiveElement to the
|
||||
* html element (in this case the dropdown button) that was clicked to
|
||||
* activate the focus trap. It also has a returnFocusOnDeactivate prop that is
|
||||
* true by default, but can be set to false to prevent this behavior.
|
||||
*
|
||||
* In the case of force push that opens a confirm dialog, we want to set the
|
||||
* focus to the aria live container and therefore we set
|
||||
* returnFocusOnDeactivate to false. We also provided the onDeactivate
|
||||
* callback of the focus trap to set that focus. See more details in
|
||||
* setScreenReaderStateMessageFocus()
|
||||
*
|
||||
* @returns true - (default behavior) if not force push, or if force and confirmation is off
|
||||
* @returns false -if force push and confirmation is on, so we can manage focus ourselves
|
||||
* */
|
||||
private returnFocusOnDeactivate = () => {
|
||||
const isForcePushOptionAvailable =
|
||||
this.props.forcePushBranchState !== ForcePushBranchState.NotAvailable
|
||||
|
||||
return (
|
||||
!isForcePushOptionAvailable || !this.props.askForConfirmationOnForcePush
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* In the case of force push that opens a confirm dialog, we want to set the
|
||||
* focus to the aria live container and we do so on the onDeactivate callback
|
||||
* of the focus trap to set that focus. Additionally, we set
|
||||
* returnFocusOnDeactivate to false to prevent the dropdowns focus traps
|
||||
* default focus management. See more details in
|
||||
* setScreenReaderStateMessageFocus()*/
|
||||
private onDropdownFocusTrapDeactivate = () => {
|
||||
if (this.returnFocusOnDeactivate()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setScreenReaderStateMessageFocus()
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a hack to get the screen reader to read the message after the force
|
||||
* push confirm dialog closes.
|
||||
*
|
||||
* Problem: The dialog component sets the focus back to what ever was in the
|
||||
* `document.ActiveElement` when the dialog was opened. However the active
|
||||
* element is the force push button that is replaced with the fetch button.
|
||||
* Thus, the force push element is no longer present when the dialog closes
|
||||
* and the focus defaults to the document body. This means the sr message is
|
||||
* not read.
|
||||
*
|
||||
* Solution: Set the `document.ActiveElement` to an element containing the sr
|
||||
* element before opening the dialog so that it returns the focus to an
|
||||
* element containing the sr. You can do this by calling the `focus` element
|
||||
* of a tab focusable element hence adding the tab index.
|
||||
*
|
||||
* Other notes: If I set the focus to the sr element directly, it causes the
|
||||
* message to be read twice.
|
||||
*/
|
||||
private setScreenReaderStateMessageFocus() {
|
||||
const srElement = document.getElementById('push-pull-button-state')
|
||||
if (srElement) {
|
||||
srElement.tabIndex = -1
|
||||
srElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
private forcePushWithLease = () => {
|
||||
this.closeDropdown()
|
||||
|
||||
this.setScreenReaderStateMessageFocus()
|
||||
this.props.dispatcher.confirmOrForcePush(this.props.repository)
|
||||
|
||||
this.setState({ actionInProgress: 'force push' })
|
||||
}
|
||||
|
||||
|
@ -278,6 +347,7 @@ export class PushPullButton extends React.Component<
|
|||
|
||||
private fetch = () => {
|
||||
this.closeDropdown()
|
||||
|
||||
this.props.dispatcher.fetch(
|
||||
this.props.repository,
|
||||
FetchType.UserInitiatedTask
|
||||
|
@ -306,9 +376,9 @@ export class PushPullButton extends React.Component<
|
|||
return (
|
||||
<>
|
||||
{this.renderButton()}
|
||||
<AriaLiveContainer>
|
||||
{this.state.screenReaderStateMessage}
|
||||
</AriaLiveContainer>
|
||||
<span id="push-pull-button-state">
|
||||
<AriaLiveContainer message={this.state.screenReaderStateMessage} />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -525,6 +595,8 @@ export class PushPullButton extends React.Component<
|
|||
dropdownContentRenderer={this.getDropdownContentRenderer(
|
||||
dropdownItemTypes
|
||||
)}
|
||||
returnFocusOnDeactivate={this.returnFocusOnDeactivate()}
|
||||
onDropdownFocusTrapDeactivate={this.onDropdownFocusTrapDeactivate}
|
||||
>
|
||||
{renderAheadBehind(aheadBehind, numTagsToPush)}
|
||||
</ToolbarDropdown>
|
||||
|
|
|
@ -105,3 +105,4 @@
|
|||
@import 'ui/_pull-request-files-changed';
|
||||
@import 'ui/_pull-request-merge-status';
|
||||
@import 'ui/_input-description';
|
||||
@import 'ui/repository-rules/_repo-rules-failure-list';
|
||||
|
|
|
@ -31,7 +31,7 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
|
|||
--button-text-color: #{$white};
|
||||
--button-focus-border-color: #{$blue-100};
|
||||
|
||||
--link-button-color: #{$blue};
|
||||
--link-button-color: #{lighten($blue, 5%)};
|
||||
--link-button-hover-color: #{$blue-600};
|
||||
--link-button-selected-hover-color: #{$blue-200};
|
||||
|
||||
|
@ -50,6 +50,15 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
|
|||
--badge-icon-color: #{$white};
|
||||
--warning-badge-icon-color: #{$orange};
|
||||
|
||||
// Colors used for icons that are inside an input box
|
||||
--input-icon-warning-color: #{$yellow-800};
|
||||
--input-icon-error-color: #{$red-600};
|
||||
--input-icon-hover-background-color: #{$gray-100};
|
||||
|
||||
// Colors used for icons in the commit message warning/error area
|
||||
--commit-message-icon-warning-color: var(--input-icon-warning-color);
|
||||
--commit-message-icon-error-color: var(--input-icon-error-color);
|
||||
|
||||
// Typography
|
||||
//
|
||||
// Font, line-height, and color for body text, headings, and more.
|
||||
|
@ -190,11 +199,10 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
|
|||
--co-author-tag-border-color: #{$blue-200};
|
||||
|
||||
/**
|
||||
* Author input (co-authors)
|
||||
* Commit warning badge icon
|
||||
*/
|
||||
--commit-warning-badge-background-color: #{$gray-000};
|
||||
--commit-warning-badge-border-color: #{$gray-300};
|
||||
--commit-warning-badge-icon-color: var(--warning-badge-icon-color);
|
||||
--commit-warning-badge-border: #{$gray-300};
|
||||
|
||||
/**
|
||||
* The height of the title bar area on Win32 platforms
|
||||
|
@ -432,6 +440,10 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
|
|||
--form-error-border-color: #{$red-200};
|
||||
--form-error-text-color: #{$red-800};
|
||||
|
||||
// Inline form errors, displayed after the input field
|
||||
--input-warning-text-color: var(--dialog-warning-color);
|
||||
--input-error-text-color: #{$red-800};
|
||||
|
||||
/** Overlay is used as a background for both modals and foldouts */
|
||||
--overlay-background-color: #{$overlay-background-color};
|
||||
|
||||
|
|
|
@ -10,6 +10,9 @@
|
|||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
|
||||
// https://www.electronjs.org/docs/api/native-theme#nativethemethemesource
|
||||
|
||||
// This blue passes WCAG 2 guidelines with $gray-100 fg and $gray-900 bg.
|
||||
$link-color: #2e8fff;
|
||||
|
||||
body.theme-dark {
|
||||
--color-new: #{$green};
|
||||
--color-deleted: #{$red};
|
||||
|
@ -17,7 +20,7 @@ body.theme-dark {
|
|||
--color-renamed: #{$blue};
|
||||
--color-conflicted: #{$orange};
|
||||
|
||||
--text-color: #{$gray-300};
|
||||
--text-color: #{$gray-100};
|
||||
--text-secondary-color: #{$gray-400};
|
||||
--text-secondary-color-muted: #{darken($gray-500, 10%)};
|
||||
--background-color: #{$gray-900};
|
||||
|
@ -27,9 +30,9 @@ body.theme-dark {
|
|||
--button-text-color: #{$white};
|
||||
--button-focus-border-color: #{$blue-600};
|
||||
|
||||
--link-button-color: #{lighten($blue-400, 3%)};
|
||||
--link-button-hover-color: #{$blue-400};
|
||||
--link-button-selected-hover-color: #{$blue-300};
|
||||
--link-button-color: #{$link-color};
|
||||
--link-button-hover-color: #{lighten($link-color, 3%)};
|
||||
--link-button-selected-hover-color: #{$link-color};
|
||||
|
||||
--secondary-button-background: #{$gray-800};
|
||||
--secondary-button-border-color: var(--box-border-contrast-color);
|
||||
|
@ -45,6 +48,15 @@ body.theme-dark {
|
|||
*/
|
||||
--badge-icon-color: #{$gray-300};
|
||||
|
||||
// Colors used for icons that are inside an input box
|
||||
--input-icon-warning-color: #{$yellow-600};
|
||||
--input-icon-error-color: #{$red-400};
|
||||
--input-icon-hover-background-color: #{$gray-800};
|
||||
|
||||
// Colors used for icons in the commit message warning/error area
|
||||
--commit-message-icon-warning-color: var(--input-icon-warning-color);
|
||||
--commit-message-icon-error-color: var(--input-icon-error-color);
|
||||
|
||||
/**
|
||||
* Background color for custom scroll bars.
|
||||
* The color is applied to the thumb part of the scrollbar.
|
||||
|
@ -151,6 +163,12 @@ body.theme-dark {
|
|||
--co-author-tag-background-color: #{$blue-800};
|
||||
--co-author-tag-border-color: #{$blue-700};
|
||||
|
||||
/**
|
||||
* Commit warning badge icon
|
||||
*/
|
||||
--commit-warning-badge-background-color: #{$gray-900};
|
||||
--commit-warning-badge-border-color: #{$gray-700};
|
||||
|
||||
--base-border: 1px solid var(--box-border-color);
|
||||
--contrast-border: 1px solid var(--box-border-contrast-color);
|
||||
|
||||
|
@ -325,6 +343,10 @@ body.theme-dark {
|
|||
--form-error-border-color: #{$red-900};
|
||||
--form-error-text-color: var(--text-color);
|
||||
|
||||
// Inline form errors, displayed after the input field
|
||||
--input-warning-text-color: var(--dialog-warning-color);
|
||||
--input-error-text-color: #{$red-300};
|
||||
|
||||
/** Overlay is used as a background for both modals and foldouts */
|
||||
--overlay-background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
|
|
|
@ -31,6 +31,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.branches-container-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pull-request-tab {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
@ -41,11 +41,18 @@
|
|||
border-radius: 9px;
|
||||
|
||||
> svg {
|
||||
color: var(--commit-warning-badge-icon-color);
|
||||
height: 10px;
|
||||
// With width=100%, the icon will be centered horizontally
|
||||
width: 100%;
|
||||
vertical-align: unset;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&.warning > svg {
|
||||
color: var(--input-icon-warning-color);
|
||||
}
|
||||
|
||||
&.error > svg {
|
||||
color: var(--input-icon-error-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -56,15 +56,34 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
section + section {
|
||||
margin-top: var(--spacing);
|
||||
legend {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
section.button-group {
|
||||
fieldset {
|
||||
border: none;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
|
||||
margin-top: 0.5em;
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset.button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.popover-component {
|
||||
width: 250px;
|
||||
}
|
||||
|
|
|
@ -13,15 +13,15 @@
|
|||
|
||||
&.input-description-warning {
|
||||
.octicon {
|
||||
fill: var(--dialog-warning-color);
|
||||
fill: var(--input-warning-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.input-description-error {
|
||||
color: var(--dialog-error-color);
|
||||
color: var(--input-error-text-color);
|
||||
|
||||
.octicon {
|
||||
fill: var(--dialog-error-color);
|
||||
fill: var(--input-error-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,17 +27,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.with-length-hint input {
|
||||
&.with-trailing-icon input {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.length-hint {
|
||||
.length-hint,
|
||||
.commit-message-failure-hint {
|
||||
$distanceFromEdge: 2px;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: $distanceFromEdge;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
height: var(--text-field-height);
|
||||
margin-right: $distanceFromEdge;
|
||||
height: calc(var(--text-field-height) - #{2 * $distanceFromEdge});
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -46,6 +49,29 @@
|
|||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.commit-message-failure-hint {
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--input-icon-hover-background-color);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: var(--input-icon-warning-color);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--input-icon-error-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popover-component {
|
||||
// a width of 300px causes more jarring movement when going from 2
|
||||
// failed rules to 1 and the user can bypass, so use a slightly smaller amount
|
||||
width: 298px;
|
||||
}
|
||||
|
||||
&.with-co-authors .description-focus-container {
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
@import '../../mixins';
|
||||
|
||||
.commit-warning-component {
|
||||
border-top: 1px solid var(--box-border-color);
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
margin-top: var(--spacing-half);
|
||||
|
||||
display: flex;
|
||||
background-color: var(--box-alt-background-color);
|
||||
padding-top: var(--spacing);
|
||||
|
||||
.warning-message {
|
||||
margin-bottom: 0;
|
||||
|
@ -27,13 +25,18 @@
|
|||
text-align: center;
|
||||
|
||||
.warning-icon,
|
||||
.information-icon {
|
||||
color: var(--dialog-warning-color);
|
||||
.information-icon,
|
||||
.error-icon {
|
||||
color: var(--commit-message-icon-error-color);
|
||||
|
||||
&.information-icon {
|
||||
color: var(--dialog-information-color);
|
||||
}
|
||||
|
||||
&.warning-icon {
|
||||
color: var(--commit-message-icon-warning-color);
|
||||
}
|
||||
|
||||
background: var(--box-alt-background-color);
|
||||
border-width: 0 var(--spacing-half);
|
||||
border: solid transparent;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
.repo-rules-failure-list-component {
|
||||
ul {
|
||||
padding-inline-start: var(--spacing-double);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import { WorkingDirectoryStatus } from '../../src/models/status'
|
|||
import { merge } from '../../src/lib/merge'
|
||||
import { IStatusResult } from '../../src/lib/git'
|
||||
import { DefaultCommitMessage } from '../../src/models/commit-message'
|
||||
import { RepoRulesInfo } from '../../src/models/repo-rules'
|
||||
|
||||
export function createState<K extends keyof IChangesState>(
|
||||
pick: Pick<IChangesState, K>
|
||||
|
@ -20,6 +21,7 @@ export function createState<K extends keyof IChangesState>(
|
|||
conflictState: null,
|
||||
stashEntry: null,
|
||||
currentBranchProtected: false,
|
||||
currentRepoRulesInfo: new RepoRulesInfo(),
|
||||
}
|
||||
|
||||
return merge(baseChangesState, pick)
|
||||
|
|
|
@ -16,7 +16,7 @@ describe('AccountsStore', () => {
|
|||
it('contains the added user', async () => {
|
||||
const newAccountLogin = 'joan'
|
||||
await accountsStore.addAccount(
|
||||
new Account(newAccountLogin, '', 'deadbeef', [], '', 1, '')
|
||||
new Account(newAccountLogin, '', 'deadbeef', [], '', 1, '', 'free')
|
||||
)
|
||||
|
||||
const users = await accountsStore.getAll()
|
||||
|
|
|
@ -19,7 +19,8 @@ describe('emails', () => {
|
|||
[],
|
||||
'',
|
||||
1234,
|
||||
'Caps Lock'
|
||||
'Caps Lock',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(lookupPreferredEmail(account)).toBe(
|
||||
|
@ -35,7 +36,8 @@ describe('emails', () => {
|
|||
[],
|
||||
'',
|
||||
1234,
|
||||
'Caps Lock'
|
||||
'Caps Lock',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(lookupPreferredEmail(account)).toBe(
|
||||
|
@ -72,7 +74,8 @@ describe('emails', () => {
|
|||
emails,
|
||||
'',
|
||||
-1,
|
||||
'Caps Lock'
|
||||
'Caps Lock',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(lookupPreferredEmail(account)).toBe('my-primary-email@example.com')
|
||||
|
@ -107,7 +110,8 @@ describe('emails', () => {
|
|||
emails,
|
||||
'',
|
||||
-1,
|
||||
'Caps Lock'
|
||||
'Caps Lock',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(lookupPreferredEmail(account)).toBe('my-primary-email@example.com')
|
||||
|
@ -142,7 +146,8 @@ describe('emails', () => {
|
|||
emails,
|
||||
'',
|
||||
-1,
|
||||
'Caps Lock'
|
||||
'Caps Lock',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(lookupPreferredEmail(account)).toBe(
|
||||
|
@ -179,7 +184,8 @@ describe('emails', () => {
|
|||
emails,
|
||||
'',
|
||||
-1,
|
||||
'Caps Lock'
|
||||
'Caps Lock',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(lookupPreferredEmail(account)).toBe(
|
||||
|
@ -210,7 +216,8 @@ describe('emails', () => {
|
|||
emails,
|
||||
'',
|
||||
-1,
|
||||
'Caps Lock'
|
||||
'Caps Lock',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(lookupPreferredEmail(account)).toBe('shiftkey@example.com')
|
||||
|
@ -241,7 +248,16 @@ describe('emails', () => {
|
|||
]
|
||||
|
||||
const endpoint = getDotComAPIEndpoint()
|
||||
const account = new Account('niik', endpoint, '', emails, '', 123, '')
|
||||
const account = new Account(
|
||||
'niik',
|
||||
endpoint,
|
||||
'',
|
||||
emails,
|
||||
'',
|
||||
123,
|
||||
'',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(isAttributableEmailFor(account, 'personal@gmail.com')).toBeTrue()
|
||||
expect(isAttributableEmailFor(account, 'company@github.com')).toBeTrue()
|
||||
|
@ -255,7 +271,7 @@ describe('emails', () => {
|
|||
|
||||
it('considers stealth emails when account has no emails', () => {
|
||||
const endpoint = getDotComAPIEndpoint()
|
||||
const account = new Account('niik', endpoint, '', [], '', 123, '')
|
||||
const account = new Account('niik', endpoint, '', [], '', 123, '', 'free')
|
||||
|
||||
expect(
|
||||
isAttributableEmailFor(account, 'niik@users.noreply.github.com')
|
||||
|
@ -267,7 +283,7 @@ describe('emails', () => {
|
|||
|
||||
it('considers stealth emails for GitHub Enterprise', () => {
|
||||
const endpoint = getDotComAPIEndpoint()
|
||||
const account = new Account('niik', endpoint, '', [], '', 123, '')
|
||||
const account = new Account('niik', endpoint, '', [], '', 123, '', 'free')
|
||||
|
||||
expect(
|
||||
isAttributableEmailFor(account, 'niik@users.noreply.github.com')
|
||||
|
@ -292,7 +308,8 @@ describe('emails', () => {
|
|||
],
|
||||
'',
|
||||
123,
|
||||
''
|
||||
'',
|
||||
'free'
|
||||
)
|
||||
|
||||
expect(isAttributableEmailFor(account, 'niik@github.com')).toBeTrue()
|
||||
|
|
|
@ -38,7 +38,8 @@ describe('findAccountForRemoteURL', () => {
|
|||
[],
|
||||
'',
|
||||
1,
|
||||
'GitHub'
|
||||
'GitHub',
|
||||
'free'
|
||||
),
|
||||
new Account(
|
||||
'joel',
|
||||
|
@ -47,7 +48,8 @@ describe('findAccountForRemoteURL', () => {
|
|||
[],
|
||||
'',
|
||||
2,
|
||||
'My Company'
|
||||
'My Company',
|
||||
'free'
|
||||
),
|
||||
]
|
||||
|
||||
|
|
|
@ -61,6 +61,32 @@ describe('git/remote', () => {
|
|||
const remotes = await getRemotes(repository)
|
||||
expect(remotes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns promisor remote', async () => {
|
||||
const repository = await setupEmptyRepository()
|
||||
|
||||
// Add a remote
|
||||
const url = 'https://github.com/desktop/not-found.git'
|
||||
await GitProcess.exec(
|
||||
['remote', 'add', 'hasBlobFilter', url],
|
||||
repository.path
|
||||
)
|
||||
|
||||
// Fetch a remote and add a filter
|
||||
await GitProcess.exec(['fetch', '--filter=blob:none'], repository.path)
|
||||
|
||||
// Shows that the new remote does have a filter
|
||||
const rawGetRemote = await GitProcess.exec(
|
||||
['remote', '-v'],
|
||||
repository.path
|
||||
)
|
||||
expect(rawGetRemote.stdout).toContain(url + ' (fetch) [blob:none]')
|
||||
|
||||
// Shows that the `getRemote` returns that remote
|
||||
const result = await getRemotes(repository)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toEqual('hasBlobFilter')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDefaultRemote', () => {
|
||||
|
|
|
@ -41,7 +41,8 @@ describe('git/tag', () => {
|
|||
[],
|
||||
'',
|
||||
-1,
|
||||
'Mona Lisa'
|
||||
'Mona Lisa',
|
||||
'free'
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -202,7 +202,16 @@ describe('PopupManager', () => {
|
|||
|
||||
describe('updatePopup', () => {
|
||||
it('updates the given popup', () => {
|
||||
const mockAccount = new Account('test', '', 'deadbeef', [], '', 1, '')
|
||||
const mockAccount = new Account(
|
||||
'test',
|
||||
'',
|
||||
'deadbeef',
|
||||
[],
|
||||
'',
|
||||
1,
|
||||
'',
|
||||
'free'
|
||||
)
|
||||
const popupTutorial: Popup = {
|
||||
type: PopupType.CreateTutorialRepository,
|
||||
account: mockAccount,
|
||||
|
|
326
app/test/unit/repo-rules-test.ts
Normal file
326
app/test/unit/repo-rules-test.ts
Normal file
|
@ -0,0 +1,326 @@
|
|||
import {
|
||||
APIRepoRuleMetadataOperator,
|
||||
APIRepoRuleType,
|
||||
IAPIRepoRule,
|
||||
IAPIRepoRuleset,
|
||||
} from '../../src/lib/api'
|
||||
import { parseRepoRules } from '../../src/lib/helpers/repo-rules'
|
||||
import {
|
||||
RepoRulesMetadataFailures,
|
||||
RepoRulesMetadataStatus,
|
||||
} from '../../src/models/repo-rules'
|
||||
|
||||
const creationRule: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.Creation,
|
||||
}
|
||||
|
||||
const creationBypassAlwaysRule: IAPIRepoRule = {
|
||||
ruleset_id: 2,
|
||||
type: APIRepoRuleType.Creation,
|
||||
}
|
||||
|
||||
const creationBypassPullRequestsOnlyRule: IAPIRepoRule = {
|
||||
ruleset_id: 3,
|
||||
type: APIRepoRuleType.Creation,
|
||||
}
|
||||
|
||||
const commitMessagePatternStartsWithRule: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: false,
|
||||
pattern: 'abc',
|
||||
operator: APIRepoRuleMetadataOperator.StartsWith,
|
||||
},
|
||||
}
|
||||
|
||||
const commitMessagePatternStartsWithBypassRule: IAPIRepoRule = {
|
||||
ruleset_id: 2,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: false,
|
||||
pattern: 'abc',
|
||||
operator: APIRepoRuleMetadataOperator.StartsWith,
|
||||
},
|
||||
}
|
||||
|
||||
const commitMessagePatternSpecialCharactersRule: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: false,
|
||||
pattern: '(a.b.c.)|(d+)\\d', // API response is backslash escaped like this
|
||||
operator: APIRepoRuleMetadataOperator.StartsWith,
|
||||
},
|
||||
}
|
||||
|
||||
const commitMessagePatternEndsWithRule: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: true,
|
||||
pattern: 'end',
|
||||
operator: APIRepoRuleMetadataOperator.EndsWith,
|
||||
},
|
||||
}
|
||||
|
||||
const commitMessagePatternContainsRule: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: true,
|
||||
pattern: 'con',
|
||||
operator: APIRepoRuleMetadataOperator.Contains,
|
||||
},
|
||||
}
|
||||
|
||||
const commitMessagePatternRegexRule1: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: false,
|
||||
pattern: '(a.b.c.)|(d+)',
|
||||
operator: APIRepoRuleMetadataOperator.RegexMatch,
|
||||
},
|
||||
}
|
||||
|
||||
const commitMessagePatternRegexRule2: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: false,
|
||||
pattern: '^\\A(d|e)oo\\d$', // API response is backslash escaped like this
|
||||
operator: APIRepoRuleMetadataOperator.RegexMatch,
|
||||
},
|
||||
}
|
||||
|
||||
const commitMessagePatternRegexMultiLineRule: IAPIRepoRule = {
|
||||
ruleset_id: 1,
|
||||
type: APIRepoRuleType.CommitMessagePattern,
|
||||
parameters: {
|
||||
name: '',
|
||||
negate: false,
|
||||
pattern: '(?m)^foo',
|
||||
operator: APIRepoRuleMetadataOperator.RegexMatch,
|
||||
},
|
||||
}
|
||||
|
||||
const rulesets: ReadonlyMap<number, IAPIRepoRuleset> = new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
id: 1,
|
||||
current_user_can_bypass: 'never',
|
||||
},
|
||||
],
|
||||
[
|
||||
2,
|
||||
{
|
||||
id: 2,
|
||||
current_user_can_bypass: 'always',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
function validateMetadataRules(
|
||||
rules: RepoRulesMetadataFailures,
|
||||
status: RepoRulesMetadataStatus,
|
||||
bypassesExpected: number,
|
||||
failuresExpected: number
|
||||
): void {
|
||||
expect(rules.status).toBe(status)
|
||||
expect(rules.bypassed.length).toBe(bypassesExpected)
|
||||
expect(rules.failed.length).toBe(failuresExpected)
|
||||
}
|
||||
|
||||
describe('parseRepoRules', () => {
|
||||
it('cannot bypass when bypass is "never"', () => {
|
||||
// the creation rule references ruleset ID 1, which has a bypass of 'never'
|
||||
const rules = [creationRule]
|
||||
const result = parseRepoRules(rules, rulesets)
|
||||
expect(result.creationRestricted).toBe(true)
|
||||
})
|
||||
|
||||
it('can bypass when bypass is "always"', () => {
|
||||
// the creationBypass rule references ruleset ID 2, which has a bypass of 'always'
|
||||
const rules = [creationBypassAlwaysRule]
|
||||
const result = parseRepoRules(rules, rulesets)
|
||||
expect(result.creationRestricted).toBe('bypass')
|
||||
})
|
||||
|
||||
it('cannot bypass when at least one bypass mode is "never" or "pull_requests_only"', () => {
|
||||
const rules = [creationRule, creationBypassAlwaysRule]
|
||||
const result = parseRepoRules(rules, rulesets)
|
||||
expect(result.creationRestricted).toBe(true)
|
||||
|
||||
const rules2 = [creationRule, creationBypassPullRequestsOnlyRule]
|
||||
const result2 = parseRepoRules(rules2, rulesets)
|
||||
expect(result2.creationRestricted).toBe(true)
|
||||
})
|
||||
|
||||
it('is not enforced when no rules are provided', () => {
|
||||
const rules: IAPIRepoRule[] = []
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
expect(repoRulesInfo.creationRestricted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('repo metadata rules', () => {
|
||||
describe('startsWith rule', () => {
|
||||
it('shows no rules and passes everything when no rules are provided', () => {
|
||||
const rules: IAPIRepoRule[] = []
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
expect(repoRulesInfo.commitMessagePatterns.hasRules).toBe(false)
|
||||
|
||||
const failedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('abc')
|
||||
validateMetadataRules(failedRules, 'pass', 0, 0)
|
||||
})
|
||||
|
||||
it('has correct matching logic for StartsWith rule', () => {
|
||||
const rules = [commitMessagePatternStartsWithRule]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
expect(repoRulesInfo.commitMessagePatterns.hasRules).toBe(true)
|
||||
|
||||
const failedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('def')
|
||||
validateMetadataRules(failedRules, 'fail', 0, 1)
|
||||
expect(failedRules.failed[0].description).toBe('must start with "abc"')
|
||||
})
|
||||
|
||||
it('has correct bypass logic for StartsWith rule', () => {
|
||||
const rules = [commitMessagePatternStartsWithBypassRule]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
|
||||
const failedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('def')
|
||||
validateMetadataRules(failedRules, 'bypass', 1, 0)
|
||||
expect(failedRules.bypassed[0].description).toBe('must start with "abc"')
|
||||
})
|
||||
|
||||
it('has correct logic when bypassed rule is included with non-bypassed rule', () => {
|
||||
const rules = [
|
||||
commitMessagePatternStartsWithRule,
|
||||
commitMessagePatternStartsWithBypassRule,
|
||||
]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
|
||||
const failedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('def')
|
||||
validateMetadataRules(failedRules, 'fail', 1, 1)
|
||||
expect(failedRules.bypassed[0].description).toBe('must start with "abc"')
|
||||
expect(failedRules.failed[0].description).toBe('must start with "abc"')
|
||||
})
|
||||
|
||||
it('escapes special characters and otherwise handles regex properly', () => {
|
||||
const rules = [commitMessagePatternSpecialCharactersRule]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
|
||||
// if the . in the pattern is interpreted as a regex special character, this will pass
|
||||
const rules1 =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('aabbcc')
|
||||
expect(rules1.status).toBe('fail')
|
||||
|
||||
const rules2 = repoRulesInfo.commitMessagePatterns.getFailedRules('dd')
|
||||
expect(rules2.status).toBe('fail')
|
||||
|
||||
const passedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('(a.b.c.)|(d+)\\d')
|
||||
expect(passedRules.status).toBe('pass')
|
||||
})
|
||||
})
|
||||
|
||||
describe('endsWith rule', () => {
|
||||
it('has correct matching logic for negated EndsWith rule', () => {
|
||||
const rules = [commitMessagePatternEndsWithRule]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
|
||||
const failedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('end')
|
||||
validateMetadataRules(failedRules, 'fail', 0, 1)
|
||||
expect(failedRules.failed[0].description).toBe('must not end with "end"')
|
||||
|
||||
const passedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('abc')
|
||||
validateMetadataRules(passedRules, 'pass', 0, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contains rule', () => {
|
||||
it('has correct matching logic for Contains rule', () => {
|
||||
const rules = [commitMessagePatternContainsRule]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
|
||||
const failedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('fooconbar')
|
||||
validateMetadataRules(failedRules, 'fail', 0, 1)
|
||||
expect(failedRules.failed[0].description).toBe('must not contain "con"')
|
||||
|
||||
const passedRules =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('foobar')
|
||||
validateMetadataRules(passedRules, 'pass', 0, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('regex rule', () => {
|
||||
it('has correct matching logic for RegexMatch rule', () => {
|
||||
const rules = [
|
||||
commitMessagePatternRegexRule1,
|
||||
commitMessagePatternRegexRule2,
|
||||
]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
|
||||
const results1 =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('doo5')
|
||||
validateMetadataRules(results1, 'pass', 0, 0)
|
||||
|
||||
const results2 =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('afbgch')
|
||||
validateMetadataRules(results2, 'fail', 0, 1)
|
||||
expect(results2.failed[0].description).toBe(
|
||||
'must match the regular expression "^\\A(d|e)oo\\d$"'
|
||||
)
|
||||
|
||||
const results3 =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('eoo4')
|
||||
validateMetadataRules(results3, 'fail', 0, 1)
|
||||
expect(results3.failed[0].description).toBe(
|
||||
'must match the regular expression "(a.b.c.)|(d+)"'
|
||||
)
|
||||
|
||||
const results4 =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('fgsa')
|
||||
validateMetadataRules(results4, 'fail', 0, 2)
|
||||
expect(results4.failed[0].description).toBe(
|
||||
'must match the regular expression "(a.b.c.)|(d+)"'
|
||||
)
|
||||
expect(results4.failed[1].description).toBe(
|
||||
'must match the regular expression "^\\A(d|e)oo\\d$"'
|
||||
)
|
||||
})
|
||||
|
||||
it('has correct matching logic for multi-line data', () => {
|
||||
const rules = [commitMessagePatternRegexMultiLineRule]
|
||||
const repoRulesInfo = parseRepoRules(rules, rulesets)
|
||||
|
||||
const results1 =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('first line\nfoo')
|
||||
validateMetadataRules(results1, 'pass', 0, 0)
|
||||
|
||||
const results2 =
|
||||
repoRulesInfo.commitMessagePatterns.getFailedRules('asdf\nbar')
|
||||
validateMetadataRules(results2, 'fail', 0, 1)
|
||||
expect(results2.failed[0].description).toBe(
|
||||
'must match the regular expression "(?m)^foo"'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -11,7 +11,16 @@ describe('repository-matching', () => {
|
|||
describe('matchGitHubRepository', () => {
|
||||
it('matches HTTPS URLs', () => {
|
||||
const accounts = [
|
||||
new Account('alovelace', 'https://api.github.com', '', [], '', 1, ''),
|
||||
new Account(
|
||||
'alovelace',
|
||||
'https://api.github.com',
|
||||
'',
|
||||
[],
|
||||
'',
|
||||
1,
|
||||
'',
|
||||
'free'
|
||||
),
|
||||
]
|
||||
const repo = matchGitHubRepository(
|
||||
accounts,
|
||||
|
@ -23,7 +32,16 @@ describe('repository-matching', () => {
|
|||
|
||||
it('matches HTTPS URLs without the git extension', () => {
|
||||
const accounts = [
|
||||
new Account('alovelace', 'https://api.github.com', '', [], '', 1, ''),
|
||||
new Account(
|
||||
'alovelace',
|
||||
'https://api.github.com',
|
||||
'',
|
||||
[],
|
||||
'',
|
||||
1,
|
||||
'',
|
||||
'free'
|
||||
),
|
||||
]
|
||||
const repo = matchGitHubRepository(
|
||||
accounts,
|
||||
|
@ -35,7 +53,16 @@ describe('repository-matching', () => {
|
|||
|
||||
it('matches git URLs', () => {
|
||||
const accounts = [
|
||||
new Account('alovelace', 'https://api.github.com', '', [], '', 1, ''),
|
||||
new Account(
|
||||
'alovelace',
|
||||
'https://api.github.com',
|
||||
'',
|
||||
[],
|
||||
'',
|
||||
1,
|
||||
'',
|
||||
'free'
|
||||
),
|
||||
]
|
||||
const repo = matchGitHubRepository(
|
||||
accounts,
|
||||
|
@ -47,7 +74,16 @@ describe('repository-matching', () => {
|
|||
|
||||
it('matches SSH URLs', () => {
|
||||
const accounts = [
|
||||
new Account('alovelace', 'https://api.github.com', '', [], '', 1, ''),
|
||||
new Account(
|
||||
'alovelace',
|
||||
'https://api.github.com',
|
||||
'',
|
||||
[],
|
||||
'',
|
||||
1,
|
||||
'',
|
||||
'free'
|
||||
),
|
||||
]
|
||||
const repo = matchGitHubRepository(
|
||||
accounts,
|
||||
|
@ -66,7 +102,8 @@ describe('repository-matching', () => {
|
|||
[],
|
||||
'',
|
||||
1,
|
||||
''
|
||||
'',
|
||||
'free'
|
||||
),
|
||||
]
|
||||
const repo = matchGitHubRepository(
|
||||
|
|
|
@ -1103,6 +1103,11 @@ rc@^1.2.7:
|
|||
minimist "^1.2.0"
|
||||
strip-json-comments "~2.0.1"
|
||||
|
||||
re2js@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/re2js/-/re2js-0.1.0.tgz#d473179f355133de922dad5efd8dba46d21d3ac2"
|
||||
integrity sha512-bPaft3p8HMOwdN6dX2Pv0NMT1RDxZT2OrDSv5hyxAVZsUpIbCy1YURaz9LtpPyen7oOdzKpWe3/qqfqD34It0Q==
|
||||
|
||||
react-css-transition-replace@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/react-css-transition-replace/-/react-css-transition-replace-3.0.3.tgz#23d3ed17f54e41435c0485300adb75d2e6e24aad"
|
||||
|
|
|
@ -1,5 +1,27 @@
|
|||
{
|
||||
"releases": {
|
||||
"3.2.8-beta2": [
|
||||
"[New] Initial support for repository rules - #16707. Thanks @vaindil!",
|
||||
"[Fixed] Enable context menu keyboard shortcut for file lists - #17143",
|
||||
"[Fixed] Adds a workaround for the macOS Ventura `aria-labelledby` and `aria-describedby` regressions such that dialog titles are always announced - #17148",
|
||||
"[Fixed] Screen readers announce branch group names correctly when there are no recent branches - #17142",
|
||||
"[Fixed] Screen readers announce the status of files within a commit - #17144",
|
||||
"[Improved] Improve contrast of text to links in dark and light themes - #17092",
|
||||
"[Improved] The errors and warnings in the \"Create a New Repository\" dialog are screen reader announced - #16993"
|
||||
],
|
||||
"3.2.8-beta1": [
|
||||
"[Fixed] Fix not recognizing remote for partial clone/fetch - #16284. Thanks @mkafrin!",
|
||||
"[Fixed] Fix association of repositories using nonstandard usernames - #17024",
|
||||
"[Improved] Add aria-label and aria-expanded attributes to diff options button - #17062",
|
||||
"[Improved] Screen readers announce number of pull requests found after refreshing the list - #17031",
|
||||
"[Improved] The context menu for the History view items can be invoked by keyboard shortcuts - #17035"
|
||||
],
|
||||
"3.2.7": [
|
||||
"[Fixed] Improved performance when selecting and viewing a large number of commits - #16880",
|
||||
"[Fixed] Fix crash using Edit -> Copy menu when no text is selected in the diff - #16876",
|
||||
"[Fixed] Emoji autocomplete list highlights filter text correctly - #16899",
|
||||
"[Fixed] Allow filtering autocomplete results using uppercase characters - #16886"
|
||||
],
|
||||
"3.2.7-beta2": [
|
||||
"[New] Checkout a commit from the History tab - #10068. Thanks @kitswas!",
|
||||
"[New] Show when a repository has been archived in the clone dialog - #7183",
|
||||
|
|
|
@ -47,6 +47,8 @@ These editors are currently supported:
|
|||
- [JetBrains Fleet](https://www.jetbrains.com/fleet/)
|
||||
- [JetBrains DataSpell](https://www.jetbrains.com/dataspell/)
|
||||
- [Zed](https://zed.dev/) - both Stable and Preview channel
|
||||
- [Pulsar](https://pulsar-edit.dev/)
|
||||
|
||||
|
||||
These are defined in a list at the top of the file:
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ type ChannelToValidate = 'production' | 'beta'
|
|||
* to a previous version of GitHub Desktop without losing all settings.
|
||||
*/
|
||||
const ValidElectronVersions: Record<ChannelToValidate, string> = {
|
||||
production: '22.0.3',
|
||||
production: '24.4.0',
|
||||
beta: '24.4.0',
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue