Merge branch 'development' into releases/2.5.7

This commit is contained in:
Markus Olsson 2020-11-05 21:44:28 +01:00 committed by GitHub
commit 3c7ca4889d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
146 changed files with 3775 additions and 693 deletions

View file

@ -1,12 +1,15 @@
---
name: "\U00002B50 Submit a request or solve a problem"
about: Surface a problem that you think should be solved
name: "⭐ Submit a feature request"
about: 'Feature requests are considered based on team''s capacity '
title: ''
labels: ''
assignees: ''
---
### Describe the feature or problem youd like to solve
A clear and concise description of what the feature or problem is.
A clear and concise description of what the feature or problem is. If this is a bug report, please use the bug report template instead.
### Proposed solution

View file

@ -1,6 +1,9 @@
---
name: "\U0001F41B Bug report"
about: Report a problem encountered while using GitHub Desktop
about: Report a bug while using GitHub Desktop (full template required)
title: ''
labels: ''
assignees: ''
---

4
.github/config.yml vendored
View file

@ -11,10 +11,12 @@ requestInfoReplyComment: >
allows the maintainers to spend more time fixing bugs, implementing
enhancements, and reviewing and merging pull requests.
Thanks for understanding and meeting us halfway 😀
Thanks for understanding and meeting us halfway. 😀
requestInfoLabelToAdd: more-info-needed
requestInfoOn:
pullRequest: false
issue: true
checkIssueTemplate: true

View file

@ -1,10 +1,10 @@
# Configuration for no-response - https://github.com/probot/no-response
# Number of days of inactivity before an Issue is closed for lack of response
daysUntilClose: 14
daysUntilClose: 7
# Label requiring a response
# TODO: also close `needs-reproduction` issues (blocked by https://github.com/probot/no-response/issues/11)
responseRequiredLabel: more-information-needed
responseRequiredLabel: more-info-needed
# Comment to post when closing an issue due to lack of response.
closeComment: >
Thank you for your issue!

View file

