merge from upstream

This commit is contained in:
vaindil 2023-07-17 13:03:17 -04:00
commit fa34c72396
108 changed files with 4674 additions and 2125 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,18 +4,62 @@ 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]
node: [18.14.0]
os: [macos-11, windows-2019]
arch: [x64, arm64]
include:
@ -24,9 +68,13 @@ jobs:
- 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
@ -39,20 +87,14 @@ jobs:
- name: Get NodeJS node-gyp lib for Windows arm64
if: ${{ matrix.os == 'windows-2019' && matrix.arch == 'arm64' }}
run: .\script\download-nodejs-win-arm64.ps1 ${{ matrix.node }}
- name: Get app version
id: version
run: echo version=$(jq -r ".version" app/package.json) >> $GITHUB_OUTPUT
- name: Install and build dependencies
run: yarn
env:
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,8 +103,8 @@ 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
@ -74,15 +116,27 @@ jobs:
- 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-${{ steps.version.outputs.version }}-${{matrix.arch}}-full.nupkg
dist/GitHubDesktop-${{ steps.version.outputs.version }}-${{matrix.arch}}-delta.nupkg
dist/GitHubDesktopSetup-${{matrix.arch}}.exe
dist/GitHubDesktopSetup-${{matrix.arch}}.msi
dist/bundle-size.json
if-no-files-found: error

View file

@ -37,7 +37,7 @@ jobs:
private_key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }}
- name: Create Release Pull Request
uses: peter-evans/create-pull-request@v5.0.1
uses: peter-evans/create-pull-request@v5.0.2
if: |
startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test')
with:

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.7-beta2",
"main": "./main.js",
"repository": {
"type": "git",

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

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

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

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

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

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

@ -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,
@ -176,6 +181,7 @@ import {
updateRemoteHEAD,
getBranchMergeBaseChangedFiles,
getBranchMergeBaseDiff,
checkoutCommit,
} from '../git'
import {
installGlobalLFSFilters,
@ -232,6 +238,7 @@ import {
} from './updates/changes-state'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { BranchPruner } from './helpers/branch-pruner'
import { enableMoveStash } from '../feature-flag'
import { Banner, BannerType } from '../../models/banner'
import { ComputedAction } from '../../models/computed-action'
import {
@ -239,6 +246,7 @@ import {
getLastDesktopStashEntryForBranch,
popStashEntry,
dropDesktopStashEntry,
moveStashEntry,
} from '../git/stash'
import {
UncommittedChangesStrategy,
@ -316,6 +324,7 @@ 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'
@ -346,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'
@ -463,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
@ -964,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,
@ -1315,32 +1328,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(
@ -1516,7 +1525,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
aheadBehind,
}
this.repositoryStateCache.updateCompareState(repository, s => ({
this.repositoryStateCache.updateCompareState(repository, () => ({
formState: newState,
filterText: comparisonBranch.name,
commitSHAs,
@ -2106,6 +2115,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
confirmDiscardStashDefault
)
this.confirmCheckoutCommit = getBoolean(
confirmCheckoutCommitKey,
confirmCheckoutCommitDefault
)
this.askForConfirmationOnForcePush = getBoolean(
confirmForcePushKey,
askForConfirmationOnForcePushDefault
@ -3789,7 +3803,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))
})
}
@ -3907,18 +3921,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.
*
@ -4072,9 +4137,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)
}
@ -5386,6 +5459,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)
@ -6712,9 +6794,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))
)
}
@ -6731,9 +6814,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

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

@ -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',
@ -235,6 +236,11 @@ export type PopupDetail =
repository: Repository
stash: IStashEntry
}
| {
type: PopupType.ConfirmCheckoutCommit
repository: Repository
commit: CommitOneLine
}
| {
type: PopupType.CreateTutorialRepository
account: Account

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

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

@ -45,15 +45,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.children !== null ? this.buildMessage() : null,
}
}
@ -62,7 +62,7 @@ export class AriaLiveContainer extends Component<
return
}
this.onTrackedInputChanged(this.buildMessage())
this.onTrackedInputChanged()
}
public componentWillUnmount() {
@ -70,7 +70,9 @@ 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 (
<>
@ -80,6 +82,21 @@ export class AriaLiveContainer extends Component<
)
}
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.children
}
// 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.children !== null ? this.state.message : ''
}
public render() {
return (
<div
@ -88,7 +105,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,7 @@ 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'
interface IAddExistingRepositoryProps {
readonly dispatcher: Dispatcher
@ -130,71 +129,95 @@ export class AddExistingRepository extends React.Component<
)
}
private renderWarning() {
private buildBareRepositoryError() {
if (
!this.state.path.length ||
!this.state.showNonGitRepositoryWarning ||
!this.state.isRepositoryBare
) {
return null
}
return 'This directory appears to be a bare repository. Bare repositories are not currently supported.'
}
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
return (
<>
<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>
</>
)
}
private buildNotAGitRepositoryError() {
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>
)
}
return (
<>
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
private renderErrors() {
const msg =
this.buildBareRepositoryError() ??
this.buildRepositoryUnsafeError() ??
this.buildNotAGitRepositoryError()
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 (
<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>
)
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}
>
{msg}
</InputError>
</Row>
)
}
@ -220,10 +243,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

@ -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'
@ -1607,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}
@ -1988,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
@ -2857,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}
@ -2868,6 +2893,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDropdownStateChanged={this.onRepositoryDropdownStateChanged}
dropdownContentRenderer={this.renderRepositoryList}
dropdownState={currentState}
enableFocusTrap={enableFocusTrap}
/>
)
}
@ -2950,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}
@ -2970,6 +3001,7 @@ export class App extends React.Component<IAppProps, IAppState> {
isDropdownOpen={isDropdownOpen}
askForConfirmationOnForcePush={this.state.askForConfirmationOnForcePush}
onDropdownStateChanged={this.onPushPullDropdownStateChanged}
enableFocusTrap={enableFocusTrap}
/>
)
}
@ -3064,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}
@ -3080,6 +3117,7 @@ export class App extends React.Component<IAppProps, IAppState> {
}
showCIStatusPopover={this.state.showCIStatusPopover}
emoji={this.state.emoji}
enableFocusTrap={enableFocusTrap}
/>
)
}
@ -3215,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

