mirror of
https://github.com/desktop/desktop
synced 2024-09-19 16:12:20 +00:00
merge from upstream
This commit is contained in:
commit
fa34c72396
|
@ -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'
|
||||
|
|
100
.github/workflows/ci.yml
vendored
100
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/release-pr.yml
vendored
2
.github/workflows/release-pr.yml
vendored
|
@ -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:
|
||||
|
|
|
@ -1 +1 @@
|
|||
16.17.1
|
||||
18.14.0
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
python 3.9.5
|
||||
nodejs 16.17.1
|
||||
nodejs 18.14.0
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
runtime = electron
|
||||
disturl = https://electronjs.org/headers
|
||||
target = 22.0.0
|
||||
target = 24.4.0
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"productName": "GitHub Desktop",
|
||||
"bundleID": "com.github.GitHubClient",
|
||||
"companyName": "GitHub, Inc.",
|
||||
"version": "3.2.4",
|
||||
"version": "3.2.7-beta2",
|
||||
"main": "./main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
export interface IFoundEditor<T> {
|
||||
readonly editor: T
|
||||
readonly path: string
|
||||
/**
|
||||
* Indicate to Desktop to launch the editor with the `shell: true` option included.
|
||||
*
|
||||
* This is available to all platforms, but is only currently used by some Windows
|
||||
* editors as their launch programs end in `.cmd`
|
||||
*/
|
||||
readonly usesShell?: boolean
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ export async function launchExternalEditor(
|
|||
const editorPath = editor.path
|
||||
const exists = await pathExists(editorPath)
|
||||
if (!exists) {
|
||||
const label = __DARWIN__ ? 'Preferences' : 'Options'
|
||||
const label = __DARWIN__ ? 'Settings' : 'Options'
|
||||
throw new ExternalEditorError(
|
||||
`Could not find executable for '${editor.editor}' at path '${editor.path}'. Please open ${label} and select an available editor.`,
|
||||
{ openPreferences: true }
|
||||
|
|
|
@ -57,7 +57,7 @@ export async function findEditorOrDefault(
|
|||
if (name) {
|
||||
const match = editors.find(p => p.editor === name) || null
|
||||
if (!match) {
|
||||
const menuItemName = __DARWIN__ ? 'Preferences' : 'Options'
|
||||
const menuItemName = __DARWIN__ ? 'Settings' : 'Options'
|
||||
const message = `The editor '${name}' could not be found. Please open ${menuItemName} and choose an available editor.`
|
||||
|
||||
throw new ExternalEditorError(message, { openPreferences: true })
|
||||
|
|
|
@ -14,7 +14,6 @@ export function fatalError(msg: string): never {
|
|||
* in an exhaustive check.
|
||||
*
|
||||
* @param message The message to be used in the runtime exception.
|
||||
*
|
||||
*/
|
||||
export function assertNever(x: never, message: string): never {
|
||||
throw new Error(message)
|
||||
|
|
|
@ -73,6 +73,11 @@ export function enableResetToCommit(): boolean {
|
|||
return enableDevelopmentFeatures()
|
||||
}
|
||||
|
||||
/** Should we allow checking out a single commit? */
|
||||
export function enableCheckoutCommit(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
/** Should ci check runs show logs? */
|
||||
export function enableCICheckRunsLogs(): boolean {
|
||||
return false
|
||||
|
@ -87,3 +92,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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
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, '--')
|
||||
}
|
||||
|
||||
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(
|
||||
const opts = await getCheckoutOpts(
|
||||
repository,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
`Checking out branch ${branch.name}`,
|
||||
branch.name,
|
||||
progressCallback,
|
||||
`Switching to ${__DARWIN__ ? 'Branch' : 'branch'}`
|
||||
)
|
||||
|
||||
// Initial progress
|
||||
progressCallback({ kind, title, value: 0, targetBranch })
|
||||
}
|
||||
const baseArgs = getCheckoutArgs(progressCallback)
|
||||
const args = [...baseArgs, ...(await getBranchCheckoutArgs(branch))]
|
||||
|
||||
const args = await getCheckoutArgs(
|
||||
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,
|
||||
branch,
|
||||
account,
|
||||
title,
|
||||
shortenSHA(commit.sha),
|
||||
progressCallback
|
||||
)
|
||||
|
||||
await git(args, repository.path, 'checkoutBranch', opts)
|
||||
const baseArgs = getCheckoutArgs(progressCallback)
|
||||
const args = [...baseArgs, commit.sha]
|
||||
|
||||
await git(args, repository.path, 'checkoutCommit', opts)
|
||||
|
||||
// we return `true` here so `GitStore.performFailableGitOperation`
|
||||
// will return _something_ differentiable from `undefined` if this succeeds
|
||||
|
|
|
@ -23,7 +23,6 @@ import { envForRemoteOperation } from './environment'
|
|||
* of the clone operation. When provided this enables
|
||||
* the '--progress' command line flag for
|
||||
* 'git clone'.
|
||||
*
|
||||
*/
|
||||
export async function clone(
|
||||
url: string,
|
||||
|
|
|
@ -309,7 +309,7 @@ export function parseConfigLockFilePathFromError(result: IGitResult) {
|
|||
function getDescriptionForError(error: DugiteError): string | null {
|
||||
if (isAuthFailureError(error)) {
|
||||
const menuHint = __DARWIN__
|
||||
? 'GitHub Desktop > Preferences.'
|
||||
? 'GitHub Desktop > Settings.'
|
||||
: 'File > Options.'
|
||||
return `Authentication failed. Some common reasons include:
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
* Example:
|
||||
*
|
||||
* `const { args, parse } = createLogParser({ sha: '%H' })`
|
||||
*
|
||||
*/
|
||||
export function createLogParser<T extends Record<string, string>>(fields: T) {
|
||||
const keys: Array<keyof T> = Object.keys(fields)
|
||||
|
@ -49,7 +48,6 @@ export function createLogParser<T extends Record<string, string>>(fields: T) {
|
|||
* Example:
|
||||
*
|
||||
* `const { args, parse } = createForEachRefParser({ sha: '%(objectname)' })`
|
||||
*
|
||||
*/
|
||||
export function createForEachRefParser<T extends Record<string, string>>(
|
||||
fields: T
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
* @param repository - The repository to update
|
||||
*
|
||||
* @param commit - The SHA of the commit to be reverted
|
||||
*
|
||||
*/
|
||||
export async function revertCommit(
|
||||
repository: Repository,
|
||||
|
|
|
@ -46,6 +46,8 @@ export async function getStashes(repository: Repository): Promise<StashResult> {
|
|||
name: '%gD',
|
||||
stashSha: '%H',
|
||||
message: '%gs',
|
||||
tree: '%T',
|
||||
parents: '%P',
|
||||
})
|
||||
|
||||
const result = await git(
|
||||
|
@ -66,17 +68,52 @@ export async function getStashes(repository: Repository): Promise<StashResult> {
|
|||
|
||||
const entries = parse(result.stdout)
|
||||
|
||||
for (const { name, message, stashSha } of entries) {
|
||||
for (const { name, message, stashSha, tree, parents } of entries) {
|
||||
const branchName = extractBranchFromMessage(message)
|
||||
|
||||
if (branchName !== null) {
|
||||
desktopEntries.push({ name, stashSha, branchName, files })
|
||||
desktopEntries.push({
|
||||
name,
|
||||
stashSha,
|
||||
branchName,
|
||||
tree,
|
||||
parents: parents.length > 0 ? parents.split(' ') : [],
|
||||
files,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { desktopEntries, stashEntryCount: entries.length - 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a stash entry to a different branch by means of creating
|
||||
* a new stash entry associated with the new branch and dropping the old
|
||||
* stash entry.
|
||||
*/
|
||||
export async function moveStashEntry(
|
||||
repository: Repository,
|
||||
{ stashSha, parents, tree }: IStashEntry,
|
||||
branchName: string
|
||||
) {
|
||||
const message = `On ${branchName}: ${createDesktopStashMessage(branchName)}`
|
||||
const parentArgs = parents.flatMap(p => ['-p', p])
|
||||
|
||||
const { stdout: commitId } = await git(
|
||||
['commit-tree', ...parentArgs, '-m', message, '--no-gpg-sign', tree],
|
||||
repository.path,
|
||||
'moveStashEntryToBranch'
|
||||
)
|
||||
|
||||
await git(
|
||||
['stash', 'store', '-m', message, commitId.trim()],
|
||||
repository.path,
|
||||
'moveStashEntryToBranch'
|
||||
)
|
||||
|
||||
await dropDesktopStashEntry(repository, stashSha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last Desktop created stash entry for the given branch
|
||||
*/
|
||||
|
|
|
@ -33,6 +33,10 @@ const remoteRegexes: ReadonlyArray<{ protocol: GitProtocol; regex: RegExp }> = [
|
|||
protocol: 'ssh',
|
||||
regex: new RegExp('^git@(.+):([^/]+)/([^/]+?)(?:/|.git)?$'),
|
||||
},
|
||||
{
|
||||
protocol: 'ssh',
|
||||
regex: new RegExp('^(?:.+)@(.+.ghe.com):([^/]+)/([^/]+?)(?:/|.git)?$'),
|
||||
},
|
||||
{
|
||||
protocol: 'ssh',
|
||||
regex: new RegExp('^git:(.+)/([^/]+)/([^/]+?)(?:/|.git)?$'),
|
||||
|
|
|
@ -83,7 +83,7 @@ export async function launchShell(
|
|||
// platform-specific build targets.
|
||||
const exists = await pathExists(shell.path)
|
||||
if (!exists) {
|
||||
const label = __DARWIN__ ? 'Preferences' : 'Options'
|
||||
const label = __DARWIN__ ? 'Settings' : 'Options'
|
||||
throw new ShellError(
|
||||
`Could not find executable for '${shell.shell}' at path '${shell.path}'. Please open ${label} and select an available shell.`
|
||||
)
|
||||
|
|
|
@ -14,7 +14,6 @@ const squirrelTimeoutRegex =
|
|||
* friendlier message to the user.
|
||||
*
|
||||
* @param error The underlying error from Squirrel.
|
||||
*
|
||||
*/
|
||||
export function parseError(error: Error): Error | null {
|
||||
if (squirrelMissingRegex.test(error.message)) {
|
||||
|
|
|
@ -17,7 +17,12 @@ import { Branch, BranchType, IAheadBehind } from '../../models/branch'
|
|||
import { BranchesTab } from '../../models/branches-tab'
|
||||
import { CloneRepositoryTab } from '../../models/clone-repository-tab'
|
||||
import { CloningRepository } from '../../models/cloning-repository'
|
||||
import { Commit, ICommitContext, CommitOneLine } from '../../models/commit'
|
||||
import {
|
||||
Commit,
|
||||
ICommitContext,
|
||||
CommitOneLine,
|
||||
shortenSHA,
|
||||
} from '../../models/commit'
|
||||
import {
|
||||
DiffSelection,
|
||||
DiffSelectionType,
|
||||
|
@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1051,7 +1051,6 @@ export class GitStore extends BaseStore {
|
|||
* @param refspec - The association between a remote and local ref to use as
|
||||
* part of this action. Refer to git-scm for more
|
||||
* information on refspecs: https://www.git-scm.com/book/tr/v2/Git-Internals-The-Refspec
|
||||
*
|
||||
*/
|
||||
public async fetchRefspec(
|
||||
account: IGitAccount | null,
|
||||
|
@ -1171,6 +1170,10 @@ export class GitStore extends BaseStore {
|
|||
: null
|
||||
}
|
||||
|
||||
public get desktopStashEntries(): ReadonlyMap<string, IStashEntry> {
|
||||
return this._desktopStashEntries
|
||||
}
|
||||
|
||||
/** The total number of stash entries */
|
||||
public get stashEntryCount(): number {
|
||||
return this._stashEntryCount
|
||||
|
|
|
@ -80,7 +80,6 @@ export class PullRequestCoordinator {
|
|||
* the `Repository`)
|
||||
* * the parent GitHub repo, if the `Repository` has one (the
|
||||
* `upstream` remote for the `Repository`)
|
||||
*
|
||||
*/
|
||||
public onPullRequestsChanged(
|
||||
fn: (
|
||||
|
@ -119,7 +118,6 @@ export class PullRequestCoordinator {
|
|||
* the `Repository`)
|
||||
* * the parent GitHub repo, if the `Repository` has one (the
|
||||
* `upstream` remote for the `Repository`)
|
||||
*
|
||||
*/
|
||||
public onIsLoadingPullRequests(
|
||||
fn: (
|
||||
|
|
|
@ -8,6 +8,27 @@ import {
|
|||
removePendingSSHSecretToStore,
|
||||
storePendingSSHSecret,
|
||||
} from '../ssh/ssh-secret-storage'
|
||||
import { GitProcess } from 'dugite'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { enableCustomGitUserAgent } from '../feature-flag'
|
||||
|
||||
export const GitUserAgent = memoizeOne(() =>
|
||||
// Can't use git() as that will call withTrampolineEnv which calls this method
|
||||
GitProcess.exec(['--version'], process.cwd())
|
||||
// https://github.com/git/git/blob/a9e066fa63149291a55f383cfa113d8bdbdaa6b3/help.c#L733-L739
|
||||
.then(r => /git version (.*)/.exec(r.stdout)?.at(1))
|
||||
.catch(e => {
|
||||
log.warn(`Could not get git version information`, e)
|
||||
return 'unknown'
|
||||
})
|
||||
.then(v => {
|
||||
const suffix = __DEV__ ? `-${__SHA__.substring(0, 10)}` : ''
|
||||
const ghdVersion = `GitHub Desktop/${__APP_VERSION__}${suffix}`
|
||||
const { platform, arch } = process
|
||||
|
||||
return `git/${v} (${ghdVersion}; ${platform} ${arch})`
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Allows invoking a function with a set of environment variables to use when
|
||||
|
@ -42,6 +63,9 @@ export async function withTrampolineEnv<T>(
|
|||
DESKTOP_TRAMPOLINE_TOKEN: token,
|
||||
GIT_ASKPASS: getDesktopTrampolinePath(),
|
||||
DESKTOP_TRAMPOLINE_IDENTIFIER: TrampolineCommandIdentifier.AskPass,
|
||||
...(enableCustomGitUserAgent()
|
||||
? { GIT_USER_AGENT: await GitUserAgent() }
|
||||
: {}),
|
||||
|
||||
...sshEnv,
|
||||
})
|
||||
|
|
|
@ -69,7 +69,7 @@ export function buildDefaultMenu({
|
|||
},
|
||||
separator,
|
||||
{
|
||||
label: 'Preferences…',
|
||||
label: 'Settings…',
|
||||
id: 'preferences',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: emit('show-preferences'),
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { RowIndexPath } from '../ui/lib/list/list-row-index-path'
|
||||
import { Commit } from './commit'
|
||||
import { GitHubRepository } from './github-repository'
|
||||
|
||||
|
@ -51,7 +52,7 @@ export type CommitTarget = {
|
|||
export type ListInsertionPointTarget = {
|
||||
type: DropTargetType.ListInsertionPoint
|
||||
data: DragData
|
||||
index: number
|
||||
index: RowIndexPath
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -60,6 +60,7 @@ export enum PopupType {
|
|||
StashAndSwitchBranch = 'StashAndSwitchBranch',
|
||||
ConfirmOverwriteStash = 'ConfirmOverwriteStash',
|
||||
ConfirmDiscardStash = 'ConfirmDiscardStash',
|
||||
ConfirmCheckoutCommit = 'ConfirmCheckoutCommit',
|
||||
CreateTutorialRepository = 'CreateTutorialRepository',
|
||||
ConfirmExitTutorial = 'ConfirmExitTutorial',
|
||||
PushRejectedDueToMissingWorkflowScope = 'PushRejectedDueToMissingWorkflowScope',
|
||||
|
@ -235,6 +236,11 @@ export type PopupDetail =
|
|||
repository: Repository
|
||||
stash: IStashEntry
|
||||
}
|
||||
| {
|
||||
type: PopupType.ConfirmCheckoutCommit
|
||||
repository: Repository
|
||||
commit: CommitOneLine
|
||||
}
|
||||
| {
|
||||
type: PopupType.CreateTutorialRepository
|
||||
account: Account
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,35 +129,36 @@ export class AddExistingRepository extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderWarning() {
|
||||
if (!this.state.path.length || !this.state.showNonGitRepositoryWarning) {
|
||||
private buildBareRepositoryError() {
|
||||
if (
|
||||
!this.state.path.length ||
|
||||
!this.state.showNonGitRepositoryWarning ||
|
||||
!this.state.isRepositoryBare
|
||||
) {
|
||||
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 appears to be a bare repository. Bare repositories are not currently supported.'
|
||||
}
|
||||
|
||||
const { isRepositoryUnsafe, repositoryUnsafePath, path } = this.state
|
||||
private buildRepositoryUnsafeError() {
|
||||
const { repositoryUnsafePath, path } = this.state
|
||||
if (
|
||||
!this.state.path.length ||
|
||||
!this.state.showNonGitRepositoryWarning ||
|
||||
!this.state.isRepositoryUnsafe ||
|
||||
repositoryUnsafePath === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
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 && (
|
||||
|
@ -167,26 +167,28 @@ export class AddExistingRepository extends React.Component<
|
|||
<Ref>{repositoryUnsafePath}</Ref>
|
||||
</>
|
||||
)}{' '}
|
||||
appears to be owned by another user on your machine. Adding
|
||||
untrusted repositories may automatically execute files in the
|
||||
repository.
|
||||
appears to be owned by another user on your machine. Adding untrusted
|
||||
repositories may automatically execute files in the repository.
|
||||
</p>
|
||||
<p>
|
||||
If you trust the owner of the directory you can
|
||||
<LinkButton onClick={this.onTrustDirectory}>
|
||||
{' '}
|
||||
add an exception for this directory
|
||||
</LinkButton>{' '}
|
||||
in order to continue.
|
||||
</p>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
private buildNotAGitRepositoryError() {
|
||||
if (!this.state.path.length || !this.state.showNonGitRepositoryWarning) {
|
||||
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{' '}
|
||||
|
@ -194,7 +196,28 @@ export class AddExistingRepository extends React.Component<
|
|||
create a repository
|
||||
</LinkButton>{' '}
|
||||
here instead?
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
private renderErrors() {
|
||||
const msg =
|
||||
this.buildBareRepositoryError() ??
|
||||
this.buildRepositoryUnsafeError() ??
|
||||
this.buildNotAGitRepositoryError()
|
||||
|
||||
if (msg === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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}`
|
||||
|
|
106
app/src/ui/checkout/confirm-checkout-commit.tsx
Normal file
106
app/src/ui/checkout/confirm-checkout-commit.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import * as React from 'react'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { Row } from '../lib/row'
|
||||
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
|
||||
import { Checkbox, CheckboxValue } from '../lib/checkbox'
|
||||
import { CommitOneLine } from '../../models/commit'
|
||||
|
||||
interface IConfirmCheckoutCommitProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly repository: Repository
|
||||
readonly commit: CommitOneLine
|
||||
readonly askForConfirmationOnCheckoutCommit: boolean
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
interface IConfirmCheckoutCommitState {
|
||||
readonly isCheckingOut: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
}
|
||||
/**
|
||||
* Dialog to confirm checking out a commit
|
||||
*/
|
||||
export class ConfirmCheckoutCommitDialog extends React.Component<
|
||||
IConfirmCheckoutCommitProps,
|
||||
IConfirmCheckoutCommitState
|
||||
> {
|
||||
public constructor(props: IConfirmCheckoutCommitProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
isCheckingOut: false,
|
||||
confirmCheckoutCommit: props.askForConfirmationOnCheckoutCommit,
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const title = __DARWIN__ ? 'Checkout Commit?' : 'Checkout commit?'
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id="checkout-commit"
|
||||
type="warning"
|
||||
title={title}
|
||||
loading={this.state.isCheckingOut}
|
||||
disabled={this.state.isCheckingOut}
|
||||
onSubmit={this.onSubmit}
|
||||
onDismissed={this.props.onDismissed}
|
||||
ariaDescribedBy="checking-out-commit-confirmation"
|
||||
role="alertdialog"
|
||||
>
|
||||
<DialogContent>
|
||||
<Row id="checking-out-commit-confirmation">
|
||||
Checking out a commit will create a detached HEAD, and you will no
|
||||
longer be on any branch. Are you sure you want to checkout this
|
||||
commit?
|
||||
</Row>
|
||||
<Row>
|
||||
<Checkbox
|
||||
label="Do not show this message again"
|
||||
value={
|
||||
this.state.confirmCheckoutCommit
|
||||
? CheckboxValue.Off
|
||||
: CheckboxValue.On
|
||||
}
|
||||
onChange={this.onaskForConfirmationOnCheckoutCommitChanged}
|
||||
/>
|
||||
</Row>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup destructive={true} okButtonText="Checkout" />
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private onaskForConfirmationOnCheckoutCommitChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = !event.currentTarget.checked
|
||||
|
||||
this.setState({ confirmCheckoutCommit: value })
|
||||
}
|
||||
|
||||
private onSubmit = async () => {
|
||||
const { dispatcher, repository, commit, onDismissed } = this.props
|
||||
|
||||
this.setState({
|
||||
isCheckingOut: true,
|
||||
})
|
||||
|
||||
try {
|
||||
dispatcher.setConfirmCheckoutCommitSetting(
|
||||
this.state.confirmCheckoutCommit
|
||||
)
|
||||
await dispatcher.checkoutCommit(repository, commit)
|
||||
} finally {
|
||||
this.setState({
|
||||
isCheckingOut: false,
|
||||
})
|
||||
}
|
||||
|
||||
onDismissed()
|
||||
}
|
||||
}
|
|
@ -15,6 +15,8 @@ import { HighlightText } from '../lib/highlight-text'
|
|||
import { ClickSource } from '../lib/list'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { Ref } from '../lib/ref'
|
||||
import { enableSectionList } from '../../lib/feature-flag'
|
||||
import { SectionFilterList } from '../lib/section-filter-list'
|
||||
|
||||
interface ICloneableRepositoryFilterListProps {
|
||||
/** The account to clone from. */
|
||||
|
@ -157,26 +159,34 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
|
|||
const { repositories, account, selectedItem } = this.props
|
||||
|
||||
const groups = this.getRepositoryGroups(repositories, account.login)
|
||||
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
|
||||
const getGroupAriaLabel = (group: number) => {
|
||||
const groupIdentifier = groups[group].identifier
|
||||
return groupIdentifier === YourRepositoriesIdentifier
|
||||
? this.getYourRepositoriesLabel()
|
||||
: groupIdentifier
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterList<ICloneableRepositoryListItem>
|
||||
className="clone-github-repo"
|
||||
rowHeight={RowHeight}
|
||||
selectedItem={selectedListItem}
|
||||
renderItem={this.renderItem}
|
||||
renderGroupHeader={this.renderGroupHeader}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
invalidationProps={groups}
|
||||
groups={groups}
|
||||
filterText={this.props.filterText}
|
||||
onFilterTextChanged={this.props.onFilterTextChanged}
|
||||
renderNoItems={this.renderNoItems}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
onItemClick={this.props.onItemClicked ? this.onItemClick : undefined}
|
||||
placeholderText="Filter your repositories"
|
||||
/>
|
||||
)
|
||||
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
|
||||
const ListComponent = enableSectionList() ? SectionFilterList : FilterList
|
||||
const filterListProps: typeof ListComponent['prototype']['props'] = {
|
||||
className: 'clone-github-repo',
|
||||
rowHeight: RowHeight,
|
||||
selectedItem: selectedListItem,
|
||||
renderItem: this.renderItem,
|
||||
renderGroupHeader: this.renderGroupHeader,
|
||||
onSelectionChanged: this.onSelectionChanged,
|
||||
invalidationProps: groups,
|
||||
groups: groups,
|
||||
filterText: this.props.filterText,
|
||||
onFilterTextChanged: this.props.onFilterTextChanged,
|
||||
renderNoItems: this.renderNoItems,
|
||||
renderPostFilter: this.renderPostFilter,
|
||||
onItemClick: this.props.onItemClicked ? this.onItemClick : undefined,
|
||||
placeholderText: 'Filter your repositories',
|
||||
getGroupAriaLabel,
|
||||
}
|
||||
|
||||
return <ListComponent {...filterListProps} />
|
||||
}
|
||||
|
||||
private onItemClick = (
|
||||
|
@ -206,10 +216,14 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
|
|||
}
|
||||
}
|
||||
|
||||
private getYourRepositoriesLabel = () => {
|
||||
return __DARWIN__ ? 'Your Repositories' : 'Your repositories'
|
||||
}
|
||||
|
||||
private renderGroupHeader = (identifier: string) => {
|
||||
let header = identifier
|
||||
if (identifier === YourRepositoriesIdentifier) {
|
||||
header = __DARWIN__ ? 'Your Repositories' : 'Your repositories'
|
||||
header = this.getYourRepositoriesLabel()
|
||||
}
|
||||
return (
|
||||
<div className="clone-repository-list-content clone-repository-list-group-header">
|
||||
|
@ -228,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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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])',
|
||||
]
|
||||
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -178,10 +178,6 @@ export class CommitDragElement extends React.Component<
|
|||
commit={commit}
|
||||
selectedCommits={selectedCommits}
|
||||
emoji={emoji}
|
||||
canBeUndone={false}
|
||||
canBeAmended={false}
|
||||
canBeResetTo={false}
|
||||
isLocal={false}
|
||||
showUnpushedIndicator={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,20 +1,15 @@
|
|||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import * as React from 'react'
|
||||
import { Commit, CommitOneLine } from '../../models/commit'
|
||||
import { Commit } from '../../models/commit'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { IAvatarUser, getAvatarUsersForCommit } from '../../models/avatar'
|
||||
import { RichText } from '../lib/rich-text'
|
||||
import { RelativeTime } from '../relative-time'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { clipboard } from 'electron'
|
||||
import { showContextualMenu } from '../../lib/menu-item'
|
||||
import { CommitAttribution } from '../lib/commit-attribution'
|
||||
import { AvatarStack } from '../lib/avatar-stack'
|
||||
import { IMenuItem } from '../../lib/menu-item'
|
||||
import { Octicon } from '../octicons'
|
||||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { Draggable } from '../lib/draggable'
|
||||
import { enableResetToCommit } from '../../lib/feature-flag'
|
||||
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
|
||||
import {
|
||||
DragType,
|
||||
|
@ -28,19 +23,6 @@ interface ICommitProps {
|
|||
readonly commit: Commit
|
||||
readonly selectedCommits: ReadonlyArray<Commit>
|
||||
readonly emoji: Map<string, string>
|
||||
readonly isLocal: boolean
|
||||
readonly canBeUndone: boolean
|
||||
readonly canBeAmended: boolean
|
||||
readonly canBeResetTo: boolean
|
||||
readonly onResetToCommit?: (commit: Commit) => void
|
||||
readonly onUndoCommit?: (commit: Commit) => void
|
||||
readonly onRevertCommit?: (commit: Commit) => void
|
||||
readonly onViewCommitOnGitHub?: (sha: string) => void
|
||||
readonly onCreateBranch?: (commit: CommitOneLine) => void
|
||||
readonly onCreateTag?: (targetCommitSha: string) => void
|
||||
readonly onDeleteTag?: (tagName: string) => void
|
||||
readonly onAmendCommit?: (commit: Commit, isLocalCommit: boolean) => void
|
||||
readonly onCherryPick?: (commits: ReadonlyArray<CommitOneLine>) => void
|
||||
readonly onRenderCommitDragElement?: (commit: Commit) => void
|
||||
readonly onRemoveDragElement?: () => void
|
||||
readonly onSquash?: (
|
||||
|
@ -48,9 +30,13 @@ interface ICommitProps {
|
|||
squashOnto: Commit,
|
||||
isInvokedByContextMenu: boolean
|
||||
) => void
|
||||
/**
|
||||
* Whether or not the commit can be dragged for certain operations like squash,
|
||||
* cherry-pick, reorder, etc. Defaults to false.
|
||||
*/
|
||||
readonly isDraggable?: boolean
|
||||
readonly showUnpushedIndicator: boolean
|
||||
readonly unpushedIndicatorTitle?: string
|
||||
readonly unpushedTags?: ReadonlyArray<string>
|
||||
readonly disableSquashing?: boolean
|
||||
readonly isMultiCommitOperationInProgress?: boolean
|
||||
}
|
||||
|
@ -126,7 +112,7 @@ export class CommitListItem extends React.PureComponent<
|
|||
author: { date },
|
||||
} = commit
|
||||
|
||||
const isDraggable = this.isDraggable()
|
||||
const isDraggable = this.props.isDraggable || false
|
||||
const hasEmptySummary = commit.summary.length === 0
|
||||
const commitSummary = hasEmptySummary
|
||||
? 'Empty commit message'
|
||||
|
@ -151,7 +137,6 @@ export class CommitListItem extends React.PureComponent<
|
|||
>
|
||||
<div
|
||||
className="commit"
|
||||
onContextMenu={this.onContextMenu}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onMouseUp={this.onMouseUp}
|
||||
|
@ -211,253 +196,6 @@ export class CommitListItem extends React.PureComponent<
|
|||
)
|
||||
}
|
||||
|
||||
private onAmendCommit = () => {
|
||||
this.props.onAmendCommit?.(this.props.commit, this.props.isLocal)
|
||||
}
|
||||
|
||||
private onCopySHA = () => {
|
||||
clipboard.writeText(this.props.commit.sha)
|
||||
}
|
||||
|
||||
private onCopyTags = () => {
|
||||
clipboard.writeText(this.props.commit.tags.join(' '))
|
||||
}
|
||||
|
||||
private onViewOnGitHub = () => {
|
||||
if (this.props.onViewCommitOnGitHub) {
|
||||
this.props.onViewCommitOnGitHub(this.props.commit.sha)
|
||||
}
|
||||
}
|
||||
|
||||
private onCreateTag = () => {
|
||||
if (this.props.onCreateTag) {
|
||||
this.props.onCreateTag(this.props.commit.sha)
|
||||
}
|
||||
}
|
||||
|
||||
private onCherryPick = () => {
|
||||
if (this.props.onCherryPick !== undefined) {
|
||||
this.props.onCherryPick(this.props.selectedCommits)
|
||||
}
|
||||
}
|
||||
|
||||
private onSquash = () => {
|
||||
if (this.props.onSquash !== undefined) {
|
||||
this.props.onSquash(this.props.selectedCommits, this.props.commit, true)
|
||||
}
|
||||
}
|
||||
|
||||
private onContextMenu = (event: React.MouseEvent<any>) => {
|
||||
event.preventDefault()
|
||||
|
||||
let items: IMenuItem[] = []
|
||||
if (this.props.selectedCommits.length > 1) {
|
||||
items = this.getContextMenuMultipleCommits()
|
||||
} else {
|
||||
items = this.getContextMenuForSingleCommit()
|
||||
}
|
||||
|
||||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private getContextMenuForSingleCommit(): IMenuItem[] {
|
||||
let viewOnGitHubLabel = 'View on GitHub'
|
||||
const gitHubRepository = this.props.gitHubRepository
|
||||
|
||||
if (
|
||||
gitHubRepository &&
|
||||
gitHubRepository.endpoint !== getDotComAPIEndpoint()
|
||||
) {
|
||||
viewOnGitHubLabel = 'View on GitHub Enterprise'
|
||||
}
|
||||
|
||||
const items: IMenuItem[] = []
|
||||
|
||||
if (this.props.canBeAmended) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Amend Commit…' : 'Amend commit…',
|
||||
action: this.onAmendCommit,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.props.canBeUndone) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Undo Commit…' : 'Undo commit…',
|
||||
action: () => {
|
||||
if (this.props.onUndoCommit) {
|
||||
this.props.onUndoCommit(this.props.commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onUndoCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (enableResetToCommit()) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Reset to Commit…' : 'Reset to commit…',
|
||||
action: () => {
|
||||
if (this.props.onResetToCommit) {
|
||||
this.props.onResetToCommit(this.props.commit)
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
this.props.canBeResetTo && this.props.onResetToCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Revert Changes in Commit'
|
||||
: 'Revert changes in commit',
|
||||
action: () => {
|
||||
if (this.props.onRevertCommit) {
|
||||
this.props.onRevertCommit(this.props.commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onRevertCommit !== undefined,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Create Branch from Commit'
|
||||
: 'Create branch from commit',
|
||||
action: () => {
|
||||
if (this.props.onCreateBranch) {
|
||||
this.props.onCreateBranch(this.props.commit)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Create Tag…',
|
||||
action: this.onCreateTag,
|
||||
enabled: this.props.onCreateTag !== undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const deleteTagsMenuItem = this.getDeleteTagsMenuItem()
|
||||
|
||||
if (deleteTagsMenuItem !== null) {
|
||||
items.push(
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
deleteTagsMenuItem
|
||||
)
|
||||
}
|
||||
const darwinTagsLabel =
|
||||
this.props.commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag'
|
||||
const windowTagsLabel =
|
||||
this.props.commit.tags.length > 1 ? 'Copy tags' : 'Copy tag'
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
|
||||
action: this.onCherryPick,
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Copy SHA',
|
||||
action: this.onCopySHA,
|
||||
},
|
||||
{
|
||||
label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel,
|
||||
action: this.onCopyTags,
|
||||
enabled: this.props.commit.tags.length > 0,
|
||||
},
|
||||
{
|
||||
label: viewOnGitHubLabel,
|
||||
action: this.onViewOnGitHub,
|
||||
enabled: !this.props.isLocal && !!gitHubRepository,
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private getContextMenuMultipleCommits(): IMenuItem[] {
|
||||
const count = this.props.selectedCommits.length
|
||||
|
||||
return [
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Cherry-pick ${count} Commits…`
|
||||
: `Cherry-pick ${count} commits…`,
|
||||
action: this.onCherryPick,
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Squash ${count} Commits…`
|
||||
: `Squash ${count} commits…`,
|
||||
action: this.onSquash,
|
||||
enabled: this.canSquash(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private isDraggable(): boolean {
|
||||
const { onCherryPick, onSquash, isMultiCommitOperationInProgress } =
|
||||
this.props
|
||||
return (
|
||||
(onCherryPick !== undefined || onSquash !== undefined) &&
|
||||
isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private canCherryPick(): boolean {
|
||||
const { onCherryPick, isMultiCommitOperationInProgress } = this.props
|
||||
return (
|
||||
onCherryPick !== undefined && isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private canSquash(): boolean {
|
||||
const { onSquash, disableSquashing, isMultiCommitOperationInProgress } =
|
||||
this.props
|
||||
return (
|
||||
onSquash !== undefined &&
|
||||
disableSquashing === false &&
|
||||
isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private getDeleteTagsMenuItem(): IMenuItem | null {
|
||||
const { unpushedTags, onDeleteTag, commit } = this.props
|
||||
|
||||
if (
|
||||
onDeleteTag === undefined ||
|
||||
unpushedTags === undefined ||
|
||||
commit.tags.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (commit.tags.length === 1) {
|
||||
const tagName = commit.tags[0]
|
||||
|
||||
return {
|
||||
label: `Delete tag ${tagName}`,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTags.includes(tagName),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tags to a Set to avoid O(n^2)
|
||||
const unpushedTagsSet = new Set(unpushedTags)
|
||||
|
||||
return {
|
||||
label: 'Delete tag…',
|
||||
submenu: commit.tags.map(tagName => {
|
||||
return {
|
||||
label: tagName,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTagsSet.has(tagName),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private onDragStart = () => {
|
||||
// Removes active status from commit selection so they do not appear
|
||||
// highlighted in commit list.
|
||||
|
|
|
@ -7,6 +7,14 @@ import { List } from '../lib/list'
|
|||
import { arrayEquals } from '../../lib/equality'
|
||||
import { DragData, DragType } from '../../models/drag-drop'
|
||||
import classNames from 'classnames'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { IMenuItem, showContextualMenu } from '../../lib/menu-item'
|
||||
import {
|
||||
enableCheckoutCommit,
|
||||
enableResetToCommit,
|
||||
} from '../../lib/feature-flag'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { clipboard } from 'electron'
|
||||
|
||||
const RowHeight = 50
|
||||
|
||||
|
@ -70,6 +78,12 @@ interface ICommitListProps {
|
|||
*/
|
||||
readonly onCreateBranch?: (commit: CommitOneLine) => void
|
||||
|
||||
/**
|
||||
* Callback to fire to checkout the selected commit in the current
|
||||
* repository
|
||||
*/
|
||||
readonly onCheckoutCommit?: (commit: CommitOneLine) => void
|
||||
|
||||
/** Callback to fire to open the dialog to create a new tag on the given commit */
|
||||
readonly onCreateTag?: (targetCommitSha: string) => void
|
||||
|
||||
|
@ -142,6 +156,11 @@ interface ICommitListProps {
|
|||
/** A component which displays the list of commits. */
|
||||
export class CommitList extends React.Component<ICommitListProps, {}> {
|
||||
private commitsHash = memoize(makeCommitsHash, arrayEquals)
|
||||
private commitIndexBySha = memoizeOne(
|
||||
(commitSHAs: ReadonlyArray<string>) =>
|
||||
new Map(commitSHAs.map((sha, index) => [sha, index]))
|
||||
)
|
||||
|
||||
private listRef = React.createRef<List>()
|
||||
|
||||
private getVisibleCommits(): ReadonlyArray<Commit> {
|
||||
|
@ -156,6 +175,9 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return commits
|
||||
}
|
||||
|
||||
private isLocalCommit = (sha: string) =>
|
||||
this.props.localCommitSHAs.includes(sha)
|
||||
|
||||
private renderCommit = (row: number) => {
|
||||
const sha = this.props.commitSHAs[row]
|
||||
const commit = this.props.commitLookup.get(sha)
|
||||
|
@ -169,52 +191,27 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return null
|
||||
}
|
||||
|
||||
const tagsToPushSet = new Set(this.props.tagsToPush ?? [])
|
||||
|
||||
const isLocal = this.props.localCommitSHAs.includes(commit.sha)
|
||||
const unpushedTags = commit.tags.filter(tagName =>
|
||||
tagsToPushSet.has(tagName)
|
||||
)
|
||||
const isLocal = this.isLocalCommit(commit.sha)
|
||||
const unpushedTags = this.getUnpushedTags(commit)
|
||||
|
||||
const showUnpushedIndicator =
|
||||
(isLocal || unpushedTags.length > 0) &&
|
||||
this.props.isLocalRepository === false
|
||||
|
||||
// The user can reset to any commit up to the first non-local one (included).
|
||||
// They cannot reset to the most recent commit... because they're already
|
||||
// in it.
|
||||
const isResettableCommit =
|
||||
row > 0 && row <= this.props.localCommitSHAs.length
|
||||
|
||||
return (
|
||||
<CommitListItem
|
||||
key={commit.sha}
|
||||
gitHubRepository={this.props.gitHubRepository}
|
||||
isLocal={isLocal}
|
||||
canBeUndone={this.props.canUndoCommits === true && isLocal && row === 0}
|
||||
canBeAmended={this.props.canAmendCommits === true && row === 0}
|
||||
canBeResetTo={
|
||||
this.props.canResetToCommits === true && isResettableCommit
|
||||
}
|
||||
showUnpushedIndicator={showUnpushedIndicator}
|
||||
unpushedIndicatorTitle={this.getUnpushedIndicatorTitle(
|
||||
isLocal,
|
||||
unpushedTags.length
|
||||
)}
|
||||
unpushedTags={unpushedTags}
|
||||
commit={commit}
|
||||
emoji={this.props.emoji}
|
||||
onCreateBranch={this.props.onCreateBranch}
|
||||
onCreateTag={this.props.onCreateTag}
|
||||
onDeleteTag={this.props.onDeleteTag}
|
||||
onCherryPick={this.props.onCherryPick}
|
||||
isDraggable={this.props.isMultiCommitOperationInProgress === false}
|
||||
onSquash={this.onSquash}
|
||||
onResetToCommit={this.props.onResetToCommit}
|
||||
onUndoCommit={this.props.onUndoCommit}
|
||||
onRevertCommit={this.props.onRevertCommit}
|
||||
onAmendCommit={this.props.onAmendCommit}
|
||||
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
|
||||
selectedCommits={this.lookupCommits(this.props.selectedSHAs)}
|
||||
selectedCommits={this.selectedCommits}
|
||||
onRenderCommitDragElement={this.onRenderCommitDragElement}
|
||||
onRemoveDragElement={this.props.onRemoveCommitDragElement}
|
||||
disableSquashing={this.props.disableSquashing}
|
||||
|
@ -252,10 +249,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
}
|
||||
|
||||
private onRenderCommitDragElement = (commit: Commit) => {
|
||||
this.props.onRenderCommitDragElement?.(
|
||||
commit,
|
||||
this.lookupCommits(this.props.selectedSHAs)
|
||||
)
|
||||
this.props.onRenderCommitDragElement?.(commit, this.selectedCommits)
|
||||
}
|
||||
|
||||
private getUnpushedIndicatorTitle(
|
||||
|
@ -275,6 +269,15 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return undefined
|
||||
}
|
||||
|
||||
private get selectedCommits() {
|
||||
return this.lookupCommits(this.props.selectedSHAs)
|
||||
}
|
||||
|
||||
private getUnpushedTags(commit: Commit) {
|
||||
const tagsToPushSet = new Set(this.props.tagsToPush ?? [])
|
||||
return commit.tags.filter(tagName => tagsToPushSet.has(tagName))
|
||||
}
|
||||
|
||||
private onSelectionChanged = (rows: ReadonlyArray<number>) => {
|
||||
const selectedShas = rows.map(r => this.props.commitSHAs[r])
|
||||
const selectedCommits = this.lookupCommits(selectedShas)
|
||||
|
@ -345,13 +348,8 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
this.props.onCompareListScrolled?.(scrollTop)
|
||||
}
|
||||
|
||||
private rowForSHA(sha_: string | null): number {
|
||||
const sha = sha_
|
||||
if (!sha) {
|
||||
return -1
|
||||
}
|
||||
|
||||
return this.props.commitSHAs.findIndex(s => s === sha)
|
||||
private rowForSHA(sha: string) {
|
||||
return this.commitIndexBySha(this.props.commitSHAs).get(sha) ?? -1
|
||||
}
|
||||
|
||||
private getRowCustomClassMap = () => {
|
||||
|
@ -410,6 +408,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
onDropDataInsertion={this.onDropDataInsertion}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
onRowContextMenu={this.onRowContextMenu}
|
||||
selectionMode="multi"
|
||||
onScroll={this.onScroll}
|
||||
insertionDragType={
|
||||
|
@ -432,6 +431,247 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
)
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
row: number,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
event.preventDefault()
|
||||
|
||||
const sha = this.props.commitSHAs[row]
|
||||
const commit = this.props.commitLookup.get(sha)
|
||||
if (commit === undefined) {
|
||||
if (__DEV__) {
|
||||
log.warn(
|
||||
`[CommitList]: the commit '${sha}' does not exist in the cache`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let items: IMenuItem[] = []
|
||||
if (this.props.selectedSHAs.length > 1) {
|
||||
items = this.getContextMenuMultipleCommits(commit)
|
||||
} else {
|
||||
items = this.getContextMenuForSingleCommit(row, commit)
|
||||
}
|
||||
|
||||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private getContextMenuForSingleCommit(
|
||||
row: number,
|
||||
commit: Commit
|
||||
): IMenuItem[] {
|
||||
const isLocal = this.isLocalCommit(commit.sha)
|
||||
|
||||
const canBeUndone =
|
||||
this.props.canUndoCommits === true && isLocal && row === 0
|
||||
const canBeAmended = this.props.canAmendCommits === true && row === 0
|
||||
// The user can reset to any commit up to the first non-local one (included).
|
||||
// They cannot reset to the most recent commit... because they're already
|
||||
// in it.
|
||||
const isResettableCommit =
|
||||
row > 0 && row <= this.props.localCommitSHAs.length
|
||||
const canBeResetTo =
|
||||
this.props.canResetToCommits === true && isResettableCommit
|
||||
const canBeCheckedOut = row > 0 //Cannot checkout the current commit
|
||||
|
||||
let viewOnGitHubLabel = 'View on GitHub'
|
||||
const gitHubRepository = this.props.gitHubRepository
|
||||
|
||||
if (
|
||||
gitHubRepository &&
|
||||
gitHubRepository.endpoint !== getDotComAPIEndpoint()
|
||||
) {
|
||||
viewOnGitHubLabel = 'View on GitHub Enterprise'
|
||||
}
|
||||
|
||||
const items: IMenuItem[] = []
|
||||
|
||||
if (canBeAmended) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Amend Commit…' : 'Amend commit…',
|
||||
action: () => this.props.onAmendCommit?.(commit, isLocal),
|
||||
})
|
||||
}
|
||||
|
||||
if (canBeUndone) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Undo Commit…' : 'Undo commit…',
|
||||
action: () => {
|
||||
if (this.props.onUndoCommit) {
|
||||
this.props.onUndoCommit(commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onUndoCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (enableResetToCommit()) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Reset to Commit…' : 'Reset to commit…',
|
||||
action: () => {
|
||||
if (this.props.onResetToCommit) {
|
||||
this.props.onResetToCommit(commit)
|
||||
}
|
||||
},
|
||||
enabled: canBeResetTo && this.props.onResetToCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (enableCheckoutCommit()) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Checkout Commit' : 'Checkout commit',
|
||||
action: () => {
|
||||
this.props.onCheckoutCommit?.(commit)
|
||||
},
|
||||
enabled: canBeCheckedOut && this.props.onCheckoutCommit !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Revert Changes in Commit'
|
||||
: 'Revert changes in commit',
|
||||
action: () => {
|
||||
if (this.props.onRevertCommit) {
|
||||
this.props.onRevertCommit(commit)
|
||||
}
|
||||
},
|
||||
enabled: this.props.onRevertCommit !== undefined,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: __DARWIN__
|
||||
? 'Create Branch from Commit'
|
||||
: 'Create branch from commit',
|
||||
action: () => {
|
||||
if (this.props.onCreateBranch) {
|
||||
this.props.onCreateBranch(commit)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Create Tag…',
|
||||
action: () => this.props.onCreateTag?.(commit.sha),
|
||||
enabled: this.props.onCreateTag !== undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const deleteTagsMenuItem = this.getDeleteTagsMenuItem(commit)
|
||||
|
||||
if (deleteTagsMenuItem !== null) {
|
||||
items.push(
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
deleteTagsMenuItem
|
||||
)
|
||||
}
|
||||
const darwinTagsLabel = commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag'
|
||||
const windowTagsLabel = commit.tags.length > 1 ? 'Copy tags' : 'Copy tag'
|
||||
items.push(
|
||||
{
|
||||
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
|
||||
action: () => this.props.onCherryPick?.(this.selectedCommits),
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Copy SHA',
|
||||
action: () => clipboard.writeText(commit.sha),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel,
|
||||
action: () => clipboard.writeText(commit.tags.join(' ')),
|
||||
enabled: commit.tags.length > 0,
|
||||
},
|
||||
{
|
||||
label: viewOnGitHubLabel,
|
||||
action: () => this.props.onViewCommitOnGitHub?.(commit.sha),
|
||||
enabled: !isLocal && !!gitHubRepository,
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private canCherryPick(): boolean {
|
||||
const { onCherryPick, isMultiCommitOperationInProgress } = this.props
|
||||
return (
|
||||
onCherryPick !== undefined && isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private canSquash(): boolean {
|
||||
const { onSquash, disableSquashing, isMultiCommitOperationInProgress } =
|
||||
this.props
|
||||
return (
|
||||
onSquash !== undefined &&
|
||||
disableSquashing === false &&
|
||||
isMultiCommitOperationInProgress === false
|
||||
)
|
||||
}
|
||||
|
||||
private getDeleteTagsMenuItem(commit: Commit): IMenuItem | null {
|
||||
const { onDeleteTag } = this.props
|
||||
const unpushedTags = this.getUnpushedTags(commit)
|
||||
|
||||
if (
|
||||
onDeleteTag === undefined ||
|
||||
unpushedTags === undefined ||
|
||||
commit.tags.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (commit.tags.length === 1) {
|
||||
const tagName = commit.tags[0]
|
||||
|
||||
return {
|
||||
label: `Delete tag ${tagName}`,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTags.includes(tagName),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tags to a Set to avoid O(n^2)
|
||||
const unpushedTagsSet = new Set(unpushedTags)
|
||||
|
||||
return {
|
||||
label: 'Delete tag…',
|
||||
submenu: commit.tags.map(tagName => {
|
||||
return {
|
||||
label: tagName,
|
||||
action: () => onDeleteTag(tagName),
|
||||
enabled: unpushedTagsSet.has(tagName),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private getContextMenuMultipleCommits(commit: Commit): IMenuItem[] {
|
||||
const count = this.props.selectedSHAs.length
|
||||
|
||||
return [
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Cherry-pick ${count} Commits…`
|
||||
: `Cherry-pick ${count} commits…`,
|
||||
action: () => this.props.onCherryPick?.(this.selectedCommits),
|
||||
enabled: this.canCherryPick(),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Squash ${count} Commits…`
|
||||
: `Squash ${count} commits…`,
|
||||
action: () => this.onSquash(this.selectedCommits, commit, true),
|
||||
enabled: this.canSquash(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private onDropDataInsertion = (row: number, data: DragData) => {
|
||||
if (
|
||||
this.props.onDropCommitInsertion === undefined ||
|
||||
|
|
|
@ -19,6 +19,7 @@ import _ from 'lodash'
|
|||
import { LinkButton } from '../lib/link-button'
|
||||
import { UnreachableCommitsTab } from './unreachable-commits-dialog'
|
||||
import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha'
|
||||
import memoizeOne from 'memoize-one'
|
||||
|
||||
interface ICommitSummaryProps {
|
||||
readonly repository: Repository
|
||||
|
@ -38,7 +39,7 @@ interface ICommitSummaryProps {
|
|||
|
||||
readonly onExpandChanged: (isExpanded: boolean) => void
|
||||
|
||||
readonly onDescriptionBottomChanged: (descriptionBottom: Number) => void
|
||||
readonly onDescriptionBottomChanged: (descriptionBottom: number) => void
|
||||
|
||||
readonly hideDescriptionBorder: boolean
|
||||
|
||||
|
@ -141,21 +142,6 @@ function getCommitSummary(selectedCommits: ReadonlyArray<Commit>) {
|
|||
: selectedCommits[0].summary
|
||||
}
|
||||
|
||||
function getCountCommitsNotInDiff(
|
||||
selectedCommits: ReadonlyArray<Commit>,
|
||||
shasInDiff: ReadonlyArray<string>
|
||||
) {
|
||||
if (selectedCommits.length === 1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const excludedCommits = selectedCommits.filter(
|
||||
({ sha }) => !shasInDiff.includes(sha)
|
||||
)
|
||||
|
||||
return excludedCommits.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function which determines if two commit objects
|
||||
* have the same commit summary and body.
|
||||
|
@ -173,6 +159,23 @@ export class CommitSummary extends React.Component<
|
|||
private updateOverflowTimeoutId: NodeJS.Immediate | null = null
|
||||
private descriptionRef: HTMLDivElement | null = null
|
||||
|
||||
private getCountCommitsNotInDiff = memoizeOne(
|
||||
(
|
||||
selectedCommits: ReadonlyArray<Commit>,
|
||||
shasInDiff: ReadonlyArray<string>
|
||||
) => {
|
||||
if (selectedCommits.length === 1) {
|
||||
return 0
|
||||
} else {
|
||||
const shas = new Set(shasInDiff)
|
||||
return selectedCommits.reduce(
|
||||
(acc, c) => acc + (shas.has(c.sha) ? 0 : 1),
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
public constructor(props: ICommitSummaryProps) {
|
||||
super(props)
|
||||
|
||||
|
@ -369,7 +372,7 @@ export class CommitSummary extends React.Component<
|
|||
return
|
||||
}
|
||||
|
||||
const excludedCommitsCount = getCountCommitsNotInDiff(
|
||||
const excludedCommitsCount = this.getCountCommitsNotInDiff(
|
||||
selectedCommits,
|
||||
shasInDiff
|
||||
)
|
||||
|
@ -455,7 +458,7 @@ export class CommitSummary extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
const commitsNotInDiff = getCountCommitsNotInDiff(
|
||||
const commitsNotInDiff = this.getCountCommitsNotInDiff(
|
||||
selectedCommits,
|
||||
shasInDiff
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -213,7 +213,7 @@ export class SelectedCommits extends React.Component<
|
|||
this.setState({ isExpanded })
|
||||
}
|
||||
|
||||
private onDescriptionBottomChanged = (descriptionBottom: Number) => {
|
||||
private onDescriptionBottomChanged = (descriptionBottom: number) => {
|
||||
if (this.historyRef) {
|
||||
const historyBottom = this.historyRef.getBoundingClientRect().bottom
|
||||
this.setState({
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Octicon } from '../octicons'
|
|||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { Ref } from './ref'
|
||||
import { IStashEntry } from '../../models/stash-entry'
|
||||
import { enableMoveStash } from '../../lib/feature-flag'
|
||||
|
||||
export function renderBranchHasRemoteWarning(branch: Branch) {
|
||||
if (branch.upstream != null) {
|
||||
|
@ -47,7 +48,7 @@ export function renderBranchNameExistsOnRemoteWarning(
|
|||
}
|
||||
|
||||
export function renderStashWillBeLostWarning(stash: IStashEntry | null) {
|
||||
if (stash === null) {
|
||||
if (stash === null || enableMoveStash()) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
|
|
|
@ -253,10 +253,6 @@ export class ConfigureGitUser extends React.Component<
|
|||
commit={dummyCommit}
|
||||
emoji={emoji}
|
||||
gitHubRepository={null}
|
||||
canBeUndone={false}
|
||||
canBeAmended={false}
|
||||
canBeResetTo={false}
|
||||
isLocal={false}
|
||||
showUnpushedIndicator={false}
|
||||
selectedCommits={[dummyCommit]}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
24
app/src/ui/lib/input-description/input-caption.tsx
Normal file
24
app/src/ui/lib/input-description/input-caption.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
IBaseInputDescriptionProps,
|
||||
InputDescription,
|
||||
InputDescriptionType,
|
||||
} from './input-description'
|
||||
|
||||
/**
|
||||
* An caption element with app-standard styles for captions to be used with inputs.
|
||||
*
|
||||
* Provide `children` elements to render as the content for the error element.
|
||||
*/
|
||||
export class Caption extends React.Component<IBaseInputDescriptionProps> {
|
||||
public render() {
|
||||
return (
|
||||
<InputDescription
|
||||
inputDescriptionType={InputDescriptionType.Caption}
|
||||
{...this.props}
|
||||
>
|
||||
{this.props.children}
|
||||
</InputDescription>
|
||||
)
|
||||
}
|
||||
}
|
122
app/src/ui/lib/input-description/input-description.tsx
Normal file
122
app/src/ui/lib/input-description/input-description.tsx
Normal 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()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
24
app/src/ui/lib/input-description/input-error.tsx
Normal file
24
app/src/ui/lib/input-description/input-error.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
IBaseInputDescriptionProps,
|
||||
InputDescription,
|
||||
InputDescriptionType,
|
||||
} from './input-description'
|
||||
|
||||
/**
|
||||
* An Error component with app-standard styles for errors to be used with inputs.
|
||||
*
|
||||
* Provide `children` elements to render as the content for the error element.
|
||||
*/
|
||||
export class InputError extends React.Component<IBaseInputDescriptionProps> {
|
||||
public render() {
|
||||
return (
|
||||
<InputDescription
|
||||
inputDescriptionType={InputDescriptionType.Error}
|
||||
{...this.props}
|
||||
>
|
||||
{this.props.children}
|
||||
</InputDescription>
|
||||
)
|
||||
}
|
||||
}
|
24
app/src/ui/lib/input-description/input-warning.tsx
Normal file
24
app/src/ui/lib/input-description/input-warning.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
IBaseInputDescriptionProps,
|
||||
InputDescription,
|
||||
InputDescriptionType,
|
||||
} from './input-description'
|
||||
|
||||
/**
|
||||
* An Warning component with app-standard styles for warnings to be used with inputs.
|
||||
*
|
||||
* Provide `children` elements to render as the content for the error element.
|
||||
*/
|
||||
export class InputWarning extends React.Component<IBaseInputDescriptionProps> {
|
||||
public render() {
|
||||
return (
|
||||
<InputDescription
|
||||
inputDescriptionType={InputDescriptionType.Warning}
|
||||
{...this.props}
|
||||
>
|
||||
{this.props.children}
|
||||
</InputDescription>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import { Disposable } from 'event-kit'
|
|||
import * as React from 'react'
|
||||
import { dragAndDropManager } from '../../../lib/drag-and-drop-manager'
|
||||
import { DragData, DragType, DropTargetType } from '../../../models/drag-drop'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
|
||||
enum InsertionFeedbackType {
|
||||
None,
|
||||
|
@ -13,11 +14,11 @@ enum InsertionFeedbackType {
|
|||
|
||||
interface IListItemInsertionOverlayProps {
|
||||
readonly onDropDataInsertion?: (
|
||||
insertionIndex: number,
|
||||
insertionIndex: RowIndexPath,
|
||||
data: DragData
|
||||
) => void
|
||||
|
||||
readonly itemIndex: number
|
||||
readonly itemIndex: RowIndexPath
|
||||
readonly dragType: DragType
|
||||
}
|
||||
|
||||
|
@ -188,7 +189,10 @@ export class ListItemInsertionOverlay extends React.PureComponent<
|
|||
let index = this.props.itemIndex
|
||||
|
||||
if (this.state.feedbackType === InsertionFeedbackType.Bottom) {
|
||||
index++
|
||||
index = {
|
||||
...index,
|
||||
row: index.row + 1,
|
||||
}
|
||||
}
|
||||
this.props.onDropDataInsertion(index, dragAndDropManager.dragData)
|
||||
}
|
||||
|
|
92
app/src/ui/lib/list/list-row-index-path.ts
Normal file
92
app/src/ui/lib/list/list-row-index-path.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
export type RowIndexPath = {
|
||||
readonly row: number
|
||||
readonly section: number
|
||||
}
|
||||
|
||||
export const InvalidRowIndexPath: RowIndexPath = { section: -1, row: -1 }
|
||||
|
||||
export function rowIndexPathEquals(a: RowIndexPath, b: RowIndexPath): boolean {
|
||||
return a.section === b.section && a.row === b.row
|
||||
}
|
||||
|
||||
export function getTotalRowCount(rowCount: ReadonlyArray<number>) {
|
||||
return rowCount.reduce((sum, count) => sum + count, 0)
|
||||
}
|
||||
|
||||
export function rowIndexPathToGlobalIndex(
|
||||
indexPath: RowIndexPath,
|
||||
rowCount: ReadonlyArray<number>
|
||||
): number | null {
|
||||
if (!isValidRow(indexPath, rowCount)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let index = 0
|
||||
|
||||
for (let section = 0; section < indexPath.section; section++) {
|
||||
index += rowCount[section]
|
||||
}
|
||||
|
||||
index += indexPath.row
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
export function globalIndexToRowIndexPath(
|
||||
index: number,
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (index < 0 || index >= getTotalRowCount(rowCount)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let section = 0
|
||||
let row = index
|
||||
|
||||
while (row >= rowCount[section]) {
|
||||
row -= rowCount[section]
|
||||
section++
|
||||
}
|
||||
|
||||
return { section, row }
|
||||
}
|
||||
|
||||
export function isValidRow(
|
||||
indexPath: RowIndexPath,
|
||||
rowCount: ReadonlyArray<number>
|
||||
) {
|
||||
return (
|
||||
indexPath.section >= 0 &&
|
||||
indexPath.section < rowCount.length &&
|
||||
indexPath.row >= 0 &&
|
||||
indexPath.row < rowCount[indexPath.section]
|
||||
)
|
||||
}
|
||||
|
||||
export function getFirstRowIndexPath(
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (rowCount.length > 0) {
|
||||
for (let section = 0; section < rowCount.length; section++) {
|
||||
if (rowCount[section] > 0) {
|
||||
return { section, row: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getLastRowIndexPath(
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (rowCount.length > 0) {
|
||||
for (let section = rowCount.length - 1; section >= 0; section--) {
|
||||
if (rowCount[section] > 0) {
|
||||
return { section, row: rowCount[section] - 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
|
@ -1,12 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
|
||||
interface IListRowProps {
|
||||
/** whether or not the section to which this row belongs has a header */
|
||||
readonly sectionHasHeader: boolean
|
||||
|
||||
/** the total number of row in this list */
|
||||
readonly rowCount: number
|
||||
|
||||
/** the index of the row in the list */
|
||||
readonly rowIndex: number
|
||||
readonly rowIndex: RowIndexPath
|
||||
|
||||
/** custom styles to provide to the row */
|
||||
readonly style?: React.CSSProperties
|
||||
|
@ -21,39 +25,51 @@ interface IListRowProps {
|
|||
readonly selected?: boolean
|
||||
|
||||
/** callback to fire when the DOM element is created */
|
||||
readonly onRowRef?: (index: number, element: HTMLDivElement | null) => void
|
||||
readonly onRowRef?: (
|
||||
index: RowIndexPath,
|
||||
element: HTMLDivElement | null
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a mousedown event */
|
||||
readonly onRowMouseDown: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowMouseDown: (
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<any>
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a mouseup event */
|
||||
readonly onRowMouseUp: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowMouseUp: (index: RowIndexPath, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row is clicked */
|
||||
readonly onRowClick: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowClick: (index: RowIndexPath, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row is double clicked */
|
||||
readonly onRowDoubleClick: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowDoubleClick: (
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<any>
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a keyboard event */
|
||||
readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void
|
||||
readonly onRowKeyDown: (
|
||||
index: RowIndexPath,
|
||||
e: React.KeyboardEvent<any>
|
||||
) => void
|
||||
|
||||
/** called when the row (or any of its descendants) receives focus */
|
||||
readonly onRowFocus?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** called when the row (and all of its descendants) loses focus */
|
||||
readonly onRowBlur?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** Called back for when the context menu is invoked (user right clicks of
|
||||
* uses keyboard shortcuts) */
|
||||
readonly onContextMenu?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
191
app/src/ui/lib/list/section-list-selection.ts
Normal file
191
app/src/ui/lib/list/section-list-selection.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
getTotalRowCount,
|
||||
globalIndexToRowIndexPath,
|
||||
InvalidRowIndexPath,
|
||||
isValidRow,
|
||||
RowIndexPath,
|
||||
rowIndexPathEquals,
|
||||
rowIndexPathToGlobalIndex,
|
||||
} from './list-row-index-path'
|
||||
|
||||
export type SelectionDirection = 'up' | 'down'
|
||||
|
||||
interface ISelectRowAction {
|
||||
/**
|
||||
* The vertical direction use when searching for a selectable row.
|
||||
*/
|
||||
readonly direction: SelectionDirection
|
||||
|
||||
/**
|
||||
* The starting row index to search from.
|
||||
*/
|
||||
readonly row: RowIndexPath
|
||||
|
||||
/**
|
||||
* A flag to indicate or not to look beyond the last or first
|
||||
* row (depending on direction) such that given the last row and
|
||||
* a downward direction will consider the first row as a
|
||||
* candidate or given the first row and an upward direction
|
||||
* will consider the last row as a candidate.
|
||||
*
|
||||
* Defaults to true if not set.
|
||||
*/
|
||||
readonly wrap?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a pointer device clicking or pressing on an item.
|
||||
*/
|
||||
export interface IMouseClickSource {
|
||||
readonly kind: 'mouseclick'
|
||||
readonly event: React.MouseEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a pointer device hovering over an item.
|
||||
* Only applicable when selectedOnHover is set.
|
||||
*/
|
||||
export interface IHoverSource {
|
||||
readonly kind: 'hover'
|
||||
readonly event: React.MouseEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a keyboard
|
||||
*/
|
||||
export interface IKeyboardSource {
|
||||
readonly kind: 'keyboard'
|
||||
readonly event: React.KeyboardEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection of all list
|
||||
* items (usually by clicking the Edit > Select all menu item in
|
||||
* the application window). This is highly specific to GitHub Desktop
|
||||
*/
|
||||
export interface ISelectAllSource {
|
||||
readonly kind: 'select-all'
|
||||
}
|
||||
|
||||
/** A type union of possible sources of a selection changed event */
|
||||
export type SelectionSource =
|
||||
| IMouseClickSource
|
||||
| IHoverSource
|
||||
| IKeyboardSource
|
||||
| ISelectAllSource
|
||||
|
||||
/**
|
||||
* Determine the next selectable row, given the direction and a starting
|
||||
* row index. Whether a row is selectable or not is determined using
|
||||
* the `canSelectRow` function, which defaults to true if not provided.
|
||||
*
|
||||
* Returns null if no row can be selected or if the only selectable row is
|
||||
* identical to the given row parameter.
|
||||
*/
|
||||
export function findNextSelectableRow(
|
||||
rowCount: ReadonlyArray<number>,
|
||||
action: ISelectRowAction,
|
||||
canSelectRow: (indexPath: RowIndexPath) => boolean = row => true
|
||||
): RowIndexPath | null {
|
||||
const totalRowCount = getTotalRowCount(rowCount)
|
||||
if (totalRowCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { direction, row } = action
|
||||
const wrap = action.wrap === undefined ? true : action.wrap
|
||||
const rowIndex = rowIndexPathEquals(InvalidRowIndexPath, row)
|
||||
? -1
|
||||
: rowIndexPathToGlobalIndex(row, rowCount)
|
||||
|
||||
if (rowIndex === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Ensure the row value is in the range between 0 and rowCount - 1
|
||||
//
|
||||
// If the row falls outside this range, use the direction
|
||||
// given to choose a suitable value:
|
||||
//
|
||||
// - move in an upward direction -> select last row
|
||||
// - move in a downward direction -> select first row
|
||||
//
|
||||
let currentRow = isValidRow(row, rowCount)
|
||||
? rowIndex
|
||||
: direction === 'up'
|
||||
? totalRowCount - 1
|
||||
: 0
|
||||
|
||||
// handle specific case from switching from filter text to list
|
||||
//
|
||||
// locking currentRow to [0,rowCount) above means that the below loops
|
||||
// will skip over the first entry
|
||||
if (direction === 'down' && rowIndexPathEquals(row, InvalidRowIndexPath)) {
|
||||
currentRow = -1
|
||||
}
|
||||
|
||||
const delta = direction === 'up' ? -1 : 1
|
||||
|
||||
// Iterate through all rows (starting offset from the
|
||||
// given row and ending on and including the given row)
|
||||
for (let i = 0; i < totalRowCount; i++) {
|
||||
currentRow += delta
|
||||
|
||||
if (currentRow >= totalRowCount) {
|
||||
// We've hit rock bottom, wrap around to the top
|
||||
// if we're allowed to or give up.
|
||||
if (wrap) {
|
||||
currentRow = 0
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else if (currentRow < 0) {
|
||||
// We've reached the top, wrap around to the bottom
|
||||
// if we're allowed to or give up
|
||||
if (wrap) {
|
||||
currentRow = totalRowCount - 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const currentRowIndexPath = globalIndexToRowIndexPath(currentRow, rowCount)
|
||||
if (
|
||||
currentRowIndexPath !== null &&
|
||||
!rowIndexPathEquals(row, currentRowIndexPath) &&
|
||||
canSelectRow(currentRowIndexPath)
|
||||
) {
|
||||
return currentRowIndexPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last selectable row in either direction, used
|
||||
* for moving to the first or last selectable row in a list,
|
||||
* i.e. Home/End key navigation.
|
||||
*/
|
||||
export function findLastSelectableRow(
|
||||
direction: SelectionDirection,
|
||||
rowCount: ReadonlyArray<number>,
|
||||
canSelectRow: (indexPath: RowIndexPath) => boolean
|
||||
): RowIndexPath | null {
|
||||
const totalRowCount = getTotalRowCount(rowCount)
|
||||
let i = direction === 'up' ? 0 : totalRowCount - 1
|
||||
const delta = direction === 'up' ? 1 : -1
|
||||
|
||||
for (; i >= 0 && i < totalRowCount; i += delta) {
|
||||
const indexPath = globalIndexToRowIndexPath(i, rowCount)
|
||||
if (indexPath !== null && canSelectRow(indexPath)) {
|
||||
return indexPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
1681
app/src/ui/lib/list/section-list.tsx
Normal file
1681
app/src/ui/lib/list/section-list.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
@ -87,7 +87,7 @@ export function findNextSelectableRow(
|
|||
}
|
||||
|
||||
const { direction, row } = action
|
||||
const wrap = action.wrap === undefined ? true : action.wrap
|
||||
const wrap = action.wrap ?? true
|
||||
|
||||
// Ensure the row value is in the range between 0 and rowCount - 1
|
||||
//
|
||||
|
|
696
app/src/ui/lib/section-filter-list.tsx
Normal file
696
app/src/ui/lib/section-filter-list.tsx
Normal 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
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -56,6 +56,7 @@ interface IPreferencesProps {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
|
@ -81,6 +82,7 @@ interface IPreferencesState {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
|
@ -128,6 +130,7 @@ export class Preferences extends React.Component<
|
|||
confirmDiscardChanges: false,
|
||||
confirmDiscardChangesPermanently: false,
|
||||
confirmDiscardStash: false,
|
||||
confirmCheckoutCommit: false,
|
||||
confirmForcePush: false,
|
||||
confirmUndoCommit: false,
|
||||
uncommittedChangesStrategy: defaultUncommittedChangesStrategy,
|
||||
|
@ -188,6 +191,7 @@ export class Preferences extends React.Component<
|
|||
confirmDiscardChangesPermanently:
|
||||
this.props.confirmDiscardChangesPermanently,
|
||||
confirmDiscardStash: this.props.confirmDiscardStash,
|
||||
confirmCheckoutCommit: this.props.confirmCheckoutCommit,
|
||||
confirmForcePush: this.props.confirmForcePush,
|
||||
confirmUndoCommit: this.props.confirmUndoCommit,
|
||||
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
|
||||
|
@ -209,7 +213,7 @@ export class Preferences extends React.Component<
|
|||
return (
|
||||
<Dialog
|
||||
id="preferences"
|
||||
title={__DARWIN__ ? 'Preferences' : 'Options'}
|
||||
title={__DARWIN__ ? 'Settings' : 'Options'}
|
||||
onDismissed={this.onCancel}
|
||||
onSubmit={this.onSave}
|
||||
>
|
||||
|
@ -364,6 +368,7 @@ export class Preferences extends React.Component<
|
|||
this.state.confirmDiscardChangesPermanently
|
||||
}
|
||||
confirmDiscardStash={this.state.confirmDiscardStash}
|
||||
confirmCheckoutCommit={this.state.confirmCheckoutCommit}
|
||||
confirmForcePush={this.state.confirmForcePush}
|
||||
confirmUndoCommit={this.state.confirmUndoCommit}
|
||||
onConfirmRepositoryRemovalChanged={
|
||||
|
@ -371,6 +376,7 @@ export class Preferences extends React.Component<
|
|||
}
|
||||
onConfirmDiscardChangesChanged={this.onConfirmDiscardChangesChanged}
|
||||
onConfirmDiscardStashChanged={this.onConfirmDiscardStashChanged}
|
||||
onConfirmCheckoutCommitChanged={this.onConfirmCheckoutCommitChanged}
|
||||
onConfirmForcePushChanged={this.onConfirmForcePushChanged}
|
||||
onConfirmDiscardChangesPermanentlyChanged={
|
||||
this.onConfirmDiscardChangesPermanentlyChanged
|
||||
|
@ -444,6 +450,10 @@ export class Preferences extends React.Component<
|
|||
this.setState({ confirmDiscardStash: value })
|
||||
}
|
||||
|
||||
private onConfirmCheckoutCommitChanged = (value: boolean) => {
|
||||
this.setState({ confirmCheckoutCommit: value })
|
||||
}
|
||||
|
||||
private onConfirmDiscardChangesPermanentlyChanged = (value: boolean) => {
|
||||
this.setState({ confirmDiscardChangesPermanently: value })
|
||||
}
|
||||
|
@ -583,6 +593,10 @@ export class Preferences extends React.Component<
|
|||
this.state.confirmDiscardStash
|
||||
)
|
||||
|
||||
await this.props.dispatcher.setConfirmCheckoutCommitSetting(
|
||||
this.state.confirmCheckoutCommit
|
||||
)
|
||||
|
||||
await this.props.dispatcher.setConfirmUndoCommitSetting(
|
||||
this.state.confirmUndoCommit
|
||||
)
|
||||
|
|
|
@ -9,12 +9,14 @@ interface IPromptsPreferencesProps {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
readonly onConfirmDiscardChangesChanged: (checked: boolean) => void
|
||||
readonly onConfirmDiscardChangesPermanentlyChanged: (checked: boolean) => void
|
||||
readonly onConfirmDiscardStashChanged: (checked: boolean) => void
|
||||
readonly onConfirmCheckoutCommitChanged: (checked: boolean) => void
|
||||
readonly onConfirmRepositoryRemovalChanged: (checked: boolean) => void
|
||||
readonly onConfirmForcePushChanged: (checked: boolean) => void
|
||||
readonly onConfirmUndoCommitChanged: (checked: boolean) => void
|
||||
|
@ -28,6 +30,7 @@ interface IPromptsPreferencesState {
|
|||
readonly confirmDiscardChanges: boolean
|
||||
readonly confirmDiscardChangesPermanently: boolean
|
||||
readonly confirmDiscardStash: boolean
|
||||
readonly confirmCheckoutCommit: boolean
|
||||
readonly confirmForcePush: boolean
|
||||
readonly confirmUndoCommit: boolean
|
||||
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
|
||||
|
@ -46,6 +49,7 @@ export class Prompts extends React.Component<
|
|||
confirmDiscardChangesPermanently:
|
||||
this.props.confirmDiscardChangesPermanently,
|
||||
confirmDiscardStash: this.props.confirmDiscardStash,
|
||||
confirmCheckoutCommit: this.props.confirmCheckoutCommit,
|
||||
confirmForcePush: this.props.confirmForcePush,
|
||||
confirmUndoCommit: this.props.confirmUndoCommit,
|
||||
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
|
||||
|
@ -79,6 +83,15 @@ export class Prompts extends React.Component<
|
|||
this.props.onConfirmDiscardStashChanged(value)
|
||||
}
|
||||
|
||||
private onConfirmCheckoutCommitChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = event.currentTarget.checked
|
||||
|
||||
this.setState({ confirmCheckoutCommit: value })
|
||||
this.props.onConfirmCheckoutCommitChanged(value)
|
||||
}
|
||||
|
||||
private onConfirmForcePushChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
|
@ -154,6 +167,15 @@ export class Prompts extends React.Component<
|
|||
}
|
||||
onChange={this.onConfirmDiscardStashChanged}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Checking out a commit"
|
||||
value={
|
||||
this.state.confirmCheckoutCommit
|
||||
? CheckboxValue.On
|
||||
: CheckboxValue.Off
|
||||
}
|
||||
onChange={this.onConfirmCheckoutCommitChanged}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Force pushing"
|
||||
value={
|
||||
|
|
|
@ -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
|
||||
|
||||
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={{
|
||||
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}
|
||||
/>
|
||||
},
|
||||
onItemContextMenu: this.onItemContextMenu,
|
||||
getGroupAriaLabel,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="repository-list">
|
||||
<ListComponent {...filterListProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
27
app/styles/ui/_input-description.scss
Normal file
27
app/styles/ui/_input-description.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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__'] = ''
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
88
app/test/unit/section-list-selection-test.ts
Normal file
88
app/test/unit/section-list-selection-test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -81,7 +81,7 @@ export const renderer = merge({}, commonConfig, {
|
|||
},
|
||||
{
|
||||
test: /\.cmd$/,
|
||||
loader: 'file-loader',
|
||||
type: 'asset/resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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.
|
|
@ -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`
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
''
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
$scriptPath = split-path -parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
$file = "$scriptPath\windows-certificate.pfx"
|
||||
|
||||
if ((Test-Path $file)) {
|
||||
Remove-Item $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() {
|
||||
const outPath = Path.join(projectRoot, 'out')
|
||||
return {
|
||||
// eslint-disable-next-line no-sync
|
||||
const rendererStats = Fs.statSync(
|
||||
Path.join(projectRoot, 'out', 'renderer.js')
|
||||
)
|
||||
rendererBundleSize: Fs.statSync(Path.join(outPath, 'renderer.js')).size,
|
||||
// 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
|
||||
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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue