Merge branch 'development' into releases/3.0.8

This commit is contained in:
Sergio Padrino 2022-09-12 10:48:49 +02:00
commit 2ae90157d1
118 changed files with 2613 additions and 660 deletions

View file

@ -15,6 +15,7 @@ extends:
- prettier/react
- plugin:@typescript-eslint/recommended
- prettier/@typescript-eslint
- plugin:github/react
rules:
##########
@ -29,6 +30,7 @@ rules:
###########
# PLUGINS #
###########
# TYPESCRIPT
'@typescript-eslint/naming-convention':
- error

View file

@ -16,17 +16,14 @@ jobs:
fail-fast: false
matrix:
node: [16.13.0]
os: [macos-10.15, windows-2019]
os: [macos-11, windows-2019]
arch: [x64, arm64]
include:
- os: macos-10.15
- os: macos-11
friendlyName: macOS
- os: windows-2019
friendlyName: Windows
timeout-minutes: 60
env:
# Needed for macOS arm64 until hosted macos-11.0 runners become available
SDKROOT: /Library/Developer/CommandLineTools/SDKs/MacOSX11.1.sdk
steps:
- uses: actions/checkout@v3
with:

View file

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

2
.markdownlint.js Normal file
View file

@ -0,0 +1,2 @@
const markdownlintGitHub = require('@github/markdownlint-github')
module.exports = markdownlintGitHub.init()

View file

@ -30,7 +30,7 @@
"desktop-trampoline": "desktop/desktop-trampoline#v0.9.8",
"dexie": "^3.2.2",
"dompurify": "^2.3.3",
"dugite": "^1.110.0",
"dugite": "^2.0.0",
"electron-window-state": "^5.0.3",
"event-kit": "^2.0.0",
"focus-trap-react": "^8.1.0",

View file