@ -672,6 +672,8 @@ export abstract class AutocompletingTextInput<
str: string,
caretPosition: number
): Promise<IAutocompletionState<AutocompleteItemType> | null> {
const lowercaseStr = str.toLowerCase()
for (const provider of this.props.autocompletionProviders) {
// NB: RegExps are stateful (AAAAAAAAAAAAAAAAAA) so defensively copy the
// regex we're given.
@ -683,7 +685,7 @@ export abstract class AutocompletingTextInput<
}
let result: RegExpExecArray | null = null
while ((result = regex.exec(str))) {
while ((result = regex.exec(lowercaseStr))) {
const index = regex.lastIndex
const text = result[1] || ''
if (index === caretPosition || this.props.alwaysAutocomplete) {

View file

@ -103,13 +103,15 @@ export class EmojiAutocompletionProvider
return <div className="title">{emoji}</div>
}
// Offset the match start by one to account for the leading ':' that was
// removed from the emoji string
const matchStart = hit.matchStart - 1
return (
<div className="title">
{emoji.substring(0, hit.matchStart)}
<mark>
{emoji.substring(hit.matchStart, hit.matchStart + hit.matchLength)}
</mark>
{emoji.substring(hit.matchStart + hit.matchLength)}
{emoji.substring(0, matchStart)}
<mark>{emoji.substring(matchStart, matchStart + hit.matchLength)}</mark>
{emoji.substring(matchStart + hit.matchLength)}
</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,15 @@ export class BranchList extends React.Component<
}
}
private getGroupAriaLabel = (group: number) => {
const GroupIdentifiers: ReadonlyArray<BranchGroupIdentifier> = [
'default',
'recent',
'other',
]
return this.getGroupLabel(GroupIdentifiers[group])
}
private renderGroupHeader = (label: string) => {
const identifier = this.parseHeader(label)

View file

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

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,6 +242,7 @@ 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>
)
}

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

@ -473,7 +473,6 @@ export class Dialog extends React.Component<DialogProps, IDialogState> {
':not([type=submit])',
':not([type=reset])',
':not([type=hidden])',
':not([type=checkbox])',
':not([type=radio])',
]

View file

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

View file

@ -695,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)
@ -2388,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)
}

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
)

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

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

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