@ -9,7 +9,7 @@ import * as Path from 'path'
* Will throw an error if the entry is not found in the packed-refs file
*
* @param gitDir The path to the Git repository's .git directory
* @param ref A qualified git ref such as 'refs/heads/master'
* @param ref A qualified git ref such as 'refs/heads/main'
*/
function readPackedRefsFile(gitDir: string, ref: string) {
const packedRefsPath = Path.join(gitDir, 'packed-refs')
@ -43,7 +43,7 @@ function readPackedRefsFile(gitDir: string, ref: string) {
* Will throw an error for unborn HEAD.
*
* @param gitDir The path to the Git repository's .git directory
* @param ref A qualified git ref such as 'HEAD' or 'refs/heads/master'
* @param ref A qualified git ref such as 'HEAD' or 'refs/heads/main'
* @returns The ref SHA
*/
function revParse(gitDir: string, ref: string): string {

View file

@ -33,6 +33,7 @@
"file-metadata": "^1.0.0",
"file-uri-to-path": "^2.0.0",
"file-url": "^2.0.2",
"focus-trap-react": "^8.1.0",
"fs-admin": "^0.15.0",
"fs-extra": "^7.0.1",
"fuzzaldrin-plus": "^0.6.0",

View file

@ -397,6 +397,12 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
'.pas': 'text/x-pascal',
},
},
{
install: () => import('codemirror/mode/toml/toml'),
mappings: {
'.toml': 'text/x-toml',
},
},
]
/**

View file

@ -353,7 +353,7 @@ export interface IAPIBranch {
/**
* The name of the branch stored on the remote.
*
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/master`)
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/main`)
*/
readonly name: string
/**

View file

@ -115,7 +115,7 @@ export interface IAppState {
* A list of currently open menus with their selected items
* in the application menu.
*
* The semantics around what constitues an open menu and how
* The semantics around what constitutes an open menu and how
* selection works is defined by the AppMenu class and the
* individual components transforming that state.
*
@ -198,6 +198,9 @@ export interface IAppState {
/** Whether we should hide white space changes in diff */
readonly hideWhitespaceInDiff: boolean
/** Whether we should show side by side diffs */
readonly showSideBySideDiff: boolean
/** The user's preferred shell. */
readonly selectedShell: Shell
@ -434,9 +437,14 @@ export interface IBranchesState {
readonly tip: Tip
/**
* The default branch for a given repository. Most commonly this
* will be the 'master' branch but GitHub users are able to change
* their default branch in the web UI.
* The default branch for a given repository. Historically it's been
* common to use 'master' as the default branch but as of September 2020
* GitHub Desktop and GitHub.com default to using 'main' as the default branch.
*
* GitHub Desktop users are able to configure the `init.defaultBranch` Git
* setting in preferences.
*
* GitHub.com users are able to change their default branch in the web UI.
*/
readonly defaultBranch: Branch | null
@ -679,9 +687,14 @@ export interface ICompareState {
readonly recentBranches: ReadonlyArray<Branch>
/**
* The default branch for a given repository. Most commonly this
* will be the 'master' branch but GitHub users are able to change
* their default branch in the web UI.
* The default branch for a given repository. Historically it's been
* common to use 'master' as the default branch but as of September 2020
* GitHub Desktop and GitHub.com default to using 'main' as the default branch.
*
* GitHub Desktop users are able to configure the `init.defaultBranch` Git
* setting in preferences.
*
* GitHub.com users are able to change their default branch in the web UI.
*/
readonly defaultBranch: Branch | null

View file

@ -13,7 +13,7 @@ export abstract class BaseDatabase extends Dexie {
* Register the version of the schema only if `targetVersion` is less than
* `version` or is `undefined`.
*
* targetVersion - The version of the schema that is being targetted. If not
* targetVersion - The version of the schema that is being targeted. If not
* provided, the given version will be registered.
* version - The version being registered.
* schema - The schema to register.

View file

@ -46,7 +46,7 @@ interface IDBMentionableUser extends IMentionableUser {
export interface IMentionableCacheEntry {
readonly gitHubRepositoryID: number
/**
* The time (in milliseconds since the epoc) that
* The time (in milliseconds since the epoch) that
* the mentionable users was last updated for this
* repository
*/

View file

@ -33,7 +33,7 @@ export interface IPullRequest {
/** The ref from which the pull request's changes are coming. */
readonly head: IPullRequestRef
/** The ref which the pull request is targetting. */
/** The ref which the pull request is targeting. */
readonly base: IPullRequestRef
/** The login of the author. */

View file

@ -35,7 +35,7 @@ export interface IDatabaseProtectedBranch {
/**
* The branch name associated with the branch protection settings
*
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/master`)
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/main`)
*/
readonly name: string
}

View file

@ -26,6 +26,7 @@ export enum ExternalEditor {
GoLand = 'GoLand',
AndroidStudio = 'Android Studio',
Rider = 'Rider',
Nova = 'Nova',
}
export function parse(label: string): ExternalEditor | null {
@ -94,6 +95,9 @@ export function parse(label: string): ExternalEditor | null {
if (label === ExternalEditor.Rider) {
return ExternalEditor.Rider
}
if (label === ExternalEditor.Nova) {
return ExternalEditor.Nova
}
return null
}
@ -146,6 +150,8 @@ function getBundleIdentifiers(editor: ExternalEditor): ReadonlyArray<string> {
return ['com.google.android.studio']
case ExternalEditor.Rider:
return ['com.jetbrains.rider']
case ExternalEditor.Nova:
return ['com.panic.Nova']
default:
return assertNever(editor, `Unknown external editor: ${editor}`)
}
@ -211,6 +217,8 @@ function getExecutableShim(
return Path.join(installPath, 'Contents', 'MacOS', 'studio')
case ExternalEditor.Rider:
return Path.join(installPath, 'Contents', 'MacOS', 'rider')
case ExternalEditor.Nova:
return Path.join(installPath, 'Contents', 'SharedSupport', 'nova')
default:
return assertNever(editor, `Unknown external editor: ${editor}`)
}
@ -267,6 +275,7 @@ export async function getAvailableEditors(): Promise<
golandPath,
androidStudioPath,
riderPath,
novaPath,
] = await Promise.all([
findApplication(ExternalEditor.Atom),
findApplication(ExternalEditor.MacVim),
@ -289,6 +298,7 @@ export async function getAvailableEditors(): Promise<
findApplication(ExternalEditor.GoLand),
findApplication(ExternalEditor.AndroidStudio),
findApplication(ExternalEditor.Rider),
findApplication(ExternalEditor.Nova),
])
if (atomPath) {
@ -381,5 +391,9 @@ export async function getAvailableEditors(): Promise<
results.push({ editor: ExternalEditor.Rider, path: riderPath })
}
if (novaPath) {
results.push({ editor: ExternalEditor.Nova, path: novaPath })
}
return results
}

View file

@ -44,7 +44,7 @@ export function lookupPreferredEmail(account: Account): string {
*/
function isEmailPublic(email: IAPIEmail): boolean {
// If an email doesn't have a visibility setting it means it's coming from an
// older Enterprise Server which doesn't have the concept of visiblity.
// older Enterprise Server which doesn't have the concept of visibility.
return email.visibility === 'public' || !email.visibility
}
@ -100,7 +100,7 @@ export function getStealthEmailForUser(
/**
* Produces a list of all email addresses that when used as the author email
* in a commit we'll know will end up getting attributted to the given
* in a commit we'll know will end up getting attributed to the given
* account when pushed to GitHub.com or GitHub Enterprise Server.
*
* The list of email addresses consists of all the email addresses we get

View file

@ -77,3 +77,27 @@ export function arrayEquals<T>(x: ReadonlyArray<T>, y: ReadonlyArray<T>) {
return true
}
/**
* Compares two maps for key reference equality.
*
* Two maps are considered equal if all their keys coincide, if they're
* both empty or if they're the same object.
*/
export function mapKeysEqual<T>(x: Map<T, unknown>, y: Map<T, unknown>) {
if (x === y) {
return true
}
if (x.size !== y.size) {
return false
}
for (const key of x.keys()) {
if (!y.has(key)) {
return false
}
}
return true
}

View file

@ -33,3 +33,18 @@ export function forceUnwrap<T>(message: string, x: T | null | undefined): T {
return x
}
}
/**
* Unwrap a value that, according to the type system, could be null or
* undefined, but which we know is not. If the value _is_ null or undefined,
* this will throw. The message should contain the rationale for knowing the
* value is defined.
*/
export function assertNonNullable<T>(
x: T,
message: string
): asserts x is NonNullable<T> {
if (x == null) {
return fatalError(message)
}
}

View file

@ -124,6 +124,22 @@ export function enableDiscardLines(): boolean {
return true
}
/**
* Should we show the checkbox to enable side by side diffs?
*
* Note: side by side diffs will use the new diff viewer.
*/
export function enableSideBySideDiffs(): boolean {
return enableBetaFeatures()
}
/**
* Should we use the new diff viewer for unified diffs?
*/
export function enableExperimentalDiffViewer(): boolean {
return false
}
/**
* Should we allow to change the default branch when creating new repositories?
*/

16
app/src/lib/git/add.ts Normal file
View file

@ -0,0 +1,16 @@
import { git } from './core'
import { Repository } from '../../models/repository'
import { WorkingDirectoryFileChange } from '../../models/status'
/**
* Add a conflicted file to the index.
*
* Typically done after having resolved conflicts either manually
* or through checkout --theirs/--ours.
*/
export async function addConflictedFile(
repository: Repository,
file: WorkingDirectoryFileChange
) {
await git(['add', '--', file.path], repository.path, 'addConflictedFile')
}

View file

@ -8,6 +8,7 @@ import { DiffType, ITextDiff, DiffSelection } from '../../models/diff'
import { Repository, WorkingTree } from '../../models/repository'
import { getWorkingDirectoryDiff } from './diff'
import { formatPatch, formatPatchToDiscardChanges } from '../patch-formatter'
import { assertNever } from '../fatal-error'
export async function applyPatchToIndex(
repository: Repository,
@ -59,8 +60,21 @@ export async function applyPatchToIndex(
const diff = await getWorkingDirectoryDiff(repository, file)
if (diff.kind !== DiffType.Text) {
throw new Error(`Unexpected diff result returned: '${diff.kind}'`)
if (diff.kind !== DiffType.Text && diff.kind !== DiffType.LargeText) {
const { kind } = diff
switch (diff.kind) {
case DiffType.Binary:
case DiffType.Image:
throw new Error(
`Can't create partial commit in binary file: ${file.path}`
)
case DiffType.Unrenderable:
throw new Error(
`File diff is too large to generate a partial commit: ${file.path}`
)
default:
assertNever(diff, `Unknown diff kind: ${kind}`)
}
}
const patch = await formatPatch(file, diff)

View file

@ -149,7 +149,7 @@ export async function getBranchesPointedAt(
{
// - 1 is returned if a common ancestor cannot be resolved
// - 129 is returned if ref is malformed
// "warning: ignoring broken ref refs/remotes/origin/master."
// "warning: ignoring broken ref refs/remotes/origin/main."
successExitCodes: new Set([0, 1, 129]),
}
)

View file

@ -13,6 +13,8 @@ import {
envForRemoteOperation,
getFallbackUrlForProxyResolve,
} from './environment'
import { WorkingDirectoryFileChange } from '../../models/status'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
export type ProgressCallback = (progress: ICheckoutProgress) => void
@ -137,3 +139,19 @@ export async function createAndCheckoutBranch(
'createAndCheckoutBranch'
)
}
/**
* Check out either stage #2 (ours) or #3 (theirs) for a conflicted
* file.
*/
export async function checkoutConflictedFile(
repository: Repository,
file: WorkingDirectoryFileChange,
resolution: ManualConflictResolution
) {
await git(
['checkout', `--${resolution}`, '--', file.path],
repository.path,
'checkoutConflictedFile'
)
}

View file

@ -60,7 +60,7 @@ export async function setGlobalConfigValue(
*
* @param path The path to execute the `git` command in. If null
* we'll use the global configuration (i.e. --global)
* and execute the Gitt call from the same location that
* and execute the Git call from the same location that
* GitHub Desktop is installed in.
* @param type Canonicalize configuration values according to the
* expected type (i.e. 0 -> false, "on" -> true etc).

View file

@ -235,7 +235,7 @@ const lockFilePathRe = /^error: could not lock config file (.+?): File exists$/m
/**
* If the `result` is associated with an config lock file error (as determined
* by `isConfigFileLockError`) this method will attempt to extract an absoluet
* by `isConfigFileLockError`) this method will attempt to extract an absolute
* path (i.e. rooted) to the configuration lock file in question from the Git
* output.
*/
@ -335,7 +335,7 @@ function getDescriptionForError(error: DugiteError): string | null {
case DugiteError.CannotMergeUnrelatedHistories:
return 'Unable to merge unrelated histories in this repository.'
case DugiteError.PushWithPrivateEmail:
return 'Cannot push these commits as they contain an email address marked as private on GitHub.'
return 'Cannot push these commits as they contain an email address marked as private on GitHub. To push anyway, visit https://github.com/settings/emails, uncheck "Keep my email address private", then switch back to GitHub Desktop to push your commits. You can then enable the setting again.'
case DugiteError.LFSAttributeDoesNotMatch:
return 'Git LFS attribute found in global Git configuration does not match expected value.'
case DugiteError.ProtectedBranchDeleteRejected:

View file

@ -27,7 +27,7 @@ export async function getFilesWithConflictMarkers(
}
// flatten the list (only does one level deep)
const flatCaptures = captures.reduce((acc, val) => acc.concat(val))
// count number of occurences
// count number of occurrences
const counted = flatCaptures.reduce(
(acc, val) => acc.set(val, (acc.get(val) || 0) + 1),
new Map<string, number>()

View file

@ -140,11 +140,10 @@ export async function getWorkingDirectoryDiff(
repository: Repository,
file: WorkingDirectoryFileChange
): Promise<IDiff> {
let successExitCodes: Set<number> | undefined
let args: Array<string>
// `--no-ext-diff` should be provided wherever we invoke `git diff` so that any
// diff.external program configured by the user is ignored
const args = ['diff', '--no-ext-diff', '--patch-with-raw', '-z', '--no-color']
const successExitCodes = new Set([0])
if (
file.status.kind === AppFileStatusKind.New ||
@ -160,18 +159,8 @@ export async function getWorkingDirectoryDiff(
//
// citation in source:
// https://github.com/git/git/blob/1f66975deb8402131fbf7c14330d0c7cdebaeaa2/diff-no-index.c#L300
successExitCodes = new Set([0, 1])
args = [
'diff',
'--no-ext-diff',
'--no-index',
'--patch-with-raw',
'-z',
'--no-color',
'--',
'/dev/null',
file.path,
]
successExitCodes.add(1)
args.push('--no-index', '--', '/dev/null', file.path)
} else if (file.status.kind === AppFileStatusKind.Renamed) {
// NB: Technically this is incorrect, the best kind of incorrect.
// In order to show exactly what will end up in the commit we should
@ -180,26 +169,9 @@ export async function getWorkingDirectoryDiff(
// already staged to the renamed file which differs from our other diffs.
// The closest I got to that was running hash-object and then using
// git diff <blob> <blob> but that seems a bit excessive.
args = [
'diff',
'--no-ext-diff',
'--patch-with-raw',
'-z',
'--no-color',
'--',
file.path,
]
args.push('--', file.path)
} else {
args = [
'diff',
'HEAD',
'--no-ext-diff',
'--patch-with-raw',
'-z',
'--no-color',
'--',
file.path,
]
args.push('HEAD', '--', file.path)
}
const { output, error } = await spawnAndComplete(

View file

@ -54,11 +54,11 @@ export function getFallbackUrlForProxyResolve(
* authentication, and resolving proxy urls if necessary.
*
* @param account The authentication information (if available) to provide
* to Git for use when connectingt to the remote
* to Git for use when connecting to the remote
* @param remoteUrl The primary remote URL for this operation. Note that Git
* might connect to other remotes in order to fulfill the
* operation. As an example, a clone of
* https://github.com/desktop/desktop could containt a submodule
* https://github.com/desktop/desktop could contain a submodule
* pointing to another host entirely. Used to resolve which
* proxy (if any) should be used for the operation.
*/
@ -88,9 +88,22 @@ export async function envForProxy(
resolve: (url: string) => Promise<string | undefined> = resolveGitProxy
): Promise<NodeJS.ProcessEnv | undefined> {
if (!enableAutomaticGitProxyConfiguration()) {
return undefined
return
}
const protocolMatch = /^(https?):\/\//i.exec(remoteUrl)
// We can only resolve and use a proxy for the protocols where cURL
// would be involved (i.e http and https). git:// relies on ssh.
if (protocolMatch === null) {
return
}
// Note that HTTPS here doesn't mean that the proxy is HTTPS, only
// that all requests to HTTPS protocols should be proxied. The
// proxy protocol is defined by the url returned by `this.resolve()`
const proto = protocolMatch[1].toLowerCase() // http or https
// We'll play it safe and say that if the user has configured
// the ALL_PROXY environment variable they probably know what
// they're doing and wouldn't want us to override it with a
@ -102,20 +115,6 @@ export async function envForProxy(
return
}
const protocolMatch = /^(https?):\/\//i.exec(remoteUrl)
// We can only resolve and use a proxy for the protocols where cURL
// would be involved (i.e http and https). git:// relies on ssh.
if (protocolMatch === null) {
log.info(`proxy url not resolved, protocol not supported`)
return
}
// Note that HTTPS here doesn't mean that the proxy is HTTPS, only
// that all requests to HTTPS protocols should be proxied. The
// proxy protocol is defined by the url returned by `this.resolve()`
const proto = protocolMatch[1].toLowerCase() // http or https
// Lower case environment variables due to
// https://ec.haxx.se/usingcurl/usingcurl-proxies#http_proxy-in-lower-case-only
const envKey = `${proto}_proxy` // http_proxy or https_proxy

View file

@ -93,7 +93,7 @@ export async function getBranches(
: BranchType.Remote
if (symref.length > 0) {
// excude symbolic refs from the branch list
// exclude symbolic refs from the branch list
continue
}

View file

@ -191,7 +191,7 @@ export async function getChangedFiles(
* Parses git `log` or `diff` output into a list of changed files
* (see `getChangedFiles` for an example of use)
*
* @param stdout raw ouput from a git `-z` and `--name-status` flags
* @param stdout raw output from a git `-z` and `--name-status` flags
* @param committish commitish command was run against
*/
export function parseChangedFiles(

View file

@ -67,7 +67,7 @@ export async function getMergeBase(
{
// - 1 is returned if a common ancestor cannot be resolved
// - 128 is returned if a ref cannot be found
// "warning: ignoring broken ref refs/remotes/origin/master."
// "warning: ignoring broken ref refs/remotes/origin/main."
successExitCodes: new Set([0, 1, 128]),
}
)

View file

@ -1,7 +1,9 @@
import { git } from './core'
import { Repository } from '../../models/repository'
/** Get the `limit` most recently checked out branches. */
/**
* Get the `limit` most recently checked out branches.
*/
export async function getRecentBranches(
repository: Repository,
limit: number
@ -36,8 +38,7 @@ export async function getRecentBranches(
const lines = result.stdout.split('\n')
const names = new Set<string>()
// exclude master from recent branches
const excludedNames = new Set<String>(['master'])
const excludedNames = new Set<String>()
for (const line of lines) {
const result = regex.exec(line)
@ -73,7 +74,7 @@ const noCommitsOnBranchRe = new RegExp(
* Returns a map keyed on branch names
*
* @param repository the repository who's reflog you want to check
* @param afterDate filters checkouts so that only those occuring on or after this date are returned
* @param afterDate filters checkouts so that only those occurring on or after this date are returned
* @returns map of branch name -> checkout date
*/
export async function getBranchCheckouts(

View file

@ -6,14 +6,14 @@ import { Repository } from '../../models/repository'
* is ambiguous are handled.
*
* Examples:
* - master -> refs/heads/master
* - heads/Microsoft/master -> refs/heads/Microsoft/master
* - main -> refs/heads/main
* - heads/Microsoft/main -> refs/heads/Microsoft/main
*
* @param branch The local branch name
*/
export function formatAsLocalRef(name: string): string {
if (name.startsWith('heads/')) {
// In some cases, Git will report this name explicitly to distingush from
// In some cases, Git will report this name explicitly to distinguish from
// a remote ref with the same name - this ensures we format it correctly.
return `refs/${name}`
} else if (!name.startsWith('refs/heads/')) {

View file

@ -1,5 +1,6 @@
import { git } from './core'
import { Repository } from '../../models/repository'
import { WorkingDirectoryFileChange } from '../../models/status'
/**
* Remove all files from the index
@ -18,3 +19,13 @@ export async function unstageAllFiles(repository: Repository): Promise<void> {
'unstageAllFiles'
)
}
/**
* Remove conflicted file from working tree and index
*/
export async function removeConflictedFile(
repository: Repository,
file: WorkingDirectoryFileChange
) {
await git(['rm', '--', file.path], repository.path, 'removeConflictedFile')
}

View file

@ -5,12 +5,11 @@ import {
GitStatusEntry,
isConflictWithMarkers,
} from '../../models/status'
import {
ManualConflictResolution,
ManualConflictResolutionKind,
} from '../../models/manual-conflict-resolution'
import { git } from '.'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { assertNever } from '../fatal-error'
import { removeConflictedFile } from './rm'
import { checkoutConflictedFile } from './checkout'
import { addConflictedFile } from './add'
/**
* Stages a file with the given manual resolution method. Useful for resolving binary conflicts at commit-time.
@ -40,33 +39,25 @@ export async function stageManualConflictResolution(
}
const chosen =
manualResolution === ManualConflictResolutionKind.theirs
manualResolution === ManualConflictResolution.theirs
? status.entry.them
: status.entry.us
const addedInBoth =
status.entry.us === GitStatusEntry.Added &&
status.entry.them === GitStatusEntry.Added
if (chosen === GitStatusEntry.UpdatedButUnmerged || addedInBoth) {
await checkoutConflictedFile(repository, file, manualResolution)
}
switch (chosen) {
case GitStatusEntry.Deleted: {
await git(['rm', file.path], repository.path, 'removeConflictedFile')
break
}
case GitStatusEntry.Added: {
await git(['add', file.path], repository.path, 'addConflictedFile')
break
}
case GitStatusEntry.UpdatedButUnmerged: {
const choiceFlag =
manualResolution === ManualConflictResolutionKind.theirs
? 'theirs'
: 'ours'
await git(
['checkout', `--${choiceFlag}`, '--', file.path],
repository.path,
'checkoutConflictedFile'
)
await git(['add', file.path], repository.path, 'addConflictedFile')
break
}
case GitStatusEntry.Deleted:
return removeConflictedFile(repository, file)
case GitStatusEntry.Added:
case GitStatusEntry.UpdatedButUnmerged:
return addConflictedFile(repository, file)
default:
assertNever(chosen, 'unnacounted for git status entry possibility')
assertNever(chosen, 'unaccounted for git status entry possibility')
}
}

View file

@ -105,7 +105,7 @@ export async function getLastDesktopStashEntryForBranch(
)
}
/** Creates a stash entry message that idicates the entry was created by Desktop */
/** Creates a stash entry message that indicates the entry was created by Desktop */
export function createDesktopStashMessage(branchName: string) {
return `${DesktopStashEntryMarker}<${branchName}>`
}

View file

@ -56,7 +56,8 @@ interface IUpdateIndexOptions {
}
/**
* Updates the index with file contents from the working tree.
* Updates the index with file contents from the working tree. This method
* is a noop when no paths are provided.
*
* @param paths A list of paths which are to be updated with file contents and
* status from the working directory.
@ -68,7 +69,7 @@ async function updateIndex(
paths: ReadonlyArray<string>,
options: IUpdateIndexOptions = {}
) {
if (!paths.length) {
if (paths.length === 0) {
return
}
@ -119,9 +120,7 @@ export async function stageFiles(
normal.push(file.path)
if (file.status.kind === AppFileStatusKind.Renamed) {
oldRenamed.push(file.status.oldPath)
}
if (file.status.kind === AppFileStatusKind.Deleted) {
} else if (file.status.kind === AppFileStatusKind.Deleted) {
deletedFiles.push(file.path)
}
} else {
@ -159,16 +158,12 @@ export async function stageFiles(
// This third step will only happen if we have files that have been marked
// for deletion. This covers us for files that were blown away in the last
// updateIndex call
if (deletedFiles.length > 0) {
await updateIndex(repository, deletedFiles, { forceRemove: true })
}
await updateIndex(repository, deletedFiles, { forceRemove: true })
// Finally we run through all files that have partial selections.
// We don't care about renamed or not here since applyPatchToIndex
// has logic to support that scenario.
if (partial.length) {
for (const file of partial) {
await applyPatchToIndex(repository, file)
}
for (const file of partial) {
await applyPatchToIndex(repository, file)
}
}

View file

@ -41,3 +41,13 @@ export function getMatches(text: string, re: RegExp): Array<RegExpExecArray> {
}
return matches
}
/**
* Replaces characters that have a semantic meaning inside of a regexp with
* their escaped equivalent (i.e. `*` becomes `\*` etc).
*
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
*/
export function escapeRegExp(expression: string) {
return expression.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&')
}

View file

@ -50,7 +50,7 @@ export function setBoolean(key: string, value: boolean) {
/**
* Retrieve a `number` value from a given local storage entry if found, or the
* provided `defaultValue` if the key doesn't exist or if the value cannot be
* convered into a number
* converted into a number
*
* @param key local storage entry to read
* @param defaultValue fallback value if unable to find key or valid value

View file

@ -164,6 +164,7 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
let branchIsUnborn = false
let rebaseInProgress = false
let branchHasStashEntry = false
// check that its a github repo and if so, that is has issues enabled
const repoIssuesEnabled =
selectedState !== null &&

View file

@ -6,10 +6,10 @@
* for a good primer on PAC files.
*
* Note that this method is not intended to be a fully compliant PAC parser
* nor is it indended to handle common PAC string mistakes (such as including
* nor is it intended to handle common PAC string mistakes (such as including
* the protocol in the host portion of the spec). It's specifically designed
* to translate PAC strings returned from Electron's resolveProxy method which
* in turn relies on Chromium's `ProxyList::ToPacString()` implementatiton.
* in turn relies on Chromium's `ProxyList::ToPacString()` implementation.
*
* Proxy protocols not supported by cURL (QUIC) will be silently omitted.
*
@ -19,7 +19,7 @@
* state. As such we can take several shortcuts not available to generic PAC
* parsers.
*
* See the following links for a high-level step-through of the logic involed
* See the following links for a high-level step-through of the logic involved
* in getting the PAC string from Electron/Chromium
*
* https://github.com/electron/electron/blob/d9321f4df751/shell/browser/net/resolve_proxy_helper.cc#L77
@ -103,7 +103,7 @@ function urlFromProtocolAndEndpoint(protocol: string, endpoint: string) {
// HTTP is an alias for PROXY (or vice versa idk). I don't believe
// we'll ever see an 'HTTP' protocol from Chromium based on my reading of
// https://github.com/chromium/chromium/blob/2ca8c5037021/net/base/proxy_server.cc#L164-L184
// but we'll suppor it nonetheless.
// but we'll support it nonetheless.
//
// SOCKS is an alias for SOCKS4
switch (protocol.toLowerCase()) {

View file

@ -1,6 +1,11 @@
import { assertNever } from '../lib/fatal-error'
import { WorkingDirectoryFileChange, AppFileStatusKind } from '../models/status'
import { DiffLineType, ITextDiff, DiffSelection } from '../models/diff'
import {
DiffLineType,
ITextDiff,
DiffSelection,
ILargeTextDiff,
} from '../models/diff'
/**
* Generates a string matching the format of a GNU unified diff header excluding
@ -123,7 +128,7 @@ function formatHunkHeader(
*/
export function formatPatch(
file: WorkingDirectoryFileChange,
diff: ITextDiff
diff: ITextDiff | ILargeTextDiff
): string {
let patch = ''
@ -241,7 +246,7 @@ export function formatPatch(
* This is used to determine the from and to paths for the
* patch header.
* @param diff All the local changes for that file.
* @param selecction A selection of lines from the diff object that we want to discard.
* @param selection A selection of lines from the diff object that we want to discard.
*/
export function formatPatchToDiscardChanges(
filePath: string,

View file

@ -32,7 +32,7 @@ export function encodePathAsUrl(...pathSegments: string[]): string {
* @param options A subset of the Path module. Requires the join,
* resolve, and normalize path functions. Defaults
* to the platform specific path functions but can
* be overriden by providing either Path.win32 or
* be overridden by providing either Path.win32 or
* Path.posix
*/
async function _resolveWithin(

View file

@ -13,7 +13,7 @@ import { TipState } from '../models/tip'
import { clamp } from './clamp'
/**
* Setup the rebase flow state when the user neeeds to select a branch as the
* Setup the rebase flow state when the user needs to select a branch as the
* base for the operation.
*/
export function initializeNewRebaseFlow(state: IRepositoryState) {
@ -48,7 +48,7 @@ export function initializeNewRebaseFlow(state: IRepositoryState) {
* Setup the rebase flow when rebase conflicts are detected in the repository.
*
* This indicates a rebase is in progress, and the application needs to guide
* the user to resolve conflicts and complete the rebae.
* the user to resolve conflicts and complete the rebase.
*
* @param conflictState current set of conflicts
*/

View file

@ -9,7 +9,7 @@ const squirrelTimeoutRegex = /A connection attempt failed because the connected
/**
* This method parses known error messages from Squirrel.Windows and returns a
* friendier message to the user.
* friendlier message to the user.
*
* @param error The underlying error from Squirrel.
*

View file

@ -44,7 +44,7 @@ export interface IDailyMeasures {
/** The number of times a branch is compared to an arbitrary branch */
readonly branchComparisons: number
/** The number of times a branch is compared to `master` */
/** The number of times a branch is compared to the default branch */
readonly defaultBranchComparisons: number
/** The number of times a merge is initiated in the `compare` sidebar */
@ -313,7 +313,7 @@ export interface IDailyMeasures {
/**
* _[Onboarding tutorial]_
* Has the user compeleted the create a PR step?
* Has the user completed the create a PR step?
*/
readonly tutorialPrCreated: boolean

View file

@ -149,7 +149,7 @@ interface IOnboardingStats {
*
* A negative value means that this action hasn't yet
* taken place while undefined means that the current
* user installed desktop prior to this metric beeing
* user installed desktop prior to this metric being
* added and we will thus never be able to provide a
* value.
*/
@ -162,7 +162,7 @@ interface IOnboardingStats {
*
* A negative value means that this action hasn't yet
* taken place while undefined means that the current
* user installed desktop prior to this metric beeing
* user installed desktop prior to this metric being
* added and we will thus never be able to provide a
* value.
*/
@ -175,7 +175,7 @@ interface IOnboardingStats {
*
* A negative value means that this action hasn't yet
* taken place while undefined means that the current
* user installed desktop prior to this metric beeing
* user installed desktop prior to this metric being
* added and we will thus never be able to provide a
* value.
*/
@ -188,7 +188,7 @@ interface IOnboardingStats {
*
* A negative value means that this action hasn't yet
* taken place while undefined means that the current
* user installed desktop prior to this metric beeing
* user installed desktop prior to this metric being
* added and we will thus never be able to provide a
* value.
*/
@ -215,7 +215,7 @@ interface IOnboardingStats {
*
* A negative value means that this action hasn't yet
* taken place while undefined means that the current
* user installed desktop prior to this metric beeing
* user installed desktop prior to this metric being
* added and we will thus never be able to provide a
* value.
*/
@ -228,7 +228,7 @@ interface IOnboardingStats {
*
* A negative value means that this action hasn't yet
* taken place while undefined means that the current
* user installed desktop prior to this metric beeing
* user installed desktop prior to this metric being
* added and we will thus never be able to provide a
* value.
*/
@ -236,7 +236,7 @@ interface IOnboardingStats {
/**
* The method that was used when authenticating a
* user in the welcome flow. If multiple succesful
* user in the welcome flow. If multiple successful
* authentications happened during the welcome flow
* due to the user stepping back and signing in to
* another account this will reflect the last one.
@ -644,7 +644,7 @@ export class StatsStore implements IStatsStore {
}))
}
/** Record that a branch comparison has been made to the `master` branch */
/** Record that a branch comparison has been made to the default branch */
public recordDefaultBranchComparison(): Promise<void> {
return this.updateDailyMeasures(m => ({
defaultBranchComparisons: m.defaultBranchComparisons + 1,
@ -862,7 +862,7 @@ export class StatsStore implements IStatsStore {
}))
// Note, this is not a typo. We track both GitHub.com and
// GitHub Enteprise under the same key
// GitHub Enterprise under the same key
createLocalStorageTimestamp(FirstPushToGitHubAtKey)
}

View file

@ -9,10 +9,7 @@ import {
WorkingDirectoryFileChange,
} from '../models/status'
import { assertNever } from './fatal-error'
import {
ManualConflictResolution,
ManualConflictResolutionKind,
} from '../models/manual-conflict-resolution'
import { ManualConflictResolution } from '../models/manual-conflict-resolution'
/**
* Convert a given `AppFileStatusKind` value to a human-readable string to be
@ -95,7 +92,7 @@ type UnmergedStatusEntry =
| GitStatusEntry.Deleted
/** Returns a human-readable description for a chosen version of a file
* intended for use with manually resolved merge conficts
* intended for use with manually resolved merge conflicts
*/
export function getUnmergedStatusEntryDescription(
entry: UnmergedStatusEntry,
@ -116,7 +113,7 @@ export function getUnmergedStatusEntryDescription(
}
/** Returns a human-readable description for an available manual resolution method
* intended for use with manually resolved merge conficts
* intended for use with manually resolved merge conflicts
*/
export function getLabelForManualResolutionOption(
entry: UnmergedStatusEntry,
@ -153,7 +150,7 @@ export function getUntrackedFiles(
/** Filter working directory changes for resolved files */
export function getResolvedFiles(
status: WorkingDirectoryStatus,
manualResolutions: Map<string, ManualConflictResolutionKind>
manualResolutions: Map<string, ManualConflictResolution>
) {
return status.files.filter(
f =>
@ -165,7 +162,7 @@ export function getResolvedFiles(
/** Filter working directory changes for conflicted files */
export function getConflictedFiles(
status: WorkingDirectoryStatus,
manualResolutions: Map<string, ManualConflictResolutionKind>
manualResolutions: Map<string, ManualConflictResolution>
) {
return status.files.filter(
f =>

View file

@ -69,7 +69,7 @@ function resolveAccount(
* An interface describing the current state of
* repositories that a particular account has explicit
* permissions to access and whether or not the list of
* repositores is being loaded or refreshed.
* repositories is being loaded or refreshed.
*
* This main purpose of this interface is to describe
* the state necessary to render a list of cloneable

View file

@ -116,7 +116,7 @@ import {
launchExternalEditor,
parse,
} from '../editors'
import { assertNever, fatalError, forceUnwrap } from '../fatal-error'
import { assertNever, fatalError } from '../fatal-error'
import { formatCommitMessage } from '../format-commit-message'
import { getGenericHostname, getGenericUsername } from '../generic-git-auth'
@ -220,10 +220,7 @@ import {
updateConflictState,
selectWorkingDirectoryFiles,
} from './updates/changes-state'
import {
ManualConflictResolution,
ManualConflictResolutionKind,
} from '../../models/manual-conflict-resolution'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { BranchPruner } from './helpers/branch-pruner'
import { enableUpdateRemoteUrl } from '../feature-flag'
import { Banner, BannerType } from '../../models/banner'
@ -268,10 +265,6 @@ import { parseRemote } from '../../lib/remote-parsing'
import { createTutorialRepository } from './helpers/create-tutorial-repository'
import { sendNonFatalException } from '../helpers/non-fatal-exception'
import { getDefaultDir } from '../../ui/lib/default-dir'
import {
UpstreamRemoteName,
findUpstreamRemote,
} from './helpers/find-upstream-remote'
import { WorkflowPreferences } from '../../models/workflow-preferences'
import { RepositoryIndicatorUpdater } from './helpers/repository-indicator-updater'
import { getAttributableEmailsFor } from '../email'
@ -313,6 +306,9 @@ const imageDiffTypeKey = 'image-diff-type'
const hideWhitespaceInDiffDefault = false
const hideWhitespaceInDiffKey = 'hide-whitespace-in-diff'
const showSideBySideDiffDefault = false
const showSideBySideDiffKey = 'show-side-by-side-diff'
const shellKey = 'shell'
const repositoryIndicatorsEnabledKey = 'enable-repository-indicators'
@ -395,6 +391,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private askForConfirmationOnForcePush = askForConfirmationOnForcePushDefault
private imageDiffType: ImageDiffType = imageDiffTypeDefault
private hideWhitespaceInDiff: boolean = hideWhitespaceInDiffDefault
private showSideBySideDiff: boolean = showSideBySideDiffDefault
private uncommittedChangesStrategyKind: UncommittedChangesStrategyKind = uncommittedChangesStrategyKindDefault
@ -762,6 +759,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
selectedExternalEditor: this.selectedExternalEditor,
imageDiffType: this.imageDiffType,
hideWhitespaceInDiff: this.hideWhitespaceInDiff,
showSideBySideDiff: this.showSideBySideDiff,
selectedShell: this.selectedShell,
repositoryFilterText: this.repositoryFilterText,
resolvedExternalEditor: this.resolvedExternalEditor,
@ -1065,7 +1063,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
repository,
allBranches,
currentPullRequest,
getRemotes
getRemotes,
cachedDefaultBranch
)
if (inferredBranch !== null) {
@ -1842,6 +1841,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
: parseInt(imageDiffTypeValue)
this.hideWhitespaceInDiff = getBoolean(hideWhitespaceInDiffKey, false)
this.showSideBySideDiff = getBoolean(showSideBySideDiffKey, false)
this.automaticallySwitchTheme = getAutoSwitchPersistedTheme()
@ -3028,9 +3028,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
// If the user is opening the repository list and we haven't yet
// started to refresh the repository indicators let's do so.
if (foldout.type === FoldoutType.Repository) {
if (
foldout.type === FoldoutType.Repository &&
this.repositoryIndicatorsEnabled
) {
// N.B: RepositoryIndicatorUpdater.prototype.start is
// indempotent.
// idempotent.
this.repositoryIndicatorUpdater.start()
}
}
@ -4567,7 +4570,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
public async _finishConflictedMerge(
repository: Repository,
workingDirectory: WorkingDirectoryStatus,
manualResolutions: Map<string, ManualConflictResolutionKind>
manualResolutions: Map<string, ManualConflictResolution>
): Promise<string | undefined> {
/**
* The assumption made here is that all other files that were part of this merge
@ -4751,6 +4754,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
public _setShowSideBySideDiff(showSideBySideDiff: boolean) {
setBoolean(showSideBySideDiffKey, showSideBySideDiff)
this.showSideBySideDiff = showSideBySideDiff
this.emitUpdate()
}
public _setUpdateBannerVisibility(visibility: boolean) {
this.isUpdateAvailableBannerVisible = visibility
@ -5501,36 +5511,38 @@ export class AppStore extends TypedBaseStore<IAppState> {
// https://youtu.be/IjmtVKOAHPM
if (branch !== null) {
await this._checkoutBranch(repository, branch)
this.statsStore.recordPRBranchCheckout()
return
}
const remoteName = forkPullRequestRemoteName(ownerLogin)
const remotes = await getRemotes(repository)
const remote =
remotes.find(r => r.name === remoteName) ||
(await addRemote(repository, remoteName, headCloneUrl))
if (remote.url !== headCloneUrl) {
const error = new Error(
`Expected PR remote ${remoteName} url to be ${headCloneUrl} got ${remote.url}.`
)
log.error(error.message)
return this.emitError(error)
}
await this._fetchRemote(repository, remote, FetchType.UserInitiatedTask)
const localBranchName = `pr/${prNumber}`
const existingBranch = this.getLocalBranch(repository, localBranchName)
if (existingBranch === null) {
await this._createBranch(
repository,
localBranchName,
`${remoteName}/${headRefName}`
)
} else {
const remoteName = forkPullRequestRemoteName(ownerLogin)
const remotes = await getRemotes(repository)
const remote =
remotes.find(r => r.name === remoteName) ||
(await addRemote(repository, remoteName, headCloneUrl))
if (remote.url !== headCloneUrl) {
const error = new Error(
`Expected PR remote ${remoteName} url to be ${headCloneUrl} got ${remote.url}.`
)
log.error(error.message)
return this.emitError(error)
}
await this._fetchRemote(repository, remote, FetchType.UserInitiatedTask)
const localBranchName = `pr/${prNumber}`
const existingBranch = this.getLocalBranch(repository, localBranchName)
if (existingBranch === null) {
await this._createBranch(
repository,
localBranchName,
`${remoteName}/${headRefName}`
)
} else {
await this._checkoutBranch(repository, existingBranch)
}
await this._checkoutBranch(repository, existingBranch)
}
this.statsStore.recordPRBranchCheckout()
@ -5541,12 +5553,30 @@ export class AppStore extends TypedBaseStore<IAppState> {
headCloneURL: string,
headRefName: string
): Promise<Branch | null> {
const gitHubRepository = repository.gitHubRepository
const isRefInThisRepo = headCloneURL === gitHubRepository.cloneURL
const isRefInUpstream =
gitHubRepository.parent !== null &&
headCloneURL === gitHubRepository.parent.cloneURL
const { cloneURL, parent } = repository.gitHubRepository
const gitStore = this.gitStoreCache.get(repository)
let remote = null
// Determine whether the ref is in the current repository or the parent
if (headCloneURL === cloneURL) {
remote = gitStore.defaultRemote
} else if (headCloneURL === parent?.cloneURL) {
remote = gitStore.upstreamRemote
}
if (remote !== null) {
return this.findPullRequestHeadInRemote(repository, remote, headRefName)
}
return null
}
private async findPullRequestHeadInRemote(
repository: Repository,
remote: IRemote,
headRefName: string
) {
const gitStore = this.gitStoreCache.get(repository)
// Find a remote branch matching the given name or a local branch
@ -5559,59 +5589,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
: branch.name === name
) ?? null
// If we don't have a default remote here, it's probably going
// to just crash and burn on checkout, but that's okay
if (isRefInThisRepo) {
const defaultRemote = forceUnwrap(
`Unexpected state: repository without a default remote`,
gitStore.defaultRemote
)
const remoteRef = `${remote.name}/${headRefName}`
const branch = findBranch(remoteRef)
// The remote ref will be something like `origin/my-cool-branch`
const remoteRef = `${defaultRemote.name}/${headRefName}`
const originBranch = findBranch(remoteRef)
if (originBranch !== null) {
return originBranch
}
// Fetch the remote and try finding the branch again
if (originBranch === null) {
await this._fetchRemote(
repository,
defaultRemote,
FetchType.UserInitiatedTask
)
}
return findBranch(remoteRef)
if (branch !== null) {
return branch
}
if (isRefInUpstream) {
// the remote ref will be something like `upstream/my-cool-branch`
const remoteRef = `${UpstreamRemoteName}/${headRefName}`
const branch = findBranch(remoteRef)
if (branch !== null) {
return branch
}
// Fetch the remote and try finding the branch again
const remotes = await getRemotes(repository)
const remoteUpstream = forceUnwrap(
'Cannot add the upstream repository as a remote of the current repository',
findUpstreamRemote(forceUnwrap('', gitHubRepository.parent), remotes)
)
await this._fetchRemote(
repository,
remoteUpstream,
FetchType.UserInitiatedTask
)
return findBranch(remoteRef)
// Fetch the remote and try finding the branch again
if (branch === null) {
await this._fetchRemote(repository, remote, FetchType.UserInitiatedTask)
}
return null
return findBranch(remoteRef)
}
/**

View file

@ -384,7 +384,10 @@ export class GitStore extends BaseStore {
const [localAndRemoteBranches, recentBranchNames] = await Promise.all([
this.performFailableOperation(() => getBranches(this.repository)) || [],
this.performFailableOperation(() =>
getRecentBranches(this.repository, RecentBranchesLimit)
// Chances are that the recent branches list will contain the default
// branch which we filter out in refreshRecentBranches. So grab one
// more than we need to account for that.
getRecentBranches(this.repository, RecentBranchesLimit + 1)
),
])
@ -394,9 +397,12 @@ export class GitStore extends BaseStore {
this._allBranches = this.mergeRemoteAndLocalBranches(localAndRemoteBranches)
this.refreshDefaultBranch()
// refreshRecentBranches is dependent on having a default branch
await this.refreshDefaultBranch()
this.refreshRecentBranches(recentBranchNames)
this.checkPullWithRebase()
await this.checkPullWithRebase()
this.emitUpdate()
}
@ -502,7 +508,7 @@ export class GitStore extends BaseStore {
/**
* Resolve the default branch name for the current repository,
* using the available API data, remote information or branch
* name conventionns.
* name conventions.
*/
private async resolveDefaultBranch(): Promise<string> {
const { gitHubRepository } = this.repository
@ -526,7 +532,7 @@ export class GitStore extends BaseStore {
) {
// strip out everything related to the remote because this
// is likely to be a tracked branch locally
// e.g. `master`, `develop`, etc
// e.g. `main`, `develop`, etc
return match.substr(remoteNamespace.length)
}
}
@ -549,6 +555,12 @@ export class GitStore extends BaseStore {
const recentBranches = new Array<Branch>()
for (const name of recentBranchNames) {
// The default branch already has its own section in the branch
// list so we exclude it here.
if (name === this.defaultBranch?.name) {
continue
}
const branch = branchesByName.get(name)
if (!branch) {
// This means the recent branch has been deleted. That's fine.
@ -556,6 +568,10 @@ export class GitStore extends BaseStore {
}
recentBranches.push(branch)
if (recentBranches.length >= RecentBranchesLimit) {
break
}
}
this._recentBranches = recentBranches
@ -566,7 +582,7 @@ export class GitStore extends BaseStore {
return this._tip
}
/** The default branch, or `master` if there is no default. */
/** The default branch or null if the default branch could not be inferred. */
public get defaultBranch(): Branch | null {
return this._defaultBranch
}
@ -1259,10 +1275,10 @@ export class GitStore extends BaseStore {
parent.cloneURL
)
await this.performFailableOperation(() =>
addRemote(this.repository, UpstreamRemoteName, url)
)
this._upstreamRemote = { name: UpstreamRemoteName, url }
this._upstreamRemote =
(await this.performFailableOperation(() =>
addRemote(this.repository, UpstreamRemoteName, url)
)) ?? null
}
/**
@ -1328,7 +1344,7 @@ export class GitStore extends BaseStore {
* This will be `null` if the repository isn't a fork, or if the fork doesn't
* have an upstream remote.
*/
private get upstreamRemote(): IRemote | null {
public get upstreamRemote(): IRemote | null {
return this._upstreamRemote
}

View file

@ -18,6 +18,7 @@ import moment from 'moment'
const BackgroundPruneMinimumInterval = 1000 * 60 * 60 * 4
const ReservedRefs = [
'HEAD',
'refs/heads/main',
'refs/heads/master',
'refs/heads/gh-pages',
'refs/heads/develop',
@ -140,7 +141,7 @@ export class BranchPruner {
const dateNow = moment()
const threshold = dateNow.subtract(24, 'hours')
// Using type coelescing behavior to deal with Dexie returning `undefined`
// Using type coalescing behavior to deal with Dexie returning `undefined`
// for records that haven't been updated with the new field yet
if (
options.enforcePruneThreshold &&
@ -189,7 +190,7 @@ export class BranchPruner {
[...recentlyCheckedOutBranches.keys()].map(formatAsLocalRef)
)
// get the locally cached branches of remotes (ie `remotes/origin/master`)
// get the locally cached branches of remotes (ie `remotes/origin/main`)
const remoteBranches = (
await getBranches(this.repository, `refs/remotes/`)
).map(b => formatAsLocalRef(b.name))

View file

@ -12,9 +12,13 @@ import { git } from '../../git'
import { friendlyEndpointName } from '../../friendly-endpoint-name'
import { IRemote } from '../../../models/remote'
import { envForRemoteOperation } from '../../git/environment'
import {
DefaultBranchInGit,
DefaultBranchInDesktop,
} from '../../helpers/default-branch'
const nl = __WIN32__ ? '\r\n' : '\n'
const InititalReadmeContents =
const InitialReadmeContents =
`# Welcome to GitHub Desktop!${nl}${nl}` +
`This is your README. READMEs are where you can communicate ` +
`what your project is and how to use it.${nl}${nl}` +
@ -63,6 +67,7 @@ async function pushRepo(
path: string,
account: Account,
remote: IRemote,
remoteBranchName: string,
progressCb: (title: string, value: number, description?: string) => void
) {
const pushTitle = `Pushing repository to ${friendlyEndpointName(account)}`
@ -80,7 +85,7 @@ async function pushRepo(
}
)
const args = ['push', '-u', remote.name, 'master']
const args = ['push', '-u', remote.name, remoteBranchName]
await git(args, path, 'tutorial:push', pushOpts)
}
@ -113,30 +118,29 @@ export async function createTutorialRepository(
}
const repo = await createAPIRepository(account, name)
const branch = repo.default_branch ?? DefaultBranchInDesktop
progressCb('Initializing local repository', 0.2)
await ensureDir(path)
await git(['init'], path, 'tutorial:init')
await writeFile(Path.join(path, 'README.md'), InititalReadmeContents)
if (branch !== DefaultBranchInGit) {
await git(['checkout', '-b', branch], path, 'tutorial:rename-branch')
}
await writeFile(Path.join(path, 'README.md'), InitialReadmeContents)
await git(['add', '--', 'README.md'], path, 'tutorial:add')
await git(
['commit', '-m', 'Initial commit', '--', 'README.md'],
path,
'tutorial:commit'
)
await git(['commit', '-m', 'Initial commit'], path, 'tutorial:commit')
const remote: IRemote = { name: 'origin', url: repo.clone_url }
await git(
['remote', 'add', remote.name, remote.url],
path,
'tutorial:add-remote'
)
await pushRepo(path, account, remote, (title, value, description) => {
await pushRepo(path, account, remote, branch, (title, value, description) => {
progressCb(title, 0.3 + value * 0.6, description)
})

View file

@ -8,7 +8,7 @@ import { urlMatchesCloneURL } from '../../repository-matching'
* protection information.
*
* If the remote branch matches the current `githubRepository` associated with
* the repostiory, this will be used. Otherwise we will fall back to using the
* the repository, this will be used. Otherwise we will fall back to using the
* branch name as that's a reasonable approximation for what would happen if the
* user tries to push the new branch.
*/

View file

@ -19,19 +19,21 @@ type RemotesGetter = (repository: Repository) => Promise<ReadonlyArray<IRemote>>
* 1. Given a pull request -> target branch of PR
* 2. Given a forked repository -> default branch on `upstream`
* 3. Given a hosted repository -> default branch on `origin`
* 4. Fallback -> `master` branch
* 4. Fallback -> default branch
*
* @param repository The repository the branch belongs to
* @param branches The list of all branches for the repository
* @param currentPullRequest The pull request to use for finding the branch
* @param getRemotes callback used to get all remotes for the current repository
* @param defaultBranch the current default branch or null if default branch is not known
*/
export async function inferComparisonBranch(
repository: Repository,
branches: ReadonlyArray<Branch>,
currentPullRequest: PullRequest | null,
getRemotes: RemotesGetter
getRemotes: RemotesGetter,
defaultBranch: Branch | null
): Promise<Branch | null> {
if (currentPullRequest !== null) {
const prBranch = getTargetBranchOfPullRequest(branches, currentPullRequest)
@ -61,11 +63,7 @@ export async function inferComparisonBranch(
}
}
return getMasterBranch(branches)
}
function getMasterBranch(branches: ReadonlyArray<Branch>): Branch | null {
return findBranch(branches, 'master')
return defaultBranch
}
function getDefaultBranchOfGitHubRepo(

View file

@ -65,7 +65,7 @@ export class RepositoryIndicatorUpdater {
}
private async refreshAllRepositories() {
// We're only ever called by the setTimout so it's safe for us to clear
// We're only ever called by the setTimeout so it's safe for us to clear
// this without calling clearTimeout
this.refreshTimeoutId = null
log.debug('[RepositoryIndicatorUpdater] Running refreshAllRepositories')

View file

@ -34,11 +34,11 @@ export class PullRequestCoordinator {
> = new Array<RepositoryWithGitHubRepository>()
/**
* Contains the last set of PRs retreived by `PullRequestCoordinator`
* Contains the last set of PRs retrieved by `PullRequestCoordinator`
* from `PullRequestStore` for a specific `GitHubRepository`.
* Keyed by `GitHubRepository` database ID to a list of pull requests.
*
* This is used to improve perforamnce by reducing
* This is used to improve performance by reducing
* duplicate queries to the pull request database.
*
*/

View file

@ -272,7 +272,7 @@ export class RepositoriesStore extends TypedBaseStore<
/**
* Update the workflow preferences for the specified repository.
*
* @param repository The repositosy to update.
* @param repository The repository to update.
* @param workflowPreferences The object with the workflow settings to use.
*/
public async updateRepositoryWorkflowPreferences(
@ -529,7 +529,7 @@ export class RepositoriesStore extends TypedBaseStore<
// This update flow is organized into two stages:
//
// - update the in-memory cache
// - update the underyling database state
// - update the underlying database state
//
// This should ensure any stale values are not being used, and avoids
// the need to query the database while the results are in memory.

View file

@ -33,7 +33,7 @@ const EnterpriseTooOldMessage = `The GitHub Enterprise Server version does not s
/**
* An enumeration of the possible steps that the sign in
* store can be in save for the unitialized state (null).
* store can be in save for the uninitialized state (null).
*/
export enum SignInStep {
EndpointEntry = 'EndpointEntry',
@ -44,7 +44,7 @@ export enum SignInStep {
/**
* The union type of all possible states that the sign in
* store can be in save the unitialized state (null).
* store can be in save the uninitialized state (null).
*/
export type SignInState =
| IEndpointEntryState
@ -140,13 +140,13 @@ export interface ITwoFactorAuthenticationState extends ISignInState {
readonly endpoint: string
/**
* The username specified by the user in the preceeding
* The username specified by the user in the preceding
* Authentication step
*/
readonly username: string
/**
* The password specified by the user in the preceeding
* The password specified by the user in the preceding
* Authentication step
*/
readonly password: string

View file

@ -27,11 +27,11 @@ export async function updateRemoteUrl(
// manually configured their remote to use this format and we don't
// want to change what they've done just to be safe
const parsedRemoteUrl = URL.parse(remoteUrl)
const parsedUpdaedRemoteUrl = URL.parse(updatedRemoteUrl)
const parsedUpdatedRemoteUrl = URL.parse(updatedRemoteUrl)
const protocolsMatch =
parsedRemoteUrl.protocol !== null &&
parsedUpdaedRemoteUrl.protocol !== null &&
parsedRemoteUrl.protocol === parsedUpdaedRemoteUrl.protocol
parsedUpdatedRemoteUrl.protocol !== null &&
parsedRemoteUrl.protocol === parsedUpdatedRemoteUrl.protocol
// Check if the default remote url has been manually changed from the
// clone url retrieved from the GitHub API previously

View file

@ -62,8 +62,8 @@ export class Branch {
/**
* A branch as loaded from Git.
*
* @param name The short name of the branch. E.g., `master`.
* @param upstream The remote-prefixed upstream name. E.g., `origin/master`.
* @param name The short name of the branch. E.g., `main`.
* @param upstream The remote-prefixed upstream name. E.g., `origin/main`.
* @param tip Basic information (sha and author) of the latest commit on the branch.
* @param type The type of branch, e.g., local or remote.
*/

View file

@ -98,7 +98,7 @@ export class DiffSelection {
// If we know which lines are selectable we need to check that
// all lines are divergent and return the inverse of default selection.
// To avoid loopting through the set that often our happy path is
// To avoid looping through the set that often our happy path is
// if there's a size mismatch.
if (selectableLines && selectableLines.size === divergingLines.size) {
const allSelectableLinesAreDivergent = [...selectableLines].every(i =>
@ -260,7 +260,7 @@ export class DiffSelection {
/**
* Returns a copy of this selection instance with a specified set of
* selecable lines. By default a DiffSelection instance allows selecting
* selectable lines. By default a DiffSelection instance allows selecting
* all lines (in fact, it has no notion of how many lines exists or what
* it is that is being selected).
*

View file

@ -16,7 +16,7 @@ export class GitHubRepository {
public readonly dbID: number | null,
public readonly isPrivate: boolean | null = null,
public readonly htmlURL: string | null = null,
public readonly defaultBranch: string | null = 'master',
public readonly defaultBranch: string | null = null,
public readonly cloneURL: string | null = null,
public readonly issuesEnabled: boolean | null = null,
public readonly isArchived: boolean | null = null,

View file

@ -1,8 +1,7 @@
export enum ManualConflictResolutionKind {
// NOTE: These strings have semantic value, they're passed directly
// as `--ours` and `--theirs` to git checkout. Please be careful
// when modifying this type.
export enum ManualConflictResolution {
theirs = 'theirs',
ours = 'ours',
}
export type ManualConflictResolution =
| ManualConflictResolutionKind.theirs
| ManualConflictResolutionKind.ours

View file

@ -106,7 +106,7 @@ export interface IRebaseProgress extends IProgress {
readonly currentCommitSummary: string
/** The number of commits currently rebased onto the base branch */
readonly rebasedCommitCount: number
/** The toal number of commits to rebase on top of the current branch */
/** The total number of commits to rebase on top of the current branch */
readonly totalCommitCount: number
}

View file

@ -22,7 +22,7 @@ export class PullRequest {
* @param title The title of the PR.
* @param number The number.
* @param head The ref from which the pull request's changes are coming.
* @param base The ref which the pull request is targetting.
* @param base The ref which the pull request is targeting.
* @param author The author's login.
*/
public constructor(

View file

@ -18,8 +18,8 @@ export interface IUnbornRepository {
/**
* The symbolic reference that the unborn repository points to currently.
*
* Typically this will be "master" but a user can easily create orphaned
* branches externally.
* Typically this will be whatever `init.defaultBranch` is set to but a user
* can create orphaned branches themselves.
*/
readonly ref: string
}

View file

@ -50,7 +50,7 @@ export async function getLicenses(): Promise<ReadonlyArray<ILicense>> {
}
function replaceToken(body: string, token: string, value: string): string {
// The license templates are inconsitent :( Sometimes they use [token] and
// The license templates are inconsistent :( Sometimes they use [token] and
// sometimes {token}. So we'll standardize first to {token} and then do
// replacements.
const oldPattern = new RegExp(`\\[${token}\\]`, 'g')

View file

@ -1384,7 +1384,7 @@ export class App extends React.Component<IAppProps, IAppState> {
const tip = state.branchesState.tip
// we should never get in this state since we disable the menu
// item in a detatched HEAD state, this check is so TSC is happy
// item in a detached HEAD state, this check is so TSC is happy
if (tip.kind !== TipState.Valid) {
return null
}
@ -1866,7 +1866,7 @@ export class App extends React.Component<IAppProps, IAppState> {
const { repository, branchToCheckout: branchToCheckout } = popup
return (
<OverwriteStash
key="overwite-stash"
key="overwrite-stash"
dispatcher={this.props.dispatcher}
repository={repository}
branchToCheckout={branchToCheckout}
@ -2021,7 +2021,7 @@ export class App extends React.Component<IAppProps, IAppState> {
if (conflictState === null || conflictState.kind === 'merge') {
log.debug(
`[App.onShowRebasConflictsBanner] no conflict state found, ignoring...`
`[App.onShowRebaseConflictsBanner] no conflict state found, ignoring...`
)
return
}
@ -2574,6 +2574,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onViewCommitOnGitHub={this.onViewCommitOnGitHub}
imageDiffType={state.imageDiffType}
hideWhitespaceInDiff={state.hideWhitespaceInDiff}
showSideBySideDiff={state.showSideBySideDiff}
focusCommitMessage={state.focusCommitMessage}
askForConfirmationOnDiscardChanges={
state.askForConfirmationOnDiscardChanges

View file

@ -176,7 +176,7 @@ export abstract class AutocompletingTextInput<
maxHeight = DefaultPopupHeight
}
// The height needed to accomodate all the matched items without overflowing
// The height needed to accommodate all the matched items without overflowing
//
// Magic number warning! The autocompletion-popup container adds a border
// which we have to account for in case we want to show N number of items

View file

@ -96,37 +96,37 @@ export class BranchesContainer extends React.Component<
}
}
private getBranchName = (): string => {
const { currentBranch, defaultBranch } = this.props
if (currentBranch != null) {
return currentBranch.name
}
if (defaultBranch != null) {
return defaultBranch.name
}
return 'master'
}
public render() {
const branchName = this.getBranchName()
return (
<div className="branches-container">
{this.renderTabBar()}
{this.renderSelectedTab()}
<Row className="merge-button-row">
<Button className="merge-button" onClick={this.onMergeClick}>
<Octicon className="icon" symbol={OcticonSymbol.gitMerge} />
<span title={`Merge a branch into ${branchName}`}>
Choose a branch to merge into <strong>{branchName}</strong>
</span>
</Button>
</Row>
{this.renderMergeButtonRow()}
</div>
)
}
private renderMergeButtonRow() {
const { currentBranch } = this.props
// This could happen if HEAD is detached, in that
// case it's better to not render anything at all.
if (currentBranch === null) {
return null
}
return (
<Row className="merge-button-row">
<Button className="merge-button" onClick={this.onMergeClick}>
<Octicon className="icon" symbol={OcticonSymbol.gitMerge} />
<span title={`Merge a branch into ${currentBranch.name}`}>
Choose a branch to merge into <strong>{currentBranch.name}</strong>
</span>
</Button>
</Row>
)
}
private renderOpenPullRequestsBubble() {
const pullRequests = this.props.pullRequests

View file

@ -4,11 +4,19 @@ import { AppFileStatus } from '../../models/status'
import { IDiff, DiffType } from '../../models/diff'
import { Octicon, OcticonSymbol, iconForStatus } from '../octicons'
import { mapStatus } from '../../lib/status'
import { enableSideBySideDiffs } from '../../lib/feature-flag'
import { DiffOptions } from '../diff/diff-options'
interface IChangedFileDetailsProps {
readonly path: string
readonly status: AppFileStatus
readonly diff: IDiff | null
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
/** Called when the user changes the side by side diffs setting. */
readonly onShowSideBySideDiffChanged: (checked: boolean) => void
}
/** Displays information about a file */
@ -25,6 +33,13 @@ export class ChangedFileDetails extends React.Component<
<PathLabel path={this.props.path} status={this.props.status} />
{this.renderDecorator()}
{enableSideBySideDiffs() && (
<DiffOptions
onShowSideBySideDiffChanged={this.props.onShowSideBySideDiffChanged}
showSideBySideDiff={this.props.showSideBySideDiff}
/>
)}
<Octicon
symbol={iconForStatus(status)}
className={'status status-' + fileStatus.toLowerCase()}

View file

@ -40,6 +40,11 @@ interface IChangesProps {
* discards changes
*/
readonly askForConfirmationOnDiscardChanges: boolean
/**
* Whether we should display side by side diffs.
*/
readonly showSideBySideDiff: boolean
}
export class Changes extends React.Component<IChangesProps, {}> {
@ -80,7 +85,13 @@ export class Changes extends React.Component<IChangesProps, {}> {
const isCommitting = this.props.isCommitting
return (
<div className="changed-file">
<ChangedFileDetails path={file.path} status={file.status} diff={diff} />
<ChangedFileDetails
path={file.path}
status={file.status}
diff={diff}
showSideBySideDiff={this.props.showSideBySideDiff}
onShowSideBySideDiffChanged={this.onShowSideBySideDiffChanged}
/>
<SeamlessDiffSwitcher
repository={this.props.repository}
imageDiffType={this.props.imageDiffType}
@ -90,6 +101,7 @@ export class Changes extends React.Component<IChangesProps, {}> {
onDiscardChanges={this.onDiscardChanges}
diff={diff}
hideWhitespaceInDiff={this.props.hideWhitespaceInDiff}
showSideBySideDiff={this.props.showSideBySideDiff}
askForConfirmationOnDiscardChanges={
this.props.askForConfirmationOnDiscardChanges
}
@ -99,4 +111,8 @@ export class Changes extends React.Component<IChangesProps, {}> {
</div>
)
}
private onShowSideBySideDiffChanged = (showSideBySideDiff: boolean) => {
this.props.dispatcher.onShowSideBySideDiffChanged(showSideBySideDiff)
}
}

View file

@ -176,7 +176,7 @@ export class NoChanges extends React.Component<
/**
* ID for the timer that's activated when the component
* mounts. See componentDidMount/componenWillUnmount.
* mounts. See componentDidMount/componentWillUnmount.
*/
private transitionTimer: number | null = null

View file

@ -643,7 +643,7 @@ export class CloneRepository extends React.Component<
try {
this.cloneImpl(url.trim(), path)
} catch (e) {
log.error(`CloneRepostiory: clone failed to complete to ${path}`, e)
log.error(`CloneRepository: clone failed to complete to ${path}`, e)
this.setState({ loading: false })
this.setSelectedTabState({ error: e })
}

View file

@ -3,7 +3,7 @@ import { Account } from '../../models/account'
import { FilterList, IFilterListGroup } from '../lib/filter-list'
import { IAPIRepository, getDotComAPIEndpoint, getHTMLURL } from '../../lib/api'
import {
IClonableRepositoryListItem,
ICloneableRepositoryListItem,
groupRepositories,
YourRepositoriesIdentifier,
} from './group-repositories'
@ -83,7 +83,7 @@ const RowHeight = 31
* the clone url of the provided repository.
*/
function findMatchingListItem(
groups: ReadonlyArray<IFilterListGroup<IClonableRepositoryListItem>>,
groups: ReadonlyArray<IFilterListGroup<ICloneableRepositoryListItem>>,
selectedRepository: IAPIRepository | null
) {
if (selectedRepository !== null) {
@ -106,7 +106,7 @@ function findMatchingListItem(
*/
function findRepositoryForListItem(
repositories: ReadonlyArray<IAPIRepository>,
listItem: IClonableRepositoryListItem
listItem: ICloneableRepositoryListItem
) {
return repositories.find(r => r.clone_url === listItem.url) || null
}
@ -162,7 +162,7 @@ export class CloneableRepositoryFilterList extends React.PureComponent<
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
return (
<FilterList<IClonableRepositoryListItem>
<FilterList<ICloneableRepositoryListItem>
className="clone-github-repo"
rowHeight={RowHeight}
selectedItem={selectedListItem}
@ -182,7 +182,7 @@ export class CloneableRepositoryFilterList extends React.PureComponent<
}
private onItemClick = (
item: IClonableRepositoryListItem,
item: ICloneableRepositoryListItem,
source: ClickSource
) => {
const { onItemClicked, repositories } = this.props
@ -198,7 +198,7 @@ export class CloneableRepositoryFilterList extends React.PureComponent<
}
}
private onSelectionChanged = (item: IClonableRepositoryListItem | null) => {
private onSelectionChanged = (item: ICloneableRepositoryListItem | null) => {
if (item === null || this.props.repositories === null) {
this.props.onSelectionChanged(null)
} else {
@ -221,7 +221,7 @@ export class CloneableRepositoryFilterList extends React.PureComponent<
}
private renderItem = (
item: IClonableRepositoryListItem,
item: ICloneableRepositoryListItem,
matches: IMatches
) => {
return (

View file

@ -6,7 +6,7 @@ import { OcticonSymbol } from '../octicons'
/** The identifier for the "Your Repositories" grouping. */
export const YourRepositoriesIdentifier = 'your-repositories'
export interface IClonableRepositoryListItem extends IFilterListItem {
export interface ICloneableRepositoryListItem extends IFilterListItem {
/** The identifier for the item. */
readonly id: string
@ -36,8 +36,8 @@ function getIcon(gitHubRepo: IAPIRepository): OcticonSymbol {
function convert(
repositories: ReadonlyArray<IAPIRepository>
): ReadonlyArray<IClonableRepositoryListItem> {
const repos: ReadonlyArray<IClonableRepositoryListItem> = repositories.map(
): ReadonlyArray<ICloneableRepositoryListItem> {
const repos: ReadonlyArray<ICloneableRepositoryListItem> = repositories.map(
repo => {
const icon = getIcon(repo)
@ -57,7 +57,7 @@ function convert(
export function groupRepositories(
repositories: ReadonlyArray<IAPIRepository>,
login: string
): ReadonlyArray<IFilterListGroup<IClonableRepositoryListItem>> {
): ReadonlyArray<IFilterListGroup<ICloneableRepositoryListItem>> {
const userRepos = repositories.filter(repo => repo.owner.type === 'User')
const orgRepos = repositories.filter(
repo => repo.owner.type === 'Organization'

View file

@ -9,7 +9,7 @@ interface IOkCancelButtonGroupProps {
readonly className?: string
/**
* Does the affirmitive (Ok) button perform a destructive action? This controls
* Does the affirmative (Ok) button perform a destructive action? This controls
* whether the Ok button, or the Cancel button will be the default button,
* defaults to false.
*/
@ -75,13 +75,13 @@ interface IOkCancelButtonGroupProps {
* See https://www.nngroup.com/articles/ok-cancel-or-cancel-ok/
*
* For the purposes of this component Ok and Cancel are
* abstract concepts indicating an affirmitive answer to a
* abstract concepts indicating an affirmative answer to a
* question posed by a dialog or a dismissal of the dialog.
* The actual labels for the buttons can be customized to
* fit the dialog contents.
*
* This component also takes care of selecting the appropriate
* default button depending on whether an affirmitive answer
* default button depending on whether an affirmative answer
* from the user would result in a destructive action or not.
*/
export class OkCancelButtonGroup extends React.Component<
@ -104,7 +104,7 @@ export class OkCancelButtonGroup extends React.Component<
// what gets clicked if the user submits the form using the keyboard.
//
// The dialog component, however, will always treat a form submission
// as the "affirmitive"/Ok action and a form reset as the cancel action
// as the "affirmative"/Ok action and a form reset as the cancel action
// so we flip the event we actually send to the dialog here.
if (this.props.destructive === true) {
event.preventDefault()

View file

@ -0,0 +1,348 @@
import * as React from 'react'
import { ILineTokens } from '../../lib/highlighter/types'
import classNames from 'classnames'
import { relativeChanges } from './changed-range'
import { mapKeysEqual } from '../../lib/equality'
import {
WorkingDirectoryFileChange,
CommittedFileChange,
} from '../../models/status'
/**
* DiffRowType defines the different types of
* rows that a diff visualization can have.
*
* It contains similar values than DiffLineType
* with the addition of `Modified`, which
* corresponds to a line that has both deleted and
* added content.
*/
export enum DiffRowType {
Context = 'Context',
Hunk = 'Hunk',
Added = 'Added',
Deleted = 'Deleted',
Modified = 'Modified',
}
export enum DiffColumn {
Before = 'before',
After = 'after',
}
export type SimplifiedDiffRowData = Omit<IDiffRowData, 'isSelected'>
export interface IDiffRowData {
/**
* The actual contents of the diff line.
*/
readonly content: string
/**
* The line number on the source file.
*/
readonly lineNumber: number
/**
* The line number on the diff.
* This is used for discarding lines
* and for partial committing lines.
*/
readonly diffLineNumber: number
/**
* Flag to display that this diff line lacks a new line.
* This is used to display when a newline is
* added or removed to the last line of a file.
*/
readonly noNewLineIndicator: boolean
/**
* Whether the diff line has been selected for partial committing.
*/
readonly isSelected: boolean
/**
* Array of tokens to do syntax highlighting on the diff line.
*/
readonly tokens: ReadonlyArray<ILineTokens>
}
/**
* IDiffRowAdded represents a row that displays an added line.
*/
interface IDiffRowAdded<T = IDiffRowData> {
readonly type: DiffRowType.Added
/**
* The data object contains information about that added line in the diff.
*/
readonly data: T
/**
* The start line of the hunk where this line belongs in the diff.
*
* In this context, a hunk is not exactly equivalent to a diff hunk, but
* instead marks a group of consecutive added/deleted lines (see hoveredHunk
* comment in the `<SideBySide />` component).
*/
readonly hunkStartLine: number
}
/**
* IDiffRowDeleted represents a row that displays a deleted line.
*/
interface IDiffRowDeleted<T = IDiffRowData> {
readonly type: DiffRowType.Deleted
/**
* The data object contains information about that deleted line in the diff.
*/
readonly data: T
/**
* The start line of the hunk where this line belongs in the diff.
*
* In this context, a hunk is not exactly equivalent to a diff hunk, but
* instead marks a group of consecutive added/deleted lines (see hoveredHunk
* comment in the `<SideBySide />` component).
*/
readonly hunkStartLine: number
}
/**
* IDiffRowModified represents a row that displays both a deleted line inline
* with an added line.
*/
interface IDiffRowModified<T = IDiffRowData> {
readonly type: DiffRowType.Modified
/**
* The beforeData object contains information about the deleted line in the diff.
*/
readonly beforeData: T
/**
* The beforeData object contains information about the added line in the diff.
*/
readonly afterData: T
/**
* The start line of the hunk where this line belongs in the diff.
*
* In this context, a hunk is not exactly equivalent to a diff hunk, but
* instead marks a group of consecutive added/deleted lines (see hoveredHunk
* comment in the `<SideBySide />` component).
*/
readonly hunkStartLine: number
}
/**
* IDiffRowContext represents a row that contains non-modified
* contextual lines around additions/deletions in a diff.
*/
interface IDiffRowContext {
readonly type: DiffRowType.Context
/**
* The actual contents of the contextual line.
*/
readonly content: string
/**
* The line number of this row in the previous state source file.
*/
readonly beforeLineNumber: number
/**
* The line number of this row in the next state source file.
*/
readonly afterLineNumber: number
/**
* Tokens to use to syntax highlight the contents of the before version of the line.
*/
readonly beforeTokens: ReadonlyArray<ILineTokens>
/**
* Tokens to use to syntax highlight the contents of the after version of the line.
*/
readonly afterTokens: ReadonlyArray<ILineTokens>
}
/**
* IDiffRowContext represents a row that contains the header
* of a diff hunk.
*/
interface IDiffRowHunk {
readonly type: DiffRowType.Hunk
/**
* The actual contents of the line.
*/
readonly content: string
}
export type DiffRow =
| IDiffRowAdded
| IDiffRowDeleted
| IDiffRowModified
| IDiffRowContext
| IDiffRowHunk
export type SimplifiedDiffRow =
| IDiffRowAdded<SimplifiedDiffRowData>
| IDiffRowDeleted<SimplifiedDiffRowData>
| IDiffRowModified<SimplifiedDiffRowData>
| IDiffRowContext
| IDiffRowHunk
export type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange
/**
* Returns an object with two ILineTokens objects that can be used to highlight
* the added and removed characters between two lines.
*
* The `before` object contains the tokens to be used against the `lineBefore` string
* while the `after` object contains the tokens to use with the `lineAfter` string.
*
* This method can be used in conjunction with the `syntaxHighlightLine()` method to
* get the difference between two lines highlighted:
*
* syntaxHighlightLine(
* lineBefore,
* getDiffTokens(lineBefore, lineAfter).before
* )
*
* @param lineBefore The first version of the line to compare.
* @param lineAfter The second version of the line to compare.
*/
export function getDiffTokens(
lineBefore: string,
lineAfter: string
): { before: ILineTokens; after: ILineTokens } {
const changeRanges = relativeChanges(lineBefore, lineAfter)
return {
before: {
[changeRanges.stringARange.location]: {
token: 'diff-delete-inner',
length: changeRanges.stringARange.length,
},
},
after: {
[changeRanges.stringBRange.location]: {
token: 'diff-add-inner',
length: changeRanges.stringBRange.length,
},
},
}
}
/**
* Returns an JSX element with syntax highlighting of the passed line using both
* the syntaxTokens and diffTokens.
*
* @param line The line to syntax highlight.
* @param tokensArray An array of ILineTokens objects that is used for syntax highlighting.
*/
export function syntaxHighlightLine(
line: string,
tokensArray: ReadonlyArray<ILineTokens>
): JSX.Element {
const elements = []
let currentElement = {
content: '',
tokens: new Map<string, number>(),
}
for (let i = 0; i < line.length; i++) {
const char = line[i]
const newTokens = new Map<string, number>()
for (const [token, endPosition] of currentElement.tokens) {
if (endPosition > i) {
newTokens.set(token, endPosition)
}
}
for (const tokens of tokensArray) {
if (tokens[i] !== undefined && tokens[i].length > 0) {
// ILineTokens can contain multiple tokens separated by spaces.
// We split them to avoid creating unneeded HTML elements when
// these tokens do not maintain the same order.
const tokenNames = tokens[i].token.split(' ')
const position = i + tokens[i].length
for (const name of tokenNames) {
const existingTokenPosition = newTokens.get(name)
// While it's rare, it's theoretically possible that the same
// token exists for the same start position with different end
// positions. If this happens, we choose the longest one.
if (
existingTokenPosition === undefined ||
position > existingTokenPosition
) {
newTokens.set(name, position)
}
}
}
}
// If the calculated tokens for the character
// are the same as the ones for the current element,
// we can just append the character on that element contents.
// Otherwise, we need to create a new element with the tokens
// and "archive" the current element.
if (mapKeysEqual(currentElement.tokens, newTokens)) {
currentElement.content += char
currentElement.tokens = newTokens
} else {
elements.push({
tokens: currentElement.tokens,
content: currentElement.content,
})
currentElement = {
content: char,
tokens: newTokens,
}
}
}
// Add the remaining current element to the list of elements.
elements.push({
tokens: currentElement.tokens,
content: currentElement.content,
})
return (
<>
{elements.map((element, i) => {
if (element.tokens.size === 0) {
// If the element does not contain any token
// we can skip creating a span.
return element.content
}
return (
<span
key={i}
className={classNames(
[...element.tokens.keys()].map(name => `cm-${name}`)
)}
>
{element.content}
</span>
)
})}
</>
)
}
/** Utility function for checking whether a file supports selection */
export function canSelect(
file: ChangedFile
): file is WorkingDirectoryFileChange {
return file instanceof WorkingDirectoryFileChange
}

View file

@ -0,0 +1,180 @@
import * as React from 'react'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { Octicon, OcticonSymbol } from '../octicons'
import { RadioButton } from '../lib/radio-button'
import { getBoolean, setBoolean } from '../../lib/local-storage'
import FocusTrap from 'focus-trap-react'
import { Options as FocusTrapOptions } from 'focus-trap'
interface IDiffOptionsProps {
readonly hideWhitespaceChanges?: boolean
readonly onHideWhitespaceChangesChanged?: (
hideWhitespaceChanges: boolean
) => void
readonly showSideBySideDiff: boolean
readonly onShowSideBySideDiffChanged: (showSideBySideDiff: boolean) => void
}
interface IDiffOptionsState {
readonly isOpen: boolean
readonly showNewCallout: boolean
}
const HasSeenSplitDiffKey = 'has-seen-split-diff-option'
export class DiffOptions extends React.Component<
IDiffOptionsProps,
IDiffOptionsState
> {
private focusTrapOptions: FocusTrapOptions
private diffOptionsRef = React.createRef<HTMLDivElement>()
public constructor(props: IDiffOptionsProps) {
super(props)
this.state = {
isOpen: false,
showNewCallout: getBoolean(HasSeenSplitDiffKey) !== true,
}
this.focusTrapOptions = {
allowOutsideClick: true,
escapeDeactivates: true,
onDeactivate: this.closePopover,
}
}
private onTogglePopover = (event: React.FormEvent<HTMLButtonElement>) => {
event.preventDefault()
if (this.state.isOpen) {
this.closePopover()
} else {
this.openPopover()
}
}
private openPopover = () => {
this.setState(prevState => {
if (!prevState.isOpen) {
document.addEventListener('mousedown', this.onDocumentMouseDown)
return { isOpen: true }
}
return null
})
}
private closePopover = () => {
this.setState(prevState => {
if (prevState.isOpen) {
if (this.state.showNewCallout) {
setBoolean(HasSeenSplitDiffKey, true)
}
document.removeEventListener('mousedown', this.onDocumentMouseDown)
return { isOpen: false, showNewCallout: false }
}
return null
})
}
public componentWillUnmount() {
document.removeEventListener('mousedown', this.onDocumentMouseDown)
}
private onDocumentMouseDown = (event: MouseEvent) => {
const { current: ref } = this.diffOptionsRef
const { target } = event
if (ref !== null && target instanceof Node && !ref.contains(target)) {
this.closePopover()
}
}
private onHideWhitespaceChangesChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
if (this.props.onHideWhitespaceChangesChanged !== undefined) {
this.props.onHideWhitespaceChangesChanged(event.currentTarget.checked)
}
}
public render() {
return (
<div className="diff-options-component" ref={this.diffOptionsRef}>
<button onClick={this.onTogglePopover}>
<Octicon symbol={OcticonSymbol.gear} />
<Octicon symbol={OcticonSymbol.triangleDown} />
{this.state.showNewCallout && (
<div className="call-to-action-bubble">New</div>
)}
</button>
{this.state.isOpen && this.renderPopover()}
</div>
)
}
private renderPopover() {
return (
<FocusTrap active={true} focusTrapOptions={this.focusTrapOptions}>
<div className="popover">
{this.renderHideWhitespaceChanges()}
{this.renderShowSideBySide()}
</div>
</FocusTrap>
)
}
private onUnifiedSelected = () => {
this.props.onShowSideBySideDiffChanged(false)
}
private onSideBySideSelected = () => {
this.props.onShowSideBySideDiffChanged(true)
}
private renderShowSideBySide() {
return (
<section>
<h3>Diff display</h3>
<RadioButton
value="Unified"
checked={!this.props.showSideBySideDiff}
label="Unified"
onSelected={this.onUnifiedSelected}
/>
<RadioButton
value="Split"
checked={this.props.showSideBySideDiff}
label={
<>
<div>Split</div>
<div className="call-to-action-bubble">Beta</div>
</>
}
onSelected={this.onSideBySideSelected}
/>
</section>
)
}
private renderHideWhitespaceChanges() {
if (this.props.hideWhitespaceChanges === undefined) {
return null
}
return (
<section>
<h3>Whitespace</h3>
<Checkbox
value={
this.props.hideWhitespaceChanges
? CheckboxValue.On
: CheckboxValue.Off
}
onChange={this.onHideWhitespaceChangesChanged}
label={
__DARWIN__ ? 'Hide Whitespace Changes' : 'Hide whitespace changes'
}
/>
</section>
)
}
}

View file

@ -0,0 +1,64 @@
import * as React from 'react'
import { TextBox } from '../lib/text-box'
interface IDiffSearchInputProps {
/**
* Called when the user indicated that they either want to initiate a search
* or want to advance to the next hit (typically done by hitting `Enter`).
*/
readonly onSearch: (query: string, direction: 'next' | 'previous') => void
/**
* Called when the user indicates that they want to abort the search,
* either by clicking outside of the component or by hitting `Escape`.
*/
readonly onClose: () => void
}
interface IDiffSearchInputState {
readonly value: string
}
export class DiffSearchInput extends React.Component<
IDiffSearchInputProps,
IDiffSearchInputState
> {
public constructor(props: IDiffSearchInputProps) {
super(props)
this.state = { value: '' }
}
public render() {
return (
<div className="diff-search">
<TextBox
placeholder="Search..."
type="search"
autoFocus={true}
onValueChanged={this.onChange}
onKeyDown={this.onKeyDown}
onBlur={this.onBlur}
value={this.state.value}
/>
</div>
)
}
private onChange = (value: string) => {
this.setState({ value })
}
private onBlur = () => {
this.props.onClose()
}
private onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
if (evt.key === 'Escape' && !evt.defaultPrevented) {
evt.preventDefault()
this.props.onClose()
} else if (evt.key === 'Enter' && !evt.defaultPrevented) {
evt.preventDefault()
this.props.onSearch(this.state.value, evt.shiftKey ? 'previous' : 'next')
}
}
}

View file

@ -51,27 +51,38 @@ function skipLine(stream: CodeMirror.StringStream, state: IState) {
* important because for context lines we might only have tokens in
* one version and we need to be resilient about that.
*/
function getTokensForDiffLine(
export function getTokensForDiffLine(
diffLine: DiffLine,
oldTokens: ITokens | undefined,
newTokens: ITokens | undefined
) {
const oldTokensResult = getTokens(diffLine.oldLineNumber, oldTokens)
if (oldTokensResult !== null) {
return oldTokensResult
}
return getTokens(diffLine.newLineNumber, newTokens)
}
/**
* Attempt to get tokens for a particular diff line. This will attempt
* to look up tokens in both the old tokens and the new which is
* important because for context lines we might only have tokens in
* one version and we need to be resilient about that.
*/
export function getTokens(
lineNumber: number | null,
tokens: ITokens | undefined
) {
// Note: Diff lines numbers start at one so we adjust this in order
// to get the line _index_ in the before or after file contents.
if (
oldTokens &&
diffLine.oldLineNumber &&
oldTokens[diffLine.oldLineNumber - 1]
tokens !== undefined &&
lineNumber !== null &&
tokens[lineNumber - 1] !== undefined
) {
return oldTokens[diffLine.oldLineNumber - 1]
}
if (
newTokens &&
diffLine.newLineNumber &&
newTokens[diffLine.newLineNumber - 1]
) {
return newTokens[diffLine.newLineNumber - 1]
return tokens[lineNumber - 1]
}
return null

View file

@ -19,7 +19,9 @@ export class ImageContainer extends React.Component<IImageProps, {}> {
const imageSource = `data:${image.mediaType};base64,${image.contents}`
return (
<img src={imageSource} style={this.props.style} onLoad={this.onLoad} />
<div className="image-wrapper">
<img src={imageSource} style={this.props.style} onLoad={this.onLoad} />
</div>
)
}

View file

@ -108,8 +108,8 @@ export class ModifiedImageDiff extends React.Component<
this.resizedTimeoutID = null
const containerSize = {
width: contentRect.width,
height: contentRect.height,
width: target.offsetWidth,
height: target.offsetHeight,
}
this.setState({ containerSize })
}

View file

@ -5,6 +5,10 @@ import { ISize } from './sizing'
import { formatBytes } from '../../lib/bytes'
import classNames from 'classnames'
function percentDiff(previous: number, current: number) {
return `${Math.abs(Math.round((current / previous) * 100))}%`
}
interface ITwoUpProps extends ICommonImageDiffProperties {
readonly previousImageSize: ISize | null
readonly currentImageSize: ISize | null
@ -12,29 +16,27 @@ interface ITwoUpProps extends ICommonImageDiffProperties {
export class TwoUp extends React.Component<ITwoUpProps, {}> {
public render() {
const percentDiff = (previous: number, current: number) => {
const diff = Math.round((100 * (current - previous)) / previous)
const sign = diff > 0 ? '+' : ''
return sign + diff + '%'
}
const zeroSize = { width: 0, height: 0 }
const previousImageSize = this.props.previousImageSize || zeroSize
const currentImageSize = this.props.currentImageSize || zeroSize
const diffPercent = percentDiff(
this.props.previous.bytes,
this.props.current.bytes
)
const diffBytes = this.props.current.bytes - this.props.previous.bytes
const diffBytesSign = diffBytes >= 0 ? '+' : '-'
const { current, previous } = this.props
const diffPercent = percentDiff(previous.bytes, current.bytes)
const diffBytes = current.bytes - previous.bytes
const diffBytesSign = diffBytes >= 0 ? '+' : ''
const style: React.CSSProperties = {
maxWidth: this.props.maxSize.width,
}
return (
<div className="image-diff-container" ref={this.props.onContainerRef}>
<div className="image-diff-two-up">
<div className="image-diff-previous">
<div className="image-diff-previous" style={style}>
<div className="image-diff-header">Deleted</div>
<ImageContainer
image={this.props.previous}
image={previous}
onElementLoad={this.props.onPreviousImageLoad}
/>
@ -42,14 +44,14 @@ export class TwoUp extends React.Component<ITwoUpProps, {}> {
<span className="strong">W:</span> {previousImageSize.width}
px | <span className="strong">H:</span> {previousImageSize.height}
px | <span className="strong">Size:</span>{' '}
{formatBytes(this.props.previous.bytes)}
{formatBytes(previous.bytes, 2, false)}
</div>
</div>
<div className="image-diff-current">
<div className="image-diff-current" style={style}>
<div className="image-diff-header">Added</div>
<ImageContainer
image={this.props.current}
image={current}
onElementLoad={this.props.onCurrentImageLoad}
/>
@ -57,7 +59,7 @@ export class TwoUp extends React.Component<ITwoUpProps, {}> {
<span className="strong">W:</span> {currentImageSize.width}
px | <span className="strong">H:</span> {currentImageSize.height}
px | <span className="strong">Size:</span>{' '}
{formatBytes(this.props.current.bytes)}
{formatBytes(current.bytes, 2, false)}
</div>
</div>
</div>
@ -70,7 +72,11 @@ export class TwoUp extends React.Component<ITwoUpProps, {}> {
})}
>
{diffBytes !== 0
? `${diffBytesSign}${formatBytes(diffBytes)} (${diffPercent})`
? `${diffBytesSign}${formatBytes(
diffBytes,
2,
false
)} (${diffPercent})`
: 'No size difference'}
</span>
</div>

View file

@ -28,6 +28,11 @@ import {
} from './image-diffs'
import { BinaryFile } from './binary-file'
import { TextDiff } from './text-diff'
import { SideBySideDiff } from './side-by-side-diff'
import {
enableExperimentalDiffViewer,
enableSideBySideDiffs,
} from '../../lib/feature-flag'
// image used when no diff is displayed
const NoDiffImage = encodePathAsUrl(__dirname, 'static/ufo-alert.svg')
@ -60,6 +65,9 @@ interface IDiffProps {
/** Hiding whitespace in diff. */
readonly hideWhitespaceInDiff: boolean
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
/** Whether we should show a confirmation dialog when the user discards changes */
readonly askForConfirmationOnDiscardChanges?: boolean
@ -238,6 +246,25 @@ export class Diff extends React.Component<IDiffProps, IDiffState> {
}
private renderTextDiff(diff: ITextDiff) {
if (
enableExperimentalDiffViewer() ||
(enableSideBySideDiffs() && this.props.showSideBySideDiff)
) {
return (
<SideBySideDiff
repository={this.props.repository}
file={this.props.file}
diff={diff}
showSideBySideDiff={this.props.showSideBySideDiff}
onIncludeChanged={this.props.onIncludeChanged}
onDiscardChanges={this.props.onDiscardChanges}
askForConfirmationOnDiscardChanges={
this.props.askForConfirmationOnDiscardChanges
}
/>
)
}
return (
<TextDiff
repository={this.props.repository}

View file

@ -49,6 +49,9 @@ interface ISeamlessDiffSwitcherProps {
/** Hiding whitespace in diff. */
readonly hideWhitespaceInDiff: boolean
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
/** Whether we should show a confirmation dialog when the user discards changes */
readonly askForConfirmationOnDiscardChanges?: boolean
@ -190,6 +193,7 @@ export class SeamlessDiffSwitcher extends React.Component<
imageDiffType,
readOnly,
hideWhitespaceInDiff,
showSideBySideDiff,
onIncludeChanged,
onDiscardChanges,
diff,
@ -220,6 +224,7 @@ export class SeamlessDiffSwitcher extends React.Component<
diff={diff}
readOnly={readOnly}
hideWhitespaceInDiff={hideWhitespaceInDiff}
showSideBySideDiff={showSideBySideDiff}
askForConfirmationOnDiscardChanges={
this.props.askForConfirmationOnDiscardChanges
}

View file

@ -0,0 +1,436 @@
import * as React from 'react'
import {
syntaxHighlightLine,
DiffRow,
DiffRowType,
IDiffRowData,
DiffColumn,
} from './diff-helpers'
import { ILineTokens } from '../../lib/highlighter/types'
import classNames from 'classnames'
import { Octicon } from '../octicons'
import { narrowNoNewlineSymbol } from './text-diff'
import { shallowEquals, structuralEquals } from '../../lib/equality'
interface ISideBySideDiffRowProps {
/**
* The row data. This contains most of the information used to render the row.
*/
readonly row: DiffRow
/**
* Whether the diff is selectable or read-only.
*/
readonly isDiffSelectable: boolean
/**
* Whether the row belongs to a hunk that is hovered.
*/
readonly isHunkHovered: boolean
/**
* Whether to display the rows side by side.
*/
readonly showSideBySideDiff: boolean
/**
* The index of the row in the displayed diff.
*/
readonly numRow: number
/**
* Called when a line selection is started. Called with the
* row and column of the selected line and a flag to indicate
* if the user is selecting or unselecting lines.
* (only relevant when isDiffSelectable is true)
*/
readonly onStartSelection: (
row: number,
column: DiffColumn,
select: boolean
) => void
/**
* Called when a line selection is updated. Called with the
* row and column of the hovered line.
* (only relevant when isDiffSelectable is true)
*/
readonly onUpdateSelection: (row: number, column: DiffColumn) => void
/**
* Called when the user hovers the hunk handle. Called with the start
* line of the hunk.
* (only relevant when isDiffSelectable is true)
*/
readonly onMouseEnterHunk: (hunkStartLine: number) => void
/**
* Called when the user unhovers the hunk handle. Called with the start
* line of the hunk.
* (only relevant when isDiffSelectable is true)
*/
readonly onMouseLeaveHunk: (hunkStartLine: number) => void
/**
* Called when the user clicks on the hunk handle. Called with the start
* line of the hunk and a flag indicating whether to select or unselect
* the hunk.
* (only relevant when isDiffSelectable is true)
*/
readonly onClickHunk: (hunkStartLine: number, select: boolean) => void
/**
* Called when the user right-clicks a line number. Called with the
* clicked diff line number.
* (only relevant when isDiffSelectable is true)
*/
readonly onContextMenuLine: (diffLineNumber: number) => void
/**
* Called when the user right-clicks a hunk handle. Called with the start
* line of the hunk.
* (only relevant when isDiffSelectable is true)
*/
readonly onContextMenuHunk: (hunkStartLine: number) => void
/**
* Called when the user right-clicks text on the diff.
*/
readonly onContextMenuText: () => void
}
export class SideBySideDiffRow extends React.Component<
ISideBySideDiffRowProps
> {
public render() {
const { row, showSideBySideDiff } = this.props
switch (row.type) {
case DiffRowType.Hunk:
return (
<div className="row hunk-info">
{this.renderLineNumber()}
{this.renderContentFromString(row.content)}
</div>
)
case DiffRowType.Context:
const { beforeLineNumber, afterLineNumber } = row
if (!showSideBySideDiff) {
return (
<div className="row context">
<div className="before">
{this.renderLineNumbers([beforeLineNumber, afterLineNumber])}
{this.renderContentFromString(row.content, row.beforeTokens)}
</div>
</div>
)
}
return (
<div className="row context">
<div className="before">
{this.renderLineNumber(beforeLineNumber)}
{this.renderContentFromString(row.content, row.beforeTokens)}
</div>
<div className="after">
{this.renderLineNumber(afterLineNumber)}
{this.renderContentFromString(row.content, row.afterTokens)}
</div>
</div>
)
case DiffRowType.Added: {
const { lineNumber, isSelected } = row.data
if (!showSideBySideDiff) {
return (
<div
className="row added"
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className="after">
{this.renderLineNumbers([undefined, lineNumber], isSelected)}
{this.renderHunkHandle()}
{this.renderContent(row.data)}
</div>
</div>
)
}
return (
<div className="row added" onMouseEnter={this.onMouseEnterLineNumber}>
<div className="before">
{this.renderLineNumber()}
{this.renderContentFromString('')}
</div>
{this.renderHunkHandle()}
<div className="after">
{this.renderLineNumber(lineNumber, isSelected)}
{this.renderContent(row.data)}
</div>
</div>
)
}
case DiffRowType.Deleted: {
const { lineNumber, isSelected } = row.data
if (!showSideBySideDiff) {
return (
<div
className="row deleted"
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className="before">
{this.renderLineNumbers([lineNumber, undefined], isSelected)}
{this.renderHunkHandle()}
{this.renderContent(row.data)}
</div>
</div>
)
}
return (
<div
className="row deleted"
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className="before">
{this.renderLineNumber(lineNumber, isSelected)}
{this.renderContent(row.data)}
</div>
{this.renderHunkHandle()}
<div className="after">
{this.renderLineNumber()}
{this.renderContentFromString('')}
</div>
</div>
)
}
case DiffRowType.Modified: {
const { beforeData: before, afterData: after } = row
return (
<div className="row modified">
<div className="before" onMouseEnter={this.onMouseEnterLineNumber}>
{this.renderLineNumber(before.lineNumber, before.isSelected)}
{this.renderContent(before)}
</div>
{this.renderHunkHandle()}
<div className="after" onMouseEnter={this.onMouseEnterLineNumber}>
{this.renderLineNumber(after.lineNumber, after.isSelected)}
{this.renderContent(after)}
</div>
</div>
)
}
}
}
public shouldComponentUpdate(nextProps: ISideBySideDiffRowProps) {
const { row: prevRow, ...restPrevProps } = this.props
const { row: nextRow, ...restNextProps } = nextProps
if (!structuralEquals(prevRow, nextRow)) {
return true
}
return !shallowEquals(restPrevProps, restNextProps)
}
private renderContentFromString(
content: string,
tokens: ReadonlyArray<ILineTokens> = []
) {
return this.renderContent({ content, tokens, noNewLineIndicator: false })
}
private renderContent(
data: Pick<IDiffRowData, 'content' | 'noNewLineIndicator' | 'tokens'>
) {
return (
<div className="content" onContextMenu={this.props.onContextMenuText}>
{syntaxHighlightLine(data.content, data.tokens)}
{data.noNewLineIndicator && (
<Octicon
symbol={narrowNoNewlineSymbol}
title="No newline at end of file"
/>
)}
</div>
)
}
private renderHunkHandle() {
if (!this.props.isDiffSelectable) {
return null
}
return (
<div
className="hunk-handle"
onMouseEnter={this.onMouseEnterHunk}
onMouseLeave={this.onMouseLeaveHunk}
onClick={this.onClickHunk}
onContextMenu={this.onContextMenuHunk}
></div>
)
}
/**
* Renders the line number box.
*
* @param lineNumbers Array with line numbers to display.
* @param isSelected Whether the line has been selected.
* If undefined is passed, the line is treated
* as non-selectable.
*/
private renderLineNumbers(
lineNumbers: Array<number | undefined>,
isSelected?: boolean
) {
if (!this.props.isDiffSelectable || isSelected === undefined) {
return (
<div className="line-number">
{lineNumbers.map((lineNumber, index) => (
<span key={index}>{lineNumber}</span>
))}
</div>
)
}
return (
<div
className={classNames('line-number', 'selectable', {
'line-selected': isSelected,
hover: this.props.isHunkHovered,
})}
onMouseDown={this.onMouseDownLineNumber}
onContextMenu={this.onContextMenuLineNumber}
>
{lineNumbers.map((lineNumber, index) => (
<span key={index}>{lineNumber}</span>
))}
</div>
)
}
/**
* Renders the line number box.
*
* @param lineNumber Line number to display.
* @param isSelected Whether the line has been selected.
* If undefined is passed, the line is treated
* as non-selectable.
*/
private renderLineNumber(lineNumber?: number, isSelected?: boolean) {
return this.renderLineNumbers([lineNumber], isSelected)
}
private getDiffColumn(targetElement?: Element): DiffColumn | null {
const { row, showSideBySideDiff } = this.props
// On unified diffs we don't have columns so we always use "before" to not
// mess up with line selections.
if (!showSideBySideDiff) {
return DiffColumn.Before
}
switch (row.type) {
case DiffRowType.Added:
return DiffColumn.After
case DiffRowType.Deleted:
return DiffColumn.Before
case DiffRowType.Modified:
return targetElement?.closest('.after')
? DiffColumn.After
: DiffColumn.Before
}
return null
}
/**
* Returns the data object for the current row if the current row is
* added, deleted or modified, null otherwise.
*
* On modified rows it normally returns the data corresponding to the
* previous state. In this situation an optional targetElement param can
* be passed which will be used to infer either the previous or the next
* state data (based on which column the target element belongs).
*
* @param targetElement Optional element to pass to infer which data to use
* on modified rows.
*/
private getDiffData(targetElement?: Element): IDiffRowData | null {
const { row } = this.props
switch (row.type) {
case DiffRowType.Added:
case DiffRowType.Deleted:
return row.data
case DiffRowType.Modified:
return targetElement?.closest('.after') ? row.afterData : row.beforeData
}
return null
}
private onMouseDownLineNumber = (evt: React.MouseEvent) => {
if (evt.buttons === 2) {
return
}
const data = this.getDiffData(evt.currentTarget)
const column = this.getDiffColumn(evt.currentTarget)
if (data !== null && column !== null) {
this.props.onStartSelection(this.props.numRow, column, !data.isSelected)
}
}
private onMouseEnterLineNumber = (evt: React.MouseEvent) => {
const data = this.getDiffData(evt.currentTarget)
const column = this.getDiffColumn(evt.currentTarget)
if (data !== null && column !== null) {
this.props.onUpdateSelection(this.props.numRow, column)
}
}
private onMouseEnterHunk = () => {
if ('hunkStartLine' in this.props.row) {
this.props.onMouseEnterHunk(this.props.row.hunkStartLine)
}
}
private onMouseLeaveHunk = () => {
if ('hunkStartLine' in this.props.row) {
this.props.onMouseLeaveHunk(this.props.row.hunkStartLine)
}
}
private onClickHunk = () => {
// Since the hunk handler lies between the previous and the next columns,
// when clicking on it on modified lines we cannot know if we should
// use the state of the previous or the next line to know whether we should
// select or unselect the hunk.
// To workaround this, we're relying on the logic of `getDiffData()` to have
// a consistent behaviour (which will use the previous column state in this case).
const data = this.getDiffData()
if (data !== null && 'hunkStartLine' in this.props.row) {
this.props.onClickHunk(this.props.row.hunkStartLine, !data.isSelected)
}
}
private onContextMenuLineNumber = (evt: React.MouseEvent) => {
const data = this.getDiffData(evt.currentTarget)
if (data !== null) {
this.props.onContextMenuLine(data.diffLineNumber)
}
}
private onContextMenuHunk = () => {
if ('hunkStartLine' in this.props.row) {
this.props.onContextMenuHunk(this.props.row.hunkStartLine)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -41,13 +41,14 @@ import { uuid } from '../../lib/uuid'
import { showContextualMenu } from '../main-process-proxy'
import { IMenuItem } from '../../lib/menu-item'
import { enableDiscardLines } from '../../lib/feature-flag'
import { canSelect } from './diff-helpers'
/** The longest line for which we'd try to calculate a line diff. */
const MaxIntraLineDiffStringLength = 4096
// This is a custom version of the no-newline octicon that's exactly as
// tall as it needs to be (8px) which helps with aligning it on the line.
const narrowNoNewlineSymbol = new OcticonSymbol(
export const narrowNoNewlineSymbol = new OcticonSymbol(
16,
8,
'm 16,1 0,3 c 0,0.55 -0.45,1 -1,1 l -3,0 0,2 -3,-3 3,-3 0,2 2,0 0,-2 2,0 z M 8,4 C 8,6.2 6.2,8 4,8 1.8,8 0,6.2 0,4 0,1.8 1.8,0 4,0 6.2,0 8,1.8 8,4 Z M 1.5,5.66 5.66,1.5 C 5.18,1.19 4.61,1 4,1 2.34,1 1,2.34 1,4 1,4.61 1.19,5.17 1.5,5.66 Z M 7,4 C 7,3.39 6.81,2.83 6.5,2.34 L 2.34,6.5 C 2.82,6.81 3.39,7 4,7 5.66,7 7,5.66 7,4 Z'
@ -128,11 +129,6 @@ function targetHasClass(target: EventTarget | null, token: string) {
return target instanceof HTMLElement && target.classList.contains(token)
}
/** Utility function for checking whether a file supports selection */
function canSelect(file: ChangedFile): file is WorkingDirectoryFileChange {
return file instanceof WorkingDirectoryFileChange
}
interface ITextDiffProps {
readonly repository: Repository
/** The file whose diff should be displayed. */

View file

@ -1506,7 +1506,7 @@ export class Dispatcher {
/**
* Change the workflow preferences for the specified repository.
*
* @param repository The repositosy to update.
* @param repository The repository to update.
* @param workflowPreferences The object with the workflow settings to use.
*/
public async updateRepositoryWorkflowPreferences(
@ -1965,6 +1965,11 @@ export class Dispatcher {
)
}
/** Change the side by side diff setting */
public onShowSideBySideDiffChanged(showSideBySideDiff: boolean) {
return this.appStore._setShowSideBySideDiff(showSideBySideDiff)
}
/** Install the global Git LFS filters. */
public installGlobalLFSFilters(force: boolean): Promise<void> {
return this.appStore._installGlobalLFSFilters(force)
@ -2093,7 +2098,7 @@ export class Dispatcher {
}
/**
* Initialze the compare state for the current repository.
* Initialize the compare state for the current repository.
*/
public initializeCompare(
repository: Repository,
@ -2450,7 +2455,7 @@ export class Dispatcher {
/**
* Increment the number of times the user has opened their repository in
* Finder/Explorerfrom the suggested next steps view
* Finder/Explorer from the suggested next steps view
*/
public recordSuggestedStepOpenWorkingDirectory(): Promise<void> {
return this.statsStore.recordSuggestedStepOpenWorkingDirectory()
@ -2497,7 +2502,7 @@ export class Dispatcher {
}
/**
* Moves unconmitted changes to the branch being checked out
* Moves uncommitted changes to the branch being checked out
*/
public async moveChangesToBranchAndCheckout(
repository: Repository,

View file

@ -10,9 +10,13 @@ import { getAvatarUsersForCommit, IAvatarUser } from '../../models/avatar'
import { AvatarStack } from '../lib/avatar-stack'
import { CommitAttribution } from '../lib/commit-attribution'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { enableGitTagsDisplay } from '../../lib/feature-flag'
import {
enableGitTagsDisplay,
enableSideBySideDiffs,
} from '../../lib/feature-flag'
import { Tokenizer, TokenResult } from '../../lib/text-token-parser'
import { wrapRichTextCommitMessage } from '../../lib/wrap-rich-text-commit-message'
import { DiffOptions } from '../diff/diff-options'
interface ICommitSummaryProps {
readonly repository: Repository
@ -36,7 +40,13 @@ interface ICommitSummaryProps {
readonly hideDescriptionBorder: boolean
readonly hideWhitespaceInDiff: boolean
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void
/** Called when the user changes the side by side diffs setting. */
readonly onShowSideBySideDiffChanged: (checked: boolean) => void
}
interface ICommitSummaryState {
@ -343,20 +353,42 @@ export class CommitSummary extends React.Component<
</li>
{this.renderTags()}
<li
className="commit-summary-meta-item without-truncation"
title={filesDescription}
>
<Checkbox
label="Hide Whitespace"
value={
this.props.hideWhitespaceInDiff
? CheckboxValue.On
: CheckboxValue.Off
}
onChange={this.onHideWhitespaceInDiffChanged}
/>
</li>
{enableSideBySideDiffs() || (
<li
className="commit-summary-meta-item without-truncation"
title="Hide Whitespace"
>
<Checkbox
label="Hide Whitespace"
value={
this.props.hideWhitespaceInDiff
? CheckboxValue.On
: CheckboxValue.Off
}
onChange={this.onHideWhitespaceInDiffChanged}
/>
</li>
)}
{enableSideBySideDiffs() && (
<>
<li
className="commit-summary-meta-item without-truncation"
title="Split View"
>
<DiffOptions
onHideWhitespaceChangesChanged={
this.props.onHideWhitespaceInDiffChanged
}
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
showSideBySideDiff={this.props.showSideBySideDiff}
onShowSideBySideDiffChanged={
this.props.onShowSideBySideDiffChanged
}
/>
</li>
</>
)}
</ul>
</div>

View file

@ -0,0 +1,56 @@
import * as React from 'react'
import { CommittedFileChange } from '../../models/status'
import { mapStatus } from '../../lib/status'
import { PathLabel } from '../lib/path-label'
import { Octicon, iconForStatus } from '../octicons'
interface ICommittedFileItemProps {
readonly availableWidth: number
readonly file: CommittedFileChange
readonly onContextMenu?: (
file: CommittedFileChange,
event: React.MouseEvent<HTMLDivElement>
) => void
}
export class CommittedFileItem extends React.Component<
ICommittedFileItemProps
> {
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.props.onContextMenu !== undefined) {
this.props.onContextMenu(this.props.file, event)
}
}
public render() {
const { file } = this.props
const status = file.status
const fileStatus = mapStatus(status)
const listItemPadding = 10 * 2
const statusWidth = 16
const filePathPadding = 5
const availablePathWidth =
this.props.availableWidth -
listItemPadding -
filePathPadding -
statusWidth
return (
<div className="file" onContextMenu={this.onContextMenu}>
<PathLabel
path={file.path}
status={file.status}
availableWidth={availablePathWidth}
/>
<Octicon
symbol={iconForStatus(status)}
className={'status status-' + fileStatus.toLowerCase()}
title={fileStatus}
/>
</div>
)
}
}

View file

@ -1,19 +1,18 @@
import * as React from 'react'
import { CommittedFileChange } from '../../models/status'
import { PathLabel } from '../lib/path-label'
import { List } from '../lib/list'
import { Octicon, iconForStatus } from '../octicons'
import { mapStatus } from '../../lib/status'
import { CommittedFileItem } from './committed-file-item'
interface IFileListProps {
readonly files: ReadonlyArray<CommittedFileChange>
readonly selectedFile: CommittedFileChange | null
readonly onSelectedFileChanged: (file: CommittedFileChange) => void
readonly availableWidth: number
readonly onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void
readonly onContextMenu?: (
file: CommittedFileChange,
event: React.MouseEvent<HTMLDivElement>
) => void
}
/**
@ -26,33 +25,12 @@ export class FileList extends React.Component<IFileListProps> {
}
private renderFile = (row: number) => {
const file = this.props.files[row]
const status = file.status
const fileStatus = mapStatus(status)
const listItemPadding = 10 * 2
const statusWidth = 16
const filePathPadding = 5
const availablePathWidth =
this.props.availableWidth -
listItemPadding -
filePathPadding -
statusWidth
return (
<div className="file" onContextMenu={this.props.onContextMenu}>
<PathLabel
path={file.path}
status={file.status}
availableWidth={availablePathWidth}
/>
<Octicon
symbol={iconForStatus(status)}
className={'status status-' + fileStatus.toLowerCase()}
title={fileStatus}
/>
</div>
<CommittedFileItem
file={this.props.files[row]}
availableWidth={this.props.availableWidth}
onContextMenu={this.props.onContextMenu}
/>
)
}

View file

@ -50,6 +50,9 @@ interface ISelectedCommitProps {
readonly onOpenInExternalEditor: (path: string) => void
readonly hideWhitespaceInDiff: boolean
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
/**
* Called when the user requests to open a binary file in an the
* system-assigned application for said file type.
@ -137,6 +140,7 @@ export class SelectedCommit extends React.Component<
diff={diff}
readOnly={true}
hideWhitespaceInDiff={this.props.hideWhitespaceInDiff}
showSideBySideDiff={this.props.showSideBySideDiff}
onOpenBinaryFile={this.props.onOpenBinaryFile}
onChangeImageDiffType={this.props.onChangeImageDiffType}
/>
@ -155,7 +159,9 @@ export class SelectedCommit extends React.Component<
onDescriptionBottomChanged={this.onDescriptionBottomChanged}
hideDescriptionBorder={this.state.hideDescriptionBorder}
hideWhitespaceInDiff={this.props.hideWhitespaceInDiff}
showSideBySideDiff={this.props.showSideBySideDiff}
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
onShowSideBySideDiffChanged={this.onShowSideBySideDiffChanged}
/>
)
}
@ -181,6 +187,10 @@ export class SelectedCommit extends React.Component<
)
}
private onShowSideBySideDiffChanged = (showSideBySideDiff: boolean) => {
this.props.dispatcher.onShowSideBySideDiffChanged(showSideBySideDiff)
}
private onCommitSummaryReset = () => {
this.props.dispatcher.resetCommitSummaryWidth()
}
@ -245,15 +255,13 @@ export class SelectedCommit extends React.Component<
)
}
private onContextMenu = async (event: React.MouseEvent<HTMLDivElement>) => {
private onContextMenu = async (
file: CommittedFileChange,
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault()
if (this.props.selectedFile == null) {
return
}
const filePath = this.props.selectedFile.path
const fullPath = Path.join(this.props.repository.path, filePath)
const fullPath = Path.join(this.props.repository.path, file.path)
const fileExistsOnDisk = await pathExists(fullPath)
if (!fileExistsOnDisk) {
showContextualMenu([
@ -267,7 +275,7 @@ export class SelectedCommit extends React.Component<
return
}
const extension = Path.extname(filePath)
const extension = Path.extname(file.path)
const isSafeExtension = isSafeFileExtension(extension)
const openInExternalEditor = this.props.externalEditorLabel
@ -281,7 +289,7 @@ export class SelectedCommit extends React.Component<
},
{
label: RevealInFileManagerLabel,
action: () => revealInFileManager(this.props.repository, filePath),
action: () => revealInFileManager(this.props.repository, file.path),
enabled: fileExistsOnDisk,
},
{
@ -291,7 +299,7 @@ export class SelectedCommit extends React.Component<
},
{
label: OpenWithDefaultProgramLabel,
action: () => this.onOpenItem(filePath),
action: () => this.onOpenItem(file.path),
enabled: isSafeExtension && fileExistsOnDisk,
},
]

View file

@ -66,7 +66,7 @@ import { PullRequestCoordinator } from '../lib/stores/pull-request-coordinator'
// We're using a polyfill for the upcoming CSS4 `:focus-ring` pseudo-selector.
// This allows us to not have to override default accessibility driven focus
// styles for buttons in the case when a user clicks on a button. This also
// gives better visiblity to individuals who navigate with the keyboard.
// gives better visibility to individuals who navigate with the keyboard.
//
// See:
// https://github.com/WICG/focus-ring

View file

@ -65,7 +65,7 @@ export class AttributeMismatch extends React.Component<
: 'Update existing Git LFS filters?'
}
onDismissed={this.props.onDismissed}
onSubmit={this.onSumit}
onSubmit={this.onSubmit}
>
<DialogContent>
<p>
@ -87,7 +87,7 @@ export class AttributeMismatch extends React.Component<
)
}
private onSumit = () => {
private onSubmit = () => {
this.props.onUpdateExistingFilters()
this.props.onDismissed()
}

View file

@ -8,7 +8,7 @@ interface IAccessTextProps {
* highlighted when the highlight property is set. Literal ampersand
* characters need to be escaped by using two ampersand characters (&&).
*
* At most one character is allowed to have a preceeding ampersand character.
* At most one character is allowed to have a preceding ampersand character.
*/
readonly text: string

View file

@ -18,7 +18,7 @@ function getApp(): Electron.App {
/**
* Get the version of the app.
*
* This is preferrable to using `remote` directly because we cache the result.
* This is preferable to using `remote` directly because we cache the result.
*/
export function getVersion(): string {
if (!version) {
@ -31,7 +31,7 @@ export function getVersion(): string {
/**
* Get the name of the app.
*
* This is preferrable to using `remote` directly because we cache the result.
* This is preferable to using `remote` directly because we cache the result.
*/
export function getName(): string {
if (!name) {
@ -44,7 +44,7 @@ export function getName(): string {
/**
* Get the path to the application.
*
* This is preferrable to using `remote` directly because we cache the result.
* This is preferable to using `remote` directly because we cache the result.
*/
export function getAppPath(): string {
if (!path) {
@ -57,7 +57,7 @@ export function getAppPath(): string {
/**
* Get the path to the user's data.
*
* This is preferrable to using `remote` directly because we cache the result.
* This is preferable to using `remote` directly because we cache the result.
*/
export function getUserDataPath(): string {
if (!userDataPath) {
@ -70,7 +70,7 @@ export function getUserDataPath(): string {
/**
* Get the path to the user's documents path.
*
* This is preferrable to using `remote` directly because we cache the result.
* This is preferable to using `remote` directly because we cache the result.
*/
export function getDocumentsPath(): string {
if (!documentsPath) {

View file

@ -6,7 +6,7 @@ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
* Formats a number of bytes into a human readable string.
*
* This method will uses the IEC representation for orders
* of magnitute (KiB/MiB rather than MB/KB) in order to match
* of magnitude (KiB/MiB rather than MB/KB) in order to match
* the format that Git uses.
*
* Example output:

View file

@ -7,7 +7,7 @@ import { isWebFlowCommitter } from '../../lib/web-flow-committer'
interface ICommitAttributionProps {
/**
* The commit from where to extract the author, commiter
* The commit from where to extract the author, committer
* and co-authors from.
*/
readonly commit: Commit

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