mirror of
https://github.com/desktop/desktop
synced 2024-08-28 12:40:55 +00:00
Merge branch 'development' into development
This commit is contained in:
commit
d4d1fd2902
|
@ -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
2
.markdownlint.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
const markdownlintGitHub = require('@github/markdownlint-github')
|
||||
module.exports = markdownlintGitHub.init()
|
|
@ -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",
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -209,6 +209,7 @@ export class AppMenuBarButton extends React.Component<
|
|||
highlightAccessKey={this.props.highlightMenuAccessKey}
|
||||
renderAcceleratorText={false}
|
||||
renderSubMenuArrow={false}
|
||||
selected={false}
|
||||
/>
|
||||
</ToolbarDropdown>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
159
app/src/ui/diff/submodule-diff.tsx
Normal file
159
app/src/ui/diff/submodule-diff.tsx
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
68
app/src/ui/lib/tooltipped-commit-sha.tsx
Normal file
68
app/src/ui/lib/tooltipped-commit-sha.tsx
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import * as React from 'react'
|
||||
import { clamp } from '../../lib/clamp'
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
import * as React from 'react'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -7,6 +7,13 @@
|
|||
user-select: contain;
|
||||
position: relative;
|
||||
|
||||
&:not(:focus-within) .content {
|
||||
&,
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.selecting-before .after,
|
||||
&.selecting-after .before {
|
||||
span,
|
||||
|
|
|
@ -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;
|
||||
|
31
app/styles/ui/changes/_submodule-diff.scss
Normal file
31
app/styles/ui/changes/_submodule-diff.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue