mirror of
https://github.com/desktop/desktop
synced 2024-08-28 12:40:55 +00:00
Merge branch 'development' into releases/2.5.7
This commit is contained in:
commit
3c7ca4889d
|
@ -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 you’d 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
|
||||
|
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -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
4
.github/config.yml
vendored
|
@ -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
|
||||
|
|
4
.github/no-response.yml
vendored
4
.github/no-response.yml
vendored
|
@ -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!
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -397,6 +397,12 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
|
|||
'.pas': 'text/x-pascal',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/toml/toml'),
|
||||
mappings: {
|
||||
'.toml': 'text/x-toml',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
16
app/src/lib/git/add.ts
Normal 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')
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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]),
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]),
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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/')) {
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}>`
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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, '\\$&')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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).
|
||||
*
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()
|
||||
|
|
348
app/src/ui/diff/diff-helpers.tsx
Normal file
348
app/src/ui/diff/diff-helpers.tsx
Normal 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
|
||||
}
|
180
app/src/ui/diff/diff-options.tsx
Normal file
180
app/src/ui/diff/diff-options.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
64
app/src/ui/diff/diff-search-input.tsx
Normal file
64
app/src/ui/diff/diff-search-input.tsx
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
436
app/src/ui/diff/side-by-side-diff-row.tsx
Normal file
436
app/src/ui/diff/side-by-side-diff-row.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
1154
app/src/ui/diff/side-by-side-diff.tsx
Normal file
1154
app/src/ui/diff/side-by-side-diff.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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. */
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
56
app/src/ui/history/committed-file-item.tsx
Normal file
56
app/src/ui/history/committed-file-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue