Merge branch 'development' into development

This commit is contained in:
KaMeHb-UA 2022-09-04 22:43:13 +03:00 committed by GitHub
commit d4d1fd2902
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2176 additions and 423 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

2
.markdownlint.js Normal file
View file

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

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "3.0.6",
"version": "3.0.7",
"main": "./main.js",
"repository": {
"type": "git",

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

View file

@ -68,16 +68,6 @@ export function enableUpdateFromEmulatedX64ToARM64(): boolean {
return enableBetaFeatures()
}
/**
* Should we allow x64 apps running under ARM translation to auto-update to
* ARM64 builds IMMEDIATELY instead of waiting for the next release?
*/
export function enableImmediateUpdateFromEmulatedX64ToARM64(): boolean {
// Because of how Squirrel.Windows works, this is only available for macOS.
// See: https://github.com/desktop/desktop/pull/14998
return __DARWIN__ && enableBetaFeatures()
}
/** Should we allow resetting to a previous commit? */
export function enableResetToCommit(): boolean {
return enableDevelopmentFeatures()
@ -112,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

@ -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,7 @@ import { git } from './core'
import { NullTreeSHA } from './diff-index'
import { GitError } from 'dugite'
import { parseRawLogWithNumstat } from './log'
import { getConfigValue } from './config'
/**
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
@ -480,16 +481,68 @@ 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) {
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 +560,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 { IRemote } from '../../models/remote'
import { envForRemoteOperation } from './environment'
import { IGitAccount } from '../../models/git-account'
import { getSymbolicRef } from './refs'
import { gitNetworkArguments } from '.'
/**
* List the remotes, sorted alphabetically by `name`, for a repository.
@ -106,7 +107,7 @@ export async function updateRemoteHEAD(
}
await git(
['remote', 'set-head', '-a', remote.name],
[...gitNetworkArguments(), 'remote', 'set-head', '-a', remote.name],
repository.path,
'updateRemoteHEAD',
options

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

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

@ -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
/** 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,12 @@ export class Diff extends React.Component<IDiffProps, IDiffState> {
return this.renderTextDiff(diff)
}
private renderSubmoduleDiff(diff: ISubmoduleDiff) {
return (
<SubmoduleDiff onOpenSubmodule={this.props.onOpenSubmodule} diff={diff} />
)
}
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,159 @@
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.fileDiff
readonly className: 'untracked-icon'
}
interface ISubmoduleDiffProps {
readonly onOpenSubmodule?: (fullPath: string) => void
readonly diff: ISubmoduleDiff
}
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() {
// TODO: only for GH submodules?
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 } = this.props
if (!diff.status.commitChanged) {
return null
}
if (diff.oldSHA === null || diff.newSHA === null) {
return null
}
return this.renderSubmoduleDiffItem(
{ octicon: OcticonSymbol.diffModified, className: 'modified-icon' },
<>
This submodule has changed its commit from{' '}
{this.renderTooltippedCommitSHA(diff.oldSHA)} to{' '}
{this.renderTooltippedCommitSHA(diff.newSHA)}. This change can be
committed to the parent repository.
</>
)
}
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() {
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

@ -316,7 +316,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 +441,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

@ -19,10 +19,7 @@ import { parseError } from '../../lib/squirrel-error-parser'
import { ReleaseSummary } from '../../models/release-notes'
import { generateReleaseSummary } from '../../lib/release-notes'
import { setNumber, getNumber } from '../../lib/local-storage'
import {
enableImmediateUpdateFromEmulatedX64ToARM64,
enableUpdateFromEmulatedX64ToARM64,
} from '../../lib/feature-flag'
import { enableUpdateFromEmulatedX64ToARM64 } from '../../lib/feature-flag'
import { offsetFromNow } from '../../lib/offset-from'
import { gte, SemVer } from 'semver'
import { getRendererGUID } from '../../lib/get-renderer-guid'
@ -124,7 +121,7 @@ class UpdateStore {
// and it's the same version we have right now (which means we spoofed
// Central with an old version of the app).
this.isX64ToARM64ImmediateAutoUpdate =
enableImmediateUpdateFromEmulatedX64ToARM64() &&
this.supportsImmediateUpdateFromEmulatedX64ToARM64() &&
this.newReleases !== null &&
this.newReleases.length === 1 &&
this.newReleases[0].latestVersion === getVersion() &&
@ -133,6 +130,17 @@ class UpdateStore {
this.emitDidChange()
}
/**
* Whether or not the app supports auto-updating x64 apps running under ARM
* translation to ARM64 builds IMMEDIATELY instead of waiting for the next
* release.
*/
private supportsImmediateUpdateFromEmulatedX64ToARM64(): boolean {
// Because of how Squirrel.Windows works, this is only available for macOS.
// See: https://github.com/desktop/desktop/pull/14998
return __DARWIN__
}
/** Register a function to call when the auto updater state changes. */
public onDidChange(fn: (state: IUpdateState) => void): Disposable {
return this.emitter.on('did-change', fn)
@ -229,7 +237,7 @@ class UpdateStore {
// If we want the app to force an auto-update from x64 to arm64 right
// after being installed, we need to spoof a really old version to trick
// both Central and Squirrel into thinking we need the update.
if (enableImmediateUpdateFromEmulatedX64ToARM64()) {
if (this.supportsImmediateUpdateFromEmulatedX64ToARM64()) {
url.searchParams.set('version', '0.0.64')
}
}

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

@ -489,6 +489,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 +504,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

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

View file

@ -7,6 +7,13 @@
user-select: contain;
position: relative;
&:not(:focus-within) .content {
&,
* {
user-select: none;
}
}
&.selecting-before .after,
&.selecting-after .before {
span,

View file

@ -1,4 +1,4 @@
#no-changes {
.changes-interstitial {
padding: var(--spacing-quad);
width: 100%;
min-height: 100%;
@ -8,7 +8,7 @@
overflow-y: auto;
overflow-x: hidden;
.header {
.interstitial-header {
display: flex;
flex-direction: row;

View file

@ -0,0 +1,31 @@
.submodule-diff {
.item {
display: flex;
flex-direction: row;
align-items: flex-start;
width: 100%;
font-size: var(--font-size-md);
gap: 0.25em;
margin-bottom: 1em;
.content {
flex: 1;
}
.octicon {
margin-top: 0.2em;
&.info-icon {
color: var(--dialog-information-color);
}
&.modified-icon {
color: var(--color-modified);
}
&.untracked-icon {
color: var(--color-deleted);
}
}
}
}

View file

@ -56,15 +56,5 @@
pointer-events: all;
}
.menu-item:hover {
&:not(.disabled) {
--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);
}
}
}
}

View file

@ -12,6 +12,7 @@ import {
DiffSelectionType,
DiffSelection,
DiffType,
ISubmoduleDiff,
} from '../../../src/models/diff'
import {
setupFixtureRepository,
@ -440,4 +441,93 @@ describe('git/diff', () => {
})
})
})
describe('with submodules', () => {
const submoduleRelativePath: string = path.join('foo', 'submodule')
let submodulePath: string
const getSubmoduleDiff = async () => {
const status = await getStatusOrThrow(repository)
const file = status.workingDirectory.files[0]
const diff = await getWorkingDirectoryDiff(repository, file)
expect(diff.kind).toBe(DiffType.Submodule)
return diff as ISubmoduleDiff
}
beforeEach(async () => {
const repoPath = await setupFixtureRepository('submodule-basic-setup')
repository = new Repository(repoPath, -1, null, false)
submodulePath = path.join(repoPath, submoduleRelativePath)
})
it('can get the diff for a submodule with the right paths', async () => {
// Just make any change to the submodule to get a diff
await FSE.writeFile(path.join(submodulePath, 'README.md'), 'hello\n')
const diff = await getSubmoduleDiff()
expect(diff.fullPath).toBe(submodulePath)
// Even on Windows, the path separator is '/' for this specific attribute
expect(diff.path).toBe('foo/submodule')
})
it('can get the diff for a submodule with only modified changes', async () => {
// Modify README.md file. Now the submodule has modified changes.
await FSE.writeFile(path.join(submodulePath, 'README.md'), 'hello\n')
const diff = await getSubmoduleDiff()
expect(diff.oldSHA).toBeNull()
expect(diff.newSHA).toBeNull()
expect(diff.status).toMatchObject({
commitChanged: false,
modifiedChanges: true,
untrackedChanges: false,
})
})
it('can get the diff for a submodule with only untracked changes', async () => {
// Create NEW.md file. Now the submodule has untracked changes.
await FSE.writeFile(path.join(submodulePath, 'NEW.md'), 'hello\n')
const diff = await getSubmoduleDiff()
expect(diff.oldSHA).toBeNull()
expect(diff.newSHA).toBeNull()
expect(diff.status).toMatchObject({
commitChanged: false,
modifiedChanges: false,
untrackedChanges: true,
})
})
it('can get the diff for a submodule a commit change', async () => {
// Make a change and commit it. Now the submodule has a commit change.
await FSE.writeFile(path.join(submodulePath, 'README.md'), 'hello\n')
await GitProcess.exec(['commit', '-a', '-m', 'test'], submodulePath)
const diff = await getSubmoduleDiff()
expect(diff.oldSHA).not.toBeNull()
expect(diff.newSHA).not.toBeNull()
expect(diff.status).toMatchObject({
commitChanged: true,
modifiedChanges: false,
untrackedChanges: false,
})
})
it('can get the diff for a submodule a all kinds of changes', async () => {
await FSE.writeFile(path.join(submodulePath, 'README.md'), 'hello\n')
await GitProcess.exec(['commit', '-a', '-m', 'test'], submodulePath)
await FSE.writeFile(path.join(submodulePath, 'README.md'), 'bye\n')
await FSE.writeFile(path.join(submodulePath, 'NEW.md'), 'new!!\n')
const diff = await getSubmoduleDiff()
expect(diff.oldSHA).not.toBeNull()
expect(diff.newSHA).not.toBeNull()
expect(diff.status).toMatchObject({
commitChanged: true,
modifiedChanges: true,
untrackedChanges: true,
})
})
})
})