@ -201,7 +201,9 @@ export class CrashApp extends React.Component<ICrashAppProps, ICrashAppState> {
}
private renderBackgroundGraphics() {
return <img className="background-graphic-bottom" src={BottomImageUri} />
return (
<img className="background-graphic-bottom" alt="" src={BottomImageUri} />
)
}
public render() {

View file

@ -31,6 +31,10 @@ const editors: IDarwinExternalEditor[] = [
name: 'MacVim',
bundleIdentifiers: ['org.vim.MacVim'],
},
{
name: 'Neovide',
bundleIdentifiers: ['com.neovide.neovide'],
},
{
name: 'Visual Studio Code',
bundleIdentifiers: ['com.microsoft.VSCode'],
@ -132,6 +136,14 @@ const editors: IDarwinExternalEditor[] = [
name: 'Nova',
bundleIdentifiers: ['com.panic.Nova'],
},
{
name: 'Emacs',
bundleIdentifiers: ['org.gnu.Emacs'],
},
{
name: 'Lite XL',
bundleIdentifiers: ['com.lite-xl'],
},
]
async function findApplication(

View file

@ -58,6 +58,10 @@ const editors: ILinuxExternalEditor[] = [
name: 'Code',
paths: ['/usr/bin/io.elementary.code'],
},
{
name: 'Lite XL',
paths: ['/usr/bin/lite-xl'],
},
]
async function getAvailablePath(paths: string[]): Promise<string | null> {

View file

@ -30,7 +30,7 @@ function enableBetaFeatures(): boolean {
/** Should git pass `--recurse-submodules` when performing operations? */
export function enableRecurseSubmodulesFlag(): boolean {
return enableBetaFeatures()
return true
}
export function enableReadmeOverwriteWarning(): boolean {
@ -102,3 +102,8 @@ export function enablePullRequestQuickView(): boolean {
export function enableMultiCommitDiffs(): boolean {
return enableBetaFeatures()
}
/** Should we enable the new interstitial for submodule diffs? */
export function enableSubmoduleDiff(): boolean {
return enableBetaFeatures()
}

View file

@ -64,6 +64,7 @@ export async function applyPatchToIndex(
const { kind } = diff
switch (diff.kind) {
case DiffType.Binary:
case DiffType.Submodule:
case DiffType.Image:
throw new Error(
`Can't create partial commit in binary file: ${file.path}`

View file

@ -161,7 +161,7 @@ export async function git(
// from a terminal or if the system environment variables
// have TERM set Git won't consider us as a smart terminal.
// See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15
opts.env = { TERM: 'dumb', ...combinedEnv } as Object
opts.env = { TERM: 'dumb', ...combinedEnv } as object
const commandName = `${name}: git ${args.join(' ')}`

View file

@ -8,6 +8,7 @@ import {
FileChange,
AppFileStatusKind,
CommittedFileChange,
SubmoduleStatus,
} from '../../models/status'
import {
DiffType,
@ -18,7 +19,6 @@ import {
LineEndingsChange,
parseLineEndingText,
ILargeTextDiff,
IUnrenderableDiff,
} from '../../models/diff'
import { spawnAndComplete } from './spawn'
@ -32,6 +32,8 @@ import { git } from './core'
import { NullTreeSHA } from './diff-index'
import { GitError } from 'dugite'
import { parseRawLogWithNumstat } from './log'
import { getConfigValue } from './config'
import { getMergeBase } from './merge'
/**
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
@ -138,6 +140,50 @@ export async function getCommitDiff(
return buildDiff(output, repository, file, commitish)
}
/**
* Render the diff between two branches with --merge-base for a file
* (Show what would be the result of merge)
*/
export async function getBranchMergeBaseDiff(
repository: Repository,
file: FileChange,
baseBranchName: string,
comparisonBranchName: string,
hideWhitespaceInDiff: boolean = false,
latestCommit: string
): Promise<IDiff> {
const args = [
'diff',
'--merge-base',
baseBranchName,
comparisonBranchName,
...(hideWhitespaceInDiff ? ['-w'] : []),
'--patch-with-raw',
'-z',
'--no-color',
'--',
file.path,
]
if (
file.status.kind === AppFileStatusKind.Renamed ||
file.status.kind === AppFileStatusKind.Copied
) {
args.push(file.status.oldPath)
}
const result = await git(args, repository.path, 'getBranchMergeBaseDiff', {
maxBuffer: Infinity,
})
return buildDiff(
Buffer.from(result.combinedOutput),
repository,
file,
latestCommit
)
}
/**
* Render the difference between two commits for a file
*
@ -200,6 +246,52 @@ export async function getCommitRangeDiff(
)
}
/**
* Get the files that were changed for the merge base comparison of two branches.
* (What would be the result of a merge)
*/
export async function getBranchMergeBaseChangedFiles(
repository: Repository,
baseBranchName: string,
comparisonBranchName: string,
latestComparisonBranchCommitRef: string
): Promise<{
files: ReadonlyArray<CommittedFileChange>
linesAdded: number
linesDeleted: number
}> {
const baseArgs = [
'diff',
'--merge-base',
baseBranchName,
comparisonBranchName,
'-C',
'-M',
'-z',
'--raw',
'--numstat',
'--',
]
const result = await git(
baseArgs,
repository.path,
'getBranchMergeBaseChangedFiles'
)
const mergeBaseCommit = await getMergeBase(
repository,
baseBranchName,
comparisonBranchName
)
return parseRawLogWithNumstat(
result.combinedOutput,
`${latestComparisonBranchCommitRef}`,
mergeBaseCommit ?? NullTreeSHA
)
}
export async function getCommitRangeChangedFiles(
repository: Repository,
shas: ReadonlyArray<string>,
@ -268,10 +360,14 @@ export async function getWorkingDirectoryDiff(
'--no-color',
]
const successExitCodes = new Set([0])
const isSubmodule = file.status.submoduleStatus !== undefined
// For added submodules, we'll use the "default" parameters, which are able
// to output the submodule commit.
if (
file.status.kind === AppFileStatusKind.New ||
file.status.kind === AppFileStatusKind.Untracked
!isSubmodule &&
(file.status.kind === AppFileStatusKind.New ||
file.status.kind === AppFileStatusKind.Untracked)
) {
// `git diff --no-index` seems to emulate the exit codes from `diff` irrespective of
// whether you set --exit-code
@ -480,16 +576,71 @@ function diffFromRawDiffOutput(output: Buffer): IRawDiff {
return parser.parse(forceUnwrap(`Invalid diff output`, pieces.at(-1)))
}
function buildDiff(
async function buildSubmoduleDiff(
buffer: Buffer,
repository: Repository,
file: FileChange,
status: SubmoduleStatus
): Promise<IDiff> {
const path = file.path
const fullPath = Path.join(repository.path, path)
const url = await getConfigValue(repository, `submodule.${path}.url`, true)
let oldSHA = null
let newSHA = null
if (
status.commitChanged ||
file.status.kind === AppFileStatusKind.New ||
file.status.kind === AppFileStatusKind.Deleted
) {
const diff = buffer.toString('utf-8')
const lines = diff.split('\n')
const baseRegex = 'Subproject commit ([^-]+)(-dirty)?$'
const oldSHARegex = new RegExp('-' + baseRegex)
const newSHARegex = new RegExp('\\+' + baseRegex)
const lineMatch = (regex: RegExp) =>
lines
.flatMap(line => {
const match = line.match(regex)
return match ? match[1] : []
})
.at(0) ?? null
oldSHA = lineMatch(oldSHARegex)
newSHA = lineMatch(newSHARegex)
}
return {
kind: DiffType.Submodule,
fullPath,
path,
url,
status,
oldSHA,
newSHA,
}
}
async function buildDiff(
buffer: Buffer,
repository: Repository,
file: FileChange,
oldestCommitish: string,
lineEndingsChange?: LineEndingsChange
): Promise<IDiff> {
if (file.status.submoduleStatus !== undefined) {
return buildSubmoduleDiff(
buffer,
repository,
file,
file.status.submoduleStatus
)
}
if (!isValidBuffer(buffer)) {
// the buffer's diff is too large to be renderable in the UI
return Promise.resolve<IUnrenderableDiff>({ kind: DiffType.Unrenderable })
return { kind: DiffType.Unrenderable }
}
const diff = diffFromRawDiffOutput(buffer)
@ -507,7 +658,7 @@ function buildDiff(
hasHiddenBidiChars: diff.hasHiddenBidiChars,
}
return Promise.resolve(largeTextDiff)
return largeTextDiff
}
return convertDiff(repository, file, diff, oldestCommitish, lineEndingsChange)

View file

@ -6,6 +6,7 @@ import {
CopiedOrRenamedFileStatus,
UntrackedFileStatus,
AppFileStatus,
SubmoduleStatus,
} from '../../models/status'
import { Repository } from '../../models/repository'
import { Commit } from '../../models/commit'
@ -15,47 +16,82 @@ import { getCaptures } from '../helpers/regex'
import { createLogParser } from './git-delimiter-parser'
import { revRange } from '.'
import { forceUnwrap } from '../fatal-error'
import { enableSubmoduleDiff } from '../feature-flag'
// File mode 160000 is used by git specifically for submodules:
// https://github.com/git/git/blob/v2.37.3/cache.h#L62-L69
const SubmoduleFileMode = '160000'
function mapSubmoduleStatusFileModes(
status: string,
srcMode: string,
dstMode: string
): SubmoduleStatus | undefined {
if (!enableSubmoduleDiff()) {
return undefined
}
return srcMode === SubmoduleFileMode &&
dstMode === SubmoduleFileMode &&
status === 'M'
? {
commitChanged: true,
untrackedChanges: false,
modifiedChanges: false,
}
: (srcMode === SubmoduleFileMode && status === 'D') ||
(dstMode === SubmoduleFileMode && status === 'A')
? {
commitChanged: false,
untrackedChanges: false,
modifiedChanges: false,
}
: undefined
}
/**
* Map the raw status text from Git to an app-friendly value
* shamelessly borrowed from GitHub Desktop (Windows)
*/
export function mapStatus(
function mapStatus(
rawStatus: string,
oldPath?: string
oldPath: string | undefined,
srcMode: string,
dstMode: string
): PlainFileStatus | CopiedOrRenamedFileStatus | UntrackedFileStatus {
const status = rawStatus.trim()
const submoduleStatus = mapSubmoduleStatusFileModes(status, srcMode, dstMode)
if (status === 'M') {
return { kind: AppFileStatusKind.Modified }
return { kind: AppFileStatusKind.Modified, submoduleStatus }
} // modified
if (status === 'A') {
return { kind: AppFileStatusKind.New }
return { kind: AppFileStatusKind.New, submoduleStatus }
} // added
if (status === '?') {
return { kind: AppFileStatusKind.Untracked }
return { kind: AppFileStatusKind.Untracked, submoduleStatus }
} // untracked
if (status === 'D') {
return { kind: AppFileStatusKind.Deleted }
return { kind: AppFileStatusKind.Deleted, submoduleStatus }
} // deleted
if (status === 'R' && oldPath != null) {
return { kind: AppFileStatusKind.Renamed, oldPath }
return { kind: AppFileStatusKind.Renamed, oldPath, submoduleStatus }
} // renamed
if (status === 'C' && oldPath != null) {
return { kind: AppFileStatusKind.Copied, oldPath }
return { kind: AppFileStatusKind.Copied, oldPath, submoduleStatus }
} // copied
// git log -M --name-status will return a RXXX - where XXX is a percentage
if (status.match(/R[0-9]+/) && oldPath != null) {
return { kind: AppFileStatusKind.Renamed, oldPath }
return { kind: AppFileStatusKind.Renamed, oldPath, submoduleStatus }
}
// git log -C --name-status will return a CXXX - where XXX is a percentage
if (status.match(/C[0-9]+/) && oldPath != null) {
return { kind: AppFileStatusKind.Copied, oldPath }
return { kind: AppFileStatusKind.Copied, oldPath, submoduleStatus }
}
return { kind: AppFileStatusKind.Modified }
return { kind: AppFileStatusKind.Modified, submoduleStatus }
}
const isCopyOrRename = (
@ -232,7 +268,19 @@ export function parseRawLogWithNumstat(
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i]
if (line.startsWith(':')) {
const status = forceUnwrap('Invalid log output', line.split(' ').at(-1))
const lineComponents = line.split(' ')
const srcMode = forceUnwrap(
'Invalid log output (srcMode)',
lineComponents[0]?.replace(':', '')
)
const dstMode = forceUnwrap(
'Invalid log output (dstMode)',
lineComponents[1]
)
const status = forceUnwrap(
'Invalid log output (status)',
lineComponents.at(-1)
)
const oldPath = /^R|C/.test(status)
? forceUnwrap('Missing old path', lines.at(++i))
: undefined
@ -242,7 +290,7 @@ export function parseRawLogWithNumstat(
files.push(
new CommittedFileChange(
path,
mapStatus(status, oldPath),
mapStatus(status, oldPath, srcMode, dstMode),
sha,
parentCommitish
)

View file

@ -104,7 +104,10 @@ function parseConflictedState(
conflictDetails.conflictCountsByPath.get(path) || 0,
}
} else {
return { kind: AppFileStatusKind.Conflicted, entry }
return {
kind: AppFileStatusKind.Conflicted,
entry,
}
}
}
case UnmergedEntrySummary.BothModified: {
@ -140,18 +143,38 @@ function convertToAppStatus(
if (entry.kind === 'ordinary') {
switch (entry.type) {
case 'added':
return { kind: AppFileStatusKind.New }
return {
kind: AppFileStatusKind.New,
submoduleStatus: entry.submoduleStatus,
}
case 'modified':
return { kind: AppFileStatusKind.Modified }
return {
kind: AppFileStatusKind.Modified,
submoduleStatus: entry.submoduleStatus,
}
case 'deleted':
return { kind: AppFileStatusKind.Deleted }
return {
kind: AppFileStatusKind.Deleted,
submoduleStatus: entry.submoduleStatus,
}
}
} else if (entry.kind === 'copied' && oldPath != null) {
return { kind: AppFileStatusKind.Copied, oldPath }
return {
kind: AppFileStatusKind.Copied,
oldPath,
submoduleStatus: entry.submoduleStatus,
}
} else if (entry.kind === 'renamed' && oldPath != null) {
return { kind: AppFileStatusKind.Renamed, oldPath }
return {
kind: AppFileStatusKind.Renamed,
oldPath,
submoduleStatus: entry.submoduleStatus,
}
} else if (entry.kind === 'untracked') {
return { kind: AppFileStatusKind.Untracked }
return {
kind: AppFileStatusKind.Untracked,
submoduleStatus: entry.submoduleStatus,
}
} else if (entry.kind === 'conflicted') {
return parseConflictedState(entry, path, conflictDetails)
}
@ -270,7 +293,7 @@ function buildStatusMap(
entry: IStatusEntry,
conflictDetails: ConflictFilesDetails
): Map<string, WorkingDirectoryFileChange> {
const status = mapStatus(entry.statusCode)
const status = mapStatus(entry.statusCode, entry.submoduleStatusCode)
if (status.kind === 'ordinary') {
// when a file is added in the index but then removed in the working
@ -300,7 +323,14 @@ function buildStatusMap(
entry.oldPath
)
const selection = DiffSelection.fromInitialSelection(DiffSelectionType.All)
const initialSelectionType =
appStatus.kind === AppFileStatusKind.Modified &&
appStatus.submoduleStatus !== undefined &&
!appStatus.submoduleStatus.commitChanged
? DiffSelectionType.None
: DiffSelectionType.All
const selection = DiffSelection.fromInitialSelection(initialSelectionType)
files.set(
entry.path,

View file

@ -1,5 +1,8 @@
/** Create a copy of an object by merging it with a subset of its properties. */
export function merge<T, K extends keyof T>(obj: T, subset: Pick<T, K>): T {
export function merge<T extends {}, K extends keyof T>(
obj: T | null | undefined,
subset: Pick<T, K>
): T {
const copy = Object.assign({}, obj)
for (const k in subset) {
copy[k] = subset[k]

View file

@ -1,8 +1,10 @@
import {
FileEntry,
GitStatusEntry,
SubmoduleStatus,
UnmergedEntrySummary,
} from '../models/status'
import { enableSubmoduleDiff } from './feature-flag'
type StatusItem = IStatusHeader | IStatusEntry
@ -21,6 +23,9 @@ export interface IStatusEntry {
/** The two character long status code */
readonly statusCode: string
/** The four character long submodule status code */
readonly submoduleStatusCode: string
/** The original path in the case of a renamed file */
readonly oldPath?: string
}
@ -102,7 +107,12 @@ function parseChangedEntry(field: string): IStatusEntry {
throw new Error(`Failed to parse status line for changed entry`)
}
return { kind: 'entry', statusCode: match[1], path: match[8] }
return {
kind: 'entry',
statusCode: match[1],
submoduleStatusCode: match[2],
path: match[8],
}
}
// 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path><sep><origPath>
@ -126,7 +136,13 @@ function parsedRenamedOrCopiedEntry(
)
}
return { kind: 'entry', statusCode: match[1], oldPath, path: match[9] }
return {
kind: 'entry',
statusCode: match[1],
submoduleStatusCode: match[2],
oldPath,
path: match[9],
}
}
// u <xy> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
@ -144,6 +160,7 @@ function parseUnmergedEntry(field: string): IStatusEntry {
return {
kind: 'entry',
statusCode: match[1],
submoduleStatusCode: match[2],
path: match[10],
}
}
@ -155,198 +172,239 @@ function parseUntrackedEntry(field: string): IStatusEntry {
// NOTE: We return ?? instead of ? here to play nice with mapStatus,
// might want to consider changing this (and mapStatus) in the future.
statusCode: '??',
submoduleStatusCode: '????',
path,
}
}
function mapSubmoduleStatus(
submoduleStatusCode: string
): SubmoduleStatus | undefined {
if (!enableSubmoduleDiff() || !submoduleStatusCode.startsWith('S')) {
return undefined
}
return {
commitChanged: submoduleStatusCode[1] === 'C',
modifiedChanges: submoduleStatusCode[2] === 'M',
untrackedChanges: submoduleStatusCode[3] === 'U',
}
}
/**
* Map the raw status text from Git to a structure we can work with in the app.
*/
export function mapStatus(status: string): FileEntry {
if (status === '??') {
return { kind: 'untracked' }
export function mapStatus(
statusCode: string,
submoduleStatusCode: string
): FileEntry {
const submoduleStatus = mapSubmoduleStatus(submoduleStatusCode)
if (statusCode === '??') {
return { kind: 'untracked', submoduleStatus }
}
if (status === '.M') {
if (statusCode === '.M') {
return {
kind: 'ordinary',
type: 'modified',
index: GitStatusEntry.Unchanged,
workingTree: GitStatusEntry.Modified,
submoduleStatus,
}
}
if (status === 'M.') {
if (statusCode === 'M.') {
return {
kind: 'ordinary',
type: 'modified',
index: GitStatusEntry.Modified,
workingTree: GitStatusEntry.Unchanged,
submoduleStatus,
}
}
if (status === '.A') {
if (statusCode === '.A') {
return {
kind: 'ordinary',
type: 'added',
index: GitStatusEntry.Unchanged,
workingTree: GitStatusEntry.Added,
submoduleStatus,
}
}
if (status === 'A.') {
if (statusCode === 'A.') {
return {
kind: 'ordinary',
type: 'added',
index: GitStatusEntry.Added,
workingTree: GitStatusEntry.Unchanged,
submoduleStatus,
}
}
if (status === '.D') {
if (statusCode === '.D') {
return {
kind: 'ordinary',
type: 'deleted',
index: GitStatusEntry.Unchanged,
workingTree: GitStatusEntry.Deleted,
submoduleStatus,
}
}
if (status === 'D.') {
if (statusCode === 'D.') {
return {
kind: 'ordinary',
type: 'deleted',
index: GitStatusEntry.Deleted,
workingTree: GitStatusEntry.Unchanged,
submoduleStatus,
}
}
if (status === 'R.') {
if (statusCode === 'R.') {
return {
kind: 'renamed',
index: GitStatusEntry.Renamed,
workingTree: GitStatusEntry.Unchanged,
submoduleStatus,
}
}
if (status === '.R') {
if (statusCode === '.R') {
return {
kind: 'renamed',
index: GitStatusEntry.Unchanged,
workingTree: GitStatusEntry.Renamed,
submoduleStatus,
}
}
if (status === 'C.') {
if (statusCode === 'C.') {
return {
kind: 'copied',
index: GitStatusEntry.Copied,
workingTree: GitStatusEntry.Unchanged,
submoduleStatus,
}
}
if (status === '.C') {
if (statusCode === '.C') {
return {
kind: 'copied',
index: GitStatusEntry.Unchanged,
workingTree: GitStatusEntry.Copied,
submoduleStatus,
}
}
if (status === 'AD') {
if (statusCode === 'AD') {
return {
kind: 'ordinary',
type: 'added',
index: GitStatusEntry.Added,
workingTree: GitStatusEntry.Deleted,
submoduleStatus,
}
}
if (status === 'AM') {
if (statusCode === 'AM') {
return {
kind: 'ordinary',
type: 'added',
index: GitStatusEntry.Added,
workingTree: GitStatusEntry.Modified,
submoduleStatus,
}
}
if (status === 'RM') {
if (statusCode === 'RM') {
return {
kind: 'renamed',
index: GitStatusEntry.Renamed,
workingTree: GitStatusEntry.Modified,
submoduleStatus,
}
}
if (status === 'RD') {
if (statusCode === 'RD') {
return {
kind: 'renamed',
index: GitStatusEntry.Renamed,
workingTree: GitStatusEntry.Deleted,
submoduleStatus,
}
}
if (status === 'DD') {
if (statusCode === 'DD') {
return {
kind: 'conflicted',
action: UnmergedEntrySummary.BothDeleted,
us: GitStatusEntry.Deleted,
them: GitStatusEntry.Deleted,
submoduleStatus,
}
}
if (status === 'AU') {
if (statusCode === 'AU') {
return {
kind: 'conflicted',
action: UnmergedEntrySummary.AddedByUs,
us: GitStatusEntry.Added,
them: GitStatusEntry.UpdatedButUnmerged,
submoduleStatus,
}
}
if (status === 'UD') {
if (statusCode === 'UD') {
return {
kind: 'conflicted',
action: UnmergedEntrySummary.DeletedByThem,
us: GitStatusEntry.UpdatedButUnmerged,
them: GitStatusEntry.Deleted,
submoduleStatus,
}
}
if (status === 'UA') {
if (statusCode === 'UA') {
return {
kind: 'conflicted',
action: UnmergedEntrySummary.AddedByThem,
us: GitStatusEntry.UpdatedButUnmerged,
them: GitStatusEntry.Added,
submoduleStatus,
}
}
if (status === 'DU') {
if (statusCode === 'DU') {
return {
kind: 'conflicted',
action: UnmergedEntrySummary.DeletedByUs,
us: GitStatusEntry.Deleted,
them: GitStatusEntry.UpdatedButUnmerged,
submoduleStatus,
}
}
if (status === 'AA') {
if (statusCode === 'AA') {
return {
kind: 'conflicted',
action: UnmergedEntrySummary.BothAdded,
us: GitStatusEntry.Added,
them: GitStatusEntry.Added,
submoduleStatus,
}
}
if (status === 'UU') {
if (statusCode === 'UU') {
return {
kind: 'conflicted',
action: UnmergedEntrySummary.BothModified,
us: GitStatusEntry.UpdatedButUnmerged,
them: GitStatusEntry.UpdatedButUnmerged,
submoduleStatus,
}
}
@ -354,5 +412,6 @@ export function mapStatus(status: string): FileEntry {
return {
kind: 'ordinary',
type: 'modified',
submoduleStatus,
}
}

View file

@ -125,7 +125,7 @@ export class RepositoryIndicatorUpdater {
private clearRefreshTimeout() {
if (this.refreshTimeoutId !== null) {
window.clearTimeout()
window.clearTimeout(this.refreshTimeoutId)
this.refreshTimeoutId = null
}
}

View file

@ -16,7 +16,7 @@ import { AccountsStore } from './accounts-store'
import { getCommit } from '../git'
import { GitHubRepository } from '../../models/github-repository'
import { PullRequestCoordinator } from './pull-request-coordinator'
import { Commit } from '../../models/commit'
import { Commit, shortenSHA } from '../../models/commit'
import {
AliveStore,
DesktopAliveEvent,
@ -253,7 +253,7 @@ export class NotificationsStore {
const pluralChecks =
numberOfFailedChecks === 1 ? 'check was' : 'checks were'
const shortSHA = commitSHA.slice(0, 9)
const shortSHA = shortenSHA(commitSHA)
const title = 'Pull Request checks failed'
const body = `${pullRequest.title} #${pullRequest.pullRequestNumber} (${shortSHA})\n${numberOfFailedChecks} ${pluralChecks} not successful.`
const onClick = () => {

View file

@ -21,7 +21,7 @@ import {
* variables.
*/
export async function withTrampolineEnv<T>(
fn: (env: Object) => Promise<T>
fn: (env: object) => Promise<T>
): Promise<T> {
const sshEnv = await getSSHEnvironment()

View file

@ -2,6 +2,11 @@ import { CommitIdentity } from './commit-identity'
import { ITrailer, isCoAuthoredByTrailer } from '../lib/git/interpret-trailers'
import { GitAuthor } from './git-author'
/** Shortens a given SHA. */
export function shortenSHA(sha: string) {
return sha.slice(0, 9)
}
/** Grouping of information required to create a commit */
export interface ICommitContext {
/**

View file

@ -1,5 +1,6 @@
import { DiffHunk } from './raw-diff'
import { Image } from './image'
import { SubmoduleStatus } from '../status'
/**
* V8 has a limit on the size of string it can create, and unless we want to
* trigger an unhandled exception we need to do the encoding conversion by hand
@ -87,6 +88,28 @@ export interface IBinaryDiff {
readonly kind: DiffType.Binary
}
export interface ISubmoduleDiff {
readonly kind: DiffType.Submodule
/** Full path of the submodule */
readonly fullPath: string
/** Path of the repository within its container repository */
readonly path: string
/** URL of the submodule */
readonly url: string | null
/** Status of the submodule */
readonly status: SubmoduleStatus
/** Previous SHA of the submodule, or null if it hasn't changed */
readonly oldSHA: string | null
/** New SHA of the submodule, or null if it hasn't changed */
readonly newSHA: string | null
}
export interface ILargeTextDiff extends ITextDiffData {
readonly kind: DiffType.LargeText
}
@ -100,5 +123,6 @@ export type IDiff =
| ITextDiff
| IImageDiff
| IBinaryDiff
| ISubmoduleDiff
| ILargeTextDiff
| IUnrenderableDiff

View file

@ -34,6 +34,7 @@ export type PlainFileStatus = {
| AppFileStatusKind.New
| AppFileStatusKind.Modified
| AppFileStatusKind.Deleted
submoduleStatus?: SubmoduleStatus
}
/**
@ -46,6 +47,7 @@ export type PlainFileStatus = {
export type CopiedOrRenamedFileStatus = {
kind: AppFileStatusKind.Copied | AppFileStatusKind.Renamed
oldPath: string
submoduleStatus?: SubmoduleStatus
}
/**
@ -56,6 +58,7 @@ export type ConflictsWithMarkers = {
kind: AppFileStatusKind.Conflicted
entry: TextConflictEntry
conflictMarkerCount: number
submoduleStatus?: SubmoduleStatus
}
/**
@ -65,6 +68,7 @@ export type ConflictsWithMarkers = {
export type ManualConflict = {
kind: AppFileStatusKind.Conflicted
entry: ManualConflictEntry
submoduleStatus?: SubmoduleStatus
}
/** Union of potential conflict scenarios the application should handle */
@ -92,7 +96,10 @@ export function isManualConflict(
}
/** Denotes an untracked file in the working directory) */
export type UntrackedFileStatus = { kind: AppFileStatusKind.Untracked }
export type UntrackedFileStatus = {
kind: AppFileStatusKind.Untracked
submoduleStatus?: SubmoduleStatus
}
/** The union of potential states associated with a file change in Desktop */
export type AppFileStatus =
@ -101,6 +108,22 @@ export type AppFileStatus =
| ConflictedFileStatus
| UntrackedFileStatus
/** The status of a submodule */
export type SubmoduleStatus = {
/** Whether or not the submodule is pointing to a different commit */
readonly commitChanged: boolean
/**
* Whether or not the submodule contains modified changes that haven't been
* committed yet
*/
readonly modifiedChanges: boolean
/**
* Whether or not the submodule contains untracked changes that haven't been
* committed yet
*/
readonly untrackedChanges: boolean
}
/** The porcelain status for an ordinary changed entry */
type OrdinaryEntry = {
readonly kind: 'ordinary'
@ -110,6 +133,8 @@ type OrdinaryEntry = {
readonly index?: GitStatusEntry
/** the status of the working tree for this entry (if known) */
readonly workingTree?: GitStatusEntry
/** the submodule status for this entry */
readonly submoduleStatus?: SubmoduleStatus
}
/** The porcelain status for a renamed or copied entry */
@ -119,6 +144,8 @@ type RenamedOrCopiedEntry = {
readonly index?: GitStatusEntry
/** the status of the working tree for this entry (if known) */
readonly workingTree?: GitStatusEntry
/** the submodule status for this entry */
readonly submoduleStatus?: SubmoduleStatus
}
export enum UnmergedEntrySummary {
@ -149,13 +176,18 @@ type TextConflictDetails =
type TextConflictEntry = {
readonly kind: 'conflicted'
/** the submodule status for this entry */
readonly submoduleStatus?: SubmoduleStatus
} & TextConflictDetails
/**
* Valid Git index states where the user needs to choose one of `us` or `them`
* in the app.
*/
type ManualConflictDetails =
type ManualConflictDetails = {
/** the submodule status for this entry */
readonly submoduleStatus?: SubmoduleStatus
} & (
| {
readonly action: UnmergedEntrySummary.BothAdded
readonly us: GitStatusEntry.Added
@ -191,9 +223,12 @@ type ManualConflictDetails =
readonly us: GitStatusEntry.Deleted
readonly them: GitStatusEntry.Deleted
}
)
type ManualConflictEntry = {
readonly kind: 'conflicted'
/** the submodule status for this entry */
readonly submoduleStatus?: SubmoduleStatus
} & ManualConflictDetails
/** The porcelain status for an unmerged entry */
@ -202,6 +237,8 @@ export type UnmergedEntry = TextConflictEntry | ManualConflictEntry
/** The porcelain status for an unmerged entry */
type UntrackedEntry = {
readonly kind: 'untracked'
/** the submodule status for this entry */
readonly submoduleStatus?: SubmoduleStatus
}
/** The union of possible entries from the git status */

View file

@ -209,6 +209,7 @@ export class AppMenuBarButton extends React.Component<
highlightAccessKey={this.props.highlightMenuAccessKey}
renderAcceleratorText={false}
renderSubMenuArrow={false}
selected={false}
/>
</ToolbarDropdown>
)

View file

@ -166,11 +166,12 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
}
}
private onItemKeyDown = (
private onPaneKeyDown = (
depth: number,
item: MenuItem,
event: React.KeyboardEvent<any>
event: React.KeyboardEvent<HTMLDivElement>
) => {
const { selectedItem } = this.props.state[depth]
if (event.key === 'ArrowLeft' || event.key === 'Escape') {
this.clearExpandCollapseTimer()
@ -191,9 +192,9 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
this.clearExpandCollapseTimer()
// Open the submenu and select the first item
if (item.type === 'submenuItem') {
if (selectedItem?.type === 'submenuItem') {
this.props.dispatcher.setAppMenuState(menu =>
menu.withOpenedMenu(item, true)
menu.withOpenedMenu(selectedItem, true)
)
this.focusPane = depth + 1
event.preventDefault()
@ -222,6 +223,12 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
}, expandCollapseTimeout)
}
private onClearSelection = (depth: number) => {
this.props.dispatcher.setAppMenuState(appMenu =>
appMenu.withDeselectedMenu(this.props.state[depth])
)
}
private onSelectionChanged = (
depth: number,
item: MenuItem,
@ -280,12 +287,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
}
private renderMenuPane(depth: number, menu: IMenu): JSX.Element {
// NB: We use the menu id instead of depth as the key here to force
// a new MenuPane instance and List. This is because we used dynamic
// row heights and the react-virtualized Grid component isn't able to
// recompute row heights accurately. Without this row indices which
// previously held a separator item will retain that height and vice-
// versa.
// If the menu doesn't have an id it's the root menu
const key = menu.id || '@'
const className = menu.id ? menuPaneClassNameFromId(menu.id) : undefined
@ -295,15 +296,15 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
key={key}
ref={this.onMenuPaneRef}
className={className}
autoHeight={this.props.autoHeight}
depth={depth}
items={menu.items}
selectedItem={menu.selectedItem}
onItemClicked={this.onItemClicked}
onMouseEnter={this.onPaneMouseEnter}
onItemKeyDown={this.onItemKeyDown}
onKeyDown={this.onPaneKeyDown}
onSelectionChanged={this.onSelectionChanged}
enableAccessKeyNavigation={this.props.enableAccessKeyNavigation}
onClearSelection={this.onClearSelection}
/>
)
}
@ -317,6 +318,7 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
this.paneRefs = this.paneRefs.slice(0, panes.length)
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div id="app-menu-foldout" onKeyDown={this.onKeyDown}>
{panes}
</div>

View file

@ -34,6 +34,35 @@ interface IMenuListItemProps {
* Defaults to true if not specified (i.e. undefined)
*/
readonly renderSubMenuArrow?: boolean
/**
* Whether or not the menu item represented by this list item is the currently
* selected menu item.
*/
readonly selected: boolean
/** Called when the user's pointer device enter the list item */
readonly onMouseEnter?: (
item: MenuItem,
event: React.MouseEvent<HTMLDivElement>
) => void
/** Called when the user's pointer device leaves the list item */
readonly onMouseLeave?: (
item: MenuItem,
event: React.MouseEvent<HTMLDivElement>
) => void
/** Called when the user's pointer device clicks on the list item */
readonly onClick?: (
item: MenuItem,
event: React.MouseEvent<HTMLDivElement>
) => void
/**
* Whether the list item should steal focus when selected. Defaults to
* false.
*/
readonly focusOnSelection?: boolean
}
/**
@ -49,6 +78,8 @@ export function friendlyAcceleratorText(accelerator: string): string {
}
export class MenuListItem extends React.Component<IMenuListItemProps, {}> {
private wrapperRef = React.createRef<HTMLDivElement>()
private getIcon(item: MenuItem): JSX.Element | null {
if (item.type === 'checkbox' && item.checked) {
return <Octicon className="icon" symbol={OcticonSymbol.check} />
@ -59,6 +90,31 @@ export class MenuListItem extends React.Component<IMenuListItemProps, {}> {
return null
}
private onMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
this.props.onMouseEnter?.(this.props.item, event)
}
private onMouseLeave = (event: React.MouseEvent<HTMLDivElement>) => {
this.props.onMouseLeave?.(this.props.item, event)
}
private onClick = (event: React.MouseEvent<HTMLDivElement>) => {
this.props.onClick?.(this.props.item, event)
}
public componentDidMount() {
if (this.props.selected && this.props.focusOnSelection) {
this.wrapperRef.current?.focus()
}
}
public componentDidUpdate(prevProps: IMenuListItemProps) {
const { focusOnSelection, selected } = this.props
if (focusOnSelection && selected && !prevProps.selected) {
this.wrapperRef.current?.focus()
}
}
public render() {
const item = this.props.item
@ -83,19 +139,26 @@ export class MenuListItem extends React.Component<IMenuListItemProps, {}> {
</div>
) : null
const className = classNames(
'menu-item',
{ disabled: !item.enabled },
{ checkbox: item.type === 'checkbox' },
{ radio: item.type === 'radio' },
{
checked:
(item.type === 'checkbox' || item.type === 'radio') && item.checked,
}
)
const { type } = item
const className = classNames('menu-item', {
disabled: !item.enabled,
checkbox: type === 'checkbox',
radio: type === 'radio',
checked: (type === 'checkbox' || type === 'radio') && item.checked,
selected: this.props.selected,
})
return (
<div className={className}>
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div
className={className}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onClick={this.onClick}
ref={this.wrapperRef}
role="menuitem"
>
{this.getIcon(item)}
<div className="label">
<AccessText

View file

@ -1,13 +1,22 @@
import * as React from 'react'
import classNames from 'classnames'
import { List, ClickSource, SelectionSource } from '../lib/list'
import {
ClickSource,
findLastSelectableRow,
findNextSelectableRow,
IHoverSource,
IKeyboardSource,
IMouseClickSource,
SelectionSource,
} from '../lib/list'
import {
MenuItem,
itemIsSelectable,
findItemByAccessKey,
} from '../../models/app-menu'
import { MenuListItem } from './menu-list-item'
import { assertNever } from '../../lib/fatal-error'
interface IMenuPaneProps {
/**
@ -47,14 +56,13 @@ interface IMenuPaneProps {
) => void
/**
* A callback for when a keyboard key is pressed on a menu item. Note that
* this only picks up on keyboard events received by a MenuItem and does
* not cover keyboard events received on the MenuPane component itself.
* Called when the user presses down on a key while focused on, or within, the
* menu pane. Consumers should inspect isDefaultPrevented to determine whether
* the event was handled by the menu pane or not.
*/
readonly onItemKeyDown?: (
readonly onKeyDown?: (
depth: number,
item: MenuItem,
event: React.KeyboardEvent<any>
event: React.KeyboardEvent<HTMLDivElement>
) => void
/**
@ -77,115 +85,52 @@ interface IMenuPaneProps {
readonly enableAccessKeyNavigation: boolean
/**
* If true the MenuPane only takes up as much vertical space needed to
* show all menu items. This does not affect maximum height, i.e. if the
* visible menu items takes up more space than what is available the menu
* will still overflow and be scrollable.
*
* @default false
* Called to deselect the currently selected menu item (if any). This
* will be called when the user's pointer device leaves a menu item.
*/
readonly autoHeight?: boolean
readonly onClearSelection: (depth: number) => void
}
interface IMenuPaneState {
/**
* A list of visible menu items that is to be rendered. This is a derivative
* of the props items with invisible items filtered out.
*/
readonly items: ReadonlyArray<MenuItem>
/** The selected row index or -1 if no selection exists. */
readonly selectedIndex: number
}
const RowHeight = 30
const SeparatorRowHeight = 10
function getSelectedIndex(
selectedItem: MenuItem | undefined,
items: ReadonlyArray<MenuItem>
) {
return selectedItem ? items.findIndex(i => i.id === selectedItem.id) : -1
}
export function getListHeight(menuItems: ReadonlyArray<MenuItem>) {
return menuItems.reduce((acc, item) => acc + getRowHeight(item), 0)
}
export function getRowHeight(item: MenuItem) {
if (!item.visible) {
return 0
}
return item.type === 'separator' ? SeparatorRowHeight : RowHeight
}
/**
* Creates a menu pane state given props. This is intentionally not
* an instance member in order to avoid mistakenly using any other
* input data or state than the received props.
*/
function createState(props: IMenuPaneProps): IMenuPaneState {
const items = new Array<MenuItem>()
const selectedItem = props.selectedItem
let selectedIndex = -1
// Filter out all invisible items and maintain the correct
// selected index (if possible)
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i]
if (item.visible) {
items.push(item)
if (item === selectedItem) {
selectedIndex = items.length - 1
}
}
}
return { items, selectedIndex }
}
export class MenuPane extends React.Component<IMenuPaneProps, IMenuPaneState> {
private list: List | null = null
public constructor(props: IMenuPaneProps) {
super(props)
this.state = createState(props)
}
public componentWillReceiveProps(nextProps: IMenuPaneProps) {
// No need to recreate the filtered list if it hasn't changed,
// we only have to update the selected item
if (this.props.items === nextProps.items) {
// Has the selection changed?
if (this.props.selectedItem !== nextProps.selectedItem) {
const selectedIndex = getSelectedIndex(
nextProps.selectedItem,
this.state.items
)
this.setState({ selectedIndex })
}
} else {
this.setState(createState(nextProps))
}
}
private onRowClick = (row: number, source: ClickSource) => {
const item = this.state.items[row]
export class MenuPane extends React.Component<IMenuPaneProps> {
private paneRef = React.createRef<HTMLDivElement>()
private onRowClick = (
item: MenuItem,
event: React.MouseEvent<HTMLDivElement>
) => {
if (item.type !== 'separator' && item.enabled) {
const source: IMouseClickSource = { kind: 'mouseclick', event }
this.props.onItemClicked(this.props.depth, item, source)
}
}
private onSelectedRowChanged = (row: number, source: SelectionSource) => {
const item = this.state.items[row]
this.props.onSelectionChanged(this.props.depth, item, source)
private tryMoveSelection(
direction: 'up' | 'down' | 'first' | 'last',
source: ClickSource
) {
const { items, selectedItem } = this.props
const row = selectedItem ? items.indexOf(selectedItem) : -1
const count = items.length
const selectable = (ix: number) => items[ix] && itemIsSelectable(items[ix])
let ix: number | null = null
if (direction === 'up' || direction === 'down') {
ix = findNextSelectableRow(count, { direction, row }, selectable)
} else if (direction === 'first' || direction === 'last') {
const d = direction === 'first' ? 'up' : 'down'
ix = findLastSelectableRow(d, count, selectable)
}
if (ix !== null && items[ix] !== undefined) {
this.props.onSelectionChanged(this.props.depth, items[ix], source)
return true
}
return false
}
private onKeyDown = (event: React.KeyboardEvent<any>) => {
private onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.defaultPrevented) {
return
}
@ -195,103 +140,115 @@ export class MenuPane extends React.Component<IMenuPaneProps, IMenuPaneState> {
return
}
const source: IKeyboardSource = { kind: 'keyboard', event }
const { selectedItem } = this.props
const { key } = event
if (isSupportedKey(key)) {
event.preventDefault()
if (key === 'ArrowUp' || key === 'ArrowDown') {
this.tryMoveSelection(key === 'ArrowUp' ? 'up' : 'down', source)
} else if (key === 'Home' || key === 'End') {
const direction = key === 'Home' ? 'first' : 'last'
this.tryMoveSelection(direction, source)
} else if (key === 'Enter' || key === ' ') {
if (selectedItem !== undefined) {
this.props.onItemClicked(this.props.depth, selectedItem, source)
}
} else {
assertNever(key, 'Unsupported key')
}
}
// If we weren't opened with the Alt key we ignore key presses other than
// arrow keys and Enter/Space etc.
if (!this.props.enableAccessKeyNavigation) {
return
if (this.props.enableAccessKeyNavigation) {
// At this point the list will already have intercepted any arrow keys
// and the list items themselves will have caught Enter/Space
const item = findItemByAccessKey(event.key, this.props.items)
if (item && itemIsSelectable(item)) {
event.preventDefault()
this.props.onSelectionChanged(this.props.depth, item, {
kind: 'keyboard',
event: event,
})
this.props.onItemClicked(this.props.depth, item, {
kind: 'keyboard',
event: event,
})
}
}
// At this point the list will already have intercepted any arrow keys
// and the list items themselves will have caught Enter/Space
const item = findItemByAccessKey(event.key, this.state.items)
if (item && itemIsSelectable(item)) {
event.preventDefault()
this.props.onSelectionChanged(this.props.depth, item, {
kind: 'keyboard',
event: event,
})
this.props.onItemClicked(this.props.depth, item, {
kind: 'keyboard',
event: event,
})
}
}
private onRowKeyDown = (row: number, event: React.KeyboardEvent<any>) => {
if (this.props.onItemKeyDown) {
const item = this.state.items[row]
this.props.onItemKeyDown(this.props.depth, item, event)
}
}
private canSelectRow = (row: number) => {
const item = this.state.items[row]
return itemIsSelectable(item)
}
private onListRef = (list: List | null) => {
this.list = list
this.props.onKeyDown?.(this.props.depth, event)
}
private onMouseEnter = (event: React.MouseEvent<any>) => {
if (this.props.onMouseEnter) {
this.props.onMouseEnter(this.props.depth)
this.props.onMouseEnter?.(this.props.depth)
}
private onRowMouseEnter = (
item: MenuItem,
event: React.MouseEvent<HTMLDivElement>
) => {
if (itemIsSelectable(item)) {
const source: IHoverSource = { kind: 'hover', event }
this.props.onSelectionChanged(this.props.depth, item, source)
}
}
private renderMenuItem = (row: number) => {
const item = this.state.items[row]
return (
<MenuListItem
key={item.id}
item={item}
highlightAccessKey={this.props.enableAccessKeyNavigation}
/>
)
}
private rowHeight = (info: { index: number }) => {
const item = this.state.items[info.index]
return item.type === 'separator' ? SeparatorRowHeight : RowHeight
private onRowMouseLeave = (
item: MenuItem,
event: React.MouseEvent<HTMLDivElement>
) => {
if (this.props.selectedItem === item) {
this.props.onClearSelection(this.props.depth)
}
}
public render(): JSX.Element {
const style: React.CSSProperties =
this.props.autoHeight === true
? { height: getListHeight(this.props.items) + 5, maxHeight: '100%' }
: {}
const className = classNames('menu-pane', this.props.className)
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={className}
onMouseEnter={this.onMouseEnter}
onKeyDown={this.onKeyDown}
style={style}
ref={this.paneRef}
tabIndex={-1}
role="menu"
>
<List
ref={this.onListRef}
rowCount={this.state.items.length}
rowHeight={this.rowHeight}
rowRenderer={this.renderMenuItem}
selectedRows={[this.state.selectedIndex]}
onRowClick={this.onRowClick}
onSelectedRowChanged={this.onSelectedRowChanged}
canSelectRow={this.canSelectRow}
onRowKeyDown={this.onRowKeyDown}
invalidationProps={this.state.items}
selectOnHover={true}
ariaMode="menu"
/>
{this.props.items
.filter(x => x.visible)
.map((item, ix) => (
<MenuListItem
key={ix + item.id}
item={item}
highlightAccessKey={this.props.enableAccessKeyNavigation}
selected={item.id === this.props.selectedItem?.id}
onMouseEnter={this.onRowMouseEnter}
onMouseLeave={this.onRowMouseLeave}
onClick={this.onRowClick}
focusOnSelection={true}
/>
))}
</div>
)
}
public focus() {
if (this.list) {
this.list.focus()
}
this.paneRef.current?.focus()
}
}
const supportedKeys = [
'ArrowUp',
'ArrowDown',
'Home',
'End',
'Enter',
' ',
] as const
const isSupportedKey = (key: string): key is typeof supportedKeys[number] =>
(supportedKeys as readonly string[]).includes(key)

View file

@ -90,7 +90,7 @@ export class EmojiAutocompletionProvider
return (
<div className="emoji" key={emoji}>
<img className="icon" src={this.emoji.get(emoji)} />
<img className="icon" src={this.emoji.get(emoji)} alt={emoji} />
{this.renderHighlightedTitle(hit)}
</div>
)

View file

@ -1,3 +1,6 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/anchor-is-valid */
import * as React from 'react'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'

View file

@ -131,6 +131,7 @@ export class BranchListItem extends React.Component<
})
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
onContextMenu={this.onContextMenu}
className={className}

View file

@ -258,6 +258,7 @@ export class BranchesContainer extends React.Component<
const label = __DARWIN__ ? 'New Branch' : 'New branch'
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="branches-list-item new-branch-drop"
onMouseEnter={this.onMouseEnterNewBranchDrop}

View file

@ -19,7 +19,7 @@ export class NoBranches extends React.Component<INoBranchesProps> {
if (this.props.canCreateNewBranch) {
return (
<div className="no-branches">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
<div className="title">Sorry, I can't find that branch</div>

View file

@ -33,7 +33,7 @@ export class NoPullRequests extends React.Component<INoPullRequestsProps, {}> {
public render() {
return (
<div className="no-pull-requests">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
{this.renderTitle()}
{this.renderCallToAction()}
</div>

View file

@ -77,6 +77,7 @@ export class PullRequestBadge extends React.Component<
public render() {
const ref = getPullRequestCommitRef(this.props.number)
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className="pr-badge" onClick={this.onBadgeClick} ref={this.onRef}>
<span className="number">#{this.props.number}</span>
<CIStatus

View file

@ -126,6 +126,7 @@ export class PullRequestListItem extends React.Component<
})
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={className}
onMouseEnter={this.onMouseEnter}

View file

@ -43,16 +43,7 @@ export class ChangedFileDetails extends React.Component<
<PathLabel path={this.props.path} status={this.props.status} />
{this.renderDecorator()}
<DiffOptions
sourceTab={RepositorySectionTab.Changes}
onHideWhitespaceChangesChanged={
this.props.onHideWhitespaceInDiffChanged
}
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
onShowSideBySideDiffChanged={this.props.onShowSideBySideDiffChanged}
showSideBySideDiff={this.props.showSideBySideDiff}
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
/>
{this.renderDiffOptions()}
<Octicon
symbol={iconForStatus(status)}
@ -63,6 +54,25 @@ export class ChangedFileDetails extends React.Component<
)
}
private renderDiffOptions() {
if (this.props.diff?.kind === DiffType.Submodule) {
return null
}
return (
<DiffOptions
sourceTab={RepositorySectionTab.Changes}
onHideWhitespaceChangesChanged={
this.props.onHideWhitespaceInDiffChanged
}
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
onShowSideBySideDiffChanged={this.props.onShowSideBySideDiffChanged}
showSideBySideDiff={this.props.showSideBySideDiff}
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
/>
)
}
private renderDecorator() {
const diff = this.props.diff

View file

@ -6,12 +6,14 @@ import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { mapStatus } from '../../lib/status'
import { WorkingDirectoryFileChange } from '../../models/status'
import { TooltipDirection } from '../lib/tooltip'
import { TooltippedContent } from '../lib/tooltipped-content'
interface IChangedFileProps {
readonly file: WorkingDirectoryFileChange
readonly include: boolean | null
readonly availableWidth: number
readonly disableSelection: boolean
readonly checkboxTooltip?: string
readonly onIncludeChanged: (path: string, include: boolean) => void
/** Callback called when user right-clicks on an item */
@ -39,7 +41,9 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
}
public render() {
const { status, path } = this.props.file
const { file, availableWidth, disableSelection, checkboxTooltip } =
this.props
const { status, path } = file
const fileStatus = mapStatus(status)
const listItemPadding = 10 * 2
@ -48,7 +52,7 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
const filePadding = 5
const availablePathWidth =
this.props.availableWidth -
availableWidth -
listItemPadding -
checkboxWidth -
filePadding -
@ -56,15 +60,21 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
return (
<div className="file" onContextMenu={this.onContextMenu}>
<Checkbox
// The checkbox doesn't need to be tab reachable since we emulate
// checkbox behavior on the list item itself, ie hitting space bar
// while focused on a row will toggle selection.
tabIndex={-1}
value={this.checkboxValue}
onChange={this.handleCheckboxChange}
disabled={this.props.disableSelection}
/>
<TooltippedContent
tooltip={checkboxTooltip}
direction={TooltipDirection.EAST}
tagName="div"
>
<Checkbox
// The checkbox doesn't need to be tab reachable since we emulate
// checkbox behavior on the list item itself, ie hitting space bar
// while focused on a row will toggle selection.
tabIndex={-1}
value={this.checkboxValue}
onChange={this.handleCheckboxChange}
disabled={disableSelection}
/>
</TooltippedContent>
<PathLabel
path={path}

View file

@ -278,6 +278,18 @@ export class ChangesList extends React.Component<
const file = workingDirectory.files[row]
const selection = file.selection.getSelectionType()
const { submoduleStatus } = file.status
const isUncommittableSubmodule =
submoduleStatus !== undefined &&
file.status.kind === AppFileStatusKind.Modified &&
!submoduleStatus.commitChanged
const isPartiallyCommittableSubmodule =
submoduleStatus !== undefined &&
(submoduleStatus.commitChanged ||
file.status.kind === AppFileStatusKind.New) &&
(submoduleStatus.modifiedChanges || submoduleStatus.untrackedChanges)
const includeAll =
selection === DiffSelectionType.All
@ -286,22 +298,31 @@ export class ChangesList extends React.Component<
? false
: null
const include =
rebaseConflictState !== null
? file.status.kind !== AppFileStatusKind.Untracked
: includeAll
const include = isUncommittableSubmodule
? false
: rebaseConflictState !== null
? file.status.kind !== AppFileStatusKind.Untracked
: includeAll
const disableSelection = isCommitting || rebaseConflictState !== null
const disableSelection =
isCommitting || rebaseConflictState !== null || isUncommittableSubmodule
const checkboxTooltip = isUncommittableSubmodule
? 'This submodule change cannot be added to a commit in this repository because it contains changes that have not been committed.'
: isPartiallyCommittableSubmodule
? 'Only changes that have been committed within the submodule will be added to this repository. You need to commit any other modified or untracked changes in the submodule before including them in this repository.'
: undefined
return (
<ChangedFile
file={file}
include={include}
include={isPartiallyCommittableSubmodule && include ? null : include}
key={file.id}
onContextMenu={this.onItemContextMenu}
onIncludeChanged={onIncludeChanged}
availableWidth={availableWidth}
disableSelection={disableSelection}
checkboxTooltip={checkboxTooltip}
/>
)
}
@ -852,6 +873,7 @@ export class ChangesList extends React.Component<
)
return (
// eslint-disable-next-line jsx-a11y/role-supports-aria-props
<button
className={className}
onClick={this.onStashEntryClicked}

View file

@ -29,6 +29,9 @@ interface IChangesProps {
*/
readonly onOpenBinaryFile: (fullPath: string) => void
/** Called when the user requests to open a submodule. */
readonly onOpenSubmodule: (fullPath: string) => void
/**
* Called when the user is viewing an image diff and requests
* to change the diff presentation mode.
@ -121,6 +124,7 @@ export class Changes extends React.Component<IChangesProps, {}> {
this.props.askForConfirmationOnDiscardChanges
}
onOpenBinaryFile={this.props.onOpenBinaryFile}
onOpenSubmodule={this.props.onOpenSubmodule}
onChangeImageDiffType={this.props.onChangeImageDiffType}
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
/>

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React from 'react'
import { Select } from '../lib/select'
import { Button } from '../lib/button'

View file

@ -766,6 +766,7 @@ export class CommitMessage extends React.Component<
})
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div
role="group"
aria-label="Create commit"

View file

@ -18,7 +18,7 @@ export class MultipleSelection extends React.Component<
public render() {
return (
<div className="panel blankslate" id="no-changes">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
<div>{this.props.count} files selected</div>
</div>
)

View file

@ -704,9 +704,9 @@ export class NoChanges extends React.Component<
public render() {
return (
<div id="no-changes">
<div className="changes-interstitial">
<div className="content">
<div className="header">
<div className="interstitial-header">
<div className="text">
<h1>No local changes</h1>
<p>
@ -714,7 +714,7 @@ export class NoChanges extends React.Component<
some friendly suggestions for what to do next.
</p>
</div>
<img src={PaperStackImage} className="blankslate-image" />
<img src={PaperStackImage} className="blankslate-image" alt="" />
</div>
{this.renderActions()}
</div>

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import * as React from 'react'
import { Octicon } from '../octicons'
import classNames from 'classnames'

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import * as React from 'react'
import { IRefCheck } from '../../lib/ci-checks/ci-checks'
import { Octicon } from '../octicons'
@ -209,6 +211,7 @@ export class CICheckRunListItem extends React.PureComponent<
<div
className={classes}
onClick={this.toggleCheckRunExpansion}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
>
{this.renderCheckStatusSymbol()}

View file

@ -242,7 +242,7 @@ export class CICheckRunPopover extends React.PureComponent<
private renderCheckRunLoadings(): JSX.Element {
return (
<div className="loading-check-runs">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
<div className="title">Stand By</div>
<div className="call-to-action">Check runs incoming!</div>
</div>
@ -340,6 +340,7 @@ export class CICheckRunPopover extends React.PureComponent<
)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<div className="ci-check-run-list-header" tabIndex={0}>
<div className="completeness-indicator">
{this.renderCompletenessIndicator(

View file

@ -220,7 +220,7 @@ export class CICheckRunRerunDialog extends React.Component<
if (this.state.loadingCheckSuites && this.props.checkRuns.length > 1) {
return (
<div className="loading-rerun-checks">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
<div className="title">Please wait</div>
<div className="call-to-action">
Determining which checks can be re-run.

View file

@ -37,6 +37,7 @@ export class CloneGenericRepository extends React.Component<
placeholder="URL or username/repository"
value={this.props.url}
onValueChanged={this.onUrlChanged}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
label={
<span>

View file

@ -590,6 +590,7 @@ export class Dialog extends React.Component<IDialogProps, IDialogState> {
)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<dialog
ref={this.onDialogRef}
id={this.props.id}

View file

@ -64,6 +64,7 @@ export class DialogHeader extends React.Component<IDialogHeaderProps, {}> {
// I don't know and we may want to revisit it at some point but for
// now an anchor will have to do.
return (
// eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<a
className="close"
onClick={this.onCloseButtonClick}

View file

@ -34,6 +34,7 @@ export class DiffSearchInput extends React.Component<
<TextBox
placeholder="Search..."
type="search"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
onValueChanged={this.onChange}
onKeyDown={this.onKeyDown}

View file

@ -20,7 +20,12 @@ export class ImageContainer extends React.Component<IImageProps, {}> {
return (
<div className="image-wrapper">
<img src={imageSource} style={this.props.style} onLoad={this.onLoad} />
<img
src={imageSource}
style={this.props.style}
onLoad={this.onLoad}
alt=""
/>
</div>
)
}

View file

@ -19,6 +19,7 @@ import {
ITextDiff,
ILargeTextDiff,
ImageDiffType,
ISubmoduleDiff,
} from '../../models/diff'
import { Button } from '../lib/button'
import {
@ -31,6 +32,7 @@ import { TextDiff } from './text-diff'
import { SideBySideDiff } from './side-by-side-diff'
import { enableExperimentalDiffViewer } from '../../lib/feature-flag'
import { IFileContents } from './syntax-highlighting'
import { SubmoduleDiff } from './submodule-diff'
// image used when no diff is displayed
const NoDiffImage = encodePathAsUrl(__dirname, 'static/ufo-alert.svg')
@ -80,6 +82,9 @@ interface IDiffProps {
*/
readonly onOpenBinaryFile: (fullPath: string) => void
/** Called when the user requests to open a submodule. */
readonly onOpenSubmodule?: (fullPath: string) => void
/**
* Called when the user is viewing an image diff and requests
* to change the diff presentation mode.
@ -121,6 +126,8 @@ export class Diff extends React.Component<IDiffProps, IDiffState> {
return this.renderText(diff)
case DiffType.Binary:
return this.renderBinaryFile()
case DiffType.Submodule:
return this.renderSubmoduleDiff(diff)
case DiffType.Image:
return this.renderImage(diff)
case DiffType.LargeText: {
@ -168,7 +175,7 @@ export class Diff extends React.Component<IDiffProps, IDiffState> {
private renderLargeTextDiff() {
return (
<div className="panel empty large-diff">
<img src={NoDiffImage} className="blankslate-image" />
<img src={NoDiffImage} className="blankslate-image" alt="" />
<p>
The diff is too large to be displayed by default.
<br />
@ -185,7 +192,7 @@ export class Diff extends React.Component<IDiffProps, IDiffState> {
private renderUnrenderableDiff() {
return (
<div className="panel empty large-diff">
<img src={NoDiffImage} />
<img src={NoDiffImage} alt="" />
<p>The diff is too large to be displayed.</p>
</div>
)
@ -243,6 +250,16 @@ export class Diff extends React.Component<IDiffProps, IDiffState> {
return this.renderTextDiff(diff)
}
private renderSubmoduleDiff(diff: ISubmoduleDiff) {
return (
<SubmoduleDiff
onOpenSubmodule={this.props.onOpenSubmodule}
diff={diff}
readOnly={this.props.readOnly}
/>
)
}
private renderBinaryFile() {
return (
<BinaryFile

View file

@ -65,6 +65,9 @@ interface ISeamlessDiffSwitcherProps {
*/
readonly onOpenBinaryFile: (fullPath: string) => void
/** Called when the user requests to open a submodule. */
readonly onOpenSubmodule?: (fullPath: string) => void
/**
* Called when the user is viewing an image diff and requests
* to change the diff presentation mode.
@ -309,6 +312,7 @@ export class SeamlessDiffSwitcher extends React.Component<
onDiscardChanges,
file,
onOpenBinaryFile,
onOpenSubmodule,
onChangeImageDiffType,
onHideWhitespaceInDiffChanged,
} = this.state.propSnapshot
@ -343,6 +347,7 @@ export class SeamlessDiffSwitcher extends React.Component<
onIncludeChanged={isLoadingDiff ? noop : onIncludeChanged}
onDiscardChanges={isLoadingDiff ? noop : onDiscardChanges}
onOpenBinaryFile={isLoadingDiff ? noop : onOpenBinaryFile}
onOpenSubmodule={isLoadingDiff ? noop : onOpenSubmodule}
onChangeImageDiffType={isLoadingDiff ? noop : onChangeImageDiffType}
onHideWhitespaceInDiffChanged={
isLoadingDiff ? noop : onHideWhitespaceInDiffChanged

View file

@ -385,6 +385,7 @@ export class SideBySideDiffRow extends React.Component<
)
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className="hunk-expansion-handle selectable hoverable"
onClick={elementInfo.handler}
@ -426,6 +427,7 @@ export class SideBySideDiffRow extends React.Component<
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className="hunk-handle hoverable"
onMouseEnter={this.onMouseEnterHunk}
@ -462,6 +464,7 @@ export class SideBySideDiffRow extends React.Component<
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={classNames('line-number', 'selectable', 'hoverable', {
'line-selected': isSelected,

View file

@ -26,6 +26,8 @@ import {
CellMeasurerCache,
CellMeasurer,
ListRowProps,
OverscanIndicesGetterParams,
defaultOverscanIndicesGetter,
} from 'react-virtualized'
import { SideBySideDiffRow } from './side-by-side-diff-row'
import memoize from 'memoize-one'
@ -75,6 +77,21 @@ export interface ISelection {
type ModifiedLine = { line: DiffLine; diffLineNumber: number }
const isElement = (n: Node): n is Element => n.nodeType === Node.ELEMENT_NODE
const closestElement = (n: Node): Element | null =>
isElement(n) ? n : n.parentElement
const closestRow = (n: Node, container: Element) => {
const row = closestElement(n)?.closest('div[role=row]')
if (row && container.contains(row)) {
const rowIndex =
row.ariaRowIndex !== null ? parseInt(row.ariaRowIndex, 10) : NaN
return isNaN(rowIndex) ? undefined : rowIndex
}
return undefined
}
interface ISideBySideDiffProps {
readonly repository: Repository
@ -142,7 +159,7 @@ interface ISideBySideDiffState {
* column is doing it. This allows us to limit text selection to that
* specific column via CSS.
*/
readonly selectingTextInRow?: 'before' | 'after'
readonly selectingTextInRow: 'before' | 'after'
/**
* The current diff selection. This is used while
@ -194,10 +211,14 @@ export class SideBySideDiff extends React.Component<
ISideBySideDiffState
> {
private virtualListRef = React.createRef<List>()
private diffContainer: HTMLDivElement | null = null
/** Diff to restore when "Collapse all expanded lines" option is used */
private diffToRestore: ITextDiff | null = null
private textSelectionStartRow: number | undefined = undefined
private textSelectionEndRow: number | undefined = undefined
public constructor(props: ISideBySideDiffProps) {
super(props)
@ -205,6 +226,7 @@ export class SideBySideDiff extends React.Component<
diff: props.diff,
isSearching: false,
selectedSearchResult: undefined,
selectingTextInRow: 'before',
}
}
@ -216,12 +238,130 @@ export class SideBySideDiff extends React.Component<
// Listen for the custom event find-text (see app.tsx)
// and trigger the search plugin if we see it.
document.addEventListener('find-text', this.showSearch)
document.addEventListener('cut', this.onCutOrCopy)
document.addEventListener('copy', this.onCutOrCopy)
document.addEventListener('selectionchange', this.onDocumentSelectionChange)
}
private onCutOrCopy = (ev: ClipboardEvent) => {
if (ev.defaultPrevented || !this.isEntireDiffSelected()) {
return
}
const lineTypes = this.props.showSideBySideDiff
? this.state.selectingTextInRow === 'before'
? [DiffLineType.Delete, DiffLineType.Context]
: [DiffLineType.Add, DiffLineType.Context]
: [DiffLineType.Add, DiffLineType.Delete, DiffLineType.Context]
const contents = this.props.diff.hunks
.flatMap(h =>
h.lines
.filter(line => lineTypes.includes(line.type))
.map(line => line.content)
)
.join('\n')
ev.preventDefault()
ev.clipboardData?.setData('text/plain', contents)
}
private onDocumentSelectionChange = (ev: Event) => {
if (!this.diffContainer) {
return
}
const selection = document.getSelection()
this.textSelectionStartRow = undefined
this.textSelectionEndRow = undefined
if (!selection || selection.isCollapsed) {
return
}
// Check to see if there's at least a partial selection within the
// diff container. If there isn't then we want to get out of here as
// quickly as possible.
if (!selection.containsNode(this.diffContainer, true)) {
return
}
if (this.isEntireDiffSelected(selection)) {
return
}
// Get the range to coerce uniform direction (i.e we don't want to have to
// care about whether the user is selecting right to left or left to right)
const range = selection.getRangeAt(0)
const { startContainer, endContainer } = range
// The (relative) happy path is when the user is currently selecting within
// the diff. That means that the start container will very likely be a text
// node somewhere within a row.
let startRow = closestRow(startContainer, this.diffContainer)
// If we couldn't find the row by walking upwards it's likely that the user
// has moved their selection to the container itself or beyond (i.e dragged
// their selection all the way up to the point where they're now selecting
// inside the commit details).
//
// If so we attempt to check if the first row we're currently rendering is
// encompassed in the selection
if (startRow === undefined) {
const firstRow = this.diffContainer.querySelector(
'div[role=row]:first-child'
)
if (firstRow && range.intersectsNode(firstRow)) {
startRow = closestRow(firstRow, this.diffContainer)
}
}
// If we don't have starting row there's no point in us trying to find
// the end row.
if (startRow === undefined) {
return
}
let endRow = closestRow(endContainer, this.diffContainer)
if (endRow === undefined) {
const lastRow = this.diffContainer.querySelector(
'div[role=row]:last-child'
)
if (lastRow && range.intersectsNode(lastRow)) {
endRow = closestRow(lastRow, this.diffContainer)
}
}
this.textSelectionStartRow = startRow
this.textSelectionEndRow = endRow
}
private isEntireDiffSelected(selection = document.getSelection()) {
const { diffContainer } = this
const ancestor = selection?.getRangeAt(0).commonAncestorContainer
// This is an artefact of the selectAllChildren call in the onSelectAll
// handler. We can get away with checking for this since we're handling
// the select-all event coupled with the fact that we have CSS rules which
// prevents text selection within the diff unless focus resides within the
// diff container.
return ancestor === diffContainer
}
public componentWillUnmount() {
window.removeEventListener('keydown', this.onWindowKeyDown)
document.removeEventListener('mouseup', this.onEndSelection)
document.removeEventListener('find-text', this.showSearch)
document.removeEventListener(
'selectionchange',
this.onDocumentSelectionChange
)
}
public componentDidUpdate(
@ -248,6 +388,17 @@ export class SideBySideDiff extends React.Component<
this.props.file.id !== prevProps.file.id
) {
this.virtualListRef.current.scrollToPosition(0)
// Reset selection
this.textSelectionStartRow = undefined
this.textSelectionEndRow = undefined
if (this.diffContainer) {
const selection = document.getSelection()
if (selection?.containsNode(this.diffContainer, true)) {
selection.empty()
}
}
}
}
@ -260,6 +411,15 @@ export class SideBySideDiff extends React.Component<
)
}
private onDiffContainerRef = (ref: HTMLDivElement | null) => {
if (ref === null) {
this.diffContainer?.removeEventListener('select-all', this.onSelectAll)
} else {
ref.addEventListener('select-all', this.onSelectAll)
}
this.diffContainer = ref
}
public render() {
const { diff } = this.state
@ -277,7 +437,12 @@ export class SideBySideDiff extends React.Component<
})
return (
<div className={containerClassName} onMouseDown={this.onMouseDown}>
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={containerClassName}
onMouseDown={this.onMouseDown}
onKeyDown={this.onKeyDown}
>
{diff.hasHiddenBidiChars && <HiddenBidiCharsWarning />}
{this.state.isSearching && (
<DiffSearchInput
@ -285,7 +450,10 @@ export class SideBySideDiff extends React.Component<
onClose={this.onSearchCancel}
/>
)}
<div className="side-by-side-diff cm-s-default">
<div
className="side-by-side-diff cm-s-default"
ref={this.onDiffContainerRef}
>
<AutoSizer onResize={this.clearListRowsHeightCache}>
{({ height, width }) => (
<List
@ -296,6 +464,7 @@ export class SideBySideDiff extends React.Component<
rowHeight={this.getRowHeight}
rowRenderer={this.renderRow}
ref={this.virtualListRef}
overscanIndicesGetter={this.overscanIndicesGetter}
// The following properties are passed to the list
// to make sure that it gets re-rendered when any of
// them change.
@ -317,6 +486,22 @@ export class SideBySideDiff extends React.Component<
)
}
private overscanIndicesGetter = (params: OverscanIndicesGetterParams) => {
const [start, end] = [this.textSelectionStartRow, this.textSelectionEndRow]
if (start === undefined || end === undefined) {
return defaultOverscanIndicesGetter(params)
}
const startIndex = Math.min(start, params.startIndex)
const stopIndex = Math.max(
params.stopIndex,
Math.min(params.cellCount - 1, end)
)
return defaultOverscanIndicesGetter({ ...params, startIndex, stopIndex })
}
private renderRow = ({ index, parent, style, key }: ListRowProps) => {
const { diff } = this.state
const rows = getDiffRows(
@ -363,7 +548,7 @@ export class SideBySideDiff extends React.Component<
parent={parent}
rowIndex={index}
>
<div key={key} style={style}>
<div key={key} style={style} role="row" aria-rowindex={index}>
<SideBySideDiffRow
row={rowWithTokens}
lineNumberWidth={lineNumberWidth}
@ -635,6 +820,27 @@ export class SideBySideDiff extends React.Component<
}
}
private onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const modifiers = event.altKey || event.metaKey || event.shiftKey
if (!__DARWIN__ && event.key === 'a' && event.ctrlKey && !modifiers) {
this.onSelectAll(event)
}
}
/**
* Called when the user presses CtrlOrCmd+A while focused within the diff
* container or when the user triggers the select-all event. Note that this
* deals with text-selection whereas several other methods in this component
* named similarly deals with selection within the gutter.
*/
private onSelectAll = (ev?: Event | React.SyntheticEvent<unknown>) => {
if (this.diffContainer) {
ev?.preventDefault()
document.getSelection()?.selectAllChildren(this.diffContainer)
}
}
private onStartSelection = (
row: number,
column: DiffColumn,
@ -738,6 +944,10 @@ export class SideBySideDiff extends React.Component<
role: selectionLength > 0 ? 'copy' : undefined,
enabled: selectionLength > 0,
},
{
label: __DARWIN__ ? 'Select All' : 'Select all',
action: () => this.onSelectAll(),
},
]
const expandMenuItem = this.buildExpandMenuItem()

View file

@ -0,0 +1,200 @@
import React from 'react'
import { parseRepositoryIdentifier } from '../../lib/remote-parsing'
import { ISubmoduleDiff } from '../../models/diff'
import { LinkButton } from '../lib/link-button'
import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { SuggestedAction } from '../suggested-actions'
type SubmoduleItemIcon =
| {
readonly octicon: typeof OcticonSymbol.info
readonly className: 'info-icon'
}
| {
readonly octicon: typeof OcticonSymbol.diffModified
readonly className: 'modified-icon'
}
| {
readonly octicon: typeof OcticonSymbol.diffAdded
readonly className: 'added-icon'
}
| {
readonly octicon: typeof OcticonSymbol.diffRemoved
readonly className: 'removed-icon'
}
| {
readonly octicon: typeof OcticonSymbol.fileDiff
readonly className: 'untracked-icon'
}
interface ISubmoduleDiffProps {
readonly onOpenSubmodule?: (fullPath: string) => void
readonly diff: ISubmoduleDiff
/**
* Whether the diff is readonly, e.g., displaying a historical diff, or the
* diff's content can be committed, e.g., displaying a change in the working
* directory.
*/
readonly readOnly: boolean
}
export class SubmoduleDiff extends React.Component<ISubmoduleDiffProps> {
public constructor(props: ISubmoduleDiffProps) {
super(props)
}
public render() {
return (
<div className="changes-interstitial submodule-diff">
<div className="content">
<div className="interstitial-header">
<div className="text">
<h1>Submodule changes</h1>
</div>
</div>
{this.renderSubmoduleInfo()}
{this.renderCommitChangeInfo()}
{this.renderSubmodulesChangesInfo()}
{this.renderOpenSubmoduleAction()}
</div>
</div>
)
}
private renderSubmoduleInfo() {
if (this.props.diff.url === null) {
return null
}
const repoIdentifier = parseRepositoryIdentifier(this.props.diff.url)
if (repoIdentifier === null) {
return null
}
const hostname =
repoIdentifier.hostname === 'github.com'
? ''
: ` (${repoIdentifier.hostname})`
return this.renderSubmoduleDiffItem(
{ octicon: OcticonSymbol.info, className: 'info-icon' },
<>
This is a submodule based on the repository{' '}
<LinkButton
uri={`https://${repoIdentifier.hostname}/${repoIdentifier.owner}/${repoIdentifier.name}`}
>
{repoIdentifier.owner}/{repoIdentifier.name}
{hostname}
</LinkButton>
.
</>
)
}
private renderCommitChangeInfo() {
const { diff, readOnly } = this.props
const { oldSHA, newSHA } = diff
const verb = readOnly ? 'was' : 'has been'
const suffix = readOnly
? ''
: ' This change can be committed to the parent repository.'
if (oldSHA !== null && newSHA !== null) {
return this.renderSubmoduleDiffItem(
{ octicon: OcticonSymbol.diffModified, className: 'modified-icon' },
<>
This submodule changed its commit from{' '}
{this.renderTooltippedCommitSHA(oldSHA)} to{' '}
{this.renderTooltippedCommitSHA(newSHA)}.{suffix}
</>
)
} else if (oldSHA === null && newSHA !== null) {
return this.renderSubmoduleDiffItem(
{ octicon: OcticonSymbol.diffAdded, className: 'added-icon' },
<>
This submodule {verb} added pointing at commit{' '}
{this.renderTooltippedCommitSHA(newSHA)}.{suffix}
</>
)
} else if (oldSHA !== null && newSHA === null) {
return this.renderSubmoduleDiffItem(
{ octicon: OcticonSymbol.diffRemoved, className: 'removed-icon' },
<>
This submodule {verb} removed while it was pointing at commit{' '}
{this.renderTooltippedCommitSHA(oldSHA)}.{suffix}
</>
)
}
return null
}
private renderTooltippedCommitSHA(sha: string) {
return <TooltippedCommitSHA commit={sha} asRef={true} />
}
private renderSubmodulesChangesInfo() {
const { diff } = this.props
if (!diff.status.untrackedChanges && !diff.status.modifiedChanges) {
return null
}
const changes =
diff.status.untrackedChanges && diff.status.modifiedChanges
? 'modified and untracked'
: diff.status.untrackedChanges
? 'untracked'
: 'modified'
return this.renderSubmoduleDiffItem(
{ octicon: OcticonSymbol.fileDiff, className: 'untracked-icon' },
<>
This submodule has {changes} changes. Those changes must be committed
inside of the submodule before they can be part of the parent
repository.
</>
)
}
private renderSubmoduleDiffItem(
icon: SubmoduleItemIcon,
content: React.ReactElement
) {
return (
<div className="item">
<Octicon symbol={icon.octicon} className={icon.className} />
<div className="content">{content}</div>
</div>
)
}
private renderOpenSubmoduleAction() {
// If no url is found for the submodule, it means it can't be opened
// This happens if the user is looking at an old commit which references
// a submodule that got later deleted.
if (this.props.diff.url === null) {
return null
}
return (
<span>
<SuggestedAction
title="Open this submodule on GitHub Desktop"
description="You can open this submodule on GitHub Desktop as a normal repository to manage and commit any changes in it."
buttonText={__DARWIN__ ? 'Open Repository' : 'Open repository'}
type="primary"
onClick={this.onOpenSubmoduleClick}
/>
</span>
)
}
private onOpenSubmoduleClick = () => {
this.props.onOpenSubmodule?.(this.props.diff.fullPath)
}
}

View file

@ -1955,6 +1955,23 @@ export class Dispatcher {
})
}
public async openOrAddRepository(path: string): Promise<Repository | null> {
const state = this.appStore.getState()
const repositories = state.repositories
const existingRepository = repositories.find(r => r.path === path)
if (existingRepository) {
return await this.selectRepository(existingRepository)
}
return this.appStore._startOpenInDesktop(() => {
this.showPopup({
type: PopupType.AddRepository,
path,
})
})
}
/**
* Install the CLI tool.
*

View file

@ -158,6 +158,7 @@ export class DropdownSelectButton extends React.Component<
>
<ul>
{options.map(o => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<li key={o.value} onClick={this.onSelectionChange(o)}>
{this.renderSelectedIcon(o)}
<div className="option-title">{o.label}</div>

View file

@ -61,6 +61,7 @@ export class GenericGitAuthentication extends React.Component<
<Row>
<TextBox
label="Username"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
value={this.state.username}
onValueChanged={this.onUsernameChange}

View file

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import * as React from 'react'
import { Commit, CommitOneLine } from '../../models/commit'
import { GitHubRepository } from '../../models/github-repository'

View file

@ -15,12 +15,11 @@ import { DiffOptions } from '../diff/diff-options'
import { RepositorySectionTab } from '../../lib/app-state'
import { IChangesetData } from '../../lib/git'
import { TooltippedContent } from '../lib/tooltipped-content'
import { clipboard } from 'electron'
import { TooltipDirection } from '../lib/tooltip'
import { AppFileStatusKind } from '../../models/status'
import _ from 'lodash'
import { LinkButton } from '../lib/link-button'
import { UnreachableCommitsTab } from './unreachable-commits-dialog'
import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha'
interface ICommitSummaryProps {
readonly repository: Repository
@ -246,6 +245,7 @@ export class CommitSummary extends React.Component<
const icon = expanded ? OcticonSymbol.fold : OcticonSymbol.unfold
return (
// eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<a onClick={onClick} className="expander">
<Octicon symbol={icon} />
{expanded ? 'Collapse' : 'Expand'}
@ -342,11 +342,6 @@ export class CommitSummary extends React.Component<
)
}
private getShaRef = (useShortSha?: boolean) => {
const { selectedCommits } = this.props
return useShortSha ? selectedCommits[0].shortSha : selectedCommits[0].sha
}
private onHighlightShasInDiff = () => {
this.props.onHighlightShas(this.props.shasInDiff)
}
@ -388,6 +383,7 @@ export class CommitSummary extends React.Component<
const commitsPluralized = excludedCommitsCount > 1 ? 'commits' : 'commit'
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<div
className="commit-unreachable-info"
onMouseOver={this.onHighlightShasNotInDiff}
@ -435,15 +431,7 @@ export class CommitSummary extends React.Component<
aria-label="SHA"
>
<Octicon symbol={OcticonSymbol.gitCommit} />
<TooltippedContent
className="sha"
tooltip={this.renderShaTooltip()}
tooltipClassName="sha-hint"
interactive={true}
direction={TooltipDirection.SOUTH}
>
{this.getShaRef(true)}
</TooltippedContent>
<TooltippedCommitSHA className="sha" commit={selectedCommits[0]} />
</li>
)
}
@ -538,20 +526,6 @@ export class CommitSummary extends React.Component<
)
}
private renderShaTooltip() {
return (
<>
<code>{this.getShaRef()}</code>
<button onClick={this.onCopyShaButtonClick}>Copy</button>
</>
)
}
private onCopyShaButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
clipboard.writeText(this.getShaRef())
}
private renderChangedFilesDescription = () => {
const fileCount = this.props.changesetData.files.length
const filesPlural = fileCount === 1 ? 'file' : 'files'

View file

@ -71,6 +71,9 @@ interface ISelectedCommitsProps {
*/
readonly onOpenBinaryFile: (fullPath: string) => void
/** Called when the user requests to open a submodule. */
readonly onOpenSubmodule: (fullPath: string) => void
/**
* Called when the user is viewing an image diff and requests
* to change the diff presentation mode.
@ -161,6 +164,7 @@ export class SelectedCommits extends React.Component<
onOpenBinaryFile={this.props.onOpenBinaryFile}
onChangeImageDiffType={this.props.onChangeImageDiffType}
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
onOpenSubmodule={this.props.onOpenSubmodule}
/>
)
}
@ -316,7 +320,7 @@ export class SelectedCommits extends React.Component<
return (
<div id="multiple-commits-selected" className="blankslate">
<div className="panel blankslate">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
<div>
<p>
Unable to display diff when multiple{' '}
@ -441,7 +445,7 @@ function NoCommitSelected() {
return (
<div className="panel blankslate">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
No commit selected
</div>
)

View file

@ -4,6 +4,7 @@ import { TabBar } from '../tab-bar'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { Commit } from '../../models/commit'
import { CommitList } from './commit-list'
import { LinkButton } from '../lib/link-button'
export enum UnreachableCommitsTab {
Unreachable,
@ -127,7 +128,10 @@ export class UnreachableCommitsDialog extends React.Component<
{this.state.selectedTab === UnreachableCommitsTab.Unreachable
? 'not'
: ''}{' '}
in the ancestry path of the most recent commit in your selection.
in the ancestry path of the most recent commit in your selection.{' '}
<LinkButton uri="https://github.com/desktop/desktop/blob/development/docs/learn-more/unreachable-commits.md">
Learn more.
</LinkButton>
</div>
)
}

View file

@ -104,6 +104,7 @@ export class AuthenticationForm extends React.Component<
<TextBox
label="Username or email address"
disabled={disabled}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
onValueChanged={this.onUsernameChange}
/>

View file

@ -174,6 +174,7 @@ export class Draggable extends React.Component<IDraggableProps> {
public render() {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div className="draggable" onMouseDown={this.onMouseDown}>
{this.props.children}
</div>

View file

@ -55,6 +55,7 @@ export class EnterpriseServerEntry extends React.Component<
<Form onSubmit={this.onSubmit}>
<TextBox
label="Enterprise or AE address"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
disabled={disableEntry}
onValueChanged={this.onServerAddressChanged}

View file

@ -41,6 +41,7 @@ export class FancyTextBox extends React.Component<
value={this.props.value}
onFocus={this.onFocus}
onBlur={this.onBlur}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={this.props.autoFocus}
disabled={this.props.disabled}
type={this.props.type}

View file

@ -250,6 +250,7 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
<TextBox
ref={this.onTextBoxRef}
type="search"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
placeholder={this.props.placeholderText || 'Filter'}
className="filter-list-filter-field"

View file

@ -100,6 +100,7 @@ export class FocusContainer extends React.Component<
})
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={className}
ref={this.onWrapperRef}

View file

@ -44,6 +44,7 @@ export class LinkButton extends React.Component<ILinkButtonProps, {}> {
const { title } = this.props
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<a
ref={this.anchorRef}
className={className}

View file

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import classNames from 'classnames'
import { Disposable } from 'event-kit'
import * as React from 'react'

View file

@ -93,6 +93,7 @@ export class ListRow extends React.Component<IListRowProps, {}> {
const style = { ...this.props.style, width: '100%' }
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<div
id={this.props.id}
aria-setsize={this.props.rowCount}

View file

@ -910,6 +910,7 @@ export class List extends React.Component<IListProps, IListState> {
const role = this.props.ariaMode === 'menu' ? 'menu' : 'listbox'
return (
// eslint-disable-next-line jsx-a11y/aria-activedescendant-has-tabindex
<div
ref={this.onRef}
id={this.props.id}
@ -972,6 +973,7 @@ export class List extends React.Component<IListProps, IListState> {
>
<Grid
aria-label={''}
// eslint-disable-next-line jsx-a11y/aria-role
role={''}
ref={this.onGridRef}
autoContainerWidth={true}

View file

@ -41,6 +41,7 @@ export class PathLabel extends React.Component<IPathLabelProps, {}> {
? availableWidth / 2 - ResizeArrowPadding
: undefined
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label {...props}>
<PathText path={status.oldPath} availableWidth={segmentWidth} />
<Octicon className="rename-arrow" symbol={OcticonSymbol.arrowRight} />
@ -49,6 +50,7 @@ export class PathLabel extends React.Component<IPathLabelProps, {}> {
)
} else {
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label {...props}>
<PathText path={this.props.path} availableWidth={availableWidth} />
</label>

View file

@ -367,6 +367,7 @@ export class SandboxedMarkdown extends React.PureComponent<
ref={this.onFrameContainingDivRef}
>
<iframe
title="sandboxed-markdown-component"
className="sandboxed-markdown-component"
sandbox=""
ref={this.onFrameRef}

View file

@ -74,6 +74,7 @@ export class TextArea extends React.Component<ITextAreaProps, {}> {
{this.props.label}
<textarea
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={this.props.autoFocus}
className={this.props.textareaClassName}
disabled={this.props.disabled}

View file

@ -244,6 +244,7 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
ref={this.onInputRef}
onFocus={this.onFocus}
onBlur={this.onBlur}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={this.props.autoFocus}
disabled={this.props.disabled}
type={this.props.type}

View file

@ -0,0 +1,68 @@
import { clipboard } from 'electron'
import React from 'react'
import { Commit, shortenSHA } from '../../models/commit'
import { Ref } from './ref'
import { TooltipDirection } from './tooltip'
import { TooltippedContent } from './tooltipped-content'
interface ITooltippedCommitSHAProps {
readonly className?: string
/** Commit or long SHA of a commit to render in the component. */
readonly commit: string | Commit
/** Whether or not render the commit as a Ref component. Default: false */
readonly asRef?: boolean
}
export class TooltippedCommitSHA extends React.Component<
ITooltippedCommitSHAProps,
{}
> {
private get shortSHA() {
const { commit } = this.props
return typeof commit === 'string' ? shortenSHA(commit) : commit.shortSha
}
private get longSHA() {
const { commit } = this.props
return typeof commit === 'string' ? commit : commit.sha
}
public render() {
const { className } = this.props
return (
<TooltippedContent
className={className}
tooltip={this.renderSHATooltip()}
tooltipClassName="sha-hint"
interactive={true}
direction={TooltipDirection.SOUTH}
>
{this.renderShortSHA()}
</TooltippedContent>
)
}
private renderShortSHA() {
return this.props.asRef === true ? (
<Ref>{this.shortSHA}</Ref>
) : (
this.shortSHA
)
}
private renderSHATooltip() {
return (
<>
<code>{this.longSHA}</code>
<button onClick={this.onCopySHAButtonClick}>Copy</button>
</>
)
}
private onCopySHAButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
clipboard.writeText(this.longSHA)
}
}

View file

@ -78,6 +78,7 @@ export class TwoFactorAuthentication extends React.Component<
<TextBox
label="Authentication code"
disabled={textEntryDisabled}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
onValueChanged={this.onOTPChange}
/>

View file

@ -64,10 +64,12 @@ export class SegmentedItem<T> extends React.Component<
const className = isSelected ? 'selected' : undefined
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
className={className}
onClick={this.onClick}
onDoubleClick={this.onDoubleClick}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="radio"
id={this.props.id}
aria-checked={isSelected ? 'true' : 'false'}

View file

@ -194,6 +194,7 @@ export class VerticalSegmentedControl<T extends Key> extends React.Component<
}
const label = this.props.label ? (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
<legend onClick={this.onLegendClick}>{this.props.label}</legend>
) : undefined

View file

@ -142,10 +142,12 @@ export class NoRepositoriesView extends React.Component<
<img
className="no-repositories-graphic-top"
src={WelcomeLeftTopImageUri}
alt=""
/>
<img
className="no-repositories-graphic-bottom"
src={WelcomeLeftBottomImageUri}
alt=""
/>
</UiView>
)

View file

@ -221,7 +221,7 @@ export class PullRequestChecksFailed extends React.Component<
private renderCheckRunStepsLoading(): JSX.Element {
return (
<div className="loading-check-runs">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
<div className="title">Stand By</div>
<div className="call-to-action">Check run steps incoming!</div>
</div>
@ -238,7 +238,7 @@ export class PullRequestChecksFailed extends React.Component<
</LinkButton>
</div>
</div>
<img src={PaperStackImage} className="blankslate-image" />
<img src={PaperStackImage} className="blankslate-image" alt="" />
</div>
)
}

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import * as React from 'react'
import { ApplicationTheme, ICustomTheme } from '../lib/application-theme'
import { SketchPicker } from 'react-color'

View file

@ -172,6 +172,7 @@ export class ReleaseNotes extends React.Component<IReleaseNotesProps, {}> {
<img
className="release-note-graphic-left"
src={ReleaseNoteHeaderLeftUri}
alt=""
/>
<div className="title">
<p className="version">Version {latestVersion}</p>
@ -180,6 +181,7 @@ export class ReleaseNotes extends React.Component<IReleaseNotesProps, {}> {
<img
className="release-note-graphic-right"
src={ReleaseNoteHeaderRightUri}
alt=""
/>
</div>
)

View file

@ -252,7 +252,7 @@ export class RepositoriesList extends React.Component<
private renderNoItems = () => {
return (
<div className="no-items no-results-found">
<img src={BlankSlateImage} className="blankslate-image" />
<img src={BlankSlateImage} className="blankslate-image" alt="" />
<div className="title">Sorry, I can't find that repository</div>
<div className="protip">

View file

@ -358,6 +358,7 @@ export class RepositoryView extends React.Component<
isWorkingTreeClean={isWorkingTreeClean}
showSideBySideDiff={this.props.showSideBySideDiff}
onOpenBinaryFile={this.onOpenBinaryFile}
onOpenSubmodule={this.onOpenSubmodule}
onChangeImageDiffType={this.onChangeImageDiffType}
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
/>
@ -412,6 +413,7 @@ export class RepositoryView extends React.Component<
hideWhitespaceInDiff={this.props.hideWhitespaceInHistoryDiff}
showSideBySideDiff={this.props.showSideBySideDiff}
onOpenBinaryFile={this.onOpenBinaryFile}
onOpenSubmodule={this.onOpenSubmodule}
onChangeImageDiffType={this.onChangeImageDiffType}
onDiffOptionsOpened={this.onDiffOptionsOpened}
showDragOverlay={showDragOverlay}
@ -489,6 +491,7 @@ export class RepositoryView extends React.Component<
hideWhitespaceInDiff={this.props.hideWhitespaceInChangesDiff}
showSideBySideDiff={this.props.showSideBySideDiff}
onOpenBinaryFile={this.onOpenBinaryFile}
onOpenSubmodule={this.onOpenSubmodule}
onChangeImageDiffType={this.onChangeImageDiffType}
askForConfirmationOnDiscardChanges={
this.props.askForConfirmationOnDiscardChanges
@ -503,6 +506,10 @@ export class RepositoryView extends React.Component<
openFile(fullPath, this.props.dispatcher)
}
private onOpenSubmodule = (fullPath: string) => {
this.props.dispatcher.openOrAddRepository(fullPath)
}
private onChangeImageDiffType = (imageDiffType: ImageDiffType) => {
this.props.dispatcher.changeImageDiffType(imageDiffType)
}

View file

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import * as React from 'react'
import { clamp } from '../../lib/clamp'

View file

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/no-autofocus */
import * as React from 'react'
import { Dispatcher } from '../dispatcher'
import {

View file

@ -47,6 +47,9 @@ interface IStashDiffViewerProps {
/** Called when the user changes the hide whitespace in diffs setting. */
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void
/** Called when the user requests to open a submodule. */
readonly onOpenSubmodule: (fullPath: string) => void
}
/**
@ -75,6 +78,7 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
fileListWidth,
onOpenBinaryFile,
onChangeImageDiffType,
onOpenSubmodule,
} = this.props
const files =
stashEntry.files.kind === StashedChangesLoadStates.Loaded
@ -96,6 +100,7 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
onHideWhitespaceInDiffChanged={
this.props.onHideWhitespaceInDiffChanged
}
onOpenSubmodule={onOpenSubmodule}
/>
) : null

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import * as React from 'react'
import { DesktopFakeRepository } from '../../lib/desktop-fake-repository'
import {
@ -64,11 +66,13 @@ export class ThankYou extends React.Component<IThankYouProps, {}> {
<img
className="release-note-graphic-left"
src={ReleaseNoteHeaderLeftUri}
alt=""
/>
<div className="img-space"></div>
<img
className="release-note-graphic-right"
src={ReleaseNoteHeaderRightUri}
alt=""
/>
</div>
<div className="title">

View file

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import * as React from 'react'
import { Octicon, OcticonSymbolType } from '../octicons'
import classNames from 'classnames'

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import * as React from 'react'
import { Octicon, OcticonSymbolType } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'

View file

@ -39,7 +39,11 @@ export class TutorialDone extends React.Component<ITutorialDoneProps, {}> {
some suggestions for what to do next.
</p>
</div>
<img src={ClappingHandsImage} className="image" />
<img
src={ClappingHandsImage}
className="image"
alt="Hands clapping"
/>
</div>
<SuggestedActionGroup>
<SuggestedAction

View file

@ -100,7 +100,7 @@ export class TutorialPanel extends React.Component<
<div className="tutorial-panel-component panel">
<div className="titleArea">
<h3>Get started</h3>
<img src={TutorialPanelImage} />
<img src={TutorialPanelImage} alt="Partially checked check list" />
</div>
<ol>
<TutorialStepInstructions

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import * as React from 'react'
import {
ValidTutorialStep,

View file

@ -25,20 +25,23 @@ export class TutorialWelcome extends React.Component {
</div>
<ul className="definitions">
<li>
<img src={CodeImage} />
<img src={CodeImage} alt="Html syntax icon" />
<p>
<strong>Git</strong> is the version control system.
</p>
</li>
<li>
<img src={TeamDiscussionImage} />
<img
src={TeamDiscussionImage}
alt="People with discussion bubbles overhead"
/>
<p>
<strong>GitHub</strong> is where you store your code and
collaborate with others.
</p>
</li>
<li>
<img src={CloudServerImage} />
<img src={CloudServerImage} alt="Server stack with cloud" />
<p>
<strong>GitHub Desktop</strong> helps you work with GitHub
locally.

View file

@ -203,16 +203,21 @@ export class Welcome extends React.Component<IWelcomeProps, IWelcomeState> {
<div className="welcome-left">
<div className="welcome-content">
{this.getComponentForCurrentStep()}
<img className="welcome-graphic-top" src={WelcomeLeftTopImageUri} />
<img
className="welcome-graphic-top"
src={WelcomeLeftTopImageUri}
alt=""
/>
<img
className="welcome-graphic-bottom"
src={WelcomeLeftBottomImageUri}
alt=""
/>
</div>
</div>
<div className="welcome-right">
<img className="welcome-graphic" src={WelcomeRightImageUri} />
<img className="welcome-graphic" src={WelcomeRightImageUri} alt="" />
</div>
</UiView>
)

View file

@ -1,34 +1,11 @@
@import '../mixins';
#app-menu-foldout {
height: 100%;
display: flex;
}
.menu-pane {
height: 100%;
width: 240px;
// Custom widths for some menus since we don't have
// auto-sizeable foldouts at the moment (or rather we
// do but our List implementation prevents them from
// working). See #3547 for an example.
&.menu-pane-branch {
width: 275px;
}
&.menu-pane-edit {
width: 175px;
}
&.menu-pane-help {
width: 220px;
}
&.menu-pane-file {
width: 205px;
}
padding-bottom: var(--spacing-half);
// Open panes (except the first one) should have a border on their
// right hand side to create a divider between them and their parent
// menu.
@ -36,20 +13,8 @@
border-left: var(--base-border);
}
&:not(:last-child) {
// We want list items in previous menus to behave as if they have focus
// even though they don't, ie we want the selected+focus state
// to be in effect for all parent selected menu items as well as
// the current
.list-item {
&.selected {
--text-color: var(--box-selected-active-text-color);
--text-secondary-color: var(--box-selected-active-text-color);
color: var(--text-color);
background-color: var(--box-selected-active-background-color);
}
}
&:focus {
outline: none;
}
// No focus outline for the list itself. When the app menu is opened without
@ -62,15 +27,21 @@
.menu-item {
display: flex;
align-items: center;
height: 100%;
width: 100%;
min-width: 0;
height: 30px;
&.disabled {
opacity: 0.3;
}
&.selected {
--text-color: var(--box-selected-active-text-color);
--text-secondary-color: var(--box-selected-active-text-color);
color: var(--text-color);
background-color: var(--box-selected-active-background-color);
}
.label {
flex-grow: 1;
margin-left: var(--spacing-double);

View file

@ -3,6 +3,7 @@
@import 'changes/changes-list';
@import 'changes/undo-commit';
@import 'changes/changes-view';
@import 'changes/no-changes';
@import 'changes/changes-interstitial';
@import 'changes/oversized-files-warning';
@import 'changes/commit-warning';
@import 'changes/submodule-diff';

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