Merge remote-tracking branch 'upstream/development' into development

This commit is contained in:
confused-Techie 2023-08-03 16:30:23 -07:00
commit 659ad53ec1
151 changed files with 7232 additions and 2410 deletions

View file

@ -123,7 +123,10 @@ rules:
jsdoc/check-tag-names: error
jsdoc/check-types: error
jsdoc/implements-on-classes: error
jsdoc/newline-after-description: error
jsdoc/tag-lines:
- error
- any
- startLines: 1
jsdoc/no-undefined-types: error
jsdoc/valid-types: error
@ -198,8 +201,7 @@ rules:
# larger web pages, e.g. to focus a form field in the main content, entirely
# skipping the header and often much else besides.
jsx-a11y/no-autofocus:
- warn
- ignoreNonDOM: true
- off
overrides:
- files: '*.d.ts'

View file

@ -4,29 +4,77 @@ on:
push:
branches:
- development
- __release-*
pull_request:
workflow_call:
inputs:
repository:
default: desktop/desktop
required: false
type: string
ref:
required: true
type: string
upload-artifacts:
default: false
required: false
type: boolean
environment:
type: string
required: true
secrets:
DESKTOP_OAUTH_CLIENT_ID:
DESKTOP_OAUTH_CLIENT_SECRET:
APPLE_ID:
APPLE_ID_PASSWORD:
APPLE_APPLICATION_CERT:
APPLE_APPLICATION_CERT_PASSWORD:
WINDOWS_CERT_PFX:
WINDOWS_CERT_PASSWORD:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
repository: ${{ inputs.repository || github.repository }}
ref: ${{ inputs.ref }}
submodules: recursive
- uses: actions/setup-node@v3
with:
node-version: 16.17.1
cache: yarn
- run: yarn
- run: yarn validate-electron-version
- run: yarn lint
- run: yarn validate-changelog
- name: Ensure a clean working directory
run: git diff --name-status --exit-code
build:
name: ${{ matrix.friendlyName }} ${{ matrix.arch }}
runs-on: ${{ matrix.os }}
permissions: read-all
permissions:
contents: read
strategy:
fail-fast: false
matrix:
node: [16.17.1]
os: [macos-11, windows-2019]
node: [18.14.0]
os: [macos-13-xl-arm64, windows-2019]
arch: [x64, arm64]
include:
- os: macos-11
- os: macos-13-xl-arm64
friendlyName: macOS
- os: windows-2019
friendlyName: Windows
timeout-minutes: 60
env:
RELEASE_CHANNEL: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v3
with:
repository: ${{ inputs.repository || github.repository }}
ref: ${{ inputs.ref }}
submodules: recursive
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v3
@ -45,14 +93,6 @@ jobs:
env:
npm_config_arch: ${{ matrix.arch }}
TARGET_ARCH: ${{ matrix.arch }}
- name: Validate Electron version
run: yarn run validate-electron-version
- name: Lint
run: yarn lint
- name: Validate changelog
run: yarn validate-changelog
- name: Ensure a clean working directory
run: git diff --name-status --exit-code
- name: Build production app
run: yarn build:prod
env:
@ -61,28 +101,41 @@ jobs:
${{ secrets.DESKTOP_OAUTH_CLIENT_SECRET }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }}
KEY_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }}
npm_config_arch: ${{ matrix.arch }}
TARGET_ARCH: ${{ matrix.arch }}
- name: Prepare testing environment
if: matrix.arch == 'x64'
run: yarn test:setup
env:
npm_config_arch: ${{ matrix.arch }}
- name: Run unit tests
if: matrix.arch == 'x64'
run: yarn test:unit
- name: Run script tests
if: matrix.arch == 'x64'
run: yarn test:script
- name: Publish production app
run: yarn run publish
- name: Install Windows code signing certificate
if: ${{ runner.os == 'Windows' }}
shell: bash
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
run: base64 -d <<<"$CERT_CONTENTS" > ./script/windows-certificate.pfx
- name: Package production app
run: yarn package
env:
npm_config_arch: ${{ matrix.arch }}
DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }}
WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
DEPLOYMENT_SECRET: ${{ secrets.DEPLOYMENT_SECRET }}
AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }}
AZURE_STORAGE_ACCESS_KEY: ${{ secrets.AZURE_STORAGE_ACCESS_KEY }}
AZURE_BLOB_CONTAINER: ${{ secrets.AZURE_BLOB_CONTAINER }}
AZURE_STORAGE_URL: ${{ secrets.AZURE_STORAGE_URL }}
- name: Upload artifacts
uses: actions/upload-artifact@v3
if: ${{ inputs.upload-artifacts }}
with:
name: ${{matrix.friendlyName}}-${{matrix.arch}}
path: |
dist/GitHub Desktop-${{matrix.arch}}.zip
dist/GitHubDesktop-*.nupkg
dist/GitHubDesktopSetup-${{matrix.arch}}.exe
dist/GitHubDesktopSetup-${{matrix.arch}}.msi
dist/bundle-size.json
if-no-files-found: error

View file

@ -1 +1 @@
16.17.1
18.14.0

2
.nvmrc
View file

@ -1 +1 @@
v16.17.1
v18.14.0

View file

@ -1,2 +1,2 @@
python 3.9.5
nodejs 16.17.1
nodejs 18.14.0

View file

