Merge branch 'development' into ft/zed-preview-editor

This commit is contained in:
tidy-dev 2023-08-10 12:07:07 +00:00 committed by GitHub
commit b2e1716e69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 2994 additions and 380 deletions

View file

@ -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

View file

@ -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",

View file

@ -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)

View file

@ -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
}
/**

View file

@ -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}`)

View file

@ -174,3 +174,9 @@ export const supportsAliveSessions = endpointSatisfies({
ae: false,
es: false,
})
export const supportsRepoRules = endpointSatisfies({
dotcom: true,
ae: false,
es: false,
})

View file

@ -102,3 +102,5 @@ export const enableCustomGitUserAgent = enableBetaFeatures
export function enableSectionList(): boolean {
return enableBetaFeatures()
}
export const enableRepoRulesBeta = enableBetaFeatures

View file

@ -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')

View file

@ -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] }))

View file

@ -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
}
}

View 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
}
}

View file

@ -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)

View file

@ -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}`
)

View file

@ -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: {

View 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
}

View file

@ -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
)
}

View file

@ -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

View 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'

View file

@ -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() {

View file

@ -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>
)

View file

@ -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()}

View file

@ -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}`)
}

View file

@ -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>
)
}

View file

@ -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) => {

View file

@ -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()
}

View file

@ -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} />
</>
)
}

View file

@ -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>
)
}

View file

@ -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 })
}
}
}

View file

@ -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>
)

View file

@ -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">

View file

@ -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}`)
}

View file

@ -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>

View file

@ -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}

View file

@ -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
}

View file

@ -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>

View file

@ -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()}

View file

@ -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>
)
}
}

View file

@ -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)
}
}

View file

@ -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}

View file

@ -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>
)
}

View file

@ -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 })
}
}
}

View file

@ -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()}

View file

@ -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)}
/>
</>
)
}

View file

@ -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'

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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

View file

@ -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}
/>

View file

@ -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()}

View file

@ -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>

View file

@ -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) {

View file

@ -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}

View file

@ -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.

View file

@ -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}

View file

@ -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}

View file

@ -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>

View file

@ -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()
}
}

View 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>
)
}
}

View 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>
)
}
}

View file

@ -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>
)
}
}

View file

@ -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}

View file

@ -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 })
}
}
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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>

View file

@ -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';

View file

@ -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};

View file

@ -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);

View file

@ -31,6 +31,11 @@
}
}
.branches-container-panel {
display: flex;
flex: 1;
}
.pull-request-tab {
display: flex;
flex-direction: row;

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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);
}
}
}

View file

@ -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 {

View file

@ -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;

View file

@ -0,0 +1,5 @@
.repo-rules-failure-list-component {
ul {
padding-inline-start: var(--spacing-double);
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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'
),
]

View file

@ -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', () => {

View file

@ -41,7 +41,8 @@ describe('git/tag', () => {
[],
'',
-1,
'Mona Lisa'
'Mona Lisa',
'free'
)
})

View file

@ -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,

View 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"'
)
})
})
})

View file

@ -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(

View file

@ -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"

View file

@ -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",

View file

@ -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:

View 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',
}