View file

@ -310,5 +310,65 @@ describe('git/status', () => {
expect(status).toBeNull()
})
})
describe('with submodules', () => {
it('returns the submodule status', async () => {
const repoPath = await setupFixtureRepository('submodule-basic-setup')
repository = new Repository(repoPath, -1, null, false)
const submodulePath = path.join(repoPath, 'foo', 'submodule')
const checkSubmoduleChanges = async (changes: {
modifiedChanges: boolean
untrackedChanges: boolean
commitChanged: boolean
}) => {
const status = await getStatusOrThrow(repository)
const files = status.workingDirectory.files
expect(files).toHaveLength(1)
const file = files[0]
expect(file.path).toBe('foo/submodule')
expect(file.status.kind).toBe(AppFileStatusKind.Modified)
expect(file.status.submoduleStatus?.modifiedChanges).toBe(
changes.modifiedChanges
)
expect(file.status.submoduleStatus?.untrackedChanges).toBe(
changes.untrackedChanges
)
expect(file.status.submoduleStatus?.commitChanged).toBe(
changes.commitChanged
)
}
// Modify README.md file. Now the submodule has modified changes.
await FSE.writeFile(
path.join(submodulePath, 'README.md'),
'hello world\n'
)
await checkSubmoduleChanges({
modifiedChanges: true,
untrackedChanges: false,
commitChanged: false,
})
// Create untracked file in submodule. Now the submodule has both
// modified and untracked changes.
await FSE.writeFile(path.join(submodulePath, 'test'), 'test\n')
await checkSubmoduleChanges({
modifiedChanges: true,
untrackedChanges: true,
commitChanged: false,
})
// Commit the changes within the submodule. Now the submodule has commit
// changes.
await GitProcess.exec(['add', '.'], submodulePath)
await GitProcess.exec(['commit', '-m', 'changes'], submodulePath)
await checkSubmoduleChanges({
modifiedChanges: false,
untrackedChanges: false,
commitChanged: true,
})
})
})
})
})

View file

@ -110,4 +110,12 @@ describe('parsePorcelainStatus', () => {
expect(entries[0].path).toBe('pdf_linux-x64/lib/libQt5Core.so.5')
expect(entries[0].statusCode).toBe('.T')
})
it('parses submodule changes', () => {
const x = `1 .M SCMU 100644 100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 submodule/submodule`
const entries = parsePorcelainStatus(x) as ReadonlyArray<IStatusEntry>
expect(entries).toHaveLength(1)
expect(entries[0].path).toBe('submodule/submodule')
expect(entries[0].submoduleStatusCode).toBe('SCMU')
})
})

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