@ -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,122 @@
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'
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
}
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() {
let typeClassName = 'input-description-caption'
if (InputDescriptionType.Warning) {
typeClassName = 'input-description-warning'
}
if (InputDescriptionType.Error) {
typeClassName = 'input-description-error'
}
return classNames('input-description', typeClassName)
}
private renderIcon() {
if (InputDescriptionType.Error) {
return <Octicon symbol={OcticonSymbol.stop} />
}
if (InputDescriptionType.Warning) {
return <Octicon symbol={OcticonSymbol.alert} />
}
return null
}
/** If a input is a warning or an error that is displayed in response to
* tracked user input. We want it announced on user input debounce. */
private renderAriaLiveContainer() {
if (
InputDescriptionType.Caption ||
this.props.trackedUserInput === undefined
) {
return null
}
return (
<AriaLiveContainer trackedUserInput={this.props.trackedUserInput}>
{this.props.children}
</AriaLiveContainer>
)
}
/** 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 (
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
@ -106,12 +122,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 +147,43 @@ 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}
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,
@ -579,11 +581,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 +646,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 +874,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 +935,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}
@ -942,7 +953,8 @@ export class List extends React.Component<IListProps, IListState> {
id={id}
onRowRef={this.onRowRef}
rowCount={this.props.rowCount}
rowIndex={rowIndex}
rowIndex={{ section: 0, row: rowIndex }}
sectionHasHeader={false}
selected={selected}
onRowClick={this.onRowClick}
onRowDoubleClick={this.onRowDoubleClick}
@ -998,7 +1010,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 +1039,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.
@ -1085,7 +1108,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 +1155,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 +1253,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 +1320,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

@ -0,0 +1,696 @@
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'
return (
<div className={classnames('filter-list', this.props.className)}>
<AriaLiveContainer trackedUserInput={this.state.filterValue}>
{itemRows.length} {resultsPluralized}
</AriaLiveContainer>
{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

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

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

@ -51,6 +51,7 @@ interface IRepositoryViewProps {
readonly showSideBySideDiff: boolean
readonly askForConfirmationOnDiscardChanges: boolean
readonly askForConfirmationOnDiscardStash: boolean
readonly askForConfirmationOnCheckoutCommit: boolean
readonly focusCommitMessage: boolean
readonly commitSpellcheckEnabled: boolean
readonly accounts: ReadonlyArray<Account>
@ -300,6 +301,9 @@ export class RepositoryView extends React.Component<
tagsToPush={tagsToPush}
aheadBehindStore={aheadBehindStore}
isMultiCommitOperationInProgress={mcos !== null}
askForConfirmationOnCheckoutCommit={
this.props.askForConfirmationOnCheckoutCommit
}
/>
)
}

View file

@ -18,7 +18,8 @@ import {
import { DialogHeader } from '../dialog/header'
import { Dispatcher } from '../dispatcher'
import { Button } from '../lib/button'
import { List } from '../lib/list'
import { RowIndexPath } from '../lib/list/list-row-index-path'
import { SectionList } from '../lib/list/section-list'
import { Loading } from '../lib/loading'
import { getPullRequestReviewStateIcon } from '../notifications/pull-request-review-helpers'
import { Octicon } from '../octicons'
@ -397,9 +398,9 @@ export class TestNotifications extends React.Component<
return (
<div>
Pull requests:
<List
<SectionList
rowHeight={40}
rowCount={pullRequests.length}
rowCount={[pullRequests.length]}
rowRenderer={this.renderPullRequestRow}
selectedRows={[]}
onRowClick={this.onPullRequestRowClick}
@ -408,8 +409,8 @@ export class TestNotifications extends React.Component<
)
}
private onPullRequestRowClick = (row: number) => {
const pullRequest = this.state.pullRequests[row]
private onPullRequestRowClick = (indexPath: RowIndexPath) => {
const pullRequest = this.state.pullRequests[indexPath.row]
const stepResults = this.state.stepResults
stepResults.set(TestNotificationStepKind.SelectPullRequest, {
kind: TestNotificationStepKind.SelectPullRequest,
@ -440,9 +441,9 @@ export class TestNotifications extends React.Component<
return (
<div>
Reviews:
<List
<SectionList
rowHeight={40}
rowCount={reviews.length}
rowCount={[reviews.length]}
rowRenderer={this.renderPullRequestReviewRow}
selectedRows={[]}
onRowClick={this.onPullRequestReviewRowClick}
@ -451,8 +452,8 @@ export class TestNotifications extends React.Component<
)
}
private onPullRequestReviewRowClick = (row: number) => {
const review = this.state.reviews[row]
private onPullRequestReviewRowClick = (indexPath: RowIndexPath) => {
const review = this.state.reviews[indexPath.row]
const stepResults = this.state.stepResults
stepResults.set(TestNotificationStepKind.SelectPullRequestReview, {
kind: TestNotificationStepKind.SelectPullRequestReview,
@ -483,9 +484,9 @@ export class TestNotifications extends React.Component<
return (
<div>
Comments:
<List
<SectionList
rowHeight={40}
rowCount={comments.length}
rowCount={[comments.length]}
rowRenderer={this.renderPullRequestCommentRow}
selectedRows={[]}
onRowClick={this.onPullRequestCommentRowClick}
@ -494,8 +495,8 @@ export class TestNotifications extends React.Component<
)
}
private onPullRequestCommentRowClick = (row: number) => {
const comment = this.state.comments[row]
private onPullRequestCommentRowClick = (indexPath: RowIndexPath) => {
const comment = this.state.comments[indexPath.row]
const stepResults = this.state.stepResults
stepResults.set(TestNotificationStepKind.SelectPullRequestComment, {
kind: TestNotificationStepKind.SelectPullRequestComment,
@ -513,8 +514,8 @@ export class TestNotifications extends React.Component<
)
}
private renderPullRequestCommentRow = (row: number) => {
const comment = this.state.comments[row]
private renderPullRequestCommentRow = (indexPath: RowIndexPath) => {
const comment = this.state.comments[indexPath.row]
return (
<TestNotificationItemRowContent
dispatcher={this.props.dispatcher}
@ -528,8 +529,8 @@ export class TestNotifications extends React.Component<
)
}
private renderPullRequestReviewRow = (row: number) => {
const review = this.state.reviews[row]
private renderPullRequestReviewRow = (indexPath: RowIndexPath) => {
const review = this.state.reviews[indexPath.row]
return (
<TestNotificationItemRowContent
@ -555,8 +556,8 @@ export class TestNotifications extends React.Component<
)
}
private renderPullRequestRow = (row: number) => {
const pullRequest = this.state.pullRequests[row]
private renderPullRequestRow = (indexPath: RowIndexPath) => {
const pullRequest = this.state.pullRequests[indexPath.row]
const repository = this.props.repository.gitHubRepository
const endpointHtmlUrl = getHTMLURL(repository.endpoint)
const htmlURL = `${endpointHtmlUrl}/${repository.owner.login}/${repository.name}/pull/${pullRequest.pullRequestNumber}`

View file

@ -63,6 +63,15 @@ interface IBranchDropdownProps {
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
/** Whether the dropdown will trap focus or not. Defaults to true.
*
* Example of usage: If a dropdown is open and then a dialog subsequently, the
* focus trap logic will stop propagation of the focus event to the dialog.
* Thus, we want to disable this when dialogs are open since they will be
* using the dialog focus management.
*/
readonly enableFocusTrap: boolean
}
/**
@ -106,7 +115,7 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
}
public render() {
const { repositoryState } = this.props
const { repositoryState, enableFocusTrap } = this.props
const { branchesState, checkoutProgress, changesState } = repositoryState
const { tip } = branchesState
const { conflictState } = changesState
@ -148,15 +157,15 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
let progressValue: number | undefined = undefined
if (checkoutProgress) {
title = checkoutProgress.targetBranch
description = __DARWIN__ ? 'Switching to Branch' : 'Switching to branch'
title = checkoutProgress.target
description = checkoutProgress.description
if (checkoutProgress.value > 0) {
const friendlyProgress = Math.round(checkoutProgress.value * 100)
description = `${description} (${friendlyProgress}%)`
}
tooltip = `Switching to ${checkoutProgress.targetBranch}`
tooltip = `Checking out ${checkoutProgress.target}`
progressValue = checkoutProgress.value
icon = syncClockwise
iconClassName = 'spin'
@ -196,6 +205,7 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
onMouseEnter={this.onMouseEnter}
onlyShowTooltipWhenOverflowed={true}
isOverflowed={isDescriptionOverflowed}
enableFocusTrap={enableFocusTrap}
>
{this.renderPullRequestInfo()}
</ToolbarDropdown>

View file

@ -114,7 +114,13 @@ export interface IToolbarDropdownProps {
/** The button's style. Defaults to `ToolbarButtonStyle.Standard`. */
readonly style?: ToolbarButtonStyle
/** Whether the dropdown will trap focus or not. Defaults to true. */
/** Whether the dropdown will trap focus or not. Defaults to true.
*
* Example of usage: If a dropdown is open and then a dialog subsequently, the
* focus trap logic will stop propagation of the focus event to the dialog.
* Thus, we want to disable this when dialogs are open since they will be
* using the HTML build in dialog focus management.
*/
readonly enableFocusTrap?: boolean
/**

View file

@ -22,6 +22,7 @@ import {
import { FoldoutType } from '../../lib/app-state'
import { ForcePushBranchState } from '../../lib/rebase'
import { PushPullButtonDropDown } from './push-pull-button-dropdown'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
export const DropdownItemClassName = 'push-pull-dropdown-item'
@ -80,6 +81,15 @@ interface IPushPullButtonProps {
/** Will the app prompt the user to confirm a force push? */
readonly askForConfirmationOnForcePush: boolean
/** Whether the dropdown will trap focus or not. Defaults to true.
*
* Example of usage: If a dropdown is open and then a dialog subsequently, the
* focus trap logic will stop propagation of the focus event to the dialog.
* Thus, we want to disable this when dialogs are open since they will be
* using the dialog focus management.
*/
readonly enableFocusTrap: boolean
/**
* An event handler for when the drop down is opened, or closed, by a pointer
* event or by pressing the space or enter key while focused.
@ -89,6 +99,13 @@ interface IPushPullButtonProps {
readonly onDropdownStateChanged: (state: DropdownState) => void
}
type ActionInProgress = 'push' | 'pull' | 'fetch' | 'force push'
interface IPushPullButtonState {
readonly screenReaderStateMessage: string | null
readonly actionInProgress: ActionInProgress | null
}
export enum DropdownItemType {
Fetch = 'fetch',
ForcePush = 'force-push',
@ -160,7 +177,61 @@ export const forcePushIcon: OcticonSymbol.OcticonSymbolType = {
* A button which pushes, pulls, or updates depending on the state of the
* repository.
*/
export class PushPullButton extends React.Component<IPushPullButtonProps> {
export class PushPullButton extends React.Component<
IPushPullButtonProps,
IPushPullButtonState
> {
public constructor(props: IPushPullButtonProps) {
super(props)
this.state = {
screenReaderStateMessage: null,
actionInProgress: null,
}
}
public componentDidUpdate(prevProps: IPushPullButtonProps) {
const progressChanged =
(this.props.progress !== null && prevProps.progress == null) ||
this.props.progress?.title !== prevProps.progress?.title
const progressComplete =
this.props.progress === null && prevProps.progress !== null
if (progressChanged) {
this.setScreenReaderLoadingStateMessage()
}
if (progressComplete) {
this.setState({
screenReaderStateMessage: `${
this.state.actionInProgress ?? 'Pull, push, or fetch'
} complete`,
actionInProgress: null,
})
}
}
private isPullPushFetchProgress(kind: string): kind is ActionInProgress {
return kind === 'push' || kind === 'pull' || kind === 'fetch'
}
private setScreenReaderLoadingStateMessage() {
const { progress } = this.props
if (progress === null) {
return
}
const { description, title, kind } = progress
const screenReaderStateMessage = `${title} ${description ?? 'Hang on…'}`
const actionInProgress: ActionInProgress | null =
this.state.actionInProgress === null && this.isPullPushFetchProgress(kind)
? kind
: this.state.actionInProgress
this.setState({ screenReaderStateMessage, actionInProgress })
}
/** The common props for all button states */
private defaultButtonProps() {
return {
@ -180,6 +251,7 @@ export class PushPullButton extends React.Component<IPushPullButtonProps> {
dropdownStyle: ToolbarDropdownStyle.MultiOption,
ariaLabel: 'Push, pull, fetch options',
dropdownState: this.props.isDropdownOpen ? 'open' : 'closed',
enableFocusTrap: this.props.enableFocusTrap,
onDropdownStateChanged: this.props.onDropdownStateChanged,
}
}
@ -196,6 +268,7 @@ export class PushPullButton extends React.Component<IPushPullButtonProps> {
private forcePushWithLease = () => {
this.closeDropdown()
this.props.dispatcher.confirmOrForcePush(this.props.repository)
this.setState({ actionInProgress: 'force push' })
}
private pull = () => {
@ -230,7 +303,14 @@ export class PushPullButton extends React.Component<IPushPullButtonProps> {
}
public render() {
return this.renderButton()
return (
<>
{this.renderButton()}
<AriaLiveContainer>
{this.state.screenReaderStateMessage}
</AriaLiveContainer>
</>
)
}
private renderButton() {

View file

@ -145,7 +145,7 @@ export class TutorialPanel extends React.Component<
<strong>{this.props.resolvedExternalEditor}</strong>. You can
change your preferred editor in{' '}
<LinkButton onClick={this.onPreferencesClick}>
{__DARWIN__ ? 'Preferences' : 'options'}
{__DARWIN__ ? 'Settings' : 'options'}
</LinkButton>
</p>
)}

View file

@ -104,4 +104,5 @@
@import 'ui/_popover-dropdown';
@import 'ui/_pull-request-files-changed';
@import 'ui/_pull-request-merge-status';
@import 'ui/_input-description';
@import 'ui/repository-rules/_repo-rules-failure-list';

View file

@ -16,4 +16,20 @@
color: var(--text-secondary-color);
}
}
.clone-repository-list-item {
.name {
flex-grow: 1;
}
.archived {
flex-shrink: 0;
margin-left: var(--spacing-half);
font-size: var(--font-size-xs);
border: var(--contrast-border);
padding: 1px 3px;
border-radius: var(--border-radius);
text-transform: uppercase;
}
}
}

View file

@ -0,0 +1,27 @@
.input-description {
font-size: var(--font-size-sm);
display: flex;
flex-direction: row;
.octicon {
margin-right: var(--spacing-half);
}
&.input-description-warning {
color: var(--text-secondary-color);
}
&.input-description-warning {
.octicon {
fill: var(--dialog-warning-color);
}
}
&.input-description-error {
color: var(--dialog-error-color);
.octicon {
fill: var(--dialog-error-color);
}
}
}

View file

@ -3,6 +3,13 @@
padding: calc(var(--spacing) * 6);
align-items: center;
> section {
display: flex;
flex-direction: column;
flex-grow: 1;
min-width: 0;
}
header {
align-self: flex-start;
margin-bottom: var(--spacing-quad);
@ -24,7 +31,6 @@
display: flex;
flex-shrink: 0;
flex-grow: 1;
height: 0;
& > .content-pane {
display: flex;

View file

@ -5,6 +5,7 @@ Dexie.dependencies.IDBKeyRange = require('fake-indexeddb/lib/FDBKeyRange')
// shims a bunch of browser specific methods
// like fetch, requestIdleCallback, etc
import 'airbnb-browser-shims/browser-only'
import { join } from 'path'
// These constants are defined by Webpack at build time, but since tests aren't
// built with Webpack we need to make sure these exist at runtime.
@ -12,6 +13,7 @@ const g: any = global
g['__WIN32__'] = process.platform === 'win32'
g['__DARWIN__'] = process.platform === 'darwin'
g['__LINUX__'] = process.platform === 'linux'
g['__APP_VERSION__'] = require(join(__dirname, '../package.json')).version
g['__DEV__'] = 'false'
g['__RELEASE_CHANNEL__'] = 'development'
g['__UPDATES_URL__'] = ''

View file

@ -195,6 +195,8 @@ describe('git/stash', () => {
name: 'refs/stash@{0}',
branchName: 'master',
stashSha: 'xyz',
tree: 'xyz',
parents: ['abc'],
files: { kind: StashedChangesLoadStates.NotLoaded },
}
@ -213,6 +215,8 @@ describe('git/stash', () => {
name: 'refs/stash@{4}',
branchName: 'master',
stashSha: 'xyz',
tree: 'xyz',
parents: ['abc'],
files: { kind: StashedChangesLoadStates.NotLoaded },
}
await generateTestStashEntry(repository, 'master', true)

View file

@ -41,6 +41,14 @@ describe('URL remote parsing', () => {
expect(remote!.name).toBe('repo')
})
it('parses SSH URLs with custom username', () => {
const remote = parseRemote('niik@niik.ghe.com:hubot/repo.git')
expect(remote).not.toBeNull()
expect(remote!.hostname).toBe('niik.ghe.com')
expect(remote!.owner).toBe('hubot')
expect(remote!.name).toBe('repo')
})
it('parses SSH URLs without the git suffix', () => {
const remote = parseRemote('git@github.com:hubot/repo')
expect(remote).not.toBeNull()

View file

@ -0,0 +1,88 @@
import {
InvalidRowIndexPath,
rowIndexPathEquals,
} from '../../src/ui/lib/list/list-row-index-path'
import { findNextSelectableRow } from '../../src/ui/lib/list/section-list-selection'
describe('section-list-selection', () => {
describe('findNextSelectableRow', () => {
const rowCount = [5, 3, 8]
it('returns first row when selecting down outside list (filter text)', () => {
const selectedRow = findNextSelectableRow(rowCount, {
direction: 'down',
row: InvalidRowIndexPath,
})
expect(selectedRow?.row).toBe(0)
})
it('returns first selectable row when header is first', () => {
const selectedRow = findNextSelectableRow(
rowCount,
{
direction: 'down',
row: InvalidRowIndexPath,
},
row => {
if (row.section === 0 && row.row === 0) {
return false
} else {
return true
}
}
)
expect(selectedRow?.row).toBe(1)
})
it('returns first row when selecting down from last row', () => {
const lastRow = rowCount[0] - 1
const selectedRow = findNextSelectableRow(rowCount, {
direction: 'down',
row: {
section: 0,
row: lastRow,
},
})
expect(selectedRow?.row).toBe(0)
})
it('returns last row when selecting up from top row', () => {
const selectedRow = findNextSelectableRow(rowCount, {
direction: 'up',
row: {
section: 0,
row: 0,
},
})
expect(
rowIndexPathEquals(selectedRow!, { section: 2, row: 7 })
).toBeTrue()
})
it('returns first row of next section when selecting down from last row of a section', () => {
const selectedRow = findNextSelectableRow(rowCount, {
direction: 'down',
row: {
section: 0,
row: 4,
},
})
expect(
rowIndexPathEquals(selectedRow!, { section: 1, row: 0 })
).toBeTrue()
})
it('returns last row of previous section when selecting up from first row of a section', () => {
const selectedRow = findNextSelectableRow(rowCount, {
direction: 'up',
row: {
section: 2,
row: 0,
},
})
expect(
rowIndexPathEquals(selectedRow!, { section: 1, row: 2 })
).toBeTrue()
})
})
})

View file

@ -81,7 +81,7 @@ export const renderer = merge({}, commonConfig, {
},
{
test: /\.cmd$/,
loader: 'file-loader',
type: 'asset/resource',
},
],
},

View file

@ -1245,25 +1245,15 @@ scheduler@^0.13.4:
loose-envify "^1.1.0"
object-assign "^4.1.1"
semver@^5.4.1:
version "5.6.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
semver@^5.4.1, semver@^5.5.0:
version "5.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
semver@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
semver@^7.2.1:
version "7.3.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938"
integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==
semver@^7.3.5:
version "7.3.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
semver@^7.2.1, semver@^7.3.5:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"

View file

@ -1,5 +1,48 @@
{
"releases": {
"3.2.7-beta2": [
"[New] Checkout a commit from the History tab - #10068. Thanks @kitswas!",
"[New] Show when a repository has been archived in the clone dialog - #7183",
"[Fixed] \"Clone a Repository\" dialog list is keyboard navigable - #16977",
"[Improved] Checkboxes in dialogs can receive initial keyboard focus - #17014",
"[Improved] The progress state of the pull, push, fetch button is announced by screen readers - #16985",
"[Improved] Inline errors are consistently announced by screen readers - #16850",
"[Improved] Group title and position is correctly announced by screen readers in repository and branch lists - #16968"
],
"3.2.7-beta1": [
"[Fixed] Recreate stash after renaming branch - #16442",
"[Fixed] Improved performance when selecting and viewing a large number of commits - #16880",
"[Fixed] Preferences renamed to Settings on macOS to follow platform convention - #16907",
"[Fixed] Allow filtering autocomplete results using uppercase characters - #16886",
"[Fixed] Emoji autocomplete list highlights filter text correctly - #16899",
"[Fixed] Fix crash using Edit -> Copy menu when no text is selected in the diff - #16876",
"[Fixed] The list of the repositories under the filter box on the \"Let's get started!\" page is visible - #16955",
"[Improved] The \"pull, push, fetch\" dropdown button has an aria-label for screen reader users - #16839",
"[Improved] The aria role of alert is applied to dialog error banners so they are announced by screen readers - #16809",
"[Improved] Add Double Click to Open in Default Editor - #2620. Thanks @digitalmaster!",
"[Improved] Upgrade to Electron v24.4.0 - #15831"
],
"3.2.6": [
"[Fixed] The list of the repositories under the filter box on the \"Let's get started!\" page is visible - #16955"
],
"3.2.5": [
"[Fixed] Entering in double forward slash does not trim the target directory in the cloning dialog - #15842. Thanks @IgnazioGul!",
"[Fixed] In the \"No Repositories\" screen, controls at the bottom stay inside window when it is resized - #16502. Thanks @samuelko123!",
"[Fixed] Link to editor settings on the tutorial screen - #16636. Thanks @IgnazioGul!",
"[Fixed] Fix crash using Edit -> Copy menu when no text is selected in the diff - #16876",
"[Improved] Improve screen reader support of the \"Create Alias\" dialog - #16802",
"[Improved] Screen readers announce the number of results in filtered lists (like repositories, branches or pull requests) - #16779",
"[Improved] Screen readers announce expanded/collapsed state of dropdowns - #16781",
"[Improved] The context menu for a branches list items can be invoked by keyboard shortcuts - #16760",
"[Improved] The context menu for a repository list item can be invoked by keyboard shortcuts - #16758",
"[Improved] Make floating elements more responsive as the window or the UI are resized - #16717",
"[Improved] Adds committing avatar popover to see git configuration and ability to open git configuration settings - #16640",
"[Improved] Password inputs have a visibility toggle. - #16714",
"[Improved] Welcome flow screen change in context is announced - #16698",
"[Improved] Focus the sign in with browser button on opening the enterprise server login screen - #16706",
"[Improved] Show the remote branch name if it does not match the local branch name - #13591. Thanks @samuelko123!",
"[Improved] Reduce retries of avatars that fail to load - #16592"
],
"3.2.4": [
"[Fixed] The misattributed commit avatar popover no longer causes the changes list to have scrollbars - #16684",
"[Fixed] Autocompletion list is always visible regardless of its position on the screen - #16609, #16650",

View file

@ -43,23 +43,3 @@ $ yarn run package
If you think you've found a solution, please submit a pull request to [`shiftkey/desktop`](https://github.com/shiftkey/desktop) explaining the change and what it fixes. If you're not quite sure, open an issue on the [`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork explaining what you've found and where you think the problem lies. Maybe someone else has insight into the issue.
[**@shiftkey**](https://github.com/shiftkey) will co-ordinate upstreaming merged pull requests to the main repository.
## Technical Details
We use `electron-packager` to generate the artifacts and `electron-builder` to generate the installer.
`electron-packager` details:
* [API options](https://github.com/electron-userland/electron-packager/blob/development/docs/api.md#options)
* [`dist-info.js` config file](https://github.com/desktop/desktop/blob/development/script/dist-info.js)
* [Usage in Desktop](https://github.com/desktop/desktop/blob/development/script/build.ts#L98-L151)
`dist-info.js` contains the various metadata we provide to Desktop as part of packaging. This seems fairly stable, but we might need to tweak some things in here for Linux-specific changes.
`electron-builder` details:
* [API options](https://www.electron.build/configuration/linux)
* [`electron-builder-linux.yml` config file](https://github.com/desktop/desktop/blob/development/script/electron-builder-linux.yml)
* [Usage in Desktop](https://github.com/desktop/desktop/blob/development/script/package.ts#L124-L145)
We use `electron-builder-linux.yml` to configure the installers, so please investigate the documentation if you find a problem with an installer to see if something has been overlooked and can be fixed fairly easily.

View file

@ -19,7 +19,7 @@ versions look similar to the below output:
```shellsession
$ node -v
v16.13.0
v18.14.0
$ yarn -v
1.21.1

View file

@ -10,7 +10,6 @@ In the interest of stability and caution we tend to stay a version (or more) beh
| Dependency | Versions Behind Latest |
| --- | --- |
| electron | >= 1 major |
| electron-builder | >= 1 minor |
| electron-packager | >= 1 major |
| electron-winstaller | >= 1 minor |
| typescript | >= 1 minor |
@ -76,7 +75,6 @@ These are the most important dependencies to the app, and include:
- `package.json`
- `@types/node`
- `electron`
- `electron-builder`
- `electron-packager`
- `electron-winstaller`
- `typescript`

View file

@ -1,220 +0,0 @@
# Releasing Updates
## Channels
We have three channels to which we can release: `production`, `beta`, and `test`.
- `production` is the channel from which the general public downloads and receives updates. It should be stable and polished.
- `beta` is released more often than `production`. We want to ensure `development` is always in a state where it can be released to users, so it should be used as the source for `beta` releases as an opportunity for additional QA before releasing to `production`.
- `test` is unlike the other two. It does not receive updates. Each test release is locked in time. It's used entirely for providing test releases.
## The Process
### 1. GitHub Access Token
From a clean working directory, set the `GITHUB_ACCESS_TOKEN` environment variable to a valid [Personal Access Token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/)
To check that this environment variable is set in your shell:
**Bash (macOS, Linux or Git Bash)**
```shellsession
$ echo $GITHUB_ACCESS_TOKEN
```
**Command Prompt**
```shellsession
$ echo %GITHUB_ACCESS_TOKEN%
```
**PowerShell**
```shellsession
$ echo $env:GITHUB_ACCESS_TOKEN
```
If you are creating a new Personal Access Token on GitHub:
* make the token memorable - use a description like `Desktop Draft Release and Changelog Generator`
* the `read:org` scope is the **only** required scope for drafting releases
To set this access token as an environment in your shell:
**Bash (macOS, Linux or Git Bash)**
```shellsession
$ export GITHUB_ACCESS_TOKEN={your token here}
```
**Command Prompt**
```shellsession
$ set GITHUB_ACCESS_TOKEN={your token here}
```
**PowerShell**
```shellsession
$ $env:GITHUB_ACCESS_TOKEN="{your token here}"
```
### 2. Switch to the Commit of the Release
You have to switch to the commit that represents the work that will be released to users:
- for `beta` releases, switch to `development` to ensure the latest changes are published
- for `production` releases, check out the latest beta tag
- to find this tag: `git tag | grep 'beta' | sort -r | head -n 1`
### 3. Create Draft Release and Release Branch
Run the script below (which relies on the your personal access token being set), which will determine the next version from what was previously published, based on the desired channel.
For `production` and `beta` releases, run:
```shellsession
$ yarn draft-release (production|beta|test)
```
If you are creating a new beta release, the `yarn draft-release beta` command will help you find the new release entries for the changelog.
If you are create a new `production` release, you should just combine and sort the previous `beta` changelog entries.
The script will output a draft changelog, which covers everything that's been merged, and probably needs some love. It will also create the release branch for you. If that fails for whatever reason and you must create the branch manually, ensure you use the `releases/[version]` pattern for its name to ensure all CI platforms are aware of the branch and will build any PRs that target the branch.
If you have pretext release note drafted in `app/static/common/pretext-draft.md`, you can add the `--pretext` flag to generate a pretext change log entry it. Example: `yarn draft-release test --pretext`
The output will then explain the next steps:
```shellsession
Here's what you should do next:
1. Update the app/package.json 'version' to '1.0.14-beta2' (make sure this aligns with semver format of 'major.minor.patch')
2. Concatenate this to the beginning of the releases element in the changelog.json as a starting point:
{
"1.0.14-beta2": [
"[???] Add RubyMine support for macOS - #3883. Thanks @gssbzn!",
"[???] Allow window to accept single click on focus - #3843",
"[???] Drop unnecessary comments before issue template - #3906",
"[???] First-class changelog script for generating release notes - #3888",
"[???] Fix expanded avatar stack overflow - #3884",
"[???] Switch to a saner default gravatar size - #3911",
"[Fixed] Add a repository settings store - #934",
"[Fixed] Ensure renames are detected when viewing commit diffs - #3673",
"[Fixed] Line endings are hard, lets go shopping - #3514",
]
}
3. Revise the release notes according to https://github.com/desktop/desktop/blob/development/docs/process/writing-release-notes.md
4. Commit the changes (on development or as new branch) and push them to GitHub
5. Read this to perform the release: https://github.com/desktop/desktop/blob/development/docs/process/releasing-updates.md
```
See our [release notes writing guide](./writing-release-notes.md) for more info on how we write and review our release notes.
_Note: You should ensure the `version` in `app/package.json` is set to the new version and follows the [semver format](https://semver.org/) of `major.minor.patch`._
Examples:
* for prod, `1.1.0` -> `1.1.1` or `1.1.13` -> `1.2.0`
* for beta, `1.1.0-beta1` -> `1.1.0-beta2` or `1.1.13-beta3` -> `1.2.0-beta1`
* for test, `1.0.14-test2` -> `1.0.14-test3` or `1.1.14-test3` -> `1.2.0-test1`
Here's an example of the previous changelog draft after it has been edited:
```json
{
"1.0.14-beta2": [
"[Added] Add RubyMine support for macOS - #3883. Thanks @gssbzn!",
"[Fixed] Allow window to accept single click on focus - #3843",
"[Fixed] Expanded avatar list hidden behind commit details - #3884",
"[Fixed] Renames not detected when viewing commit diffs - #3673",
"[Fixed] Ignore action assumes CRLF when core.autocrlf is unset - #3514",
"[Improved] Use smaller default size when rendering Gravatar avatars - #3911",
]
}
```
Add your new changelog entries to `changelog.json`, update the version in `app/package.json`, commit the changes, and push this branch to GitHub. This becomes the release branch, and lets other maintainers continue to merge into `development` without affecting your release.
If a maintainer would like to backport a pull request to the next release, it is their responsibility to co-ordinate with the release owner and ensure they are fine with accepting this work.
After pushing the branch, a [GitHub Action](https://github.com/desktop/desktop/blob/development/.github/workflows/release-pr.yml) will create a release Pull Request for it. If that action fails for whatever reason, you can fall back to using the `yarn draft-release:pr` command, or create it manually.
Once your release branch is ready to review and ship, ask the other maintainers to review and approve the changes!
IMPORTANT NOTE: Do NOT "Update branch" and merge development into the release branch. This might be tempting if the "branch is out-of-date with the base branch" dotcom feature is enabled. However, doing so would inadvertently release everything on development to production or beta 🙀
### 4. Releasing
When you are ready to start the deployment, run this command in chat (where `X.Y.Z-release` is the name of your release branch):
```
.release! desktop/X.Y.Z-release to {production|beta|test}
```
We're using `.release` with a bang so that we don't have to wait for any current CI on the branch to finish. This might feel a little wrong, but it's OK since making the release itself will also run CI.
If you're releasing a `production` update, release a `beta` update for the next version too, so that beta users are on the latest release. For example, if the version just released to production is `1.2.0` then the beta release should be `1.2.1-beta0` to indicate there are no new changes on top of what's currently on `production`.
IMPORTANT NOTE: Ensure that you indicate which channel to release to. If not, chatops will default to releasing to production 🙀
### 5. Check for Completed Release
Go to [Central's Deployments](https://central.githubapp.com/deployments) to find your release; you'll see something at the top of the page like:
```
desktop/desktop deployed from {YOUR_BRANCH}@{HASH_ABBREVIATION_FOR_COMMIT} to {production|beta|test}
```
it will initially specify its state as `State: pending` and will be completed when it says `State: released`
You will also see this in Chat:
`desktopbot tagged desktop/release-{YOUR_VERSION}`
### 6. Enable the Release
Production releases are disabled by default. To enable them, go to [Central's Release Control](https://central.githubapp.com/release_control), select the percentage of users that will be able to auto-update to this new version, and then click the "Apply" button.
### 7. Test that your app auto-updates to new version
When the release in Central is in `State: released` for `beta` or `production`, switch to your installed Desktop instance and make sure that the corresponding (prod|beta) app auto-updates.
Testing that an update is detected, downloaded, and applied correctly is very important - if this is somehow broken during development then our users will not likely stay up to date!
If you don't have the app for `beta`, for example, you can always download the previous version on Central to see it update
_Make sure you move your application out of the Downloads folder and into the Applications folder for macOS or it won't auto-update_.
### 8. Merge PR with changelog entries
So that we keep the `changelog.json` up to date. Beta entries will be used for the upcoming production release.
### 9. Check Error Reporting
If an error occurs during the release process, a needle will be reported to Central's [Haystack](https://haystack.githubapp.com/central).
After the release is deployed, you should monitor Desktop's [Haystack](https://haystack.githubapp.com/desktop) closely for 15 minutes to ensure no unexpected needles appear.
#### Final Beta release
If the active beta is the last beta prior to a production release, extra care should be taken when looking at Desktop's [Haystack](https://haystack.githubapp.com/desktop) roll-ups. The lead engineer responsible for deployment should produce a _Haystack report_ the day before and after the release. The report should contain a list of any new or unexpected errors from the past beta releases in the milestone and be published to the team's Slack channel.
### 10. Celebrate
Once your app updates and you see the visible changes in your app and there are no spikes in errors, celebrate 🎉!!! You did it!
Also it might make sense to continue to monitor Haystack in the background for the next 24 hours.
## Retrying a Failed Release
Sometimes deployments will fail for any reason: one of the CI jobs times out, uploading the builds fails…
When that happens, we should never just re-run the CI jobs, because that could cause problems with the updates of the Windows app.
Instead, we have two options:
1. Delete the failed release from Central (and its `release-${version}-${channel}` tag, if it exists), and then create a new release from the same branch as usual.
2. Just create a new version (bumping the version number) and release it instead.
## Stopping a Release Mid-flight
So let's say you kicked off a release with chatops on accident. Here's how you fix that.
When you kicked off the release, a branch with the prefix `__release-${channel}-` was created in the GitHub repo. Use that branch name to find the proper CI jobs below.
1. Delete the pending release from Central
2. Cancel the GitHub Action release job
3. Delete the CI release job branch from GitHub
4. Breathe a sigh of relief
You don't need to do anything with your manually created release branch, that you referred to in the chatops command. Feel free to re-use it.

View file

@ -107,16 +107,8 @@ Other things to note about the Windows packaging process:
### Linux
Desktop uses `electron-builder` to generate these three packages:
- `.deb` package for Debian-based distributions
- `.rpm` package for RPM-based various distributions
- `.AppImage` package for various distributions (no elevated permissions
required)
- `.snap` package for various distributions
The `script/electron-builder-linux.yml` configuration file contains the details
applied to each package (if applicable).
Refer to the [`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork
for packaging details about Linux.
## `script/publish.ts`

View file

@ -31,7 +31,6 @@
"lint:src:fix": "yarn eslint --fix",
"eslint": "eslint --cache --rulesdir ./eslint-rules \"./eslint-rules/**/*.js\" \"./script/**/*.ts{,x}\" \"./app/{src,typings,test}/**/*.{j,t}s{,x}\" \"./changelog.json\"",
"eslint-check": "eslint --print-config .eslintrc.* | eslint-config-prettier-check",
"publish": "ts-node -P script/tsconfig.json script/publish.ts",
"validate-electron-version": "ts-node -P script/tsconfig.json script/validate-electron-version.ts",
"clean-slate": "rimraf out node_modules app/node_modules && yarn",
"rebuild-hard:dev": "yarn clean-slate && yarn build:dev",
@ -70,13 +69,12 @@
"css-loader": "^6.7.1",
"eslint": "^7.3.1",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-jsdoc": "^37.7.0",
"eslint-plugin-jsdoc": "^43.0.1",
"eslint-plugin-json": "^2.1.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "7.26.1",
"express": "^4.17.3",
"fake-indexeddb": "^2.0.4",
"file-loader": "^6.2.0",
"front-matter": "^2.3.0",
"fs-extra": "^9.0.1",
"glob": "^7.1.2",
@ -95,7 +93,7 @@
"rimraf": "^2.5.2",
"sass": "^1.27.0",
"sass-loader": "^10.0.3",
"semver": "^5.5.0",
"semver": "^5.7.2",
"split2": "^3.2.2",
"style-loader": "^3.3.1",
"to-camel-case": "^1.0.0",
@ -156,8 +154,7 @@
"@types/webpack-hot-middleware": "^2.25.6",
"@types/webpack-merge": "^5.0.0",
"@types/xml2js": "^0.4.11",
"electron": "22.0.3",
"electron-builder": "^23.6.0",
"electron": "24.4.0",
"electron-packager": "^17.1.1",
"electron-winstaller": "^5.0.0",
"eslint-plugin-github": "^4.3.7",

View file

@ -1,27 +1,4 @@
export function getSha() {
if (isCircleCI() && process.env.CIRCLE_SHA1 != null) {
return process.env.CIRCLE_SHA1
}
if (isAppveyor() && process.env.APPVEYOR_REPO_COMMIT != null) {
return process.env.APPVEYOR_REPO_COMMIT
}
if (isTravis() && process.env.TRAVIS_COMMIT != null) {
return process.env.TRAVIS_COMMIT
}
const branchCommitId = process.env.BUILD_SOURCEVERSION
// this check is for a CI build from a local branch
if (isAzurePipelines() && branchCommitId != null) {
return branchCommitId
}
const pullRequestCommitId = process.env.SYSTEM_PULLREQUEST_SOURCECOMMITID
if (isAzurePipelines() && pullRequestCommitId != null) {
return pullRequestCommitId
}
const gitHubSha = process.env.GITHUB_SHA
if (isGitHubActions() && gitHubSha !== undefined && gitHubSha.length > 0) {
return gitHubSha
@ -32,40 +9,6 @@ export function getSha() {
)
}
export function isTravis() {
return process.platform === 'linux' && process.env.TRAVIS === 'true'
}
export function isCircleCI() {
return process.platform === 'darwin' && process.env.CIRCLECI === 'true'
}
export function isAppveyor() {
return process.platform === 'win32' && process.env.APPVEYOR === 'True'
}
export function isAzurePipelines() {
return (
process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI ===
'https://github.visualstudio.com/'
)
}
export function isGitHubActions() {
return process.env.GITHUB_ACTIONS === 'true'
}
export function getReleaseBranchName(): string {
// GitHub Actions
if (process.env.GITHUB_REF !== undefined) {
return process.env.GITHUB_REF.replace(/^refs\/heads\//, '')
}
return (
process.env.CIRCLE_BRANCH || // macOS
process.env.APPVEYOR_REPO_BRANCH || // Windows
process.env.TRAVIS_BRANCH || // Travis CI
process.env.BUILD_SOURCEBRANCHNAME || // Azure Pipelines
''
)
}

View file

@ -36,7 +36,7 @@ import {
getIconFileName,
getDistArchitecture,
} from './dist-info'
import { isCircleCI, isGitHubActions } from './build-platforms'
import { isGitHubActions } from './build-platforms'
import { updateLicenseDump } from './licenses/update-license-dump'
import { verifyInjectedSassVariables } from './validate-sass/validate-all'
@ -151,7 +151,7 @@ function packageApp() {
: undefined
if (
isPublishableBuild &&
(isCircleCI() || isGitHubActions()) &&
isGitHubActions() &&
process.platform === 'darwin' &&
notarizationCredentials === undefined
) {

View file

@ -1,7 +0,0 @@
$scriptPath = split-path -parent $MyInvocation.MyCommand.Definition
$file = "$scriptPath\windows-certificate.pfx"
if ((Test-Path $file)) {
Remove-Item $file
}

View file

@ -2,15 +2,12 @@ import * as Path from 'path'
import * as Fs from 'fs'
import { getProductName, getVersion } from '../app/package-info'
import { getReleaseBranchName } from './build-platforms'
const productName = getProductName()
const version = getVersion()
const projectRoot = Path.join(__dirname, '..')
const publishChannels = ['production', 'test', 'beta']
export function getDistRoot() {
return Path.join(projectRoot, 'dist')
}
@ -101,47 +98,19 @@ export function getWindowsIdentifierName() {
}
export function getBundleSizes() {
// eslint-disable-next-line no-sync
const rendererStats = Fs.statSync(
Path.join(projectRoot, 'out', 'renderer.js')
)
// eslint-disable-next-line no-sync
const mainStats = Fs.statSync(Path.join(projectRoot, 'out', 'main.js'))
return { rendererSize: rendererStats.size, mainSize: mainStats.size }
}
export function isPublishable(): boolean {
const channelFromBranch = getChannelFromBranch()
return channelFromBranch !== undefined
? publishChannels.includes(channelFromBranch)
: false
}
export function getChannel() {
const channelFromBranch = getChannelFromBranch()
return channelFromBranch !== undefined
? channelFromBranch
: process.env.NODE_ENV || 'development'
}
function getChannelFromBranch(): string | undefined {
// Branch name format: __release-CHANNEL-DEPLOY_ID
const pieces = getReleaseBranchName().split('-')
if (pieces.length < 3 || pieces[0] !== '__release') {
return
const outPath = Path.join(projectRoot, 'out')
return {
// eslint-disable-next-line no-sync
rendererBundleSize: Fs.statSync(Path.join(outPath, 'renderer.js')).size,
// eslint-disable-next-line no-sync
mainBundleSize: Fs.statSync(Path.join(outPath, 'main.js')).size,
}
return pieces[1]
}
export const isPublishable = () =>
['production', 'beta', 'test'].includes(getChannel())
export function getReleaseSHA() {
// Branch name format: __release-CHANNEL-DEPLOY_ID
const pieces = getReleaseBranchName().split('-')
if (pieces.length < 3 || pieces[0] !== '__release') {
return null
}
return pieces[2]
}
export const getChannel = () =>
process.env.RELEASE_CHANNEL ?? process.env.NODE_ENV ?? 'development'
export function getDistArchitecture(): 'arm64' | 'x64' {
// If a specific npm_config_arch is set, we use that one instead of the OS arch (to support cross compilation)
@ -177,8 +146,7 @@ export function getUpdatesURL() {
export function shouldMakeDelta() {
// Only production and beta channels include deltas. Test releases aren't
// necessarily sequential so deltas wouldn't make sense.
const channelsWithDeltas = ['production', 'beta']
return channelsWithDeltas.indexOf(getChannel()) > -1
return ['production', 'beta'].includes(getChannel())
}
export function getIconFileName(): string {

View file

@ -85,7 +85,7 @@ function printInstructions(nextVersion: string, entries: Array<string>) {
'Revise the release notes according to https://github.com/desktop/desktop/blob/development/docs/process/writing-release-notes.md',
'Lint them with: yarn draft-release:format',
'Commit these changes (on a "release" branch) and push them to GitHub',
'Read this to perform the release: https://github.com/desktop/desktop/blob/development/docs/process/releasing-updates.md',
'See the deploy repo for details on performing the release: https://github.com/desktop/deploy',
]
// if an empty list, we assume the new entries have already been
// written to the changelog file

View file

@ -1,36 +0,0 @@
productName: 'GitHubDesktop'
artifactName: '${productName}-${os}-${arch}-${version}.${ext}'
linux:
category: 'GNOME;GTK;Development'
packageCategory: 'GNOME;GTK;Development'
icon: 'app/static/logos'
target:
- deb
- rpm
- AppImage
maintainer: 'GitHub, Inc <opensource+desktop@github.com>'
deb:
afterInstall: './script/linux-after-install.sh'
afterRemove: './script/linux-after-remove.sh'
depends:
# default Electron dependencies
- gconf2
- gconf-service
- libnotify4
- libappindicator1
- libxtst6
- libnss3
# dugite-native dependencies
- libcurl3 | libcurl4
# keytar dependencies
- libsecret-1-0
rpm:
depends:
# default Electron dependencies
- libXScrnSaver
- libappindicator
- libnotify
# dugite-native dependencies
- libcurl
# keytar dependencies
- libsecret

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