@ -1,3 +1,3 @@
runtime = electron
disturl = https://electronjs.org/headers
target = 22.0.0
target = 24.4.0

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "3.2.4",
"version": "3.2.8-beta2",
"main": "./main.js",
"repository": {
"type": "git",
@ -46,6 +46,7 @@
"primer-support": "^4.0.0",
"prop-types": "^15.7.2",
"quick-lru": "^3.0.0",
"re2js": "^0.1.0",
"react": "^16.8.4",
"react-css-transition-replace": "^3.0.3",
"react-dom": "^16.8.4",

View file

@ -14,6 +14,8 @@ export function openDesktop(url: string = '') {
// https://github.com/nodejs/node/blob/b39dabefe6d/lib/child_process.js#L565-L577
const shell = process.env.comspec || 'cmd.exe'
return ChildProcess.spawn(shell, ['/d', '/c', 'start', url], { env })
} else if (__LINUX__) {
return ChildProcess.spawn('xdg-open', [url], { env })
} else {
throw new Error(
`Desktop command line interface not currently supported on platform ${process.platform}`

View file

@ -485,6 +485,100 @@ export interface IAPIBranch {
readonly protected: boolean
}
/** Repository rule information returned by the GitHub API */
export interface IAPIRepoRule {
/**
* The ID of the ruleset this rule is configured in.
*/
readonly ruleset_id: number
/**
* The type of the rule.
*/
readonly type: APIRepoRuleType
/**
* The parameters that apply to the rule if it is a metadata rule.
* Other rule types may have parameters, but they are not used in
* this app so they are ignored. Do not attempt to use this field
* unless you know {@link type} matches a metadata rule type.
*/
readonly parameters?: IAPIRepoRuleMetadataParameters
}
/**
* A non-exhaustive list of rules that can be configured. Only the rule
* types used by this app are included.
*/
export enum APIRepoRuleType {
Creation = 'creation',
Update = 'update',
RequiredDeployments = 'required_deployments',
RequiredSignatures = 'required_signatures',
RequiredStatusChecks = 'required_status_checks',
PullRequest = 'pull_request',
CommitMessagePattern = 'commit_message_pattern',
CommitAuthorEmailPattern = 'commit_author_email_pattern',
CommitterEmailPattern = 'committer_email_pattern',
BranchNamePattern = 'branch_name_pattern',
}
/**
* A ruleset returned from the GitHub API's "get all rulesets for a repo" endpoint.
* This endpoint returns a slimmed-down version of the full ruleset object, though
* only the ID is used.
*/
export interface IAPISlimRepoRuleset {
readonly id: number
}
/**
* A ruleset returned from the GitHub API's "get a ruleset for a repo" endpoint.
*/
export interface IAPIRepoRuleset extends IAPISlimRepoRuleset {
/**
* Whether the user making the API request can bypass the ruleset.
*/
readonly current_user_can_bypass: 'always' | 'pull_requests_only' | 'never'
}
/**
* Metadata parameters for a repo rule metadata rule.
*/
export interface IAPIRepoRuleMetadataParameters {
/**
* User-supplied name/description of the rule
*/
name: string
/**
* Whether the operator is negated. For example, if `true`
* and {@link operator} is `starts_with`, then the rule
* will be negated to 'does not start with'.
*/
negate: boolean
/**
* The pattern to match against. If the operator is 'regex', then
* this is a regex string match. Otherwise, it is a raw string match
* of the type specified by {@link operator} with no additional parsing.
*/
pattern: string
/**
* The type of match to use for the pattern. For example, `starts_with`
* means {@link pattern} must be at the start of the string.
*/
operator: APIRepoRuleMetadataOperator
}
export enum APIRepoRuleMetadataOperator {
StartsWith = 'starts_with',
EndsWith = 'ends_with',
Contains = 'contains',
RegexMatch = 'regex',
}
interface IAPIPullRequestRef {
readonly ref: string
readonly sha: string
@ -1555,6 +1649,72 @@ export class API {
}
}
/**
* Fetches all repository rules that apply to the provided branch.
*/
public async fetchRepoRulesForBranch(
owner: string,
name: string,
branch: string
): Promise<ReadonlyArray<IAPIRepoRule>> {
const path = `repos/${owner}/${name}/rules/branches/${encodeURIComponent(
branch
)}`
try {
const response = await this.request('GET', path)
return await parsedResponse<IAPIRepoRule[]>(response)
} catch (err) {
log.info(
`[fetchRepoRulesForBranch] unable to fetch repo rules for branch: ${branch} | ${path}`,
err
)
return new Array<IAPIRepoRule>()
}
}
/**
* Fetches slim versions of all repo rulesets for the given repository. Utilize the cache
* in IAppState instead of querying this if possible.
*/
public async fetchAllRepoRulesets(
owner: string,
name: string
): Promise<ReadonlyArray<IAPISlimRepoRuleset> | null> {
const path = `repos/${owner}/${name}/rulesets`
try {
const response = await this.request('GET', path)
return await parsedResponse<ReadonlyArray<IAPISlimRepoRuleset>>(response)
} catch (err) {
log.info(
`[fetchAllRepoRulesets] unable to fetch all repo rulesets | ${path}`,
err
)
return null
}
}
/**
* Fetches the repo ruleset with the given ID. Utilize the cache in IAppState
* instead of querying this if possible.
*/
public async fetchRepoRuleset(
owner: string,
name: string,
id: number
): Promise<IAPIRepoRuleset | null> {
const path = `repos/${owner}/${name}/rulesets/${id}`
try {
const response = await this.request('GET', path)
return await parsedResponse<IAPIRepoRuleset>(response)
} catch (err) {
log.info(
`[fetchRepoRuleset] unable to fetch repo ruleset for ID: ${id} | ${path}`,
err
)
return null
}
}
/**
* Authenticated requests to a paginating resource such as issues.
*

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,
@ -210,6 +212,9 @@ export interface IAppState {
/** Should the app prompt the user to confirm a discard stash */
readonly askForConfirmationOnDiscardStash: boolean
/** Should the app prompt the user to confirm a commit checkout? */
readonly askForConfirmationOnCheckoutCommit: boolean
/** Should the app prompt the user to confirm a force push? */
readonly askForConfirmationOnForcePush: boolean
@ -323,6 +328,12 @@ export interface IAppState {
readonly pullRequestSuggestedNextAction:
| PullRequestSuggestedNextAction
| undefined
/**
* Cached repo rulesets. Used to prevent repeatedly querying the same
* rulesets to check their bypass status.
*/
readonly cachedRepoRulesets: ReadonlyMap<number, IAPIRepoRuleset>
}
export enum FoldoutType {
@ -713,6 +724,11 @@ export interface IChangesState {
/** `true` if the GitHub API reports that the branch is protected */
readonly currentBranchProtected: boolean
/**
* Repo rules that apply to the current branch.
*/
readonly currentRepoRulesInfo: RepoRulesInfo
}
/**

View file

@ -1,5 +1,11 @@
export interface IFoundEditor<T> {
readonly editor: T
readonly path: string
/**
* Indicate to Desktop to launch the editor with the `shell: true` option included.
*
* This is available to all platforms, but is only currently used by some Windows
* editors as their launch programs end in `.cmd`
*/
readonly usesShell?: boolean
}

View file

@ -15,7 +15,7 @@ export async function launchExternalEditor(
const editorPath = editor.path
const exists = await pathExists(editorPath)
if (!exists) {
const label = __DARWIN__ ? 'Preferences' : 'Options'
const label = __DARWIN__ ? 'Settings' : 'Options'
throw new ExternalEditorError(
`Could not find executable for '${editor.editor}' at path '${editor.path}'. Please open ${label} and select an available editor.`,
{ openPreferences: true }

View file

@ -57,7 +57,7 @@ export async function findEditorOrDefault(
if (name) {
const match = editors.find(p => p.editor === name) || null
if (!match) {
const menuItemName = __DARWIN__ ? 'Preferences' : 'Options'
const menuItemName = __DARWIN__ ? 'Settings' : 'Options'
const message = `The editor '${name}' could not be found. Please open ${menuItemName} and choose an available editor.`
throw new ExternalEditorError(message, { openPreferences: true })

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,7 +485,7 @@ const editors: WindowsExternalEditor[] = [
registryKeys: registryKeysForJetBrainsIDE('DataSpell'),
executableShimPaths: executableShimPathsForJetBrainsIDE('dataspell'),
jetBrainsToolboxScriptName: 'dataspell',
displayNamePrefix: 'DataSpell ',
displayNamePrefixes: ['DataSpell '],
publishers: ['JetBrains s.r.o.'],
},
{
@ -521,7 +530,7 @@ async function findApplication(editor: WindowsExternalEditor) {
const { displayName, publisher, installLocation } = getAppInfo(editor, keys)
if (
!displayName.startsWith(editor.displayNamePrefix) ||
!validateStartsWith(displayName, editor.displayNamePrefixes) ||
!editor.publishers.includes(publisher)
) {
log.debug(`Unexpected registry entries for ${editor.name}`)

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

@ -14,7 +14,6 @@ export function fatalError(msg: string): never {
* in an exhaustive check.
*
* @param message The message to be used in the runtime exception.
*
*/
export function assertNever(x: never, message: string): never {
throw new Error(message)

View file

@ -73,6 +73,11 @@ export function enableResetToCommit(): boolean {
return enableDevelopmentFeatures()
}
/** Should we allow checking out a single commit? */
export function enableCheckoutCommit(): boolean {
return enableBetaFeatures()
}
/** Should ci check runs show logs? */
export function enableCICheckRunsLogs(): boolean {
return false
@ -87,3 +92,15 @@ export function enablePreviousTagSuggestions(): boolean {
export function enablePullRequestQuickView(): boolean {
return enableDevelopmentFeatures()
}
export function enableMoveStash(): boolean {
return enableBetaFeatures()
}
export const enableCustomGitUserAgent = enableBetaFeatures
export function enableSectionList(): boolean {
return enableBetaFeatures()
}
export const enableRepoRules = enableBetaFeatures

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

@ -15,20 +15,18 @@ import {
} from './environment'
import { WorkingDirectoryFileChange } from '../../models/status'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { CommitOneLine, shortenSHA } from '../../models/commit'
export type ProgressCallback = (progress: ICheckoutProgress) => void
async function getCheckoutArgs(
repository: Repository,
branch: Branch,
account: IGitAccount | null,
progressCallback?: ProgressCallback
) {
const baseArgs =
progressCallback != null
? [...gitNetworkArguments(), 'checkout', '--progress']
: [...gitNetworkArguments(), 'checkout']
function getCheckoutArgs(progressCallback?: ProgressCallback) {
return progressCallback != null
? [...gitNetworkArguments(), 'checkout', '--progress']
: [...gitNetworkArguments(), 'checkout']
}
async function getBranchCheckoutArgs(branch: Branch) {
const baseArgs: ReadonlyArray<string> = []
if (enableRecurseSubmodulesFlag()) {
return branch.type === BranchType.Remote
? baseArgs.concat(
@ -39,11 +37,62 @@ async function getCheckoutArgs(
'--'
)
: baseArgs.concat(branch.name, '--recurse-submodules', '--')
} else {
return branch.type === BranchType.Remote
? baseArgs.concat(branch.name, '-b', branch.nameWithoutRemote, '--')
: baseArgs.concat(branch.name, '--')
}
return branch.type === BranchType.Remote
? baseArgs.concat(branch.name, '-b', branch.nameWithoutRemote, '--')
: baseArgs.concat(branch.name, '--')
}
async function getCheckoutOpts(
repository: Repository,
account: IGitAccount | null,
title: string,
target: string,
progressCallback?: ProgressCallback,
initialDescription?: string
): Promise<IGitExecutionOptions> {
const opts: IGitExecutionOptions = {
env: await envForRemoteOperation(
account,
getFallbackUrlForProxyResolve(account, repository)
),
expectedErrors: AuthenticationErrors,
}
if (!progressCallback) {
return opts
}
const kind = 'checkout'
// Initial progress
progressCallback({
kind,
title,
description: initialDescription ?? title,
value: 0,
target,
})
return await executionOptionsWithProgress(
{ ...opts, trackLFSProgress: true },
new CheckoutProgressParser(),
progress => {
if (progress.kind === 'progress') {
const description = progress.details.text
const value = progress.percent
progressCallback({
kind,
title,
description,
value,
target,
})
}
}
)
}
/**
@ -66,44 +115,59 @@ export async function checkoutBranch(
branch: Branch,
progressCallback?: ProgressCallback
): Promise<true> {
let opts: IGitExecutionOptions = {
env: await envForRemoteOperation(
account,
getFallbackUrlForProxyResolve(account, repository)
),
expectedErrors: AuthenticationErrors,
}
if (progressCallback) {
const title = `Checking out branch ${branch.name}`
const kind = 'checkout'
const targetBranch = branch.name
opts = await executionOptionsWithProgress(
{ ...opts, trackLFSProgress: true },
new CheckoutProgressParser(),
progress => {
if (progress.kind === 'progress') {
const description = progress.details.text
const value = progress.percent
progressCallback({ kind, title, description, value, targetBranch })
}
}
)
// Initial progress
progressCallback({ kind, title, value: 0, targetBranch })
}
const args = await getCheckoutArgs(
const opts = await getCheckoutOpts(
repository,
branch,
account,
`Checking out branch ${branch.name}`,
branch.name,
progressCallback,
`Switching to ${__DARWIN__ ? 'Branch' : 'branch'}`
)
const baseArgs = getCheckoutArgs(progressCallback)
const args = [...baseArgs, ...(await getBranchCheckoutArgs(branch))]
await git(args, repository.path, 'checkoutBranch', opts)
// we return `true` here so `GitStore.performFailableGitOperation`
// will return _something_ differentiable from `undefined` if this succeeds
return true
}
/**
* Check out the given commit.
* Literally invokes `git checkout <commit SHA>`.
*
* @param repository - The repository in which the branch checkout should
* take place
*
* @param commit - The commit that should be checked out
*
* @param progressCallback - An optional function which will be invoked
* with information about the current progress
* of the checkout operation. When provided this
* enables the '--progress' command line flag for
* 'git checkout'.
*/
export async function checkoutCommit(
repository: Repository,
account: IGitAccount | null,
commit: CommitOneLine,
progressCallback?: ProgressCallback
): Promise<true> {
const title = `Checking out ${__DARWIN__ ? 'Commit' : 'commit'}`
const opts = await getCheckoutOpts(
repository,
account,
title,
shortenSHA(commit.sha),
progressCallback
)
await git(args, repository.path, 'checkoutBranch', opts)
const baseArgs = getCheckoutArgs(progressCallback)
const args = [...baseArgs, commit.sha]
await git(args, repository.path, 'checkoutCommit', opts)
// we return `true` here so `GitStore.performFailableGitOperation`
// will return _something_ differentiable from `undefined` if this succeeds

View file

@ -23,7 +23,6 @@ import { envForRemoteOperation } from './environment'
* of the clone operation. When provided this enables
* the '--progress' command line flag for
* 'git clone'.
*
*/
export async function clone(
url: string,

View file

@ -309,7 +309,7 @@ export function parseConfigLockFilePathFromError(result: IGitResult) {
function getDescriptionForError(error: DugiteError): string | null {
if (isAuthFailureError(error)) {
const menuHint = __DARWIN__
? 'GitHub Desktop > Preferences.'
? 'GitHub Desktop > Settings.'
: 'File > Options.'
return `Authentication failed. Some common reasons include:

View file

@ -12,7 +12,6 @@
* Example:
*
* `const { args, parse } = createLogParser({ sha: '%H' })`
*
*/
export function createLogParser<T extends Record<string, string>>(fields: T) {
const keys: Array<keyof T> = Object.keys(fields)
@ -49,7 +48,6 @@ export function createLogParser<T extends Record<string, string>>(fields: T) {
* Example:
*
* `const { args, parse } = createForEachRefParser({ sha: '%(objectname)' })`
*
*/
export function createForEachRefParser<T extends Record<string, string>>(
fields: T

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

@ -18,7 +18,6 @@ import {
* @param repository - The repository to update
*
* @param commit - The SHA of the commit to be reverted
*
*/
export async function revertCommit(
repository: Repository,

View file

@ -46,6 +46,8 @@ export async function getStashes(repository: Repository): Promise<StashResult> {
name: '%gD',
stashSha: '%H',
message: '%gs',
tree: '%T',
parents: '%P',
})
const result = await git(
@ -66,17 +68,52 @@ export async function getStashes(repository: Repository): Promise<StashResult> {
const entries = parse(result.stdout)
for (const { name, message, stashSha } of entries) {
for (const { name, message, stashSha, tree, parents } of entries) {
const branchName = extractBranchFromMessage(message)
if (branchName !== null) {
desktopEntries.push({ name, stashSha, branchName, files })
desktopEntries.push({
name,
stashSha,
branchName,
tree,
parents: parents.length > 0 ? parents.split(' ') : [],
files,
})
}
}
return { desktopEntries, stashEntryCount: entries.length - 1 }
}
/**
* Moves a stash entry to a different branch by means of creating
* a new stash entry associated with the new branch and dropping the old
* stash entry.
*/
export async function moveStashEntry(
repository: Repository,
{ stashSha, parents, tree }: IStashEntry,
branchName: string
) {
const message = `On ${branchName}: ${createDesktopStashMessage(branchName)}`
const parentArgs = parents.flatMap(p => ['-p', p])
const { stdout: commitId } = await git(
['commit-tree', ...parentArgs, '-m', message, '--no-gpg-sign', tree],
repository.path,
'moveStashEntryToBranch'
)
await git(
['stash', 'store', '-m', message, commitId.trim()],
repository.path,
'moveStashEntryToBranch'
)
await dropDesktopStashEntry(repository, stashSha)
}
/**
* Returns the last Desktop created stash entry for the given branch
*/

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,163 @@
import { RE2, RE2JS } from 're2js'
import {
RepoRulesInfo,
IRepoRulesMetadataRule,
RepoRulesMetadataMatcher,
RepoRuleEnforced,
} from '../../models/repo-rules'
import {
APIRepoRuleMetadataOperator,
APIRepoRuleType,
IAPIRepoRule,
IAPIRepoRuleMetadataParameters,
IAPIRepoRuleset,
} from '../api'
/**
* Parses the GitHub API response for a branch's repo rules into a more useable
* format.
*/
export function parseRepoRules(
rules: ReadonlyArray<IAPIRepoRule>,
rulesets: ReadonlyMap<number, IAPIRepoRuleset>
): RepoRulesInfo {
const info = new RepoRulesInfo()
for (const rule of rules) {
// if a ruleset is null/undefined, then act as if the rule doesn't exist because
// we don't know what will happen when they push
const ruleset = rulesets.get(rule.ruleset_id)
if (ruleset == null) {
continue
}
// a rule may be configured multiple times, and the strictest value always applies.
// since the rule will not exist in the API response if it's not enforced, we know
// we're always assigning either 'bypass' or true below. therefore, we only need
// to check if the existing value is true, otherwise it can always be overridden.
const enforced =
ruleset.current_user_can_bypass === 'always' ? 'bypass' : true
switch (rule.type) {
case APIRepoRuleType.Update:
case APIRepoRuleType.RequiredDeployments:
case APIRepoRuleType.RequiredSignatures:
case APIRepoRuleType.RequiredStatusChecks:
info.basicCommitWarning =
info.basicCommitWarning !== true ? enforced : true
break
case APIRepoRuleType.Creation:
info.creationRestricted =
info.creationRestricted !== true ? enforced : true
break
case APIRepoRuleType.PullRequest:
info.pullRequestRequired =
info.pullRequestRequired !== true ? enforced : true
break
case APIRepoRuleType.CommitMessagePattern:
info.commitMessagePatterns.push(toMetadataRule(rule, enforced))
break
case APIRepoRuleType.CommitAuthorEmailPattern:
info.commitAuthorEmailPatterns.push(toMetadataRule(rule, enforced))
break
case APIRepoRuleType.CommitterEmailPattern:
info.committerEmailPatterns.push(toMetadataRule(rule, enforced))
break
case APIRepoRuleType.BranchNamePattern:
info.branchNamePatterns.push(toMetadataRule(rule, enforced))
break
}
}
return info
}
function toMetadataRule(
rule: IAPIRepoRule | undefined,
enforced: RepoRuleEnforced
): IRepoRulesMetadataRule | undefined {
if (!rule?.parameters) {
return undefined
}
return {
enforced,
matcher: toMatcher(rule.parameters),
humanDescription: toHumanDescription(rule.parameters),
rulesetId: rule.ruleset_id,
}
}
function toHumanDescription(apiParams: IAPIRepoRuleMetadataParameters): string {
let description = 'must '
if (apiParams.negate) {
description += 'not '
}
if (apiParams.operator === APIRepoRuleMetadataOperator.RegexMatch) {
return description + `match the regular expression "${apiParams.pattern}"`
}
switch (apiParams.operator) {
case APIRepoRuleMetadataOperator.StartsWith:
description += 'start with '
break
case APIRepoRuleMetadataOperator.EndsWith:
description += 'end with '
break
case APIRepoRuleMetadataOperator.Contains:
description += 'contain '
break
}
return description + `"${apiParams.pattern}"`
}
/**
* Converts the given metadata rule into a matcher function that uses regex to test the rule.
*/
function toMatcher(
rule: IAPIRepoRuleMetadataParameters | undefined
): RepoRulesMetadataMatcher {
if (!rule) {
return () => false
}
let regex: RE2
switch (rule.operator) {
case APIRepoRuleMetadataOperator.StartsWith:
regex = RE2JS.compile(`^${RE2JS.quote(rule.pattern)}`)
break
case APIRepoRuleMetadataOperator.EndsWith:
regex = RE2JS.compile(`${RE2JS.quote(rule.pattern)}$`)
break
case APIRepoRuleMetadataOperator.Contains:
regex = RE2JS.compile(`.*${RE2JS.quote(rule.pattern)}.*`)
break
case APIRepoRuleMetadataOperator.RegexMatch:
regex = RE2JS.compile(rule.pattern)
break
}
if (regex) {
if (rule.negate) {
return (toMatch: string) => !regex.matcher(toMatch).find()
} else {
return (toMatch: string) => regex.matcher(toMatch).find()
}
} else {
return () => false
}
}

View file

@ -33,6 +33,10 @@ const remoteRegexes: ReadonlyArray<{ protocol: GitProtocol; regex: RegExp }> = [
protocol: 'ssh',
regex: new RegExp('^git@(.+):([^/]+)/([^/]+?)(?:/|.git)?$'),
},
{
protocol: 'ssh',
regex: new RegExp('^(?:.+)@(.+.ghe.com):([^/]+)/([^/]+?)(?:/|.git)?$'),
},
{
protocol: 'ssh',
regex: new RegExp('^git:(.+)/([^/]+)/([^/]+?)(?:/|.git)?$'),

View file

@ -83,7 +83,7 @@ export async function launchShell(
// platform-specific build targets.
const exists = await pathExists(shell.path)
if (!exists) {
const label = __DARWIN__ ? 'Preferences' : 'Options'
const label = __DARWIN__ ? 'Settings' : 'Options'
throw new ShellError(
`Could not find executable for '${shell.shell}' at path '${shell.path}'. Please open ${label} and select an available shell.`
)

View file

@ -14,7 +14,6 @@ const squirrelTimeoutRegex =
* friendlier message to the user.
*
* @param error The underlying error from Squirrel.
*
*/
export function parseError(error: Error): Error | null {
if (squirrelMissingRegex.test(error.message)) {

View file

@ -17,7 +17,12 @@ import { Branch, BranchType, IAheadBehind } from '../../models/branch'
import { BranchesTab } from '../../models/branches-tab'
import { CloneRepositoryTab } from '../../models/clone-repository-tab'
import { CloningRepository } from '../../models/cloning-repository'
import { Commit, ICommitContext, CommitOneLine } from '../../models/commit'
import {
Commit,
ICommitContext,
CommitOneLine,
shortenSHA,
} from '../../models/commit'
import {
DiffSelection,
DiffSelectionType,
@ -95,6 +100,7 @@ import {
getEndpointForRepository,
IAPIFullRepository,
IAPIComment,
IAPIRepoRuleset,
} from '../api'
import { shell } from '../app-shell'
import {
@ -175,6 +181,7 @@ import {
updateRemoteHEAD,
getBranchMergeBaseChangedFiles,
getBranchMergeBaseDiff,
checkoutCommit,
} from '../git'
import {
installGlobalLFSFilters,
@ -231,6 +238,7 @@ import {
} from './updates/changes-state'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { BranchPruner } from './helpers/branch-pruner'
import { enableMoveStash, enableRepoRules } from '../feature-flag'
import { Banner, BannerType } from '../../models/banner'
import { ComputedAction } from '../../models/computed-action'
import {
@ -238,6 +246,7 @@ import {
getLastDesktopStashEntryForBranch,
popStashEntry,
dropDesktopStashEntry,
moveStashEntry,
} from '../git/stash'
import {
UncommittedChangesStrategy,
@ -315,6 +324,10 @@ import { ValidNotificationPullRequestReview } from '../valid-notification-pull-r
import { determineMergeability } from '../git/merge-tree'
import { PopupManager } from '../popup-manager'
import { resizableComponentClass } from '../../ui/resizable'
import { compare } from '../compare'
import { parseRepoRules } from '../helpers/repo-rules'
import { RepoRulesInfo } from '../../models/repo-rules'
import { supportsRepoRules } from '../endpoint-capabilities'
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
@ -342,12 +355,14 @@ const confirmRepoRemovalDefault: boolean = true
const confirmDiscardChangesDefault: boolean = true
const confirmDiscardChangesPermanentlyDefault: boolean = true
const confirmDiscardStashDefault: boolean = true
const confirmCheckoutCommitDefault: boolean = true
const askForConfirmationOnForcePushDefault = true
const confirmUndoCommitDefault: boolean = true
const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder'
const confirmRepoRemovalKey: string = 'confirmRepoRemoval'
const confirmDiscardChangesKey: string = 'confirmDiscardChanges'
const confirmDiscardStashKey: string = 'confirmDiscardStash'
const confirmCheckoutCommitKey: string = 'confirmCheckoutCommit'
const confirmDiscardChangesPermanentlyKey: string =
'confirmDiscardChangesPermanentlyKey'
const confirmForcePushKey: string = 'confirmForcePush'
@ -459,6 +474,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private confirmDiscardChangesPermanently: boolean =
confirmDiscardChangesPermanentlyDefault
private confirmDiscardStash: boolean = confirmDiscardStashDefault
private confirmCheckoutCommit: boolean = confirmCheckoutCommitDefault
private askForConfirmationOnForcePush = askForConfirmationOnForcePushDefault
private confirmUndoCommit: boolean = confirmUndoCommitDefault
private imageDiffType: ImageDiffType = imageDiffTypeDefault
@ -518,6 +534,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
| PullRequestSuggestedNextAction
| undefined = undefined
private cachedRepoRulesets = new Map<number, IAPIRepoRuleset>()
public constructor(
private readonly gitHubUserStore: GitHubUserStore,
private readonly cloningRepositoriesStore: CloningRepositoriesStore,
@ -958,6 +976,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
askForConfirmationOnDiscardChangesPermanently:
this.confirmDiscardChangesPermanently,
askForConfirmationOnDiscardStash: this.confirmDiscardStash,
askForConfirmationOnCheckoutCommit: this.confirmCheckoutCommit,
askForConfirmationOnForcePush: this.askForConfirmationOnForcePush,
askForConfirmationOnUndoCommit: this.confirmUndoCommit,
uncommittedChangesStrategy: this.uncommittedChangesStrategy,
@ -986,6 +1005,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
notificationsEnabled: getNotificationsEnabled(),
pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction,
resizablePaneActive: this.resizablePaneActive,
cachedRepoRulesets: this.cachedRepoRulesets,
}
}
@ -1103,6 +1123,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private clearBranchProtectionState(repository: Repository) {
this.repositoryStateCache.updateChangesState(repository, () => ({
currentBranchProtected: false,
currentRepoRulesInfo: new RepoRulesInfo(),
}))
this.emitUpdate()
}
@ -1134,6 +1155,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
if (!hasWritePermission(gitHubRepo)) {
this.repositoryStateCache.updateChangesState(repository, () => ({
currentBranchProtected: false,
currentRepoRulesInfo: new RepoRulesInfo(),
}))
this.emitUpdate()
return
@ -1146,13 +1168,60 @@ export class AppStore extends TypedBaseStore<IAppState> {
const pushControl = await api.fetchPushControl(owner, name, branchName)
const currentBranchProtected = !isBranchPushable(pushControl)
let currentRepoRulesInfo = new RepoRulesInfo()
if (enableRepoRules() && supportsRepoRules(gitHubRepo.endpoint)) {
const slimRulesets = await api.fetchAllRepoRulesets(owner, name)
// ultimate goal here is to fetch all rulesets that apply to the repo
// so they're already cached when needed later on
if (slimRulesets?.length) {
const rulesetIds = slimRulesets.map(r => r.id)
const calls: Promise<IAPIRepoRuleset | null>[] = []
for (const id of rulesetIds) {
// check the cache and don't re-query any that are already in there
if (!this.cachedRepoRulesets.has(id)) {
calls.push(api.fetchRepoRuleset(owner, name, id))
}
}
if (calls.length > 0) {
const rulesets = await Promise.all(calls)
this._updateCachedRepoRulesets(rulesets)
}
}
const branchRules = await api.fetchRepoRulesForBranch(
owner,
name,
branchName
)
if (branchRules.length > 0) {
currentRepoRulesInfo = parseRepoRules(
branchRules,
this.cachedRepoRulesets
)
}
}
this.repositoryStateCache.updateChangesState(repository, () => ({
currentBranchProtected,
currentRepoRulesInfo,
}))
this.emitUpdate()
}
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _updateCachedRepoRulesets(rulesets: Array<IAPIRepoRuleset | null>) {
for (const rs of rulesets) {
if (rs !== null) {
this.cachedRepoRulesets.set(rs.id, rs)
}
}
}
private clearSelectedCommit(repository: Repository) {
this.repositoryStateCache.updateCommitSelection(repository, () => ({
shas: [],
@ -1251,32 +1320,28 @@ export class AppStore extends TypedBaseStore<IAppState> {
isContiguous: boolean,
commitLookup: Map<string, Commit>
) {
const shasInDiff = new Array<string>()
if (selectedShas.length <= 1 || !isContiguous) {
return selectedShas
}
const shasInDiff = new Set<string>()
const selected = new Set(selectedShas)
const shasToTraverse = [selectedShas.at(-1)]
do {
const currentSha = shasToTraverse.pop()
if (currentSha === undefined) {
continue
let sha
while ((sha = shasToTraverse.pop()) !== undefined) {
if (!shasInDiff.has(sha)) {
shasInDiff.add(sha)
commitLookup.get(sha)?.parentSHAs?.forEach(parentSha => {
if (selected.has(parentSha) && !shasInDiff.has(parentSha)) {
shasToTraverse.push(parentSha)
}
})
}
}
shasInDiff.push(currentSha)
// shas are selection of history -> should be in lookup -> `|| []` is for typing sake
const parentSHAs = commitLookup.get(currentSha)?.parentSHAs || []
const parentsInSelection = parentSHAs.filter(parentSha =>
selectedShas.includes(parentSha)
)
shasToTraverse.push(...parentsInSelection)
} while (shasToTraverse.length > 0)
return shasInDiff
return Array.from(shasInDiff)
}
private updateOrSelectFirstCommit(
@ -1452,7 +1517,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
aheadBehind,
}
this.repositoryStateCache.updateCompareState(repository, s => ({
this.repositoryStateCache.updateCompareState(repository, () => ({
formState: newState,
filterText: comparisonBranch.name,
commitSHAs,
@ -2042,6 +2107,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
confirmDiscardStashDefault
)
this.confirmCheckoutCommit = getBoolean(
confirmCheckoutCommitKey,
confirmCheckoutCommitDefault
)
this.askForConfirmationOnForcePush = getBoolean(
confirmForcePushKey,
askForConfirmationOnForcePushDefault
@ -3725,7 +3795,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
return this.checkoutImplementation(repository, branch, account, strategy)
.then(() => this.onSuccessfulCheckout(repository, branch))
.catch(e => this.emitError(new CheckoutError(e, repository, branch)))
.then(() => this.refreshAfterCheckout(repository, branch))
.then(() => this.refreshAfterCheckout(repository, branch.name))
.finally(() => this.updateCheckoutProgress(repository, null))
})
}
@ -3843,18 +3913,69 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.hasUserViewedStash = false
}
private async refreshAfterCheckout(repository: Repository, branch: Branch) {
/**
* @param commitish A branch name or a commit hash
*/
private async refreshAfterCheckout(
repository: Repository,
commitish: string
) {
this.updateCheckoutProgress(repository, {
kind: 'checkout',
title: `Refreshing ${__DARWIN__ ? 'Repository' : 'repository'}`,
description: 'Checking out',
value: 1,
targetBranch: branch.name,
target: commitish,
})
await this._refreshRepository(repository)
return repository
}
/**
* Checkout the given commit, ignoring any local changes.
*
* Note: This shouldn't be called directly. See `Dispatcher`.
*/
public async _checkoutCommit(
repository: Repository,
commit: CommitOneLine
): Promise<Repository> {
const repositoryState = this.repositoryStateCache.get(repository)
const { branchesState } = repositoryState
const { tip } = branchesState
// No point in checking out the currently checked out commit.
if (
(tip.kind === TipState.Valid && tip.branch.tip.sha === commit.sha) ||
(tip.kind === TipState.Detached && tip.currentSha === commit.sha)
) {
return repository
}
return this.withAuthenticatingUser(repository, (repository, account) => {
// We always want to end with refreshing the repository regardless of
// whether the checkout succeeded or not in order to present the most
// up-to-date information to the user.
return this.checkoutCommitDefaultBehaviour(repository, commit, account)
.catch(e => this.emitError(new Error(e)))
.then(() =>
this.refreshAfterCheckout(repository, shortenSHA(commit.sha))
)
.finally(() => this.updateCheckoutProgress(repository, null))
})
}
private async checkoutCommitDefaultBehaviour(
repository: Repository,
commit: CommitOneLine,
account: IGitAccount | null
) {
await checkoutCommit(repository, account, commit, progress => {
this.updateCheckoutProgress(repository, progress)
})
}
/**
* Creates a stash associated to the current checked out branch.
*
@ -4008,9 +4129,17 @@ export class AppStore extends TypedBaseStore<IAppState> {
newName: string
): Promise<void> {
const gitStore = this.gitStoreCache.get(repository)
await gitStore.performFailableOperation(() =>
renameBranch(repository, branch, newName)
)
await gitStore.performFailableOperation(async () => {
await renameBranch(repository, branch, newName)
if (enableMoveStash()) {
const stashEntry = gitStore.desktopStashEntries.get(branch.name)
if (stashEntry) {
await moveStashEntry(repository, stashEntry, newName)
}
}
})
return this._refreshRepository(repository)
}
@ -5322,6 +5451,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
return Promise.resolve()
}
public _setConfirmCheckoutCommitSetting(value: boolean): Promise<void> {
this.confirmCheckoutCommit = value
setBoolean(confirmCheckoutCommitKey, value)
this.emitUpdate()
return Promise.resolve()
}
public _setConfirmForcePushSetting(value: boolean): Promise<void> {
this.askForConfirmationOnForcePush = value
setBoolean(confirmForcePushKey, value)
@ -6443,10 +6581,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** This shouldn't be called directly. See `Dispatcher`. */
public async _popStashEntry(repository: Repository, stashEntry: IStashEntry) {
const gitStore = this.gitStoreCache.get(repository)
await gitStore.performFailableOperation(() => {
return popStashEntry(repository, stashEntry.stashSha)
})
await popStashEntry(repository, stashEntry.stashSha)
log.info(
`[AppStore. _popStashEntry] popped stash with commit id ${stashEntry.stashSha}`
)
@ -6648,9 +6783,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
) {
const { compareState } = this.repositoryStateCache.get(repository)
const { commitSHAs } = compareState
const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i]))
return [...commits].sort(
(a, b) => commitSHAs.indexOf(b.sha) - commitSHAs.indexOf(a.sha)
return [...commits].sort((a, b) =>
compare(commitIndexBySha.get(b.sha), commitIndexBySha.get(a.sha))
)
}
@ -6667,9 +6803,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
) {
const { compareState } = this.repositoryStateCache.get(repository)
const { commitSHAs } = compareState
const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i]))
return [...commits].sort(
(a, b) => commitSHAs.indexOf(b) - commitSHAs.indexOf(a)
return [...commits].sort((a, b) =>
compare(commitIndexBySha.get(b), commitIndexBySha.get(a))
)
}

View file

@ -1051,7 +1051,6 @@ export class GitStore extends BaseStore {
* @param refspec - The association between a remote and local ref to use as
* part of this action. Refer to git-scm for more
* information on refspecs: https://www.git-scm.com/book/tr/v2/Git-Internals-The-Refspec
*
*/
public async fetchRefspec(
account: IGitAccount | null,
@ -1171,6 +1170,10 @@ export class GitStore extends BaseStore {
: null
}
public get desktopStashEntries(): ReadonlyMap<string, IStashEntry> {
return this._desktopStashEntries
}
/** The total number of stash entries */
public get stashEntryCount(): number {
return this._stashEntryCount

View file

@ -80,7 +80,6 @@ export class PullRequestCoordinator {
* the `Repository`)
* * the parent GitHub repo, if the `Repository` has one (the
* `upstream` remote for the `Repository`)
*
*/
public onPullRequestsChanged(
fn: (
@ -119,7 +118,6 @@ export class PullRequestCoordinator {
* the `Repository`)
* * the parent GitHub repo, if the `Repository` has one (the
* `upstream` remote for the `Repository`)
*
*/
public onIsLoadingPullRequests(
fn: (

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

@ -8,6 +8,27 @@ import {
removePendingSSHSecretToStore,
storePendingSSHSecret,
} from '../ssh/ssh-secret-storage'
import { GitProcess } from 'dugite'
import memoizeOne from 'memoize-one'
import { enableCustomGitUserAgent } from '../feature-flag'
export const GitUserAgent = memoizeOne(() =>
// Can't use git() as that will call withTrampolineEnv which calls this method
GitProcess.exec(['--version'], process.cwd())
// https://github.com/git/git/blob/a9e066fa63149291a55f383cfa113d8bdbdaa6b3/help.c#L733-L739
.then(r => /git version (.*)/.exec(r.stdout)?.at(1))
.catch(e => {
log.warn(`Could not get git version information`, e)
return 'unknown'
})
.then(v => {
const suffix = __DEV__ ? `-${__SHA__.substring(0, 10)}` : ''
const ghdVersion = `GitHub Desktop/${__APP_VERSION__}${suffix}`
const { platform, arch } = process
return `git/${v} (${ghdVersion}; ${platform} ${arch})`
})
)
/**
* Allows invoking a function with a set of environment variables to use when
@ -42,6 +63,9 @@ export async function withTrampolineEnv<T>(
DESKTOP_TRAMPOLINE_TOKEN: token,
GIT_ASKPASS: getDesktopTrampolinePath(),
DESKTOP_TRAMPOLINE_IDENTIFIER: TrampolineCommandIdentifier.AskPass,
...(enableCustomGitUserAgent()
? { GIT_USER_AGENT: await GitUserAgent() }
: {}),
...sshEnv,
})

View file

@ -69,7 +69,7 @@ export function buildDefaultMenu({
},
separator,
{
label: 'Preferences…',
label: 'Settings…',
id: 'preferences',
accelerator: 'CmdOrCtrl+,',
click: emit('show-preferences'),

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

@ -1,3 +1,4 @@
import { RowIndexPath } from '../ui/lib/list/list-row-index-path'
import { Commit } from './commit'
import { GitHubRepository } from './github-repository'
@ -51,7 +52,7 @@ export type CommitTarget = {
export type ListInsertionPointTarget = {
type: DropTargetType.ListInsertionPoint
data: DragData
index: number
index: RowIndexPath
}
/**

View file

@ -60,6 +60,7 @@ export enum PopupType {
StashAndSwitchBranch = 'StashAndSwitchBranch',
ConfirmOverwriteStash = 'ConfirmOverwriteStash',
ConfirmDiscardStash = 'ConfirmDiscardStash',
ConfirmCheckoutCommit = 'ConfirmCheckoutCommit',
CreateTutorialRepository = 'CreateTutorialRepository',
ConfirmExitTutorial = 'ConfirmExitTutorial',
PushRejectedDueToMissingWorkflowScope = 'PushRejectedDueToMissingWorkflowScope',
@ -93,6 +94,7 @@ export enum PopupType {
TestNotifications = 'TestNotifications',
PullRequestComment = 'PullRequestComment',
UnknownAuthors = 'UnknownAuthors',
ConfirmRepoRulesBypass = 'ConfirmRepoRulesBypass',
}
interface IBasePopup {
@ -234,6 +236,11 @@ export type PopupDetail =
repository: Repository
stash: IStashEntry
}
| {
type: PopupType.ConfirmCheckoutCommit
repository: Repository
commit: CommitOneLine
}
| {
type: PopupType.CreateTutorialRepository
account: Account
@ -409,5 +416,11 @@ export type PopupDetail =
authors: ReadonlyArray<UnknownAuthor>
onCommit: () => void
}
| {
type: PopupType.ConfirmRepoRulesBypass
repository: GitHubRepository
branch: string
onConfirm: () => void
}
export type Popup = IBasePopup & PopupDetail

View file

@ -42,8 +42,13 @@ export interface IGenericProgress extends IProgress {
export interface ICheckoutProgress extends IProgress {
kind: 'checkout'
/** The branch that's currently being checked out */
readonly targetBranch: string
/** The branch or commit that's currently being checked out */
readonly target: string
/**
* Infotext for the user.
*/
readonly description: string
}
/**

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

@ -12,6 +12,9 @@ export interface IStashEntry {
/** The list of files this stash touches */
readonly files: StashedFileChanges
readonly tree: string
readonly parents: ReadonlyArray<string>
}
/** Whether file changes for a stash entry are loaded or not */

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
@ -45,15 +53,15 @@ export class AriaLiveContainer extends Component<
IAriaLiveContainerState
> {
private suffix: string = ''
private onTrackedInputChanged = debounce((message: JSX.Element | null) => {
this.setState({ message })
private onTrackedInputChanged = debounce(() => {
this.setState({ message: this.buildMessage() })
}, 1000)
public constructor(props: IAriaLiveContainerProps) {
super(props)
this.state = {
message: null,
message: this.props.message !== null ? this.buildMessage() : null,
}
}
@ -62,7 +70,7 @@ export class AriaLiveContainer extends Component<
return
}
this.onTrackedInputChanged(this.buildMessage())
this.onTrackedInputChanged()
}
public componentWillUnmount() {
@ -70,16 +78,33 @@ export class AriaLiveContainer extends Component<
}
private buildMessage() {
this.suffix = this.suffix === '' ? '\u00A0' : ''
// We need to toggle from two non-breaking spaces to one non-breaking space
// because VoiceOver does not detect the empty string as a change.
this.suffix = this.suffix === '\u00A0\u00A0' ? '\u00A0' : '\u00A0\u00A0'
return (
<>
{this.props.children}
{this.props.message}
{this.suffix}
</>
)
}
private renderMessage() {
// We are just using this as a typical aria-live container where the message
// changes per usage - no need to force re-reading of the same message.
if (this.props.trackedUserInput === undefined) {
return this.props.message
}
// We are using this as a container to force re-reading of the same message,
// so we are re-building message based on user input changes.
// If we get a null for the children, go ahead an empty out the
// message so we don't get an erroneous reading of a message after it is
// gone.
return this.props.message !== null ? this.state.message : ''
}
public render() {
return (
<div
@ -88,7 +113,7 @@ export class AriaLiveContainer extends Component<
aria-live="polite"
aria-atomic="true"
>
{this.state.message}
{this.renderMessage()}
</div>
)
}

View file

@ -6,8 +6,6 @@ import { Button } from '../lib/button'
import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { LinkButton } from '../lib/link-button'
import { PopupType } from '../../models/popup'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
@ -16,6 +14,8 @@ import { FoldoutType } from '../../lib/app-state'
import untildify from 'untildify'
import { showOpenDialog } from '../main-process-proxy'
import { Ref } from '../lib/ref'
import { InputError } from '../lib/input-description/input-error'
import { IAccessibleMessage } from '../../models/accessible-message'
interface IAddExistingRepositoryProps {
readonly dispatcher: Dispatcher
@ -130,71 +130,110 @@ export class AddExistingRepository extends React.Component<
)
}
private renderWarning() {
private buildBareRepositoryError() {
if (
!this.state.path.length ||
!this.state.showNonGitRepositoryWarning ||
!this.state.isRepositoryBare
) {
return null
}
const msg =
'This directory appears to be a bare repository. Bare repositories are not currently supported.'
return { screenReaderMessage: msg, displayedMessage: msg }
}
private buildRepositoryUnsafeError() {
const { repositoryUnsafePath, path } = this.state
if (
!this.state.path.length ||
!this.state.showNonGitRepositoryWarning ||
!this.state.isRepositoryUnsafe ||
repositoryUnsafePath === undefined
) {
return null
}
// Git for Windows will replace backslashes with slashes in the error
// message so we'll do the same to not show "the repo at path c:/repo"
// when the entered path is `c:\repo`.
const convertedPath = __WIN32__ ? path.replaceAll('\\', '/') : path
const displayedMessage = (
<>
<p>
The Git repository
{repositoryUnsafePath !== convertedPath && (
<>
{' at '}
<Ref>{repositoryUnsafePath}</Ref>
</>
)}{' '}
appears to be owned by another user on your machine. Adding untrusted
repositories may automatically execute files in the repository.
</p>
<p>
If you trust the owner of the directory you can
<LinkButton onClick={this.onTrustDirectory}>
{' '}
add an exception for this directory
</LinkButton>{' '}
in order to continue.
</p>
</>
)
const screenReaderMessage = `The Git repository appears to be owned by another user on your machine.
Adding untrusted repositories may automatically execute files in the repository.
If you trust the owner of the directory you can add an exception for this directory in order to continue.`
return { screenReaderMessage, displayedMessage }
}
private buildNotAGitRepositoryError(): IAccessibleMessage | null {
if (!this.state.path.length || !this.state.showNonGitRepositoryWarning) {
return null
}
if (this.state.isRepositoryBare) {
return (
<Row className="warning-helper-text">
<Octicon symbol={OcticonSymbol.alert} />
<p>
This directory appears to be a bare repository. Bare repositories
are not currently supported.
</p>
</Row>
)
}
const displayedMessage = (
<>
This directory does not appear to be a Git repository.
<br />
Would you like to{' '}
<LinkButton onClick={this.onCreateRepositoryClicked}>
create a repository
</LinkButton>{' '}
here instead?
</>
)
const { isRepositoryUnsafe, repositoryUnsafePath, path } = this.state
const screenReaderMessage =
'This directory does not appear to be a Git repository. Would you like to create a repository here instead?'
if (isRepositoryUnsafe && repositoryUnsafePath !== undefined) {
// Git for Windows will replace backslashes with slashes in the error
// message so we'll do the same to not show "the repo at path c:/repo"
// when the entered path is `c:\repo`.
const convertedPath = __WIN32__ ? path.replaceAll('\\', '/') : path
return { screenReaderMessage, displayedMessage }
}
return (
<Row className="warning-helper-text">
<Octicon symbol={OcticonSymbol.alert} />
<div>
<p>
The Git repository
{repositoryUnsafePath !== convertedPath && (
<>
{' at '}
<Ref>{repositoryUnsafePath}</Ref>
</>
)}{' '}
appears to be owned by another user on your machine. Adding
untrusted repositories may automatically execute files in the
repository.
</p>
<p>
If you trust the owner of the directory you can
<LinkButton onClick={this.onTrustDirectory}>
add an exception for this directory
</LinkButton>{' '}
in order to continue.
</p>
</div>
</Row>
)
private renderErrors() {
const msg: IAccessibleMessage | null =
this.buildBareRepositoryError() ??
this.buildRepositoryUnsafeError() ??
this.buildNotAGitRepositoryError()
if (msg === null) {
return null
}
return (
<Row className="warning-helper-text">
<Octicon symbol={OcticonSymbol.alert} />
<p>
This directory does not appear to be a Git repository.
<br />
Would you like to{' '}
<LinkButton onClick={this.onCreateRepositoryClicked}>
create a repository
</LinkButton>{' '}
here instead?
</p>
<Row>
<InputError
id="add-existing-repository-path-error"
trackedUserInput={this.state.path}
ariaLiveMessage={msg.screenReaderMessage}
>
{msg.displayedMessage}
</InputError>
</Row>
)
}
@ -220,10 +259,11 @@ export class AddExistingRepository extends React.Component<
label={__DARWIN__ ? 'Local Path' : 'Local path'}
placeholder="repository path"
onValueChanged={this.onPathChanged}
ariaDescribedBy="add-existing-repository-path-error"
/>
<Button onClick={this.showFilePicker}>Choose</Button>
</Row>
{this.renderWarning()}
{this.renderErrors()}
</DialogContent>
<DialogFooter>

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

@ -100,6 +100,7 @@ import { Banner, BannerType } from '../models/banner'
import { StashAndSwitchBranch } from './stash-changes/stash-and-switch-branch-dialog'
import { OverwriteStash } from './stash-changes/overwrite-stashed-changes-dialog'
import { ConfirmDiscardStashDialog } from './stashing/confirm-discard-stash'
import { ConfirmCheckoutCommitDialog } from './checkout/confirm-checkout-commit'
import { CreateTutorialRepositoryDialog } from './no-repositories/create-tutorial-repository-dialog'
import { ConfirmExitTutorial } from './tutorial'
import { TutorialStep, isValidTutorialStep } from '../models/tutorial-step'
@ -171,6 +172,7 @@ import { UnknownAuthors } from './unknown-authors/unknown-authors-dialog'
import { UnsupportedOSBannerDismissedAtKey } from './banners/windows-version-no-longer-supported-banner'
import { offsetFromNow } from '../lib/offset-from'
import { getNumber } from '../lib/local-storage'
import { RepoRulesBypassConfirmation } from './repository-rules/repo-rules-bypass-confirmation'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -1606,6 +1608,9 @@ export class App extends React.Component<IAppProps, IAppState> {
this.state.askForConfirmationOnDiscardChangesPermanently
}
confirmDiscardStash={this.state.askForConfirmationOnDiscardStash}
confirmCheckoutCommit={
this.state.askForConfirmationOnCheckoutCommit
}
confirmForcePush={this.state.askForConfirmationOnForcePush}
confirmUndoCommit={this.state.askForConfirmationOnUndoCommit}
uncommittedChangesStrategy={this.state.uncommittedChangesStrategy}
@ -1713,6 +1718,8 @@ export class App extends React.Component<IAppProps, IAppState> {
repository={repository}
targetCommit={popup.targetCommit}
upstreamGitHubRepository={upstreamGhRepo}
accounts={this.state.accounts}
cachedRepoRulesets={this.state.cachedRepoRulesets}
onBranchCreatedFromCommit={this.onBranchCreatedFromCommit}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
@ -1985,6 +1992,22 @@ export class App extends React.Component<IAppProps, IAppState> {
/>
)
}
case PopupType.ConfirmCheckoutCommit: {
const { repository, commit } = popup
return (
<ConfirmCheckoutCommitDialog
key="confirm-checkout-commit-dialog"
dispatcher={this.props.dispatcher}
askForConfirmationOnCheckoutCommit={
this.state.askForConfirmationOnDiscardStash
}
repository={repository}
commit={commit}
onDismissed={onPopupDismissedFn}
/>
)
}
case PopupType.CreateTutorialRepository: {
return (
<CreateTutorialRepositoryDialog
@ -2157,6 +2180,8 @@ export class App extends React.Component<IAppProps, IAppState> {
showBranchProtected={
repositoryState.changesState.currentBranchProtected
}
repoRulesInfo={repositoryState.changesState.currentRepoRulesInfo}
aheadBehind={repositoryState.aheadBehind}
showCoAuthoredBy={popup.showCoAuthoredBy}
showNoWriteAccess={!hasWritePermissionForRepository}
onDismissed={onPopupDismissedFn}
@ -2195,6 +2220,8 @@ export class App extends React.Component<IAppProps, IAppState> {
askForConfirmationOnForcePush={
this.state.askForConfirmationOnForcePush
}
accounts={this.state.accounts}
cachedRepoRulesets={this.state.cachedRepoRulesets}
openFileInExternalEditor={this.openFileInExternalEditor}
resolvedExternalEditor={this.state.resolvedExternalEditor}
openRepositoryInShell={this.openCurrentRepositoryInShell}
@ -2482,6 +2509,17 @@ export class App extends React.Component<IAppProps, IAppState> {
/>
)
}
case PopupType.ConfirmRepoRulesBypass: {
return (
<RepoRulesBypassConfirmation
key="repo-rules-bypass-confirmation"
repository={popup.repository}
branch={popup.branch}
onConfirm={popup.onConfirm}
onDismissed={onPopupDismissedFn}
/>
)
}
default:
return assertNever(popup, `Unknown popup type: ${popup}`)
}
@ -2839,6 +2877,11 @@ export class App extends React.Component<IAppProps, IAppState> {
top: 0,
}
/** The dropdown focus trap will stop focus event propagation we made need
* in some of our dialogs (noticed with Lists). Disabled this when dialogs
* are open */
const enableFocusTrap = this.state.currentPopup === null
return (
<ToolbarDropdown
icon={icon}
@ -2850,6 +2893,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDropdownStateChanged={this.onRepositoryDropdownStateChanged}
dropdownContentRenderer={this.renderRepositoryList}
dropdownState={currentState}
enableFocusTrap={enableFocusTrap}
/>
)
}
@ -2932,6 +2976,11 @@ export class App extends React.Component<IAppProps, IAppState> {
aheadBehind
)
/** The dropdown focus trap will stop focus event propagation we made need
* in some of our dialogs (noticed with Lists). Disabled this when dialogs
* are open */
const enableFocusTrap = this.state.currentPopup === null
return (
<PushPullButton
dispatcher={this.props.dispatcher}
@ -2952,6 +3001,7 @@ export class App extends React.Component<IAppProps, IAppState> {
isDropdownOpen={isDropdownOpen}
askForConfirmationOnForcePush={this.state.askForConfirmationOnForcePush}
onDropdownStateChanged={this.onPushPullDropdownStateChanged}
enableFocusTrap={enableFocusTrap}
/>
)
}
@ -3046,6 +3096,11 @@ export class App extends React.Component<IAppProps, IAppState> {
const repository = selection.repository
const { branchesState } = selection.state
/** The dropdown focus trap will stop focus event propagation we made need
* in some of our dialogs (noticed with Lists). Disabled this when dialogs
* are open */
const enableFocusTrap = this.state.currentPopup === null
return (
<BranchDropdown
dispatcher={this.props.dispatcher}
@ -3062,6 +3117,7 @@ export class App extends React.Component<IAppProps, IAppState> {
}
showCIStatusPopover={this.state.showCIStatusPopover}
emoji={this.state.emoji}
enableFocusTrap={enableFocusTrap}
/>
)
}
@ -3197,6 +3253,9 @@ export class App extends React.Component<IAppProps, IAppState> {
askForConfirmationOnDiscardStash={
state.askForConfirmationOnDiscardStash
}
askForConfirmationOnCheckoutCommit={
state.askForConfirmationOnCheckoutCommit
}
accounts={state.accounts}
externalEditorLabel={externalEditorLabel}
resolvedExternalEditor={state.resolvedExternalEditor}

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

@ -22,6 +22,8 @@ import { NoBranches } from './no-branches'
import { SelectionDirection, ClickSource } from '../lib/list'
import { generateBranchContextMenuItems } from './branch-list-item-context-menu'
import { showContextualMenu } from '../../lib/menu-item'
import { enableSectionList } from '../../lib/feature-flag'
import { SectionFilterList } from '../lib/section-filter-list'
const RowHeight = 30
@ -168,7 +170,10 @@ export class BranchList extends React.Component<
IBranchListProps,
IBranchListState
> {
private branchFilterList: FilterList<IBranchListItem> | null = null
private branchFilterList:
| FilterList<IBranchListItem>
| SectionFilterList<IBranchListItem>
| null = null
public constructor(props: IBranchListProps) {
super(props)
@ -186,7 +191,32 @@ export class BranchList extends React.Component<
}
public render() {
return (
return enableSectionList() ? (
<SectionFilterList<IBranchListItem>
ref={this.onBranchesFilterListRef}
className="branches-list"
rowHeight={RowHeight}
filterText={this.props.filterText}
onFilterTextChanged={this.props.onFilterTextChanged}
onFilterKeyDown={this.props.onFilterKeyDown}
selectedItem={this.state.selectedItem}
renderItem={this.renderItem}
renderGroupHeader={this.renderGroupHeader}
onItemClick={this.onItemClick}
onSelectionChanged={this.onSelectionChanged}
onEnterPressedWithoutFilteredItems={this.onCreateNewBranch}
groups={this.state.groups}
invalidationProps={this.props.allBranches}
renderPostFilter={this.onRenderNewButton}
renderNoItems={this.onRenderNoItems}
filterTextBox={this.props.textbox}
hideFilterRow={this.props.hideFilterRow}
onFilterListResultsChanged={this.props.onFilterListResultsChanged}
renderPreList={this.props.renderPreList}
onItemContextMenu={this.onBranchContextMenu}
getGroupAriaLabel={this.getGroupAriaLabel}
/>
) : (
<FilterList<IBranchListItem>
ref={this.onBranchesFilterListRef}
className="branches-list"
@ -237,7 +267,10 @@ export class BranchList extends React.Component<
}
private onBranchesFilterListRef = (
filterList: FilterList<IBranchListItem> | null
filterList:
| FilterList<IBranchListItem>
| SectionFilterList<IBranchListItem>
| null
) => {
this.branchFilterList = filterList
}
@ -257,6 +290,12 @@ export class BranchList extends React.Component<
}
}
private getGroupAriaLabel = (group: number) => {
const identifier = this.state.groups[group]
.identifier as BranchGroupIdentifier
return this.getGroupLabel(identifier)
}
private renderGroupHeader = (label: string) => {
const identifier = this.parseHeader(label)

View file

@ -21,6 +21,7 @@ import { startTimer } from '../lib/timing'
import { DragType } from '../../models/drag-drop'
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
import { formatRelative } from '../../lib/format-relative'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
interface IPullRequestListItem extends IFilterListItem {
readonly id: string
@ -82,6 +83,7 @@ interface IPullRequestListState {
readonly filterText: string
readonly groupedItems: ReadonlyArray<IFilterListGroup<IPullRequestListItem>>
readonly selectedItem: IPullRequestListItem | null
readonly screenReaderStateMessage: string | null
}
function resolveSelectedItem(
@ -120,6 +122,7 @@ export class PullRequestList extends React.Component<
filterText: '',
groupedItems: [group],
selectedItem,
screenReaderStateMessage: null,
}
}
@ -130,27 +133,47 @@ export class PullRequestList extends React.Component<
nextProps,
this.state.selectedItem
)
this.setState({ groupedItems: [group], selectedItem })
const loadingStarted =
!this.props.isLoadingPullRequests && nextProps.isLoadingPullRequests
const loadingComplete =
this.props.isLoadingPullRequests && !nextProps.isLoadingPullRequests
const numPullRequests = this.props.pullRequests.length
const plural = numPullRequests === 1 ? '' : 's'
const screenReaderStateMessage = loadingStarted
? 'Hang Tight. Loading pull requests as fast as I can!'
: loadingComplete
? `${numPullRequests} pull request${plural} found`
: null
this.setState({
groupedItems: [group],
selectedItem,
screenReaderStateMessage,
})
}
public render() {
return (
<FilterList<IPullRequestListItem>
className="pull-request-list"
rowHeight={RowHeight}
groups={this.state.groupedItems}
selectedItem={this.state.selectedItem}
renderItem={this.renderPullRequest}
filterText={this.state.filterText}
onFilterTextChanged={this.onFilterTextChanged}
invalidationProps={this.props.pullRequests}
onItemClick={this.onItemClick}
onSelectionChanged={this.onSelectionChanged}
onFilterKeyDown={this.props.onFilterKeyDown}
renderGroupHeader={this.renderListHeader}
renderNoItems={this.renderNoItems}
renderPostFilter={this.renderPostFilter}
/>
<>
<FilterList<IPullRequestListItem>
className="pull-request-list"
rowHeight={RowHeight}
groups={this.state.groupedItems}
selectedItem={this.state.selectedItem}
renderItem={this.renderPullRequest}
filterText={this.state.filterText}
onFilterTextChanged={this.onFilterTextChanged}
invalidationProps={this.props.pullRequests}
onItemClick={this.onItemClick}
onSelectionChanged={this.onSelectionChanged}
onFilterKeyDown={this.props.onFilterKeyDown}
renderGroupHeader={this.renderListHeader}
renderNoItems={this.renderNoItems}
renderPostFilter={this.renderPostFilter}
/>
<AriaLiveContainer message={this.state.screenReaderStateMessage} />
</>
)
}
@ -289,11 +312,14 @@ export class PullRequestList extends React.Component<
}
private renderPostFilter = () => {
const tooltip = 'Refresh the list of pull requests'
return (
<Button
disabled={this.props.isLoadingPullRequests}
onClick={this.onRefreshPullRequests}
tooltip="Refresh the list of pull requests"
ariaLabel={tooltip}
tooltip={tooltip}
>
<Octicon
symbol={syncClockwise}

View file

@ -60,6 +60,10 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
? 'partially included'
: 'not included'
const pathScreenReaderMessage = `${path} ${mapStatus(
status
)} ${includedText}`
return (
<div className="file">
<TooltippedContent
@ -85,9 +89,7 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
ariaHidden={true}
/>
<AriaLiveContainer>
{path} {mapStatus(status)} {includedText}
</AriaLiveContainer>
<AriaLiveContainer message={pathScreenReaderMessage} />
<Octicon
symbol={iconForStatus(status)}

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
@ -732,6 +736,7 @@ export class ChangesList extends React.Component<
isCommitting,
commitToAmend,
currentBranchProtected,
currentRepoRulesInfo: currentRepoRulesInfo,
} = this.props
if (rebaseConflictState !== null) {
@ -784,6 +789,7 @@ export class ChangesList extends React.Component<
branch={this.props.branch}
mostRecentLocalCommit={this.props.mostRecentLocalCommit}
commitAuthor={this.props.commitAuthor}
dispatcher={this.props.dispatcher}
isShowingModal={this.props.isShowingModal}
isShowingFoldout={this.props.isShowingFoldout}
anyFilesSelected={anyFilesSelected}
@ -804,6 +810,8 @@ export class ChangesList extends React.Component<
prepopulateCommitSummary={prepopulateCommitSummary}
key={repository.id}
showBranchProtected={fileCount > 0 && currentBranchProtected}
repoRulesInfo={currentRepoRulesInfo}
aheadBehind={this.props.aheadBehind}
showNoWriteAccess={fileCount > 0 && !hasWritePermissionForRepository}
shouldNudge={this.props.shouldNudgeToCommit}
commitSpellcheckEnabled={this.props.commitSpellcheckEnabled}

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()}
@ -181,7 +217,7 @@ export class CommitMessageAvatar extends React.Component<
const location = isGitConfigLocal ? 'local' : 'global'
const locationDesc = isGitConfigLocal ? 'for your repository' : ''
const settingsName = __DARWIN__ ? 'preferences' : 'options'
const settingsName = __DARWIN__ ? 'settings' : 'options'
const settings = isGitConfigLocal
? 'repository settings'
: `git ${settingsName}`
@ -216,33 +252,20 @@ export class CommitMessageAvatar extends React.Component<
)
}
private renderMisattributedCommitPopover() {
const accountTypeSuffix = this.props.isEnterpriseAccount
? ' Enterprise'
: ''
private renderWarningPopover() {
const { warningType, emailRuleFailures } = this.props
const updateEmailTitle = __DARWIN__ ? 'Update Email' : 'Update email'
const userName =
this.props.user && this.props.user.name
? ` for ${this.props.user.name}`
: ''
return (
const sharedHeader = (
<>
The email in your global Git config (
<span className="git-email">{this.props.email}</span>)
</>
)
const sharedFooter = (
<>
<Row>
<div>
The email in your global Git config (
<span className="git-email">{this.props.email}</span>) doesn't match
your GitHub{accountTypeSuffix} account{userName}.{' '}
<LinkButton
ariaLabel="Learn more about commit attribution"
uri="https://docs.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user"
>
Learn more
</LinkButton>
</div>
</Row>
<Row>
<Select
label="Your Account Emails"
@ -275,6 +298,54 @@ export class CommitMessageAvatar extends React.Component<
</Row>
</>
)
if (warningType === 'misattribution') {
const accountTypeSuffix = this.props.isEnterpriseAccount
? ' Enterprise'
: ''
const userName =
this.props.user && this.props.user.name
? ` for ${this.props.user.name}`
: ''
return (
<>
<Row>
<div>
{sharedHeader} doesn't match your GitHub{accountTypeSuffix}{' '}
account{userName}.{' '}
<LinkButton
ariaLabel="Learn more about commit attribution"
uri="https://docs.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user"
>
Learn more
</LinkButton>
</div>
</Row>
{sharedFooter}
</>
)
} else if (
warningType === 'disallowedEmail' &&
emailRuleFailures &&
this.props.branch &&
this.props.repository.gitHubRepository
) {
return (
<>
<RepoRulesMetadataFailureList
repository={this.props.repository.gitHubRepository}
branch={this.props.branch}
failures={emailRuleFailures}
leadingText={sharedHeader}
/>
{sharedFooter}
</>
)
}
return
}
private getCommittingAsTitle(): string | JSX.Element | undefined {
@ -298,12 +369,27 @@ export class CommitMessageAvatar extends React.Component<
}
private renderPopover() {
const { warningBadgeVisible } = this.props
const { warningType } = this.props
let header: string | JSX.Element | undefined = ''
switch (this.props.warningType) {
case 'misattribution':
header = 'This commit will be misattributed'
break
case 'disallowedEmail':
header = 'This email address is disallowed'
break
default:
header = this.getCommittingAsTitle()
break
}
return (
<Popover
anchor={
warningBadgeVisible
warningType !== 'none'
? this.warningBadgeRef.current
: this.avatarButtonRef
}
@ -312,14 +398,10 @@ export class CommitMessageAvatar extends React.Component<
onClickOutside={this.closePopover}
ariaLabelledby="commit-avatar-popover-header"
>
<h3 id="commit-avatar-popover-header">
{warningBadgeVisible
? 'This commit will be misattributed'
: this.getCommittingAsTitle()}
</h3>
<h3 id="commit-avatar-popover-header">{header}</h3>
{warningBadgeVisible
? this.renderMisattributedCommitPopover()
{warningType !== 'none'
? this.renderWarningPopover()
: this.renderGitConfigPopover()}
</Popover>
)

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 { enableRepoRules } from '../../lib/feature-flag'
import { formatCommitMessage } from '../../lib/format-commit-message'
const addAuthorIcon = {
w: 18,
@ -54,6 +73,7 @@ interface ICommitMessageProps {
readonly onCreateCommit: (context: ICommitContext) => Promise<boolean>
readonly branch: string | null
readonly commitAuthor: CommitIdentity | null
readonly dispatcher: Dispatcher
readonly anyFilesSelected: boolean
readonly isShowingModal: boolean
readonly isShowingFoldout: boolean
@ -73,6 +93,8 @@ interface ICommitMessageProps {
readonly placeholder: string
readonly prepopulateCommitSummary: boolean
readonly showBranchProtected: boolean
readonly repoRulesInfo: RepoRulesInfo
readonly aheadBehind: IAheadBehind | null
readonly showNoWriteAccess: boolean
/**
@ -152,6 +174,12 @@ interface ICommitMessageState {
readonly descriptionObscured: boolean
readonly isCommittingStatusMessage: string
readonly isRuleFailurePopoverOpen: boolean
readonly repoRuleCommitMessageFailures: RepoRulesMetadataFailures
readonly repoRuleCommitAuthorFailures: RepoRulesMetadataFailures
readonly repoRuleBranchNameFailures: RepoRulesMetadataFailures
}
function findCommitMessageAutoCompleteProvider(
@ -187,6 +215,8 @@ export class CommitMessage extends React.Component<
private coAuthorInputRef = React.createRef<AuthorInput>()
private readonly COMMIT_MSG_ERROR_BTN_ID = 'commit-message-failure-hint'
public constructor(props: ICommitMessageProps) {
super(props)
const { commitMessage } = this.props
@ -201,6 +231,10 @@ export class CommitMessage extends React.Component<
),
descriptionObscured: false,
isCommittingStatusMessage: '',
isRuleFailurePopoverOpen: false,
repoRuleCommitMessageFailures: new RepoRulesMetadataFailures(),
repoRuleCommitAuthorFailures: new RepoRulesMetadataFailures(),
repoRuleBranchNameFailures: new RepoRulesMetadataFailures(),
}
}
@ -211,8 +245,9 @@ export class CommitMessage extends React.Component<
window.removeEventListener('keydown', this.onKeyDown)
}
public componentDidMount() {
public async componentDidMount() {
window.addEventListener('keydown', this.onKeyDown)
await this.updateRepoRuleFailures(undefined, undefined, true)
}
/**
@ -258,7 +293,10 @@ export class CommitMessage extends React.Component<
})
}
public componentDidUpdate(prevProps: ICommitMessageProps) {
public async componentDidUpdate(
prevProps: ICommitMessageProps,
prevState: ICommitMessageState
) {
if (
this.props.autocompletionProviders !== prevProps.autocompletionProviders
) {
@ -305,6 +343,111 @@ export class CommitMessage extends React.Component<
isCommittingStatusMessage: `Committed Just now - ${this.props.mostRecentLocalCommit.summary} (Sha: ${this.props.mostRecentLocalCommit.shortSha})`,
})
}
await this.updateRepoRuleFailures(prevProps, prevState)
}
private async updateRepoRuleFailures(
prevProps?: ICommitMessageProps,
prevState?: ICommitMessageState,
forceUpdate: boolean = false
) {
if (!enableRepoRules()) {
return
}
await this.updateRepoRulesCommitMessageFailures(
prevProps,
prevState,
forceUpdate
)
this.updateRepoRulesCommitAuthorFailures(prevProps, forceUpdate)
this.updateRepoRulesBranchNameFailures(prevProps, forceUpdate)
}
private async updateRepoRulesCommitMessageFailures(
prevProps?: ICommitMessageProps,
prevState?: ICommitMessageState,
forceUpdate?: boolean
) {
if (
forceUpdate ||
prevState?.summary !== this.state.summary ||
prevState?.description !== this.state.description ||
prevProps?.coAuthors !== this.props.coAuthors ||
prevProps?.commitToAmend !== this.props.commitToAmend ||
prevProps?.repository !== this.props.repository ||
prevProps?.repoRulesInfo.commitMessagePatterns !==
this.props.repoRulesInfo.commitMessagePatterns
) {
let summary = this.state.summary
if (!summary && !this.state.description) {
summary = this.summaryOrPlaceholder
}
const context: ICommitContext = {
summary,
description: this.state.description,
trailers: this.getCoAuthorTrailers(),
amend: this.props.commitToAmend !== null,
}
const msg = await formatCommitMessage(this.props.repository, context)
const failures =
this.props.repoRulesInfo.commitMessagePatterns.getFailedRules(msg)
this.setState({ repoRuleCommitMessageFailures: failures })
}
}
private updateRepoRulesCommitAuthorFailures(
prevProps?: ICommitMessageProps,
forceUpdate?: boolean
) {
if (
forceUpdate ||
prevProps?.commitAuthor?.email !== this.props.commitAuthor?.email ||
prevProps?.repoRulesInfo.commitAuthorEmailPatterns !==
this.props.repoRulesInfo.commitAuthorEmailPatterns
) {
const email = this.props.commitAuthor?.email
let failures: RepoRulesMetadataFailures
if (!email) {
failures = new RepoRulesMetadataFailures()
} else {
failures =
this.props.repoRulesInfo.commitAuthorEmailPatterns.getFailedRules(
email
)
}
this.setState({ repoRuleCommitAuthorFailures: failures })
}
}
private updateRepoRulesBranchNameFailures(
prevProps?: ICommitMessageProps,
forceUpdate?: boolean
) {
if (
forceUpdate ||
prevProps?.branch !== this.props.branch ||
prevProps?.repoRulesInfo.branchNamePatterns !==
this.props.repoRulesInfo.branchNamePatterns
) {
const branch = this.props.branch
let failures: RepoRulesMetadataFailures
if (!branch) {
failures = new RepoRulesMetadataFailures()
} else {
failures =
this.props.repoRulesInfo.branchNamePatterns.getFailedRules(branch)
}
this.setState({ repoRuleBranchNameFailures: failures })
}
}
private clearCommitMessage() {
@ -327,7 +470,19 @@ export class CommitMessage extends React.Component<
}
private onSubmit = () => {
this.createCommit()
if (
this.shouldWarnForRepoRuleBypass() &&
this.props.repository.gitHubRepository &&
this.props.branch
) {
this.props.dispatcher.showRepoRulesCommitBypassWarning(
this.props.repository.gitHubRepository,
this.props.branch,
() => this.createCommit()
)
} else {
this.createCommit()
}
}
private getCoAuthorTrailers() {
@ -391,15 +546,72 @@ export class CommitMessage extends React.Component<
private canCommit(): boolean {
return (
(this.props.anyFilesSelected === true && this.state.summary.length > 0) ||
this.props.prepopulateCommitSummary
((this.props.anyFilesSelected === true &&
this.state.summary.length > 0) ||
this.props.prepopulateCommitSummary) &&
!this.hasRepoRuleFailure()
)
}
private canAmend(): boolean {
return (
this.props.commitToAmend !== null &&
(this.state.summary.length > 0 || this.props.prepopulateCommitSummary)
(this.state.summary.length > 0 || this.props.prepopulateCommitSummary) &&
!this.hasRepoRuleFailure()
)
}
/**
* Whether the user will be prevented from pushing this commit due to a repo rule failure.
*/
private hasRepoRuleFailure(): boolean {
if (!enableRepoRules()) {
return false
}
return (
this.state.repoRuleCommitMessageFailures.status === 'fail' ||
this.state.repoRuleCommitAuthorFailures.status === 'fail' ||
(this.props.aheadBehind === null &&
(this.props.repoRulesInfo.creationRestricted === true ||
this.state.repoRuleBranchNameFailures.status === 'fail'))
)
}
/**
* If true, then rules exist for the branch but the user is bypassing all of them.
* Used to display a confirmation prompt.
*/
private shouldWarnForRepoRuleBypass(): boolean {
const { aheadBehind, branch, repoRulesInfo } = this.props
if (!enableRepoRules()) {
return false
}
// if all rules pass, then nothing to warn about. if at least one rule fails, then the user won't hit this
// in the first place because the button will be disabled. therefore, only need to check if any single
// value is 'bypass'.
if (
repoRulesInfo.basicCommitWarning === 'bypass' ||
repoRulesInfo.pullRequestRequired === 'bypass'
) {
return true
}
if (
this.state.repoRuleCommitMessageFailures.status === 'bypass' ||
this.state.repoRuleCommitAuthorFailures.status === 'bypass'
) {
return true
}
return (
aheadBehind === null &&
branch !== null &&
(repoRulesInfo.creationRestricted === 'bypass' ||
this.state.repoRuleBranchNameFailures.status === 'bypass')
)
}
@ -436,11 +648,21 @@ export class CommitMessage extends React.Component<
const accountEmails = repositoryAccount?.emails.map(e => e.email) ?? []
const email = commitAuthor?.email
const warningBadgeVisible =
email !== undefined &&
repositoryAccount !== null &&
repositoryAccount !== undefined &&
isAttributableEmailFor(repositoryAccount, email) === false
let warningType: CommitMessageAvatarWarningType = 'none'
if (email !== undefined) {
if (
enableRepoRules() &&
this.state.repoRuleCommitAuthorFailures.status !== 'pass'
) {
warningType = 'disallowedEmail'
} else if (
repositoryAccount !== null &&
repositoryAccount !== undefined &&
isAttributableEmailFor(repositoryAccount, email) === false
) {
warningType = 'misattribution'
}
}
return (
<CommitMessageAvatar
@ -449,7 +671,9 @@ export class CommitMessage extends React.Component<
isEnterpriseAccount={
repositoryAccount?.endpoint !== getDotComAPIEndpoint()
}
warningBadgeVisible={warningBadgeVisible}
warningType={warningType}
emailRuleFailures={this.state.repoRuleCommitAuthorFailures}
branch={this.props.branch}
accountEmails={accountEmails}
preferredAccountEmail={
repositoryAccount !== null && repositoryAccount !== undefined
@ -671,14 +895,8 @@ export class CommitMessage extends React.Component<
return <div className={className}>{this.renderCoAuthorToggleButton()}</div>
}
private renderPermissionsCommitWarning() {
const {
commitToAmend,
showBranchProtected,
showNoWriteAccess,
repository,
branch,
} = this.props
private renderAmendCommitNotice() {
const { commitToAmend } = this.props
if (commitToAmend !== null) {
return (
@ -690,7 +908,59 @@ export class CommitMessage extends React.Component<
to make these changes as a new commit.
</CommitWarning>
)
} else if (showNoWriteAccess) {
} else {
return null
}
}
private renderBranchProtectionsRepoRulesCommitWarning() {
const {
showNoWriteAccess,
showBranchProtected,
repoRulesInfo,
aheadBehind,
repository,
branch,
} = this.props
const { repoRuleBranchNameFailures } = this.state
// if one of these is not bypassable, then a failure message needs to be shown rather than just displaying
// the first one in the if statement.
let repoRuleWarningToDisplay: 'publish' | 'basic' | null = null
if (enableRepoRules()) {
let publishStatus: RepoRuleEnforced = false
const basicStatus = repoRulesInfo.basicCommitWarning
if (aheadBehind === null && branch !== null) {
if (
repoRulesInfo.creationRestricted === true ||
repoRuleBranchNameFailures.status === 'fail'
) {
publishStatus = true
} else if (
repoRulesInfo.creationRestricted === 'bypass' ||
repoRuleBranchNameFailures.status === 'bypass'
) {
publishStatus = 'bypass'
} else {
publishStatus = false
}
}
if (publishStatus === true && basicStatus) {
repoRuleWarningToDisplay = 'publish'
} else if (basicStatus === true) {
repoRuleWarningToDisplay = 'basic'
} else if (publishStatus) {
repoRuleWarningToDisplay = 'publish'
} else if (basicStatus) {
repoRuleWarningToDisplay = 'basic'
}
}
if (showNoWriteAccess) {
return (
<CommitWarning icon={CommitWarningIcon.Warning}>
You don't have write access to <strong>{repository.name}</strong>.
@ -706,7 +976,7 @@ export class CommitMessage extends React.Component<
// If the branch is null that means we haven't loaded the tip yet or
// we're on a detached head. We shouldn't ever end up here with
// showBranchProtected being true without a branch but who knows
// what fun and exiting edge cases the future might hold
// what fun and exciting edge cases the future might hold
return null
}
@ -717,11 +987,119 @@ export class CommitMessage extends React.Component<
?
</CommitWarning>
)
} else if (repoRuleWarningToDisplay === 'publish') {
const canBypass = !(
repoRulesInfo.creationRestricted === true ||
this.state.repoRuleBranchNameFailures.status === 'fail'
)
return (
<CommitWarning
icon={canBypass ? CommitWarningIcon.Warning : CommitWarningIcon.Error}
>
The branch name <strong>{branch}</strong> fails{' '}
<RepoRulesetsForBranchLink
repository={repository.gitHubRepository}
branch={branch}
>
one or more rules
</RepoRulesetsForBranchLink>{' '}
that {canBypass ? 'would' : 'will'} prevent it from being published
{canBypass && ', but you can bypass them. Proceed with caution!'}
{!canBypass && (
<>
. Want to{' '}
<LinkButton onClick={this.onSwitchBranch}>
switch branches
</LinkButton>
?
</>
)}
</CommitWarning>
)
} else if (repoRuleWarningToDisplay === 'basic') {
const canBypass = repoRulesInfo.basicCommitWarning === 'bypass'
return (
<CommitWarning
icon={canBypass ? CommitWarningIcon.Warning : CommitWarningIcon.Error}
>
<RepoRulesetsForBranchLink
repository={repository.gitHubRepository}
branch={branch}
>
One or more rules
</RepoRulesetsForBranchLink>{' '}
apply to the branch <strong>{branch}</strong> that{' '}
{canBypass ? 'would' : 'will'} prevent pushing
{canBypass && ', but you can bypass them. Proceed with caution!'}
{!canBypass && (
<>
. Want to{' '}
<LinkButton onClick={this.onSwitchBranch}>
switch branches
</LinkButton>
?
</>
)}
</CommitWarning>
)
} else {
return null
}
}
private renderRuleFailurePopover() {
const { branch, repository } = this.props
// the failure status is checked here separately from whether the popover is open. if the
// user has it open but rules pass as they're typing, then keep the popover logic open
// but just don't render it. as they keep typing, if the message fails again, then the
// popover will open back up.
if (
!branch ||
!repository.gitHubRepository ||
!enableRepoRules() ||
this.state.repoRuleCommitMessageFailures.status === 'pass'
) {
return
}
const header = __DARWIN__
? 'Commit Message Rule Failures'
: 'Commit message rule failures'
return (
<Popover
anchor={this.summaryTextInput}
anchorPosition={PopoverAnchorPosition.Right}
decoration={PopoverDecoration.Balloon}
minHeight={200}
trapFocus={false}
ariaLabelledby="commit-message-rule-failure-popover-header"
onClickOutside={this.closeRuleFailurePopover}
>
<h3 id="commit-message-rule-failure-popover-header">{header}</h3>
<RepoRulesMetadataFailureList
repository={repository.gitHubRepository}
branch={branch}
failures={this.state.repoRuleCommitMessageFailures}
leadingText="This commit message"
/>
</Popover>
)
}
private toggleRuleFailurePopover = () => {
this.setState({
isRuleFailurePopoverOpen: !this.state.isRuleFailurePopoverOpen,
})
}
public closeRuleFailurePopover = () => {
this.setState({ isRuleFailurePopoverOpen: false })
}
private onSwitchBranch = () => {
this.props.onShowFoldout({ type: FoldoutType.Branch })
}
@ -843,6 +1221,9 @@ export class CommitMessage extends React.Component<
</div>
</>
}
ariaLiveMessage={
'Great commit summaries contain fewer than 50 characters. Place extra information in the description field.'
}
direction={TooltipDirection.NORTH}
className="length-hint"
tooltipClassName="length-hint-tooltip"
@ -853,6 +1234,42 @@ export class CommitMessage extends React.Component<
)
}
private renderRepoRuleCommitMessageFailureHint(): JSX.Element | null {
// enableRepoRules FF is checked before this method
if (this.state.repoRuleCommitMessageFailures.status === 'pass') {
return null
}
const canBypass =
this.state.repoRuleCommitMessageFailures.status === 'bypass'
let ariaLabelPrefix: string
let bypassMessage = ''
if (canBypass) {
ariaLabelPrefix = 'Warning'
bypassMessage = ', but you can bypass them'
} else {
ariaLabelPrefix = 'Error'
}
return (
<button
id="commit-message-failure-hint"
className="commit-message-failure-hint button-component"
aria-label={`${ariaLabelPrefix}: Commit message fails repository rules${bypassMessage}. View details.`}
aria-haspopup="dialog"
aria-expanded={this.state.isRuleFailurePopoverOpen}
onClick={this.toggleRuleFailurePopover}
>
<Octicon
symbol={canBypass ? OcticonSymbol.alert : OcticonSymbol.stop}
className={canBypass ? 'warning-icon' : 'error-icon'}
/>
</button>
)
}
public render() {
const className = classNames('commit-message-component', {
'with-action-bar': this.isActionBarEnabled,
@ -863,14 +1280,27 @@ export class CommitMessage extends React.Component<
'with-overflow': this.state.descriptionObscured,
})
const showSummaryLengthHint = this.state.summary.length > IdealSummaryLength
// both of these are calculated, but only the repo rule icon is displayed if both are true, see below
const showRepoRuleCommitMessageFailureHint =
enableRepoRules() &&
this.state.repoRuleCommitMessageFailures.status !== 'pass'
const showSummaryLengthHint =
!showRepoRuleCommitMessageFailureHint &&
this.state.summary.length > IdealSummaryLength
const summaryClassName = classNames('summary', {
'with-length-hint': showSummaryLengthHint,
'with-trailing-icon':
showRepoRuleCommitMessageFailureHint || showSummaryLengthHint,
})
const summaryInputClassName = classNames('summary-field', 'nudge-arrow', {
'nudge-arrow-left': this.props.shouldNudge === true,
})
const ariaDescribedBy = showRepoRuleCommitMessageFailureHint
? this.COMMIT_MSG_ERROR_BTN_ID
: undefined
const { placeholder, isCommitting, commitSpellcheckEnabled } = this.props
return (
@ -896,13 +1326,18 @@ export class CommitMessage extends React.Component<
autocompletionProviders={
this.state.commitMessageAutocompletionProviders
}
aria-describedby={ariaDescribedBy}
onContextMenu={this.onAutocompletingInputContextMenu}
disabled={isCommitting === true}
spellcheck={commitSpellcheckEnabled}
/>
{showRepoRuleCommitMessageFailureHint &&
this.renderRepoRuleCommitMessageFailureHint()}
{showSummaryLengthHint && this.renderSummaryLengthHint()}
</div>
{this.state.isRuleFailurePopoverOpen && this.renderRuleFailurePopover()}
<FocusContainer
className="description-focus-container"
onClick={this.onFocusContainerClick}
@ -916,6 +1351,7 @@ export class CommitMessage extends React.Component<
autocompletionProviders={
this.state.commitMessageAutocompletionProviders
}
aria-describedby={ariaDescribedBy}
ref={this.onDescriptionFieldRef}
onElementRef={this.onDescriptionTextAreaRef}
onContextMenu={this.onAutocompletingInputContextMenu}
@ -927,7 +1363,8 @@ export class CommitMessage extends React.Component<
{this.renderCoAuthorInput()}
{this.renderPermissionsCommitWarning()}
{this.renderAmendCommitNotice()}
{this.renderBranchProtectionsRepoRulesCommitWarning()}
{this.renderSubmitButton()}
<span className="sr-only" aria-live="polite" aria-atomic="true">

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

@ -0,0 +1,106 @@
import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Repository } from '../../models/repository'
import { Dispatcher } from '../dispatcher'
import { Row } from '../lib/row'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { CommitOneLine } from '../../models/commit'
interface IConfirmCheckoutCommitProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
readonly commit: CommitOneLine
readonly askForConfirmationOnCheckoutCommit: boolean
readonly onDismissed: () => void
}
interface IConfirmCheckoutCommitState {
readonly isCheckingOut: boolean
readonly confirmCheckoutCommit: boolean
}
/**
* Dialog to confirm checking out a commit
*/
export class ConfirmCheckoutCommitDialog extends React.Component<
IConfirmCheckoutCommitProps,
IConfirmCheckoutCommitState
> {
public constructor(props: IConfirmCheckoutCommitProps) {
super(props)
this.state = {
isCheckingOut: false,
confirmCheckoutCommit: props.askForConfirmationOnCheckoutCommit,
}
}
public render() {
const title = __DARWIN__ ? 'Checkout Commit?' : 'Checkout commit?'
return (
<Dialog
id="checkout-commit"
type="warning"
title={title}
loading={this.state.isCheckingOut}
disabled={this.state.isCheckingOut}
onSubmit={this.onSubmit}
onDismissed={this.props.onDismissed}
ariaDescribedBy="checking-out-commit-confirmation"
role="alertdialog"
>
<DialogContent>
<Row id="checking-out-commit-confirmation">
Checking out a commit will create a detached HEAD, and you will no
longer be on any branch. Are you sure you want to checkout this
commit?
</Row>
<Row>
<Checkbox
label="Do not show this message again"
value={
this.state.confirmCheckoutCommit
? CheckboxValue.Off
: CheckboxValue.On
}
onChange={this.onaskForConfirmationOnCheckoutCommitChanged}
/>
</Row>
</DialogContent>
<DialogFooter>
<OkCancelButtonGroup destructive={true} okButtonText="Checkout" />
</DialogFooter>
</Dialog>
)
}
private onaskForConfirmationOnCheckoutCommitChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = !event.currentTarget.checked
this.setState({ confirmCheckoutCommit: value })
}
private onSubmit = async () => {
const { dispatcher, repository, commit, onDismissed } = this.props
this.setState({
isCheckingOut: true,
})
try {
dispatcher.setConfirmCheckoutCommitSetting(
this.state.confirmCheckoutCommit
)
await dispatcher.checkoutCommit(repository, commit)
} finally {
this.setState({
isCheckingOut: false,
})
}
onDismissed()
}
}

View file

@ -15,6 +15,8 @@ import { HighlightText } from '../lib/highlight-text'
import { ClickSource } from '../lib/list'
import { LinkButton } from '../lib/link-button'
import { Ref } from '../lib/ref'
import { enableSectionList } from '../../lib/feature-flag'
import { SectionFilterList } from '../lib/section-filter-list'
interface ICloneableRepositoryFilterListProps {
/** The account to clone from. */
@ -157,26 +159,34 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
const { repositories, account, selectedItem } = this.props
const groups = this.getRepositoryGroups(repositories, account.login)
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
const getGroupAriaLabel = (group: number) => {
const groupIdentifier = groups[group].identifier
return groupIdentifier === YourRepositoriesIdentifier
? this.getYourRepositoriesLabel()
: groupIdentifier
}
return (
<FilterList<ICloneableRepositoryListItem>
className="clone-github-repo"
rowHeight={RowHeight}
selectedItem={selectedListItem}
renderItem={this.renderItem}
renderGroupHeader={this.renderGroupHeader}
onSelectionChanged={this.onSelectionChanged}
invalidationProps={groups}
groups={groups}
filterText={this.props.filterText}
onFilterTextChanged={this.props.onFilterTextChanged}
renderNoItems={this.renderNoItems}
renderPostFilter={this.renderPostFilter}
onItemClick={this.props.onItemClicked ? this.onItemClick : undefined}
placeholderText="Filter your repositories"
/>
)
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
const ListComponent = enableSectionList() ? SectionFilterList : FilterList
const filterListProps: typeof ListComponent['prototype']['props'] = {
className: 'clone-github-repo',
rowHeight: RowHeight,
selectedItem: selectedListItem,
renderItem: this.renderItem,
renderGroupHeader: this.renderGroupHeader,
onSelectionChanged: this.onSelectionChanged,
invalidationProps: groups,
groups: groups,
filterText: this.props.filterText,
onFilterTextChanged: this.props.onFilterTextChanged,
renderNoItems: this.renderNoItems,
renderPostFilter: this.renderPostFilter,
onItemClick: this.props.onItemClicked ? this.onItemClick : undefined,
placeholderText: 'Filter your repositories',
getGroupAriaLabel,
}
return <ListComponent {...filterListProps} />
}
private onItemClick = (
@ -206,10 +216,14 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
}
}
private getYourRepositoriesLabel = () => {
return __DARWIN__ ? 'Your Repositories' : 'Your repositories'
}
private renderGroupHeader = (identifier: string) => {
let header = identifier
if (identifier === YourRepositoriesIdentifier) {
header = __DARWIN__ ? 'Your Repositories' : 'Your repositories'
header = this.getYourRepositoriesLabel()
}
return (
<div className="clone-repository-list-content clone-repository-list-group-header">
@ -228,16 +242,20 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
<div className="name" title={item.text[0]}>
<HighlightText text={item.text[0]} highlight={matches.title} />
</div>
{item.archived && <div className="archived">Archived</div>}
</div>
)
}
private renderPostFilter = () => {
const tooltip = 'Refresh the list of repositories'
return (
<Button
disabled={this.props.loading}
onClick={this.refreshRepositories}
tooltip="Refresh the list of repositories"
ariaLabel={tooltip}
tooltip={tooltip}
>
<Octicon
symbol={syncClockwise}

View file

@ -23,6 +23,9 @@ export interface ICloneableRepositoryListItem extends IFilterListItem {
/** The clone URL. */
readonly url: string
/** Whether or not the repository is archived */
readonly archived?: boolean
}
function getIcon(gitHubRepo: IAPIRepository): OcticonSymbolType {
@ -44,6 +47,7 @@ const toListItems = (repositories: ReadonlyArray<IAPIRepository>) =>
url: repo.clone_url,
name: repo.name,
icon: getIcon(repo),
archived: repo.archived,
}))
.sort((x, y) => compare(x.name, y.name))

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,21 @@ import { CommitOneLine } from '../../models/commit'
import { PopupType } from '../../models/popup'
import { RepositorySettingsTab } from '../repository-settings/repository-settings'
import { isRepositoryWithForkedGitHubRepository } from '../../models/repository'
import { debounce } from 'lodash'
import { API, APIRepoRuleType, IAPIRepoRuleset } from '../../lib/api'
import { Account } from '../../models/account'
import { getAccountForRepository } from '../../lib/get-account-for-repository'
import { supportsRepoRules } from '../../lib/endpoint-capabilities'
import { enableRepoRules } from '../../lib/feature-flag'
import { InputError } from '../lib/input-description/input-error'
import { InputWarning } from '../lib/input-description/input-warning'
interface ICreateBranchProps {
readonly repository: Repository
readonly targetCommit?: CommitOneLine
readonly upstreamGitHubRepository: GitHubRepository | null
readonly accounts: ReadonlyArray<Account>
readonly cachedRepoRulesets: ReadonlyMap<number, IAPIRepoRuleset>
readonly dispatcher: Dispatcher
readonly onBranchCreatedFromCommit?: () => void
readonly onDismissed: () => void
@ -63,7 +73,7 @@ interface ICreateBranchProps {
}
interface ICreateBranchState {
readonly currentError: Error | null
readonly currentError: { error: Error; isWarning: boolean } | null
readonly branchName: string
readonly startPoint: StartPoint
@ -101,6 +111,92 @@ export class CreateBranch extends React.Component<
ICreateBranchProps,
ICreateBranchState
> {
/**
* Checks repo rules to see if the provided branch name is valid for the
* current user and repository. The "get all rules for a branch" endpoint
* is called first, and if a "creation" or "branch name" rule is found,
* then those rulesets are checked to see if the current user can bypass
* them.
*/
private checkBranchRules = debounce(async (branchName: string) => {
if (
this.props.accounts.length === 0 ||
this.props.upstreamGitHubRepository === null ||
branchName === '' ||
this.state.currentError !== null ||
!enableRepoRules() ||
!supportsRepoRules(this.props.upstreamGitHubRepository.endpoint)
) {
return
}
const account = getAccountForRepository(
this.props.accounts,
this.props.repository
)
if (account === null) {
return
}
const api = API.fromAccount(account)
const branchRules = await api.fetchRepoRulesForBranch(
this.props.upstreamGitHubRepository.owner.login,
this.props.upstreamGitHubRepository.name,
branchName
)
// filter the rules to only the relevant ones and get their IDs. use a Set to dedupe.
const toCheckForBypass = new Set(
branchRules
.filter(
r =>
r.type === APIRepoRuleType.Creation ||
r.type === APIRepoRuleType.BranchNamePattern
)
.map(r => r.ruleset_id)
)
// there are no relevant rules for this branch name, so return
if (toCheckForBypass.size === 0) {
return
}
// check cached rulesets to see which ones the user can bypass
let cannotBypass = false
for (const id of toCheckForBypass) {
const rs = this.props.cachedRepoRulesets.get(id)
if (!rs?.current_user_can_bypass) {
// the user cannot bypass, so stop checking
cannotBypass = true
break
}
}
if (cannotBypass) {
this.setState({
currentError: {
error: new Error(
`Branch name '${branchName}' is restricted by repo rules.`
),
isWarning: false,
},
})
} else {
this.setState({
currentError: {
error: new Error(
`Branch name '${branchName}' is restricted by repo rules, but you can bypass them. Proceed with caution!`
),
isWarning: true,
},
})
}
}, 500)
private readonly ERRORS_ID = 'branch-name-errors'
public constructor(props: ICreateBranchProps) {
super(props)
@ -190,6 +286,37 @@ export class CreateBranch extends React.Component<
}
}
private renderBranchNameErrors() {
const { currentError } = this.state
if (!currentError) {
return null
}
if (currentError.isWarning) {
return (
<Row>
<InputWarning
id={this.ERRORS_ID}
trackedUserInput={this.state.branchName}
>
{currentError.error.message}
</InputWarning>
</Row>
)
} else {
return (
<Row>
<InputError
id={this.ERRORS_ID}
trackedUserInput={this.state.branchName}
>
{currentError.error.message}
</InputError>
</Row>
)
}
}
private onBaseBranchChanged = (startPoint: StartPoint) => {
this.setState({
startPoint,
@ -199,9 +326,9 @@ export class CreateBranch extends React.Component<
public render() {
const disabled =
this.state.branchName.length <= 0 ||
!!this.state.currentError ||
(!!this.state.currentError && !this.state.currentError.isWarning) ||
/^\s*$/.test(this.state.branchName)
const error = this.state.currentError
const hasError = !!this.state.currentError
return (
<Dialog
@ -212,15 +339,16 @@ export class CreateBranch extends React.Component<
loading={this.state.isCreatingBranch}
disabled={this.state.isCreatingBranch}
>
{error ? <DialogError>{error.message}</DialogError> : null}
<DialogContent>
<RefNameTextBox
label="Name"
ariaDescribedBy={hasError ? this.ERRORS_ID : undefined}
initialValue={this.props.initialName}
onValueChange={this.onBranchNameChange}
/>
{this.renderBranchNameErrors()}
{renderBranchNameExistsOnRemoteWarning(
this.state.branchName,
this.props.allBranches
@ -259,14 +387,21 @@ export class CreateBranch extends React.Component<
this.updateBranchName(name)
}
private updateBranchName(branchName: string) {
private async updateBranchName(branchName: string) {
const alreadyExists =
this.props.allBranches.findIndex(b => b.name === branchName) > -1
const currentError = alreadyExists
? new Error(`A branch named ${branchName} already exists`)
? {
error: new Error(`A branch named ${branchName} already exists.`),
isWarning: false,
}
: null
if (!currentError) {
await this.checkBranchRules(branchName)
}
this.setState({
branchName,
currentError,
@ -288,7 +423,10 @@ export class CreateBranch extends React.Component<
// to make sure the startPoint state is valid given the current props.
if (!defaultBranch) {
this.setState({
currentError: new Error('Could not determine the default branch'),
currentError: {
error: new Error('Could not determine the default branch.'),
isWarning: false,
},
})
return
}
@ -299,7 +437,10 @@ export class CreateBranch extends React.Component<
// to make sure the startPoint state is valid given the current props.
if (!upstreamDefaultBranch) {
this.setState({
currentError: new Error('Could not determine the default branch'),
currentError: {
error: new Error('Could not determine the default branch.'),
isWarning: false,
},
})
return
}

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,14 +470,23 @@ export class Dialog extends React.Component<DialogProps, IDialogState> {
// anchor tag masquerading as a button)
let firstTabbable: HTMLElement | null = null
const closeButton = dialog.querySelector(':scope > header button.close')
const closeButton = dialog.querySelector(
':scope > div.dialog-header button.close'
)
if (
closeButton instanceof HTMLElement &&
this.props.focusCloseButtonOnOpen
) {
closeButton.focus()
return
}
const excludedInputTypes = [
':not([type=button])',
':not([type=submit])',
':not([type=reset])',
':not([type=hidden])',
':not([type=checkbox])',
':not([type=radio])',
]
@ -710,6 +723,58 @@ export class Dialog extends React.Component<DialogProps, IDialogState> {
)
}
/**
* Gets the aria-labelledby and aria-describedby attributes for the dialog
* element.
*
* The correct semantics are that the dialog element should have the
* aria-labelledby and the aria-describedby is optional unless the dialog has
* a role of alertdialog, in which case both are required.
*
* However, macOs Ventura introduced a regression in that:
*
* For role of 'dialog' (default), the aria-labelledby is not announced and
* if provided prevents the aria-describedby from being announced. Thus,
* this method will add the aria-labelledby to the aria-describedby in this
* case.
*
* For role of 'alertdialog', the aria-labelledby is announced but not the
* aria-describedby. Thus, this method will add both to the
* aria-labelledby.
*
* Neither of the above is semantically correct tho, hopefully, macOs will be
* fixed in a future release. The issue is known for macOS versions 13.0 to
* the current version of 13.5 as of 2023-07-31.
*
* A known macOS behavior is that if two ids are provided to the
* aria-describedby only the first one is announced with a note about the
* second one existing. This currently does not impact us as we only provide
* one id for non-alert dialogs and the alert dialogs are handled with the
* `aria-labelledby` where both ids are announced.
*
*/
private getAriaAttributes() {
if (!isMacOSVentura()) {
// correct semantics for all other os
return {
'aria-labelledby': this.state.titleId,
'aria-describedby': this.props.ariaDescribedBy,
}
}
if (this.props.role === 'alertdialog') {
return {
'aria-labelledby': `${this.state.titleId} ${this.props.ariaDescribedBy}`,
}
}
return {
'aria-describedby': `${this.state.titleId} ${
this.props.ariaDescribedBy ?? ''
}`,
}
}
public render() {
const className = classNames(
{
@ -729,8 +794,7 @@ export class Dialog extends React.Component<DialogProps, IDialogState> {
onMouseDown={this.onDialogMouseDown}
onKeyDown={this.onKeyDown}
className={className}
aria-labelledby={this.state.titleId}
aria-describedby={this.props.ariaDescribedBy}
{...this.getAriaAttributes()}
tabIndex={-1}
>
{this.renderHeader()}

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>
@ -102,7 +117,7 @@ export class DiffOptions extends React.Component<
onClickOutside={this.closePopover}
>
<h3 id="diff-options-popover-header">
Diff {__DARWIN__ ? 'Preferences' : 'Options'}
Diff {__DARWIN__ ? 'Settings' : 'Options'}
</h3>
{this.renderHideWhitespaceChanges()}
{this.renderShowSideBySide()}
@ -119,8 +134,8 @@ export class DiffOptions extends React.Component<
private renderShowSideBySide() {
return (
<section>
<h4>Diff display</h4>
<fieldset role="radiogroup">
<legend>Diff display</legend>
<RadioButton
value="Unified"
checked={!this.props.showSideBySideDiff}
@ -137,14 +152,14 @@ export class DiffOptions extends React.Component<
}
onSelected={this.onSideBySideSelected}
/>
</section>
</fieldset>
)
}
private renderHideWhitespaceChanges() {
return (
<section>
<h4>Whitespace</h4>
<fieldset>
<legend>Whitespace</legend>
<Checkbox
value={
this.props.hideWhitespaceChanges
@ -162,7 +177,7 @@ export class DiffOptions extends React.Component<
hiding whitespace.
</p>
)}
</section>
</fieldset>
)
}
}

View file

@ -5,6 +5,7 @@ import {
IAPIPullRequest,
IAPIFullRepository,
IAPICheckSuite,
IAPIRepoRuleset,
} from '../../lib/api'
import { shell } from '../../lib/app-shell'
import {
@ -694,6 +695,14 @@ export class Dispatcher {
return this.appStore._checkoutBranch(repository, branch, strategy)
}
/** Check out the given commit. */
public checkoutCommit(
repository: Repository,
commit: CommitOneLine
): Promise<Repository> {
return this.appStore._checkoutCommit(repository, commit)
}
/** Push the current branch. */
public push(repository: Repository): Promise<void> {
return this.appStore._push(repository)
@ -1561,6 +1570,19 @@ export class Dispatcher {
})
}
public async showRepoRulesCommitBypassWarning(
repository: GitHubRepository,
branch: string,
onConfirm: () => void
) {
return this.appStore._showPopup({
type: PopupType.ConfirmRepoRulesBypass,
repository,
branch,
onConfirm,
})
}
/**
* Register a new error handler.
*
@ -2374,6 +2396,10 @@ export class Dispatcher {
return this.appStore._setConfirmDiscardStashSetting(value)
}
public setConfirmCheckoutCommitSetting(value: boolean) {
return this.appStore._setConfirmCheckoutCommitSetting(value)
}
public setConfirmForcePushSetting(value: boolean) {
return this.appStore._setConfirmForcePushSetting(value)
}
@ -4064,4 +4090,8 @@ export class Dispatcher {
public appFocusedElementChanged() {
this.appStore._appFocusedElementChanged()
}
public updateCachedRepoRulesets(rulesets: Array<IAPIRepoRuleset | null>) {
this.appStore._updateCachedRepoRulesets(rulesets)
}
}

View file

@ -178,10 +178,6 @@ export class CommitDragElement extends React.Component<
commit={commit}
selectedCommits={selectedCommits}
emoji={emoji}
canBeUndone={false}
canBeAmended={false}
canBeResetTo={false}
isLocal={false}
showUnpushedIndicator={false}
/>
</div>

View file

@ -1,20 +1,15 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import * as React from 'react'
import { Commit, CommitOneLine } from '../../models/commit'
import { Commit } from '../../models/commit'
import { GitHubRepository } from '../../models/github-repository'
import { IAvatarUser, getAvatarUsersForCommit } from '../../models/avatar'
import { RichText } from '../lib/rich-text'
import { RelativeTime } from '../relative-time'
import { getDotComAPIEndpoint } from '../../lib/api'
import { clipboard } from 'electron'
import { showContextualMenu } from '../../lib/menu-item'
import { CommitAttribution } from '../lib/commit-attribution'
import { AvatarStack } from '../lib/avatar-stack'
import { IMenuItem } from '../../lib/menu-item'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { Draggable } from '../lib/draggable'
import { enableResetToCommit } from '../../lib/feature-flag'
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
import {
DragType,
@ -28,19 +23,6 @@ interface ICommitProps {
readonly commit: Commit
readonly selectedCommits: ReadonlyArray<Commit>
readonly emoji: Map<string, string>
readonly isLocal: boolean
readonly canBeUndone: boolean
readonly canBeAmended: boolean
readonly canBeResetTo: boolean
readonly onResetToCommit?: (commit: Commit) => void
readonly onUndoCommit?: (commit: Commit) => void
readonly onRevertCommit?: (commit: Commit) => void
readonly onViewCommitOnGitHub?: (sha: string) => void
readonly onCreateBranch?: (commit: CommitOneLine) => void
readonly onCreateTag?: (targetCommitSha: string) => void
readonly onDeleteTag?: (tagName: string) => void
readonly onAmendCommit?: (commit: Commit, isLocalCommit: boolean) => void
readonly onCherryPick?: (commits: ReadonlyArray<CommitOneLine>) => void
readonly onRenderCommitDragElement?: (commit: Commit) => void
readonly onRemoveDragElement?: () => void
readonly onSquash?: (
@ -48,9 +30,13 @@ interface ICommitProps {
squashOnto: Commit,
isInvokedByContextMenu: boolean
) => void
/**
* Whether or not the commit can be dragged for certain operations like squash,
* cherry-pick, reorder, etc. Defaults to false.
*/
readonly isDraggable?: boolean
readonly showUnpushedIndicator: boolean
readonly unpushedIndicatorTitle?: string
readonly unpushedTags?: ReadonlyArray<string>
readonly disableSquashing?: boolean
readonly isMultiCommitOperationInProgress?: boolean
}
@ -126,7 +112,7 @@ export class CommitListItem extends React.PureComponent<
author: { date },
} = commit
const isDraggable = this.isDraggable()
const isDraggable = this.props.isDraggable || false
const hasEmptySummary = commit.summary.length === 0
const commitSummary = hasEmptySummary
? 'Empty commit message'
@ -151,7 +137,6 @@ export class CommitListItem extends React.PureComponent<
>
<div
className="commit"
onContextMenu={this.onContextMenu}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onMouseUp={this.onMouseUp}
@ -211,253 +196,6 @@ export class CommitListItem extends React.PureComponent<
)
}
private onAmendCommit = () => {
this.props.onAmendCommit?.(this.props.commit, this.props.isLocal)
}
private onCopySHA = () => {
clipboard.writeText(this.props.commit.sha)
}
private onCopyTags = () => {
clipboard.writeText(this.props.commit.tags.join(' '))
}
private onViewOnGitHub = () => {
if (this.props.onViewCommitOnGitHub) {
this.props.onViewCommitOnGitHub(this.props.commit.sha)
}
}
private onCreateTag = () => {
if (this.props.onCreateTag) {
this.props.onCreateTag(this.props.commit.sha)
}
}
private onCherryPick = () => {
if (this.props.onCherryPick !== undefined) {
this.props.onCherryPick(this.props.selectedCommits)
}
}
private onSquash = () => {
if (this.props.onSquash !== undefined) {
this.props.onSquash(this.props.selectedCommits, this.props.commit, true)
}
}
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
let items: IMenuItem[] = []
if (this.props.selectedCommits.length > 1) {
items = this.getContextMenuMultipleCommits()
} else {
items = this.getContextMenuForSingleCommit()
}
showContextualMenu(items)
}
private getContextMenuForSingleCommit(): IMenuItem[] {
let viewOnGitHubLabel = 'View on GitHub'
const gitHubRepository = this.props.gitHubRepository
if (
gitHubRepository &&
gitHubRepository.endpoint !== getDotComAPIEndpoint()
) {
viewOnGitHubLabel = 'View on GitHub Enterprise'
}
const items: IMenuItem[] = []
if (this.props.canBeAmended) {
items.push({
label: __DARWIN__ ? 'Amend Commit…' : 'Amend commit…',
action: this.onAmendCommit,
})
}
if (this.props.canBeUndone) {
items.push({
label: __DARWIN__ ? 'Undo Commit…' : 'Undo commit…',
action: () => {
if (this.props.onUndoCommit) {
this.props.onUndoCommit(this.props.commit)
}
},
enabled: this.props.onUndoCommit !== undefined,
})
}
if (enableResetToCommit()) {
items.push({
label: __DARWIN__ ? 'Reset to Commit…' : 'Reset to commit…',
action: () => {
if (this.props.onResetToCommit) {
this.props.onResetToCommit(this.props.commit)
}
},
enabled:
this.props.canBeResetTo && this.props.onResetToCommit !== undefined,
})
}
items.push(
{
label: __DARWIN__
? 'Revert Changes in Commit'
: 'Revert changes in commit',
action: () => {
if (this.props.onRevertCommit) {
this.props.onRevertCommit(this.props.commit)
}
},
enabled: this.props.onRevertCommit !== undefined,
},
{ type: 'separator' },
{
label: __DARWIN__
? 'Create Branch from Commit'
: 'Create branch from commit',
action: () => {
if (this.props.onCreateBranch) {
this.props.onCreateBranch(this.props.commit)
}
},
},
{
label: 'Create Tag…',
action: this.onCreateTag,
enabled: this.props.onCreateTag !== undefined,
}
)
const deleteTagsMenuItem = this.getDeleteTagsMenuItem()
if (deleteTagsMenuItem !== null) {
items.push(
{
type: 'separator',
},
deleteTagsMenuItem
)
}
const darwinTagsLabel =
this.props.commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag'
const windowTagsLabel =
this.props.commit.tags.length > 1 ? 'Copy tags' : 'Copy tag'
items.push(
{
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
action: this.onCherryPick,
enabled: this.canCherryPick(),
},
{ type: 'separator' },
{
label: 'Copy SHA',
action: this.onCopySHA,
},
{
label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel,
action: this.onCopyTags,
enabled: this.props.commit.tags.length > 0,
},
{
label: viewOnGitHubLabel,
action: this.onViewOnGitHub,
enabled: !this.props.isLocal && !!gitHubRepository,
}
)
return items
}
private getContextMenuMultipleCommits(): IMenuItem[] {
const count = this.props.selectedCommits.length
return [
{
label: __DARWIN__
? `Cherry-pick ${count} Commits…`
: `Cherry-pick ${count} commits…`,
action: this.onCherryPick,
enabled: this.canCherryPick(),
},
{
label: __DARWIN__
? `Squash ${count} Commits…`
: `Squash ${count} commits…`,
action: this.onSquash,
enabled: this.canSquash(),
},
]
}
private isDraggable(): boolean {
const { onCherryPick, onSquash, isMultiCommitOperationInProgress } =
this.props
return (
(onCherryPick !== undefined || onSquash !== undefined) &&
isMultiCommitOperationInProgress === false
)
}
private canCherryPick(): boolean {
const { onCherryPick, isMultiCommitOperationInProgress } = this.props
return (
onCherryPick !== undefined && isMultiCommitOperationInProgress === false
)
}
private canSquash(): boolean {
const { onSquash, disableSquashing, isMultiCommitOperationInProgress } =
this.props
return (
onSquash !== undefined &&
disableSquashing === false &&
isMultiCommitOperationInProgress === false
)
}
private getDeleteTagsMenuItem(): IMenuItem | null {
const { unpushedTags, onDeleteTag, commit } = this.props
if (
onDeleteTag === undefined ||
unpushedTags === undefined ||
commit.tags.length === 0
) {
return null
}
if (commit.tags.length === 1) {
const tagName = commit.tags[0]
return {
label: `Delete tag ${tagName}`,
action: () => onDeleteTag(tagName),
enabled: unpushedTags.includes(tagName),
}
}
// Convert tags to a Set to avoid O(n^2)
const unpushedTagsSet = new Set(unpushedTags)
return {
label: 'Delete tag…',
submenu: commit.tags.map(tagName => {
return {
label: tagName,
action: () => onDeleteTag(tagName),
enabled: unpushedTagsSet.has(tagName),
}
}),
}
}
private onDragStart = () => {
// Removes active status from commit selection so they do not appear
// highlighted in commit list.

View file

@ -7,6 +7,14 @@ import { List } from '../lib/list'
import { arrayEquals } from '../../lib/equality'
import { DragData, DragType } from '../../models/drag-drop'
import classNames from 'classnames'
import memoizeOne from 'memoize-one'
import { IMenuItem, showContextualMenu } from '../../lib/menu-item'
import {
enableCheckoutCommit,
enableResetToCommit,
} from '../../lib/feature-flag'
import { getDotComAPIEndpoint } from '../../lib/api'
import { clipboard } from 'electron'
const RowHeight = 50
@ -70,6 +78,12 @@ interface ICommitListProps {
*/
readonly onCreateBranch?: (commit: CommitOneLine) => void
/**
* Callback to fire to checkout the selected commit in the current
* repository
*/
readonly onCheckoutCommit?: (commit: CommitOneLine) => void
/** Callback to fire to open the dialog to create a new tag on the given commit */
readonly onCreateTag?: (targetCommitSha: string) => void
@ -142,6 +156,11 @@ interface ICommitListProps {
/** A component which displays the list of commits. */
export class CommitList extends React.Component<ICommitListProps, {}> {
private commitsHash = memoize(makeCommitsHash, arrayEquals)
private commitIndexBySha = memoizeOne(
(commitSHAs: ReadonlyArray<string>) =>
new Map(commitSHAs.map((sha, index) => [sha, index]))
)
private listRef = React.createRef<List>()
private getVisibleCommits(): ReadonlyArray<Commit> {
@ -156,6 +175,9 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
return commits
}
private isLocalCommit = (sha: string) =>
this.props.localCommitSHAs.includes(sha)
private renderCommit = (row: number) => {
const sha = this.props.commitSHAs[row]
const commit = this.props.commitLookup.get(sha)
@ -169,52 +191,27 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
return null
}
const tagsToPushSet = new Set(this.props.tagsToPush ?? [])
const isLocal = this.props.localCommitSHAs.includes(commit.sha)
const unpushedTags = commit.tags.filter(tagName =>
tagsToPushSet.has(tagName)
)
const isLocal = this.isLocalCommit(commit.sha)
const unpushedTags = this.getUnpushedTags(commit)
const showUnpushedIndicator =
(isLocal || unpushedTags.length > 0) &&
this.props.isLocalRepository === false
// The user can reset to any commit up to the first non-local one (included).
// They cannot reset to the most recent commit... because they're already
// in it.
const isResettableCommit =
row > 0 && row <= this.props.localCommitSHAs.length
return (
<CommitListItem
key={commit.sha}
gitHubRepository={this.props.gitHubRepository}
isLocal={isLocal}
canBeUndone={this.props.canUndoCommits === true && isLocal && row === 0}
canBeAmended={this.props.canAmendCommits === true && row === 0}
canBeResetTo={
this.props.canResetToCommits === true && isResettableCommit
}
showUnpushedIndicator={showUnpushedIndicator}
unpushedIndicatorTitle={this.getUnpushedIndicatorTitle(
isLocal,
unpushedTags.length
)}
unpushedTags={unpushedTags}
commit={commit}
emoji={this.props.emoji}
onCreateBranch={this.props.onCreateBranch}
onCreateTag={this.props.onCreateTag}
onDeleteTag={this.props.onDeleteTag}
onCherryPick={this.props.onCherryPick}
isDraggable={this.props.isMultiCommitOperationInProgress === false}
onSquash={this.onSquash}
onResetToCommit={this.props.onResetToCommit}
onUndoCommit={this.props.onUndoCommit}
onRevertCommit={this.props.onRevertCommit}
onAmendCommit={this.props.onAmendCommit}
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
selectedCommits={this.lookupCommits(this.props.selectedSHAs)}
selectedCommits={this.selectedCommits}
onRenderCommitDragElement={this.onRenderCommitDragElement}
onRemoveDragElement={this.props.onRemoveCommitDragElement}
disableSquashing={this.props.disableSquashing}
@ -252,10 +249,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
}
private onRenderCommitDragElement = (commit: Commit) => {
this.props.onRenderCommitDragElement?.(
commit,
this.lookupCommits(this.props.selectedSHAs)
)
this.props.onRenderCommitDragElement?.(commit, this.selectedCommits)
}
private getUnpushedIndicatorTitle(
@ -275,6 +269,15 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
return undefined
}
private get selectedCommits() {
return this.lookupCommits(this.props.selectedSHAs)
}
private getUnpushedTags(commit: Commit) {
const tagsToPushSet = new Set(this.props.tagsToPush ?? [])
return commit.tags.filter(tagName => tagsToPushSet.has(tagName))
}
private onSelectionChanged = (rows: ReadonlyArray<number>) => {
const selectedShas = rows.map(r => this.props.commitSHAs[r])
const selectedCommits = this.lookupCommits(selectedShas)
@ -345,13 +348,8 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
this.props.onCompareListScrolled?.(scrollTop)
}
private rowForSHA(sha_: string | null): number {
const sha = sha_
if (!sha) {
return -1
}
return this.props.commitSHAs.findIndex(s => s === sha)
private rowForSHA(sha: string) {
return this.commitIndexBySha(this.props.commitSHAs).get(sha) ?? -1
}
private getRowCustomClassMap = () => {
@ -410,6 +408,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
onDropDataInsertion={this.onDropDataInsertion}
onSelectionChanged={this.onSelectionChanged}
onSelectedRowChanged={this.onSelectedRowChanged}
onRowContextMenu={this.onRowContextMenu}
selectionMode="multi"
onScroll={this.onScroll}
insertionDragType={
@ -432,6 +431,247 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
)
}
private onRowContextMenu = (
row: number,
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault()
const sha = this.props.commitSHAs[row]
const commit = this.props.commitLookup.get(sha)
if (commit === undefined) {
if (__DEV__) {
log.warn(
`[CommitList]: the commit '${sha}' does not exist in the cache`
)
}
return
}
let items: IMenuItem[] = []
if (this.props.selectedSHAs.length > 1) {
items = this.getContextMenuMultipleCommits(commit)
} else {
items = this.getContextMenuForSingleCommit(row, commit)
}
showContextualMenu(items)
}
private getContextMenuForSingleCommit(
row: number,
commit: Commit
): IMenuItem[] {
const isLocal = this.isLocalCommit(commit.sha)
const canBeUndone =
this.props.canUndoCommits === true && isLocal && row === 0
const canBeAmended = this.props.canAmendCommits === true && row === 0
// The user can reset to any commit up to the first non-local one (included).
// They cannot reset to the most recent commit... because they're already
// in it.
const isResettableCommit =
row > 0 && row <= this.props.localCommitSHAs.length
const canBeResetTo =
this.props.canResetToCommits === true && isResettableCommit
const canBeCheckedOut = row > 0 //Cannot checkout the current commit
let viewOnGitHubLabel = 'View on GitHub'
const gitHubRepository = this.props.gitHubRepository
if (
gitHubRepository &&
gitHubRepository.endpoint !== getDotComAPIEndpoint()
) {
viewOnGitHubLabel = 'View on GitHub Enterprise'
}
const items: IMenuItem[] = []
if (canBeAmended) {
items.push({
label: __DARWIN__ ? 'Amend Commit…' : 'Amend commit…',
action: () => this.props.onAmendCommit?.(commit, isLocal),
})
}
if (canBeUndone) {
items.push({
label: __DARWIN__ ? 'Undo Commit…' : 'Undo commit…',
action: () => {
if (this.props.onUndoCommit) {
this.props.onUndoCommit(commit)
}
},
enabled: this.props.onUndoCommit !== undefined,
})
}
if (enableResetToCommit()) {
items.push({
label: __DARWIN__ ? 'Reset to Commit…' : 'Reset to commit…',
action: () => {
if (this.props.onResetToCommit) {
this.props.onResetToCommit(commit)
}
},
enabled: canBeResetTo && this.props.onResetToCommit !== undefined,
})
}
if (enableCheckoutCommit()) {
items.push({
label: __DARWIN__ ? 'Checkout Commit' : 'Checkout commit',
action: () => {
this.props.onCheckoutCommit?.(commit)
},
enabled: canBeCheckedOut && this.props.onCheckoutCommit !== undefined,
})
}
items.push(
{
label: __DARWIN__
? 'Revert Changes in Commit'
: 'Revert changes in commit',
action: () => {
if (this.props.onRevertCommit) {
this.props.onRevertCommit(commit)
}
},
enabled: this.props.onRevertCommit !== undefined,
},
{ type: 'separator' },
{
label: __DARWIN__
? 'Create Branch from Commit'
: 'Create branch from commit',
action: () => {
if (this.props.onCreateBranch) {
this.props.onCreateBranch(commit)
}
},
},
{
label: 'Create Tag…',
action: () => this.props.onCreateTag?.(commit.sha),
enabled: this.props.onCreateTag !== undefined,
}
)
const deleteTagsMenuItem = this.getDeleteTagsMenuItem(commit)
if (deleteTagsMenuItem !== null) {
items.push(
{
type: 'separator',
},
deleteTagsMenuItem
)
}
const darwinTagsLabel = commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag'
const windowTagsLabel = commit.tags.length > 1 ? 'Copy tags' : 'Copy tag'
items.push(
{
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
action: () => this.props.onCherryPick?.(this.selectedCommits),
enabled: this.canCherryPick(),
},
{ type: 'separator' },
{
label: 'Copy SHA',
action: () => clipboard.writeText(commit.sha),
},
{
label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel,
action: () => clipboard.writeText(commit.tags.join(' ')),
enabled: commit.tags.length > 0,
},
{
label: viewOnGitHubLabel,
action: () => this.props.onViewCommitOnGitHub?.(commit.sha),
enabled: !isLocal && !!gitHubRepository,
}
)
return items
}
private canCherryPick(): boolean {
const { onCherryPick, isMultiCommitOperationInProgress } = this.props
return (
onCherryPick !== undefined && isMultiCommitOperationInProgress === false
)
}
private canSquash(): boolean {
const { onSquash, disableSquashing, isMultiCommitOperationInProgress } =
this.props
return (
onSquash !== undefined &&
disableSquashing === false &&
isMultiCommitOperationInProgress === false
)
}
private getDeleteTagsMenuItem(commit: Commit): IMenuItem | null {
const { onDeleteTag } = this.props
const unpushedTags = this.getUnpushedTags(commit)
if (
onDeleteTag === undefined ||
unpushedTags === undefined ||
commit.tags.length === 0
) {
return null
}
if (commit.tags.length === 1) {
const tagName = commit.tags[0]
return {
label: `Delete tag ${tagName}`,
action: () => onDeleteTag(tagName),
enabled: unpushedTags.includes(tagName),
}
}
// Convert tags to a Set to avoid O(n^2)
const unpushedTagsSet = new Set(unpushedTags)
return {
label: 'Delete tag…',
submenu: commit.tags.map(tagName => {
return {
label: tagName,
action: () => onDeleteTag(tagName),
enabled: unpushedTagsSet.has(tagName),
}
}),
}
}
private getContextMenuMultipleCommits(commit: Commit): IMenuItem[] {
const count = this.props.selectedSHAs.length
return [
{
label: __DARWIN__
? `Cherry-pick ${count} Commits…`
: `Cherry-pick ${count} commits…`,
action: () => this.props.onCherryPick?.(this.selectedCommits),
enabled: this.canCherryPick(),
},
{
label: __DARWIN__
? `Squash ${count} Commits…`
: `Squash ${count} commits…`,
action: () => this.onSquash(this.selectedCommits, commit, true),
enabled: this.canSquash(),
},
]
}
private onDropDataInsertion = (row: number, data: DragData) => {
if (
this.props.onDropCommitInsertion === undefined ||

View file

@ -19,6 +19,7 @@ import _ from 'lodash'
import { LinkButton } from '../lib/link-button'
import { UnreachableCommitsTab } from './unreachable-commits-dialog'
import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha'
import memoizeOne from 'memoize-one'
interface ICommitSummaryProps {
readonly repository: Repository
@ -38,7 +39,7 @@ interface ICommitSummaryProps {
readonly onExpandChanged: (isExpanded: boolean) => void
readonly onDescriptionBottomChanged: (descriptionBottom: Number) => void
readonly onDescriptionBottomChanged: (descriptionBottom: number) => void
readonly hideDescriptionBorder: boolean
@ -141,21 +142,6 @@ function getCommitSummary(selectedCommits: ReadonlyArray<Commit>) {
: selectedCommits[0].summary
}
function getCountCommitsNotInDiff(
selectedCommits: ReadonlyArray<Commit>,
shasInDiff: ReadonlyArray<string>
) {
if (selectedCommits.length === 1) {
return 0
}
const excludedCommits = selectedCommits.filter(
({ sha }) => !shasInDiff.includes(sha)
)
return excludedCommits.length
}
/**
* Helper function which determines if two commit objects
* have the same commit summary and body.
@ -173,6 +159,23 @@ export class CommitSummary extends React.Component<
private updateOverflowTimeoutId: NodeJS.Immediate | null = null
private descriptionRef: HTMLDivElement | null = null
private getCountCommitsNotInDiff = memoizeOne(
(
selectedCommits: ReadonlyArray<Commit>,
shasInDiff: ReadonlyArray<string>
) => {
if (selectedCommits.length === 1) {
return 0
} else {
const shas = new Set(shasInDiff)
return selectedCommits.reduce(
(acc, c) => acc + (shas.has(c.sha) ? 0 : 1),
0
)
}
}
)
public constructor(props: ICommitSummaryProps) {
super(props)
@ -369,7 +372,7 @@ export class CommitSummary extends React.Component<
return
}
const excludedCommitsCount = getCountCommitsNotInDiff(
const excludedCommitsCount = this.getCountCommitsNotInDiff(
selectedCommits,
shasInDiff
)
@ -455,7 +458,7 @@ export class CommitSummary extends React.Component<
)
}
const commitsNotInDiff = getCountCommitsNotInDiff(
const commitsNotInDiff = this.getCountCommitsNotInDiff(
selectedCommits,
shasInDiff
)
@ -501,10 +504,7 @@ export class CommitSummary extends React.Component<
{this.renderLinesChanged()}
{this.renderTags()}
<li
className="commit-summary-meta-item without-truncation"
title="Diff Options"
>
<li className="commit-summary-meta-item without-truncation">
<DiffOptions
isInteractiveDiff={false}
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}

View file

@ -8,22 +8,12 @@ import { Octicon, iconForStatus } from '../octicons'
interface ICommittedFileItemProps {
readonly availableWidth: number
readonly file: CommittedFileChange
readonly onContextMenu?: (
file: CommittedFileChange,
event: React.MouseEvent<HTMLDivElement>
) => void
}
export class CommittedFileItem extends React.Component<ICommittedFileItemProps> {
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.props.onContextMenu !== undefined) {
this.props.onContextMenu(this.props.file, event)
}
}
public render() {
const { file } = this.props
const status = file.status
const { status } = file
const fileStatus = mapStatus(status)
const listItemPadding = 10 * 2
@ -36,11 +26,12 @@ export class CommittedFileItem extends React.Component<ICommittedFileItemProps>
statusWidth
return (
<div className="file" onContextMenu={this.onContextMenu}>
<div className="file">
<PathLabel
path={file.path}
status={file.status}
availableWidth={availablePathWidth}
ariaHidden={true}
/>
<Octicon

View file

@ -38,6 +38,7 @@ interface ICompareSidebarProps {
readonly emoji: Map<string, string>
readonly commitLookup: Map<string, Commit>
readonly localCommitSHAs: ReadonlyArray<string>
readonly askForConfirmationOnCheckoutCommit: boolean
readonly dispatcher: Dispatcher
readonly currentBranch: Branch | null
readonly selectedCommitShas: ReadonlyArray<string>
@ -255,6 +256,7 @@ export class CompareSidebar extends React.Component<
onCommitsSelected={this.onCommitsSelected}
onScroll={this.onScroll}
onCreateBranch={this.onCreateBranch}
onCheckoutCommit={this.onCheckoutCommit}
onCreateTag={this.onCreateTag}
onDeleteTag={this.onDeleteTag}
onCherryPick={this.onCherryPick}
@ -599,6 +601,20 @@ export class CompareSidebar extends React.Component<
})
}
private onCheckoutCommit = (commit: CommitOneLine) => {
const { repository, dispatcher, askForConfirmationOnCheckoutCommit } =
this.props
if (!askForConfirmationOnCheckoutCommit) {
dispatcher.checkoutCommit(repository, commit)
} else {
dispatcher.showPopup({
type: PopupType.ConfirmCheckoutCommit,
commit: commit,
repository,
})
}
}
private onDeleteTag = (tagName: string) => {
this.props.dispatcher.showDeleteTagDialog(this.props.repository, tagName)
}

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'
@ -30,7 +31,6 @@ export class FileList extends React.Component<IFileListProps> {
<CommittedFileItem
file={this.props.files[row]}
availableWidth={this.props.availableWidth}
onContextMenu={this.props.onContextMenu}
/>
)
}
@ -39,6 +39,20 @@ export class FileList extends React.Component<IFileListProps> {
return file ? this.props.files.findIndex(f => f.path === file.path) : -1
}
private onRowContextMenu = (
row: number,
event: React.MouseEvent<HTMLDivElement>
) => {
this.props.onContextMenu?.(this.props.files[row], event)
}
private getFileAriaLabel = (row: number) => {
const file = this.props.files[row]
const { path, status } = file
const fileStatus = mapStatus(status)
return `${path} ${fileStatus}`
}
public render() {
return (
<div className="file-list">
@ -49,6 +63,8 @@ export class FileList extends React.Component<IFileListProps> {
selectedRows={[this.rowForFile(this.props.selectedFile)]}
onSelectedRowChanged={this.onSelectedRowChanged}
onRowDoubleClick={this.props.onRowDoubleClick}
onRowContextMenu={this.onRowContextMenu}
getRowAriaLabel={this.getFileAriaLabel}
/>
</div>
)

View file

@ -213,7 +213,7 @@ export class SelectedCommits extends React.Component<
this.setState({ isExpanded })
}
private onDescriptionBottomChanged = (descriptionBottom: Number) => {
private onDescriptionBottomChanged = (descriptionBottom: number) => {
if (this.historyRef) {
const historyBottom = this.historyRef.getBoundingClientRect().bottom
this.setState({

View file

@ -6,6 +6,7 @@ import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { Ref } from './ref'
import { IStashEntry } from '../../models/stash-entry'
import { enableMoveStash } from '../../lib/feature-flag'
export function renderBranchHasRemoteWarning(branch: Branch) {
if (branch.upstream != null) {
@ -47,7 +48,7 @@ export function renderBranchNameExistsOnRemoteWarning(
}
export function renderStashWillBeLostWarning(stash: IStashEntry | null) {
if (stash === null) {
if (stash === null || enableMoveStash()) {
return null
}
return (

View file

@ -253,10 +253,6 @@ export class ConfigureGitUser extends React.Component<
commit={dummyCommit}
emoji={emoji}
gitHubRepository={null}
canBeUndone={false}
canBeAmended={false}
canBeResetTo={false}
isLocal={false}
showUnpushedIndicator={false}
selectedCommits={[dummyCommit]}
/>

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()}
@ -358,7 +360,9 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
rowCount={this.state.rows.length}
rowRenderer={this.renderRow}
rowHeight={this.props.rowHeight}
selectedRows={[this.state.selectedRow]}
selectedRows={
this.state.selectedRow === -1 ? [] : [this.state.selectedRow]
}
onSelectedRowChanged={this.onSelectedRowChanged}
onRowClick={this.onRowClick}
onRowKeyDown={this.onRowKeyDown}

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

@ -0,0 +1,24 @@
import * as React from 'react'
import {
IBaseInputDescriptionProps,
InputDescription,
InputDescriptionType,
} from './input-description'
/**
* An caption element with app-standard styles for captions to be used with inputs.
*
* Provide `children` elements to render as the content for the error element.
*/
export class Caption extends React.Component<IBaseInputDescriptionProps> {
public render() {
return (
<InputDescription
inputDescriptionType={InputDescriptionType.Caption}
{...this.props}
>
{this.props.children}
</InputDescription>
)
}
}

View file

@ -0,0 +1,131 @@
import * as React from 'react'
import { Octicon } from '../../octicons'
import * as OcticonSymbol from '../../octicons/octicons.generated'
import classNames from 'classnames'
import { AriaLiveContainer } from '../../accessibility/aria-live-container'
import { assertNever } from '../../../lib/fatal-error'
export enum InputDescriptionType {
Caption,
Warning,
Error,
}
export interface IBaseInputDescriptionProps {
/** The ID for description. This ID needs be linked to the associated input
* using the `aria-describedby` attribute for screen reader users. */
readonly id: string
/**
* There is a common pattern that we may need to announce a message in
* response to user input. Unfortunately, aria-live announcements are
* interrupted by continued user input. We can force a rereading of a message
* by appending an invisible character when the user finishes their input.
*
* This prop allows us to pass in when the user input changes. We can append
* the invisible character to force the screen reader to read the message
* again after each input. To prevent the message from being read too much, we
* debounce the message.
*/
readonly trackedUserInput?: string | boolean
readonly ariaLiveMessage?: string
}
export interface IInputDescriptionProps extends IBaseInputDescriptionProps {
/** Whether the description is a caption, a warning, or an error.
*
* Captions are styled with a muted color and are used to provide additional information about the input.
* Warnings are styled with a orange color with warning icon and are used to communicate that the input is valid but may have unintended consequences.
* Errors are styled with a red color with error icon and are used to communicate that the input is invalid.
*/
readonly inputDescriptionType: InputDescriptionType
}
/**
* An Input description element with app-standard styles for captions, warnings,
* and errors of inputs.
*
* Provide `children` elements to render as the content for the error element.
*/
export class InputDescription extends React.Component<IInputDescriptionProps> {
private getClassName() {
const { inputDescriptionType: type } = this.props
switch (type) {
case InputDescriptionType.Caption:
return classNames('input-description', 'input-description-caption')
case InputDescriptionType.Warning:
return classNames('input-description', 'input-description-warning')
case InputDescriptionType.Error:
return classNames('input-description', 'input-description-error')
default:
return assertNever(type, `Unknown input type ${type}`)
}
}
private renderIcon() {
const { inputDescriptionType: type } = this.props
switch (type) {
case InputDescriptionType.Caption:
return null
case InputDescriptionType.Warning:
return <Octicon symbol={OcticonSymbol.alert} />
case InputDescriptionType.Error:
return <Octicon symbol={OcticonSymbol.stop} />
default:
return assertNever(type, `Unknown input type ${type}`)
}
}
/** If a input is a warning or an error that is displayed in response to
* tracked user input. We want it announced on user input debounce. */
private renderAriaLiveContainer() {
if (
this.props.inputDescriptionType === InputDescriptionType.Caption ||
this.props.trackedUserInput === undefined ||
this.props.ariaLiveMessage === undefined
) {
return null
}
return (
<AriaLiveContainer
message={this.props.ariaLiveMessage}
trackedUserInput={this.props.trackedUserInput}
/>
)
}
/** If the input is an error, and we are not announcing it based on user
* input. We should have a role of alert so that it at least announced once.
* This may be true if the error is displayed in response to a form submission.
* */
private getRole() {
if (
this.props.inputDescriptionType === InputDescriptionType.Error &&
this.props.trackedUserInput === undefined
) {
return 'alert'
}
return undefined
}
public render() {
return (
<>
<div
id={this.props.id}
className={this.getClassName()}
role={this.getRole()}
>
{this.renderIcon()}
<div>{this.props.children}</div>
</div>
{this.renderAriaLiveContainer()}
</>
)
}
}

View file

@ -0,0 +1,24 @@
import * as React from 'react'
import {
IBaseInputDescriptionProps,
InputDescription,
InputDescriptionType,
} from './input-description'
/**
* An Error component with app-standard styles for errors to be used with inputs.
*
* Provide `children` elements to render as the content for the error element.
*/
export class InputError extends React.Component<IBaseInputDescriptionProps> {
public render() {
return (
<InputDescription
inputDescriptionType={InputDescriptionType.Error}
{...this.props}
>
{this.props.children}
</InputDescription>
)
}
}

View file

@ -0,0 +1,24 @@
import * as React from 'react'
import {
IBaseInputDescriptionProps,
InputDescription,
InputDescriptionType,
} from './input-description'
/**
* An Warning component with app-standard styles for warnings to be used with inputs.
*
* Provide `children` elements to render as the content for the error element.
*/
export class InputWarning extends React.Component<IBaseInputDescriptionProps> {
public render() {
return (
<InputDescription
inputDescriptionType={InputDescriptionType.Warning}
{...this.props}
>
{this.props.children}
</InputDescription>
)
}
}

View file

@ -4,6 +4,7 @@ import { Disposable } from 'event-kit'
import * as React from 'react'
import { dragAndDropManager } from '../../../lib/drag-and-drop-manager'
import { DragData, DragType, DropTargetType } from '../../../models/drag-drop'
import { RowIndexPath } from './list-row-index-path'
enum InsertionFeedbackType {
None,
@ -13,11 +14,11 @@ enum InsertionFeedbackType {
interface IListItemInsertionOverlayProps {
readonly onDropDataInsertion?: (
insertionIndex: number,
insertionIndex: RowIndexPath,
data: DragData
) => void
readonly itemIndex: number
readonly itemIndex: RowIndexPath
readonly dragType: DragType
}
@ -188,7 +189,10 @@ export class ListItemInsertionOverlay extends React.PureComponent<
let index = this.props.itemIndex
if (this.state.feedbackType === InsertionFeedbackType.Bottom) {
index++
index = {
...index,
row: index.row + 1,
}
}
this.props.onDropDataInsertion(index, dragAndDropManager.dragData)
}

View file

@ -0,0 +1,92 @@
export type RowIndexPath = {
readonly row: number
readonly section: number
}
export const InvalidRowIndexPath: RowIndexPath = { section: -1, row: -1 }
export function rowIndexPathEquals(a: RowIndexPath, b: RowIndexPath): boolean {
return a.section === b.section && a.row === b.row
}
export function getTotalRowCount(rowCount: ReadonlyArray<number>) {
return rowCount.reduce((sum, count) => sum + count, 0)
}
export function rowIndexPathToGlobalIndex(
indexPath: RowIndexPath,
rowCount: ReadonlyArray<number>
): number | null {
if (!isValidRow(indexPath, rowCount)) {
return null
}
let index = 0
for (let section = 0; section < indexPath.section; section++) {
index += rowCount[section]
}
index += indexPath.row
return index
}
export function globalIndexToRowIndexPath(
index: number,
rowCount: ReadonlyArray<number>
): RowIndexPath | null {
if (index < 0 || index >= getTotalRowCount(rowCount)) {
return null
}
let section = 0
let row = index
while (row >= rowCount[section]) {
row -= rowCount[section]
section++
}
return { section, row }
}
export function isValidRow(
indexPath: RowIndexPath,
rowCount: ReadonlyArray<number>
) {
return (
indexPath.section >= 0 &&
indexPath.section < rowCount.length &&
indexPath.row >= 0 &&
indexPath.row < rowCount[indexPath.section]
)
}
export function getFirstRowIndexPath(
rowCount: ReadonlyArray<number>
): RowIndexPath | null {
if (rowCount.length > 0) {
for (let section = 0; section < rowCount.length; section++) {
if (rowCount[section] > 0) {
return { section, row: 0 }
}
}
}
return null
}
export function getLastRowIndexPath(
rowCount: ReadonlyArray<number>
): RowIndexPath | null {
if (rowCount.length > 0) {
for (let section = rowCount.length - 1; section >= 0; section--) {
if (rowCount[section] > 0) {
return { section, row: rowCount[section] - 1 }
}
}
}
return null
}

View file

@ -1,12 +1,16 @@
import * as React from 'react'
import classNames from 'classnames'
import { RowIndexPath } from './list-row-index-path'
interface IListRowProps {
/** whether or not the section to which this row belongs has a header */
readonly sectionHasHeader: boolean
/** the total number of row in this list */
readonly rowCount: number
/** the index of the row in the list */
readonly rowIndex: number
readonly rowIndex: RowIndexPath
/** custom styles to provide to the row */
readonly style?: React.CSSProperties
@ -21,39 +25,51 @@ interface IListRowProps {
readonly selected?: boolean
/** callback to fire when the DOM element is created */
readonly onRowRef?: (index: number, element: HTMLDivElement | null) => void
readonly onRowRef?: (
index: RowIndexPath,
element: HTMLDivElement | null
) => void
/** callback to fire when the row receives a mousedown event */
readonly onRowMouseDown: (index: number, e: React.MouseEvent<any>) => void
readonly onRowMouseDown: (
index: RowIndexPath,
e: React.MouseEvent<any>
) => void
/** callback to fire when the row receives a mouseup event */
readonly onRowMouseUp: (index: number, e: React.MouseEvent<any>) => void
readonly onRowMouseUp: (index: RowIndexPath, e: React.MouseEvent<any>) => void
/** callback to fire when the row is clicked */
readonly onRowClick: (index: number, e: React.MouseEvent<any>) => void
readonly onRowClick: (index: RowIndexPath, e: React.MouseEvent<any>) => void
/** callback to fire when the row is double clicked */
readonly onRowDoubleClick: (index: number, e: React.MouseEvent<any>) => void
readonly onRowDoubleClick: (
index: RowIndexPath,
e: React.MouseEvent<any>
) => void
/** callback to fire when the row receives a keyboard event */
readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void
readonly onRowKeyDown: (
index: RowIndexPath,
e: React.KeyboardEvent<any>
) => void
/** called when the row (or any of its descendants) receives focus */
readonly onRowFocus?: (
index: number,
index: RowIndexPath,
e: React.FocusEvent<HTMLDivElement>
) => void
/** called when the row (and all of its descendants) loses focus */
readonly onRowBlur?: (
index: number,
index: RowIndexPath,
e: React.FocusEvent<HTMLDivElement>
) => void
/** Called back for when the context menu is invoked (user right clicks of
* uses keyboard shortcuts) */
readonly onContextMenu?: (
index: number,
index: RowIndexPath,
e: React.MouseEvent<HTMLDivElement>
) => void
@ -66,6 +82,14 @@ interface IListRowProps {
/** a custom css class to apply to the row */
readonly className?: string
/**
* aria label value for screen readers
*
* Note: you may need to apply an aria-hidden attribute to any child text
* elements for this to take precedence.
*/
readonly ariaLabel?: string
}
export class ListRow extends React.Component<IListRowProps, {}> {
@ -106,12 +130,23 @@ export class ListRow extends React.Component<IListRowProps, {}> {
}
public render() {
const selected = this.props.selected
const className = classNames(
const {
selected,
selectable,
className,
style,
rowCount,
id,
tabIndex,
rowIndex,
children,
sectionHasHeader,
} = this.props
const rowClassName = classNames(
'list-item',
{ selected },
{ 'not-selectable': this.props.selectable === false },
this.props.className
{ 'not-selectable': selectable === false },
className
)
// react-virtualized gives us an explicit pixel width for rows, but that
// width doesn't take into account whether or not the scroll bar needs
@ -120,29 +155,44 @@ export class ListRow extends React.Component<IListRowProps, {}> {
// *But* the parent Grid uses `autoContainerWidth` which means its width
// *does* reflect any width needed by the scroll bar. So we should just use
// that width.
const style = { ...this.props.style, width: '100%' }
const fullWidthStyle = { ...style, width: '100%' }
let ariaSetSize: number | undefined = rowCount
let ariaPosInSet: number | undefined = rowIndex.row + 1
if (sectionHasHeader) {
if (rowIndex.row === 0) {
ariaSetSize = undefined
ariaPosInSet = undefined
} else {
ariaSetSize -= 1
ariaPosInSet -= 1
}
}
return (
<div
id={this.props.id}
role="option"
aria-setsize={this.props.rowCount}
aria-posinset={this.props.rowIndex + 1}
aria-selected={this.props.selectable ? this.props.selected : undefined}
className={className}
tabIndex={this.props.tabIndex}
id={id}
role={
sectionHasHeader && rowIndex.row === 0 ? 'presentation' : 'option'
}
aria-setsize={ariaSetSize}
aria-posinset={ariaPosInSet}
aria-selected={selectable ? selected : undefined}
aria-label={this.props.ariaLabel}
className={rowClassName}
tabIndex={tabIndex}
ref={this.onRef}
onMouseDown={this.onRowMouseDown}
onMouseUp={this.onRowMouseUp}
onClick={this.onRowClick}
onDoubleClick={this.onRowDoubleClick}
onKeyDown={this.onRowKeyDown}
style={style}
style={fullWidthStyle}
onFocus={this.onFocus}
onBlur={this.onBlur}
onContextMenu={this.onContextMenu}
>
{this.props.children}
{children}
</div>
)
}

View file

@ -18,6 +18,8 @@ import { range } from '../../../lib/range'
import { ListItemInsertionOverlay } from './list-item-insertion-overlay'
import { DragData, DragType } from '../../../models/drag-drop'
import memoizeOne from 'memoize-one'
import { RowIndexPath } from './list-row-index-path'
import { sendNonFatalException } from '../../../lib/helpers/non-fatal-exception'
/**
* Describe the first argument given to the cellRenderer,
@ -254,6 +256,15 @@ interface IListProps {
/** The aria-label attribute for the list component. */
readonly ariaLabel?: string
/**
* Optional callback for providing an aria label for screen readers for each
* row.
*
* Note: you may need to apply an aria-hidden attribute to any child text
* elements for this to take precedence.
*/
readonly getRowAriaLabel?: (row: number) => string | undefined
}
interface IListState {
@ -579,11 +590,11 @@ export class List extends React.Component<IListProps, IListState> {
}
private onRowKeyDown = (
rowIndex: number,
indexPath: RowIndexPath,
event: React.KeyboardEvent<any>
) => {
if (this.props.onRowKeyDown) {
this.props.onRowKeyDown(rowIndex, event)
this.props.onRowKeyDown(indexPath.row, event)
}
const hasModifier =
@ -644,21 +655,27 @@ export class List extends React.Component<IListProps, IListState> {
})
}
private onRowFocus = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
this.focusRow = index
private onRowFocus = (
indexPath: RowIndexPath,
e: React.FocusEvent<HTMLDivElement>
) => {
this.focusRow = indexPath.row
}
private onRowBlur = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
if (this.focusRow === index) {
private onRowBlur = (
indexPath: RowIndexPath,
e: React.FocusEvent<HTMLDivElement>
) => {
if (this.focusRow === indexPath.row) {
this.focusRow = -1
}
}
private onRowContextMenu = (
row: number,
indexPath: RowIndexPath,
e: React.MouseEvent<HTMLDivElement>
) => {
this.props.onRowContextMenu?.(row, e)
this.props.onRowContextMenu?.(indexPath.row, e)
}
/** Convenience method for invoking canSelectRow callback when it exists */
@ -866,14 +883,17 @@ export class List extends React.Component<IListProps, IListState> {
}
}
private onRowRef = (rowIndex: number, element: HTMLDivElement | null) => {
private onRowRef = (
indexPath: RowIndexPath,
element: HTMLDivElement | null
) => {
if (element === null) {
this.rowRefs.delete(rowIndex)
this.rowRefs.delete(indexPath.row)
} else {
this.rowRefs.set(rowIndex, element)
this.rowRefs.set(indexPath.row, element)
}
if (rowIndex === this.focusRow) {
if (indexPath.row === this.focusRow) {
// The currently focused row is going being unmounted so we'll move focus
// programmatically to the grid so that keyboard navigation still works
if (element === null) {
@ -924,8 +944,8 @@ export class List extends React.Component<IListProps, IListState> {
const element =
this.props.insertionDragType !== undefined ? (
<ListItemInsertionOverlay
onDropDataInsertion={this.props.onDropDataInsertion}
itemIndex={rowIndex}
onDropDataInsertion={this.onDropDataInsertion}
itemIndex={{ section: 0, row: rowIndex }}
dragType={this.props.insertionDragType}
>
{row}
@ -936,14 +956,21 @@ export class List extends React.Component<IListProps, IListState> {
const id = this.getRowId(rowIndex)
const ariaLabel =
this.props.getRowAriaLabel !== undefined
? this.props.getRowAriaLabel(rowIndex)
: undefined
return (
<ListRow
key={params.key}
id={id}
onRowRef={this.onRowRef}
rowCount={this.props.rowCount}
rowIndex={rowIndex}
rowIndex={{ section: 0, row: rowIndex }}
sectionHasHeader={false}
selected={selected}
ariaLabel={ariaLabel}
onRowClick={this.onRowClick}
onRowDoubleClick={this.onRowDoubleClick}
onRowKeyDown={this.onRowKeyDown}
@ -998,7 +1025,6 @@ export class List extends React.Component<IListProps, IListState> {
*
* @param width - The width of the Grid as given by AutoSizer
* @param height - The height of the Grid as given by AutoSizer
*
*/
private renderContents(width: number, height: number) {
if (__WIN32__) {
@ -1028,6 +1054,18 @@ export class List extends React.Component<IListProps, IListState> {
* @param height - The height of the Grid as given by AutoSizer
*/
private renderGrid(width: number, height: number) {
// It is possible to send an invalid array such as [-1] to this component,
// if you do, you get weird focus problems. We shouldn't be doing this.. but
// if we do, send a non fatal exception to tell us about it.
if (this.props.selectedRows[0] < 0) {
sendNonFatalException(
'The selected rows of the List.tsx contained a negative number.',
new Error(
`Invalid selected rows that contained a negative number passed to List component. This will cause keyboard navigation and focus problems.`
)
)
}
// The currently selected list item is focusable but if there's no focused
// item the list itself needs to be focusable so that you can reach it with
// keyboard navigation and select an item.
@ -1060,6 +1098,9 @@ export class List extends React.Component<IListProps, IListState> {
height={height}
columnWidth={width}
columnCount={1}
aria-multiselectable={
this.props.selectionMode !== 'single' ? true : undefined
}
rowCount={this.props.rowCount}
rowHeight={this.props.rowHeight}
cellRenderer={this.renderRow}
@ -1085,7 +1126,6 @@ export class List extends React.Component<IListProps, IListState> {
* and accurately positions the fake scroll bar.
*
* @param height The height of the Grid as given by AutoSizer
*
*/
private renderFakeScroll(height: number) {
let totalHeight: number = 0
@ -1133,7 +1173,12 @@ export class List extends React.Component<IListProps, IListState> {
}
}
private onRowMouseDown = (row: number, event: React.MouseEvent<any>) => {
private onRowMouseDown = (
indexPath: RowIndexPath,
event: React.MouseEvent<any>
) => {
const { row } = indexPath
if (this.canSelectRow(row)) {
if (this.props.onRowMouseDown) {
this.props.onRowMouseDown(row, event)
@ -1226,7 +1271,12 @@ export class List extends React.Component<IListProps, IListState> {
}
}
private onRowMouseUp = (row: number, event: React.MouseEvent<any>) => {
private onRowMouseUp = (
indexPath: RowIndexPath,
event: React.MouseEvent<any>
) => {
const { row } = indexPath
if (!this.canSelectRow(row)) {
return
}
@ -1288,27 +1338,37 @@ export class List extends React.Component<IListProps, IListState> {
}
}
private onRowClick = (row: number, event: React.MouseEvent<any>) => {
if (this.canSelectRow(row) && this.props.onRowClick) {
private onDropDataInsertion = (indexPath: RowIndexPath, data: DragData) => {
this.props.onDropDataInsertion?.(indexPath.row, data)
}
private onRowClick = (
indexPath: RowIndexPath,
event: React.MouseEvent<any>
) => {
if (this.canSelectRow(indexPath.row) && this.props.onRowClick) {
const rowCount = this.props.rowCount
if (row < 0 || row >= rowCount) {
if (indexPath.row < 0 || indexPath.row >= rowCount) {
log.debug(
`[List.onRowClick] unable to onRowClick for row ${row} as it is outside the bounds of the array [0, ${rowCount}]`
`[List.onRowClick] unable to onRowClick for row ${indexPath.row} as it is outside the bounds of the array [0, ${rowCount}]`
)
return
}
this.props.onRowClick(row, { kind: 'mouseclick', event })
this.props.onRowClick(indexPath.row, { kind: 'mouseclick', event })
}
}
private onRowDoubleClick = (row: number, event: React.MouseEvent<any>) => {
private onRowDoubleClick = (
indexPath: RowIndexPath,
event: React.MouseEvent<any>
) => {
if (!this.props.onRowDoubleClick) {
return
}
this.props.onRowDoubleClick(row, { kind: 'mouseclick', event })
this.props.onRowDoubleClick(indexPath.row, { kind: 'mouseclick', event })
}
private onScroll = ({

View file

@ -0,0 +1,191 @@
import * as React from 'react'
import {
getTotalRowCount,
globalIndexToRowIndexPath,
InvalidRowIndexPath,
isValidRow,
RowIndexPath,
rowIndexPathEquals,
rowIndexPathToGlobalIndex,
} from './list-row-index-path'
export type SelectionDirection = 'up' | 'down'
interface ISelectRowAction {
/**
* The vertical direction use when searching for a selectable row.
*/
readonly direction: SelectionDirection
/**
* The starting row index to search from.
*/
readonly row: RowIndexPath
/**
* A flag to indicate or not to look beyond the last or first
* row (depending on direction) such that given the last row and
* a downward direction will consider the first row as a
* candidate or given the first row and an upward direction
* will consider the last row as a candidate.
*
* Defaults to true if not set.
*/
readonly wrap?: boolean
}
/**
* Interface describing a user initiated selection change event
* originating from a pointer device clicking or pressing on an item.
*/
export interface IMouseClickSource {
readonly kind: 'mouseclick'
readonly event: React.MouseEvent<any>
}
/**
* Interface describing a user initiated selection change event
* originating from a pointer device hovering over an item.
* Only applicable when selectedOnHover is set.
*/
export interface IHoverSource {
readonly kind: 'hover'
readonly event: React.MouseEvent<any>
}
/**
* Interface describing a user initiated selection change event
* originating from a keyboard
*/
export interface IKeyboardSource {
readonly kind: 'keyboard'
readonly event: React.KeyboardEvent<any>
}
/**
* Interface describing a user initiated selection of all list
* items (usually by clicking the Edit > Select all menu item in
* the application window). This is highly specific to GitHub Desktop
*/
export interface ISelectAllSource {
readonly kind: 'select-all'
}
/** A type union of possible sources of a selection changed event */
export type SelectionSource =
| IMouseClickSource
| IHoverSource
| IKeyboardSource
| ISelectAllSource
/**
* Determine the next selectable row, given the direction and a starting
* row index. Whether a row is selectable or not is determined using
* the `canSelectRow` function, which defaults to true if not provided.
*
* Returns null if no row can be selected or if the only selectable row is
* identical to the given row parameter.
*/
export function findNextSelectableRow(
rowCount: ReadonlyArray<number>,
action: ISelectRowAction,
canSelectRow: (indexPath: RowIndexPath) => boolean = row => true
): RowIndexPath | null {
const totalRowCount = getTotalRowCount(rowCount)
if (totalRowCount === 0) {
return null
}
const { direction, row } = action
const wrap = action.wrap === undefined ? true : action.wrap
const rowIndex = rowIndexPathEquals(InvalidRowIndexPath, row)
? -1
: rowIndexPathToGlobalIndex(row, rowCount)
if (rowIndex === null) {
return null
}
// Ensure the row value is in the range between 0 and rowCount - 1
//
// If the row falls outside this range, use the direction
// given to choose a suitable value:
//
// - move in an upward direction -> select last row
// - move in a downward direction -> select first row
//
let currentRow = isValidRow(row, rowCount)
? rowIndex
: direction === 'up'
? totalRowCount - 1
: 0
// handle specific case from switching from filter text to list
//
// locking currentRow to [0,rowCount) above means that the below loops
// will skip over the first entry
if (direction === 'down' && rowIndexPathEquals(row, InvalidRowIndexPath)) {
currentRow = -1
}
const delta = direction === 'up' ? -1 : 1
// Iterate through all rows (starting offset from the
// given row and ending on and including the given row)
for (let i = 0; i < totalRowCount; i++) {
currentRow += delta
if (currentRow >= totalRowCount) {
// We've hit rock bottom, wrap around to the top
// if we're allowed to or give up.
if (wrap) {
currentRow = 0
} else {
break
}
} else if (currentRow < 0) {
// We've reached the top, wrap around to the bottom
// if we're allowed to or give up
if (wrap) {
currentRow = totalRowCount - 1
} else {
break
}
}
const currentRowIndexPath = globalIndexToRowIndexPath(currentRow, rowCount)
if (
currentRowIndexPath !== null &&
!rowIndexPathEquals(row, currentRowIndexPath) &&
canSelectRow(currentRowIndexPath)
) {
return currentRowIndexPath
}
}
return null
}
/**
* Find the last selectable row in either direction, used
* for moving to the first or last selectable row in a list,
* i.e. Home/End key navigation.
*/
export function findLastSelectableRow(
direction: SelectionDirection,
rowCount: ReadonlyArray<number>,
canSelectRow: (indexPath: RowIndexPath) => boolean
): RowIndexPath | null {
const totalRowCount = getTotalRowCount(rowCount)
let i = direction === 'up' ? 0 : totalRowCount - 1
const delta = direction === 'up' ? 1 : -1
for (; i >= 0 && i < totalRowCount; i += delta) {
const indexPath = globalIndexToRowIndexPath(i, rowCount)
if (indexPath !== null && canSelectRow(indexPath)) {
return indexPath
}
}
return null
}

File diff suppressed because it is too large Load diff

View file

@ -87,7 +87,7 @@ export function findNextSelectableRow(
}
const { direction, row } = action
const wrap = action.wrap === undefined ? true : action.wrap
const wrap = action.wrap ?? true
// Ensure the row value is in the range between 0 and rowCount - 1
//

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

@ -0,0 +1,698 @@
import * as React from 'react'
import classnames from 'classnames'
import { SectionList, ClickSource } from '../lib/list/section-list'
import {
findNextSelectableRow,
SelectionDirection,
} from '../lib/list/section-list-selection'
import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { match, IMatch, IMatches } from '../../lib/fuzzy-find'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
import {
InvalidRowIndexPath,
RowIndexPath,
rowIndexPathEquals,
} from './list/list-row-index-path'
import {
IFilterListGroup,
IFilterListItem,
SelectionSource,
} from './filter-list'
interface IFlattenedGroup {
readonly kind: 'group'
readonly identifier: string
}
interface IFlattenedItem<T extends IFilterListItem> {
readonly kind: 'item'
readonly item: T
/** Array of indexes in `item.text` that should be highlighted */
readonly matches: IMatches
}
/**
* A row in the list. This is used internally after the user-provided groups are
* flattened.
*/
type IFilterListRow<T extends IFilterListItem> =
| IFlattenedGroup
| IFlattenedItem<T>
interface ISectionFilterListProps<T extends IFilterListItem> {
/** A class name for the wrapping element. */
readonly className?: string
/** The height of the rows. */
readonly rowHeight: number
/** The ordered groups to display in the list. */
readonly groups: ReadonlyArray<IFilterListGroup<T>>
/** The selected item. */
readonly selectedItem: T | null
/** Called to render each visible item. */
readonly renderItem: (item: T, matches: IMatches) => JSX.Element | null
/** Called to render header for the group with the given identifier. */
readonly renderGroupHeader?: (identifier: string) => JSX.Element | null
/** Called to render content before/above the filter and list. */
readonly renderPreList?: () => JSX.Element | null
/**
* This function will be called when a pointer device is pressed and then
* released on a selectable row. Note that this follows the conventions
* of button elements such that pressing Enter or Space on a keyboard
* while focused on a particular row will also trigger this event. Consumers
* can differentiate between the two using the source parameter.
*
* Note that this event handler will not be called for keyboard events
* if `event.preventDefault()` was called in the onRowKeyDown event handler.
*
* Consumers of this event do _not_ have to call event.preventDefault,
* when this event is subscribed to the list will automatically call it.
*/
readonly onItemClick?: (item: T, source: ClickSource) => void
/**
* This function will be called when the selection changes as a result of a
* user keyboard or mouse action (i.e. not when props change). This function
* will not be invoked when an already selected row is clicked on.
*
* @param selectedItem - The item that was just selected
* @param source - The kind of user action that provoked the change,
* either a pointer device press, or a keyboard event
* (arrow up/down)
*/
readonly onSelectionChanged?: (
selectedItem: T | null,
source: SelectionSource
) => void
/**
* Called when a key down happens in the filter text input. Users have a
* chance to respond or cancel the default behavior by calling
* `preventDefault()`.
*/
readonly onFilterKeyDown?: (
event: React.KeyboardEvent<HTMLInputElement>
) => void
/** Called when the Enter key is pressed in field of type search */
readonly onEnterPressedWithoutFilteredItems?: (text: string) => void
/** Aria label for a specific group */
readonly getGroupAriaLabel?: (group: number) => string | undefined
/** The current filter text to use in the form */
readonly filterText?: string
/** Called when the filter text is changed by the user */
readonly onFilterTextChanged?: (text: string) => void
/**
* Whether or not the filter list should allow selection
* and filtering. Defaults to false.
*/
readonly disabled?: boolean
/** Any props which should cause a re-render if they change. */
readonly invalidationProps: any
/** Called to render content after the filter. */
readonly renderPostFilter?: () => JSX.Element | null
/** Called when there are no items to render. */
readonly renderNoItems?: () => JSX.Element | null
/**
* A reference to a TextBox that will be used to control this component.
*
* See https://github.com/desktop/desktop/issues/4317 for refactoring work to
* make this more composable which should make this unnecessary.
*/
readonly filterTextBox?: TextBox
/**
* Callback to fire when the items in the filter list are updated
*/
readonly onFilterListResultsChanged?: (resultCount: number) => void
/** Placeholder text for text box. Default is "Filter". */
readonly placeholderText?: string
/** If true, we do not render the filter. */
readonly hideFilterRow?: boolean
/**
* A handler called whenever a context menu event is received on the
* row container element.
*
* The context menu is invoked when a user right clicks the row or
* uses keyboard shortcut.s
*/
readonly onItemContextMenu?: (
item: T,
event: React.MouseEvent<HTMLDivElement>
) => void
}
interface IFilterListState<T extends IFilterListItem> {
readonly rows: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>
readonly selectedRow: RowIndexPath
readonly filterValue: string
// Indices of groups in the filtered list
readonly groups: ReadonlyArray<number>
}
/** A List which includes the ability to filter based on its contents. */
export class SectionFilterList<
T extends IFilterListItem
> extends React.Component<ISectionFilterListProps<T>, IFilterListState<T>> {
private list: SectionList | null = null
private filterTextBox: TextBox | null = null
public constructor(props: ISectionFilterListProps<T>) {
super(props)
this.state = createStateUpdate(props)
}
public componentWillMount() {
if (this.props.filterTextBox !== undefined) {
this.filterTextBox = this.props.filterTextBox
}
}
public componentWillReceiveProps(nextProps: ISectionFilterListProps<T>) {
this.setState(createStateUpdate(nextProps))
}
public componentDidUpdate(
prevProps: ISectionFilterListProps<T>,
prevState: IFilterListState<T>
) {
if (this.props.onSelectionChanged) {
const oldSelectedItemId = getItemIdFromRowIndex(
prevState.rows,
prevState.selectedRow
)
const newSelectedItemId = getItemIdFromRowIndex(
this.state.rows,
this.state.selectedRow
)
if (oldSelectedItemId !== newSelectedItemId) {
const propSelectionId = this.props.selectedItem
? this.props.selectedItem.id
: null
if (propSelectionId !== newSelectedItemId) {
const newSelectedItem = getItemFromRowIndex(
this.state.rows,
this.state.selectedRow
)
this.props.onSelectionChanged(newSelectedItem, {
kind: 'filter',
filterText: this.props.filterText || '',
})
}
}
}
if (this.props.onFilterListResultsChanged !== undefined) {
const itemCount = this.state.rows
.flat()
.filter(row => row.kind === 'item').length
this.props.onFilterListResultsChanged(itemCount)
}
}
public componentDidMount() {
if (this.filterTextBox !== null) {
this.filterTextBox.selectAll()
}
}
public renderTextBox() {
return (
<TextBox
ref={this.onTextBoxRef}
type="search"
autoFocus={true}
placeholder={this.props.placeholderText || 'Filter'}
className="filter-list-filter-field"
onValueChanged={this.onFilterValueChanged}
onEnterPressed={this.onEnterPressed}
onKeyDown={this.onKeyDown}
value={this.props.filterText}
disabled={this.props.disabled}
/>
)
}
public renderFilterRow() {
if (this.props.hideFilterRow === true) {
return null
}
return (
<Row className="filter-field-row">
{this.props.filterTextBox === undefined ? this.renderTextBox() : null}
{this.props.renderPostFilter ? this.props.renderPostFilter() : null}
</Row>
)
}
public render() {
const itemRows = this.state.rows.flat().filter(row => row.kind === 'item')
const resultsPluralized = itemRows.length === 1 ? 'result' : 'results'
const screenReaderMessage = `${itemRows.length} ${resultsPluralized}`
return (
<div className={classnames('filter-list', this.props.className)}>
<AriaLiveContainer
trackedUserInput={this.state.filterValue}
message={screenReaderMessage}
/>
{this.props.renderPreList ? this.props.renderPreList() : null}
{this.renderFilterRow()}
<div className="filter-list-container">{this.renderContent()}</div>
</div>
)
}
public selectNextItem(
focus: boolean = false,
inDirection: SelectionDirection = 'down'
) {
if (this.list === null) {
return
}
let next: RowIndexPath | null = null
const rowCount = this.state.rows.map(r => r.length)
if (
this.state.selectedRow.row === -1 ||
this.state.selectedRow.row === this.state.rows.length
) {
next = findNextSelectableRow(
rowCount,
{
direction: inDirection,
row: InvalidRowIndexPath,
},
this.canSelectRow
)
} else {
next = findNextSelectableRow(
rowCount,
{
direction: inDirection,
row: this.state.selectedRow,
},
this.canSelectRow
)
}
if (next !== null) {
this.setState({ selectedRow: next }, () => {
if (focus && this.list !== null) {
this.list.focus()
}
})
}
}
private renderContent() {
if (this.state.rows.length === 0 && this.props.renderNoItems) {
return this.props.renderNoItems()
} else {
return (
<SectionList
ref={this.onListRef}
rowCount={this.state.rows.map(r => r.length)}
rowRenderer={this.renderRow}
sectionHasHeader={this.sectionHasHeader}
getSectionAriaLabel={this.getGroupAriaLabel}
rowHeight={this.props.rowHeight}
selectedRows={
rowIndexPathEquals(this.state.selectedRow, InvalidRowIndexPath)
? []
: [this.state.selectedRow]
}
onSelectedRowChanged={this.onSelectedRowChanged}
onRowClick={this.onRowClick}
onRowKeyDown={this.onRowKeyDown}
onRowContextMenu={this.onRowContextMenu}
canSelectRow={this.canSelectRow}
invalidationProps={{
...this.props,
...this.props.invalidationProps,
}}
/>
)
}
}
private sectionHasHeader = (section: number) => {
const rows = this.state.rows[section]
return rows.length > 0 && rows[0].kind === 'group'
}
private getGroupAriaLabel = (group: number) => {
return this.props.getGroupAriaLabel?.(this.state.groups[group])
}
private renderRow = (index: RowIndexPath) => {
const row = this.state.rows[index.section][index.row]
if (row.kind === 'item') {
return this.props.renderItem(row.item, row.matches)
} else if (this.props.renderGroupHeader) {
return this.props.renderGroupHeader(row.identifier)
} else {
return null
}
}
private onTextBoxRef = (component: TextBox | null) => {
this.filterTextBox = component
}
private onListRef = (instance: SectionList | null) => {
this.list = instance
}
private onFilterValueChanged = (text: string) => {
if (this.props.onFilterTextChanged) {
this.props.onFilterTextChanged(text)
}
}
private onEnterPressed = (text: string) => {
const rows = this.state.rows.length
if (
rows === 0 &&
text.trim().length > 0 &&
this.props.onEnterPressedWithoutFilteredItems !== undefined
) {
this.props.onEnterPressedWithoutFilteredItems(text)
}
}
private onSelectedRowChanged = (
index: RowIndexPath,
source: SelectionSource
) => {
this.setState({ selectedRow: index })
if (this.props.onSelectionChanged) {
const row = this.state.rows[index.section][index.row]
if (row.kind === 'item') {
this.props.onSelectionChanged(row.item, source)
}
}
}
private canSelectRow = (index: RowIndexPath) => {
if (this.props.disabled) {
return false
}
const row = this.state.rows[index.section][index.row]
return row.kind === 'item'
}
private onRowClick = (index: RowIndexPath, source: ClickSource) => {
if (this.props.onItemClick) {
const row = this.state.rows[index.section][index.row]
if (row.kind === 'item') {
this.props.onItemClick(row.item, source)
}
}
}
private onRowContextMenu = (
index: RowIndexPath,
source: React.MouseEvent<HTMLDivElement>
) => {
if (!this.props.onItemContextMenu) {
return
}
const row = this.state.rows[index.section][index.row]
if (row.kind !== 'item') {
return
}
this.props.onItemContextMenu(row.item, source)
}
private onRowKeyDown = (
indexPath: RowIndexPath,
event: React.KeyboardEvent<any>
) => {
const list = this.list
if (!list) {
return
}
const rowCount = this.state.rows.map(r => r.length)
const firstSelectableRow = findNextSelectableRow(
rowCount,
{ direction: 'down', row: InvalidRowIndexPath },
this.canSelectRow
)
const lastSelectableRow = findNextSelectableRow(
rowCount,
{
direction: 'up',
row: {
section: 0,
row: 0,
},
},
this.canSelectRow
)
let shouldFocus = false
if (
event.key === 'ArrowUp' &&
firstSelectableRow &&
rowIndexPathEquals(indexPath, firstSelectableRow)
) {
shouldFocus = true
} else if (
event.key === 'ArrowDown' &&
lastSelectableRow &&
rowIndexPathEquals(indexPath, lastSelectableRow)
) {
shouldFocus = true
}
if (shouldFocus) {
const textBox = this.filterTextBox
if (textBox) {
event.preventDefault()
textBox.focus()
}
}
}
private onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const list = this.list
const key = event.key
if (!list) {
return
}
if (this.props.onFilterKeyDown) {
this.props.onFilterKeyDown(event)
}
if (event.defaultPrevented) {
return
}
const rowCount = this.state.rows.map(r => r.length)
if (key === 'ArrowDown') {
if (rowCount.length > 0) {
const selectedRow = findNextSelectableRow(
rowCount,
{ direction: 'down', row: InvalidRowIndexPath },
this.canSelectRow
)
if (selectedRow != null) {
this.setState({ selectedRow }, () => {
list.focus()
})
}
}
event.preventDefault()
} else if (key === 'ArrowUp') {
if (rowCount.length > 0) {
const selectedRow = findNextSelectableRow(
rowCount,
{
direction: 'up',
row: {
section: 0,
row: 0,
},
},
this.canSelectRow
)
if (selectedRow != null) {
this.setState({ selectedRow }, () => {
list.focus()
})
}
}
event.preventDefault()
} else if (key === 'Enter') {
// no repositories currently displayed, bail out
if (rowCount.length === 0) {
return event.preventDefault()
}
const filterText = this.props.filterText
if (filterText !== undefined && !/\S/.test(filterText)) {
return event.preventDefault()
}
const row = findNextSelectableRow(
rowCount,
{ direction: 'down', row: InvalidRowIndexPath },
this.canSelectRow
)
if (row != null) {
this.onRowClick(row, { kind: 'keyboard', event })
}
}
}
}
export function getText<T extends IFilterListItem>(
item: T
): ReadonlyArray<string> {
return item['text']
}
function getFirstVisibleRow<T extends IFilterListItem>(
rows: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>
): RowIndexPath {
for (let i = 0; i < rows.length; i++) {
const groupRows = rows[i]
for (let j = 0; j < groupRows.length; j++) {
const row = groupRows[j]
if (row.kind === 'item') {
return { section: i, row: j }
}
}
}
return InvalidRowIndexPath
}
function createStateUpdate<T extends IFilterListItem>(
props: ISectionFilterListProps<T>
) {
const rows = new Array<Array<IFilterListRow<T>>>()
const filter = (props.filterText || '').toLowerCase()
let selectedRow = InvalidRowIndexPath
let section = 0
const selectedItem = props.selectedItem
const groupIndices = []
for (const [idx, group] of props.groups.entries()) {
const groupRows = new Array<IFilterListRow<T>>()
const items: ReadonlyArray<IMatch<T>> = filter
? match(filter, group.items, getText)
: group.items.map(item => ({
score: 1,
matches: { title: [], subtitle: [] },
item,
}))
if (!items.length) {
continue
}
groupIndices.push(idx)
if (props.renderGroupHeader) {
groupRows.push({ kind: 'group', identifier: group.identifier })
}
for (const { item, matches } of items) {
if (selectedItem && item.id === selectedItem.id) {
selectedRow = {
section,
row: groupRows.length,
}
}
groupRows.push({ kind: 'item', item, matches })
}
rows.push(groupRows)
section++
}
if (selectedRow.row < 0 && filter.length) {
// If the selected item isn't in the list (e.g., filtered out), then
// select the first visible item.
selectedRow = getFirstVisibleRow(rows)
}
return { rows: rows, selectedRow, filterValue: filter, groups: groupIndices }
}
function getItemFromRowIndex<T extends IFilterListItem>(
items: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>,
index: RowIndexPath
): T | null {
if (index.section < 0 || index.section >= items.length) {
return null
}
const group = items[index.section]
if (index.row < 0 || index.row >= group.length) {
return null
}
const row = group[index.row]
if (row.kind === 'item') {
return row.item
}
return null
}
function getItemIdFromRowIndex<T extends IFilterListItem>(
items: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>,
index: RowIndexPath
): string | null {
const item = getItemFromRowIndex(items, index)
return item ? item.id : null
}

View file

@ -84,6 +84,10 @@ export interface ITextBoxProps {
/** Optional aria-label attribute */
readonly ariaLabel?: string
/** Optional aria-describedby attribute - usually for associating a descriptive
* message to the input such as a validation error, warning, or caption */
readonly ariaDescribedBy?: string
readonly ariaControls?: string
}
@ -277,6 +281,7 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
spellCheck={this.props.spellcheck === true}
aria-label={this.props.ariaLabel}
aria-controls={this.props.ariaControls}
aria-describedby={this.props.ariaDescribedBy}
required={this.props.required}
/>
</div>

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

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

@ -56,6 +56,7 @@ interface IPreferencesProps {
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmCheckoutCommit: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
@ -81,6 +82,7 @@ interface IPreferencesState {
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmCheckoutCommit: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
@ -128,6 +130,7 @@ export class Preferences extends React.Component<
confirmDiscardChanges: false,
confirmDiscardChangesPermanently: false,
confirmDiscardStash: false,
confirmCheckoutCommit: false,
confirmForcePush: false,
confirmUndoCommit: false,
uncommittedChangesStrategy: defaultUncommittedChangesStrategy,
@ -188,6 +191,7 @@ export class Preferences extends React.Component<
confirmDiscardChangesPermanently:
this.props.confirmDiscardChangesPermanently,
confirmDiscardStash: this.props.confirmDiscardStash,
confirmCheckoutCommit: this.props.confirmCheckoutCommit,
confirmForcePush: this.props.confirmForcePush,
confirmUndoCommit: this.props.confirmUndoCommit,
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
@ -209,7 +213,7 @@ export class Preferences extends React.Component<
return (
<Dialog
id="preferences"
title={__DARWIN__ ? 'Preferences' : 'Options'}
title={__DARWIN__ ? 'Settings' : 'Options'}
onDismissed={this.onCancel}
onSubmit={this.onSave}
>
@ -364,6 +368,7 @@ export class Preferences extends React.Component<
this.state.confirmDiscardChangesPermanently
}
confirmDiscardStash={this.state.confirmDiscardStash}
confirmCheckoutCommit={this.state.confirmCheckoutCommit}
confirmForcePush={this.state.confirmForcePush}
confirmUndoCommit={this.state.confirmUndoCommit}
onConfirmRepositoryRemovalChanged={
@ -371,6 +376,7 @@ export class Preferences extends React.Component<
}
onConfirmDiscardChangesChanged={this.onConfirmDiscardChangesChanged}
onConfirmDiscardStashChanged={this.onConfirmDiscardStashChanged}
onConfirmCheckoutCommitChanged={this.onConfirmCheckoutCommitChanged}
onConfirmForcePushChanged={this.onConfirmForcePushChanged}
onConfirmDiscardChangesPermanentlyChanged={
this.onConfirmDiscardChangesPermanentlyChanged
@ -444,6 +450,10 @@ export class Preferences extends React.Component<
this.setState({ confirmDiscardStash: value })
}
private onConfirmCheckoutCommitChanged = (value: boolean) => {
this.setState({ confirmCheckoutCommit: value })
}
private onConfirmDiscardChangesPermanentlyChanged = (value: boolean) => {
this.setState({ confirmDiscardChangesPermanently: value })
}
@ -583,6 +593,10 @@ export class Preferences extends React.Component<
this.state.confirmDiscardStash
)
await this.props.dispatcher.setConfirmCheckoutCommitSetting(
this.state.confirmCheckoutCommit
)
await this.props.dispatcher.setConfirmUndoCommitSetting(
this.state.confirmUndoCommit
)

View file

@ -9,12 +9,14 @@ interface IPromptsPreferencesProps {
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmCheckoutCommit: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
readonly onConfirmDiscardChangesChanged: (checked: boolean) => void
readonly onConfirmDiscardChangesPermanentlyChanged: (checked: boolean) => void
readonly onConfirmDiscardStashChanged: (checked: boolean) => void
readonly onConfirmCheckoutCommitChanged: (checked: boolean) => void
readonly onConfirmRepositoryRemovalChanged: (checked: boolean) => void
readonly onConfirmForcePushChanged: (checked: boolean) => void
readonly onConfirmUndoCommitChanged: (checked: boolean) => void
@ -28,6 +30,7 @@ interface IPromptsPreferencesState {
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmCheckoutCommit: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
@ -46,6 +49,7 @@ export class Prompts extends React.Component<
confirmDiscardChangesPermanently:
this.props.confirmDiscardChangesPermanently,
confirmDiscardStash: this.props.confirmDiscardStash,
confirmCheckoutCommit: this.props.confirmCheckoutCommit,
confirmForcePush: this.props.confirmForcePush,
confirmUndoCommit: this.props.confirmUndoCommit,
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
@ -79,6 +83,15 @@ export class Prompts extends React.Component<
this.props.onConfirmDiscardStashChanged(value)
}
private onConfirmCheckoutCommitChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = event.currentTarget.checked
this.setState({ confirmCheckoutCommit: value })
this.props.onConfirmCheckoutCommitChanged(value)
}
private onConfirmForcePushChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
@ -154,6 +167,15 @@ export class Prompts extends React.Component<
}
onChange={this.onConfirmDiscardStashChanged}
/>
<Checkbox
label="Checking out a commit"
value={
this.state.confirmCheckoutCommit
? CheckboxValue.On
: CheckboxValue.Off
}
onChange={this.onConfirmCheckoutCommitChanged}
/>
<Checkbox
label="Force pushing"
value={

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

@ -24,6 +24,8 @@ import { TooltippedContent } from '../lib/tooltipped-content'
import memoizeOne from 'memoize-one'
import { KeyboardShortcut } from '../keyboard-shortcut/keyboard-shortcut'
import { generateRepositoryListContextMenu } from '../repositories-list/repository-list-item-context-menu'
import { SectionFilterList } from '../lib/section-filter-list'
import { enableSectionList } from '../../lib/feature-flag'
const BlankSlateImage = encodePathAsUrl(__dirname, 'static/empty-no-repo.svg')
@ -241,25 +243,31 @@ export class RepositoriesList extends React.Component<
]
: baseGroups
const getGroupAriaLabel = (group: number) => groups[group].identifier
const ListComponent = enableSectionList() ? SectionFilterList : FilterList
const filterListProps: typeof ListComponent['prototype']['props'] = {
rowHeight: RowHeight,
selectedItem: selectedItem,
filterText: this.props.filterText,
onFilterTextChanged: this.props.onFilterTextChanged,
renderItem: this.renderItem,
renderGroupHeader: this.renderGroupHeader,
onItemClick: this.onItemClick,
renderPostFilter: this.renderPostFilter,
renderNoItems: this.renderNoItems,
groups: groups,
invalidationProps: {
repositories: this.props.repositories,
filterText: this.props.filterText,
},
onItemContextMenu: this.onItemContextMenu,
getGroupAriaLabel,
}
return (
<div className="repository-list">
<FilterList<IRepositoryListItem>
rowHeight={RowHeight}
selectedItem={selectedItem}
filterText={this.props.filterText}
onFilterTextChanged={this.props.onFilterTextChanged}
renderItem={this.renderItem}
renderGroupHeader={this.renderGroupHeader}
onItemClick={this.onItemClick}
renderPostFilter={this.renderPostFilter}
renderNoItems={this.renderNoItems}
groups={groups}
invalidationProps={{
repositories: this.props.repositories,
filterText: this.props.filterText,
}}
onItemContextMenu={this.onItemContextMenu}
/>
<ListComponent {...filterListProps} />
</div>
)
}

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

Some files were not shown because too many files have changed in this diff Show more