Merge branch 'development' into releases/3.0.2

This commit is contained in:
Sergio Padrino 2022-06-13 12:39:56 +02:00 committed by GitHub
commit 7975b5b857
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 1246 additions and 312 deletions

View file

@ -1,16 +0,0 @@
# 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: 7
# Label requiring a response
# TODO: also close `needs-reproduction` issues (blocked by https://github.com/probot/no-response/issues/11)
responseRequiredLabel: more-info-needed
# Comment to post when closing an issue due to lack of response.
closeComment: >
Thank you for your issue!
We havent gotten a response to our questions above. With only the information
that is currently in the issue, we dont have enough information to take
action. Were going to close this but dont hesitate to reach out if you have
or find the answers we need. If you answer our questions above, a maintainer
will reopen this issue.

32
.github/workflows/no-response.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: No Response
# Both `issue_comment` and `scheduled` event types are required for this Action
# to work properly.
on:
issue_comment:
types: [created]
schedule:
# Schedule for five minutes after the hour, every hour
- cron: '5 * * * *'
permissions:
issues: write
jobs:
noResponse:
runs-on: ubuntu-latest
steps:
- uses: lee-dohm/no-response@v0.5.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
closeComment: >
Thank you for your issue!
We havent gotten a response to our questions above. With only the
information that is currently in the issue, we dont have enough
information to take action. Were going to close this but dont
hesitate to reach out if you have or find the answers we need. If
you answer our questions above, this issue will automatically
reopen.
daysUntilClose: 7
responseRequiredLabel: more-info-needed

View file

@ -28,7 +28,7 @@
"deep-equal": "^1.0.1",
"desktop-notifications": "^0.2.2",
"desktop-trampoline": "desktop/desktop-trampoline#v0.9.8",
"dexie": "^2.0.0",
"dexie": "^3.2.2",
"dompurify": "^2.3.3",
"dugite": "^1.109.0",
"electron-window-state": "^5.0.3",

View file

@ -19,6 +19,7 @@ declare namespace CodeMirror {
interface StringStreamContext {
lines: string[]
line: number
lookAhead: (n: number) => string
}
/**

View file

@ -635,7 +635,11 @@ onmessage = async (ev: MessageEvent) => {
continue
}
const lineCtx = { lines, line: ix }
const lineCtx = {
lines,
line: ix,
lookAhead: (n: number) => lines[ix + n],
}
const lineStream = new StringStream(line, tabSize, lineCtx)
while (!lineStream.eol()) {

View file

@ -553,6 +553,20 @@ export interface ICommitSelection {
/** The commits currently selected in the app */
readonly shas: ReadonlyArray<string>
/**
* Whether the a selection of commits are group of adjacent to each other.
* Example: Given these are indexes of sha's in history, 3, 4, 5, 6 is contiguous as
* opposed to 3, 5, 8.
*
* Technically order does not matter, but shas are stored in order.
*
* Contiguous selections can be diffed. Non-contiguous selections can be
* cherry-picked, reordered, or squashed.
*
* Assumed that a selections of zero or one commit are contiguous.
* */
readonly isContiguous: boolean
/** The changeset data associated with the selected commit */
readonly changesetData: IChangesetData

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie'
import Dexie, { Transaction } from 'dexie'
export abstract class BaseDatabase extends Dexie {
private schemaVersion: number | undefined
@ -23,7 +23,7 @@ export abstract class BaseDatabase extends Dexie {
protected async conditionalVersion(
version: number,
schema: { [key: string]: string | null },
upgrade?: (t: Dexie.Transaction) => Promise<void>
upgrade?: (t: Transaction) => Promise<void>
) {
if (this.schemaVersion != null && this.schemaVersion < version) {
return

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie'
import Dexie, { Transaction } from 'dexie'
import { BaseDatabase } from './base-database'
export interface IIssue {
@ -37,7 +37,7 @@ export class IssuesDatabase extends BaseDatabase {
}
}
function clearIssues(transaction: Dexie.Transaction) {
function clearIssues(transaction: Transaction) {
// Clear deprecated localStorage keys, we compute the since parameter
// using the database now.
clearDeprecatedKeys()

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie'
import Dexie, { Transaction } from 'dexie'
import { BaseDatabase } from './base-database'
import { WorkflowPreferences } from '../../models/workflow-preferences'
import { assertNonNullable } from '../fatal-error'
@ -144,7 +144,7 @@ export class RepositoriesDatabase extends BaseDatabase {
/**
* Remove any duplicate GitHub repositories that have the same owner and name.
*/
function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) {
function removeDuplicateGitHubRepositories(transaction: Transaction) {
const table = transaction.table<IDatabaseGitHubRepository, number>(
'gitHubRepositories'
)
@ -164,7 +164,7 @@ function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) {
})
}
async function ensureNoUndefinedParentID(tx: Dexie.Transaction) {
async function ensureNoUndefinedParentID(tx: Transaction) {
return tx
.table<IDatabaseGitHubRepository, number>('gitHubRepositories')
.toCollection()
@ -185,7 +185,7 @@ async function ensureNoUndefinedParentID(tx: Dexie.Transaction) {
* (https://github.com/desktop/desktop/pull/1242). This scenario ought to be
* incredibly unlikely.
*/
async function createOwnerKey(tx: Dexie.Transaction) {
async function createOwnerKey(tx: Transaction) {
const ownersTable = tx.table<IDatabaseOwner, number>('owners')
const ghReposTable = tx.table<IDatabaseGitHubRepository, number>(
'gitHubRepositories'

View file

@ -23,6 +23,10 @@ const editors: IDarwinExternalEditor[] = [
name: 'Atom',
bundleIdentifiers: ['com.github.atom'],
},
{
name: 'Aptana Studio',
bundleIdentifiers: ['aptana.studio'],
},
{
name: 'MacVim',
bundleIdentifiers: ['org.vim.MacVim'],

View file

@ -299,6 +299,15 @@ const editors: WindowsExternalEditor[] = [
displayNamePrefix: 'SlickEdit',
publisher: 'SlickEdit Inc.',
},
{
name: 'Aptana Studio 3',
registryKeys: [
Wow64LocalMachineUninstallKey('{2D6C1116-78C6-469C-9923-3E549218773F}'),
],
executableShimPaths: [['AptanaStudio3.exe']],
displayNamePrefix: 'Aptana Studio',
publisher: 'Appcelerator',
},
{
name: 'JetBrains Webstorm',
registryKeys: registryKeysForJetBrainsIDE('WebStorm'),

View file

@ -172,3 +172,8 @@ export function enablePullRequestReviewNotifications(): boolean {
export function enableReRunFailedAndSingleCheckJobs(): boolean {
return true
}
/** Should we enable displaying multi commit diffs. This also switches diff logic from one commit */
export function enableMultiCommitDiffs(): boolean {
return enableDevelopmentFeatures()
}

View file

@ -68,7 +68,7 @@ function getNoRenameIndexStatus(status: string): NoRenameIndexStatus {
}
/** The SHA for the null tree. */
const NullTreeSHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
export const NullTreeSHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
/**
* Get a list of files which have recorded changes in the index as compared to

View file

@ -7,6 +7,7 @@ import {
WorkingDirectoryFileChange,
FileChange,
AppFileStatusKind,
CommittedFileChange,
} from '../../models/status'
import {
DiffType,
@ -27,6 +28,10 @@ import { getOldPathOrDefault } from '../get-old-path'
import { getCaptures } from '../helpers/regex'
import { readFile } from 'fs/promises'
import { forceUnwrap } from '../fatal-error'
import { git } from './core'
import { NullTreeSHA } from './diff-index'
import { GitError } from 'dugite'
import { mapStatus } from './log'
/**
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
@ -133,6 +138,201 @@ export async function getCommitDiff(
return buildDiff(output, repository, file, commitish)
}
/**
* Render the difference between two commits for a file
*
*/
export async function getCommitRangeDiff(
repository: Repository,
file: FileChange,
commits: ReadonlyArray<string>,
hideWhitespaceInDiff: boolean = false,
useNullTreeSHA: boolean = false
): Promise<IDiff> {
if (commits.length === 0) {
throw new Error('No commits to diff...')
}
const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${commits[0]}^`
const latestCommit = commits.at(-1) ?? '' // can't be undefined since commits.length > 0
const args = [
'diff',
oldestCommitRef,
latestCommit,
...(hideWhitespaceInDiff ? ['-w'] : []),
'--patch-with-raw',
'-z',
'--no-color',
'--',
file.path,
]
if (
file.status.kind === AppFileStatusKind.Renamed ||
file.status.kind === AppFileStatusKind.Copied
) {
args.push(file.status.oldPath)
}
const result = await git(args, repository.path, 'getCommitsDiff', {
maxBuffer: Infinity,
expectedErrors: new Set([GitError.BadRevision]),
})
// This should only happen if the oldest commit does not have a parent (ex:
// initial commit of a branch) and therefore `SHA^` is not a valid reference.
// In which case, we will retry with the null tree sha.
if (result.gitError === GitError.BadRevision && useNullTreeSHA === false) {
return getCommitRangeDiff(
repository,
file,
commits,
hideWhitespaceInDiff,
true
)
}
return buildDiff(
Buffer.from(result.combinedOutput),
repository,
file,
latestCommit
)
}
export async function getCommitRangeChangedFiles(
repository: Repository,
shas: ReadonlyArray<string>,
useNullTreeSHA: boolean = false
): Promise<{
files: ReadonlyArray<CommittedFileChange>
linesAdded: number
linesDeleted: number
}> {
if (shas.length === 0) {
throw new Error('No commits to diff...')
}
const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${shas[0]}^`
const latestCommitRef = shas.at(-1) ?? '' // can't be undefined since shas.length > 0
const baseArgs = [
'diff',
oldestCommitRef,
latestCommitRef,
'-C',
'-M',
'-z',
'--raw',
'--numstat',
'--',
]
const result = await git(
baseArgs,
repository.path,
'getCommitRangeChangedFiles',
{
expectedErrors: new Set([GitError.BadRevision]),
}
)
// This should only happen if the oldest commit does not have a parent (ex:
// initial commit of a branch) and therefore `SHA^` is not a valid reference.
// In which case, we will retry with the null tree sha.
if (result.gitError === GitError.BadRevision && useNullTreeSHA === false) {
const useNullTreeSHA = true
return getCommitRangeChangedFiles(repository, shas, useNullTreeSHA)
}
return parseChangedFilesAndNumStat(
result.combinedOutput,
`${oldestCommitRef}..${latestCommitRef}`
)
}
/**
* Parses output of diff flags -z --raw --numstat.
*
* Given the -z flag the new lines are separated by \0 character (left them as
* new lines below for ease of reading)
*
* For modified, added, deleted, untracked:
* 100644 100644 5716ca5 db3c77d M
* file_one_path
* :100644 100644 0835e4f 28096ea M
* file_two_path
* 1 0 file_one_path
* 1 0 file_two_path
*
* For copied or renamed:
* 100644 100644 5716ca5 db3c77d M
* file_one_original_path
* file_one_new_path
* :100644 100644 0835e4f 28096ea M
* file_two_original_path
* file_two_new_path
* 1 0
* file_one_original_path
* file_one_new_path
* 1 0
* file_two_original_path
* file_two_new_path
*/
function parseChangedFilesAndNumStat(stdout: string, committish: string) {
const lines = stdout.split('\0')
// Remove the trailing empty line
lines.splice(-1, 1)
const files: CommittedFileChange[] = []
let linesAdded = 0
let linesDeleted = 0
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split('\t')
if (parts.length === 1) {
const statusParts = parts[0].split(' ')
const statusText = statusParts.at(-1) ?? ''
let oldPath: string | undefined = undefined
if (
statusText.length > 0 &&
(statusText[0] === 'R' || statusText[0] === 'C')
) {
oldPath = lines[++i]
}
const status = mapStatus(statusText, oldPath)
const path = lines[++i]
files.push(new CommittedFileChange(path, status, committish))
}
if (parts.length === 3) {
const [added, deleted, file] = parts
if (added === '-' || deleted === '-') {
continue
}
linesAdded += parseInt(added, 10)
linesDeleted += parseInt(deleted, 10)
// If a file is not renamed or copied, the file name is with the
// add/deleted lines other wise the 2 files names are the next two lines
if (file === '' && lines[i + 1].split('\t').length === 1) {
i = i + 2
}
}
}
return {
files,
linesAdded,
linesDeleted,
}
}
/**
* Render the diff for a file within the repository working directory. The file will be
* compared against HEAD if it's tracked, if not it'll be compared to an empty file meaning
@ -198,7 +398,7 @@ export async function getWorkingDirectoryDiff(
async function getImageDiff(
repository: Repository,
file: FileChange,
commitish: string
oldestCommitish: string
): Promise<IImageDiff> {
let current: Image | undefined = undefined
let previous: Image | undefined = undefined
@ -232,7 +432,7 @@ async function getImageDiff(
} else {
// File status can't be conflicted for a file in a commit
if (file.status.kind !== AppFileStatusKind.Deleted) {
current = await getBlobImage(repository, file.path, commitish)
current = await getBlobImage(repository, file.path, oldestCommitish)
}
// File status can't be conflicted for a file in a commit
@ -247,7 +447,7 @@ async function getImageDiff(
previous = await getBlobImage(
repository,
getOldPathOrDefault(file),
`${commitish}^`
`${oldestCommitish}^`
)
}
}
@ -263,7 +463,7 @@ export async function convertDiff(
repository: Repository,
file: FileChange,
diff: IRawDiff,
commitish: string,
oldestCommitish: string,
lineEndingsChange?: LineEndingsChange
): Promise<IDiff> {
const extension = Path.extname(file.path).toLowerCase()
@ -275,7 +475,7 @@ export async function convertDiff(
kind: DiffType.Binary,
}
} else {
return getImageDiff(repository, file, commitish)
return getImageDiff(repository, file, oldestCommitish)
}
}
@ -370,7 +570,7 @@ function buildDiff(
buffer: Buffer,
repository: Repository,
file: FileChange,
commitish: string,
oldestCommitish: string,
lineEndingsChange?: LineEndingsChange
): Promise<IDiff> {
if (!isValidBuffer(buffer)) {
@ -396,7 +596,7 @@ function buildDiff(
return Promise.resolve(largeTextDiff)
}
return convertDiff(repository, file, diff, commitish, lineEndingsChange)
return convertDiff(repository, file, diff, oldestCommitish, lineEndingsChange)
}
/**

View file

@ -22,7 +22,7 @@ import { enableLineChangesInCommit } from '../feature-flag'
* Map the raw status text from Git to an app-friendly value
* shamelessly borrowed from GitHub Desktop (Windows)
*/
function mapStatus(
export function mapStatus(
rawStatus: string,
oldPath?: string
): PlainFileStatus | CopiedOrRenamedFileStatus | UntrackedFileStatus {

View file

@ -76,6 +76,7 @@ export type RequestChannels = {
'set-native-theme-source': (themeName: ThemeSource) => void
'focus-window': () => void
'notification-event': NotificationCallback<DesktopAliveEvent>
'set-window-zoom-factor': (zoomFactor: number) => void
}
/**

View file

@ -50,7 +50,7 @@ export function setBoolean(key: string, value: boolean) {
}
/**
* Retrieve a `number` value from a given local storage entry if found, or the
* Retrieve a integer 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
* converted into a number
*
@ -77,6 +77,34 @@ export function getNumber(
return value
}
/**
* Retrieve a floating point 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 converted into a number
*
* @param key local storage entry to read
* @param defaultValue fallback value if unable to find key or valid value
*/
export function getFloatNumber(key: string): number | undefined
export function getFloatNumber(key: string, defaultValue: number): number
export function getFloatNumber(
key: string,
defaultValue?: number
): number | undefined {
const numberAsText = localStorage.getItem(key)
if (numberAsText === null || numberAsText.length === 0) {
return defaultValue
}
const value = parseFloat(numberAsText)
if (isNaN(value)) {
return defaultValue
}
return value
}
/**
* Set the provided key in local storage to a numeric value, or update the
* existing value if a key is already defined.

View file

@ -1,28 +1,60 @@
import DOMPurify from 'dompurify'
import { Disposable, Emitter } from 'event-kit'
import { marked } from 'marked'
import { GitHubRepository } from '../../models/github-repository'
import {
applyNodeFilters,
buildCustomMarkDownNodeFilterPipe,
MarkdownContext,
ICustomMarkdownFilterOptions,
} from './node-filter'
interface ICustomMarkdownFilterOptions {
emoji: Map<string, string>
repository?: GitHubRepository
markdownContext?: MarkdownContext
/**
* The MarkdownEmitter extends the Emitter functionality to be able to keep
* track of the last emitted value and return it upon subscription.
*/
export class MarkdownEmitter extends Emitter {
public constructor(private markdown: null | string = null) {
super()
}
public onMarkdownUpdated(handler: (value: string) => void): Disposable {
if (this.markdown !== null) {
handler(this.markdown)
}
return super.on('markdown', handler)
}
public emit(value: string): void {
this.markdown = value
super.emit('markdown', value)
}
public get latestMarkdown() {
return this.markdown
}
}
/**
* Takes string of markdown and runs it through the MarkedJs parser with github
* flavored flags enabled followed by running that through domPurify, and lastly
* if custom markdown options are provided, it applies the custom markdown
* flavored flags followed by sanitization with domPurify.
*
* If custom markdown options are provided, it applies the custom markdown
* filters.
*
* Rely `repository` custom markdown option:
* - TeamMentionFilter
* - MentionFilter
* - CommitMentionFilter
* - CommitMentionLinkFilter
*
* Rely `markdownContext` custom markdown option:
* - IssueMentionFilter
* - IssueLinkFilter
* - CloseKeyWordFilter
*/
export async function parseMarkdown(
export function parseMarkdown(
markdown: string,
customMarkdownOptions?: ICustomMarkdownFilterOptions
) {
): MarkdownEmitter {
const parsedMarkdown = marked(markdown, {
// https://marked.js.org/using_advanced If true, use approved GitHub
// Flavored Markdown (GFM) specification.
@ -33,27 +65,26 @@ export async function parseMarkdown(
breaks: true,
})
const sanitizedHTML = DOMPurify.sanitize(parsedMarkdown)
const sanitizedMarkdown = DOMPurify.sanitize(parsedMarkdown)
const markdownEmitter = new MarkdownEmitter(sanitizedMarkdown)
return customMarkdownOptions !== undefined
? await applyCustomMarkdownFilters(sanitizedHTML, customMarkdownOptions)
: sanitizedHTML
if (customMarkdownOptions !== undefined) {
applyCustomMarkdownFilters(markdownEmitter, customMarkdownOptions)
}
return markdownEmitter
}
/**
* Applies custom markdown filters to parsed markdown html. This is done
* through converting the markdown html into a DOM document and then
* traversing the nodes to apply custom filters such as emoji, issue, username
* mentions, etc.
* mentions, etc. (Expects a markdownEmitter with an initial markdown value)
*/
function applyCustomMarkdownFilters(
parsedMarkdown: string,
markdownEmitter: MarkdownEmitter,
options: ICustomMarkdownFilterOptions
): Promise<string> {
const nodeFilters = buildCustomMarkDownNodeFilterPipe(
options.emoji,
options.repository,
options.markdownContext
)
return applyNodeFilters(nodeFilters, parsedMarkdown)
): void {
const nodeFilters = buildCustomMarkDownNodeFilterPipe(options)
applyNodeFilters(nodeFilters, markdownEmitter)
}

View file

@ -1,5 +1,4 @@
import memoizeOne from 'memoize-one'
import { GitHubRepository } from '../../models/github-repository'
import { EmojiFilter } from './emoji-filter'
import { IssueLinkFilter } from './issue-link-filter'
import { IssueMentionFilter } from './issue-mention-filter'
@ -13,6 +12,8 @@ import {
isIssueClosingContext,
} from './close-keyword-filter'
import { CommitMentionLinkFilter } from './commit-mention-link-filter'
import { MarkdownEmitter } from './markdown-filter'
import { GitHubRepository } from '../../models/github-repository'
export interface INodeFilter {
/**
@ -37,19 +38,20 @@ export interface INodeFilter {
filter(node: Node): Promise<ReadonlyArray<Node> | null>
}
export interface ICustomMarkdownFilterOptions {
emoji: Map<string, string>
repository?: GitHubRepository
markdownContext?: MarkdownContext
}
/**
* Builds an array of node filters to apply to markdown html. Referring to it as pipe
* because they will be applied in the order they are entered in the returned
* array. This is important as some filters impact others.
*
* @param emoji Map from the emoji shortcut (e.g., :+1:) to the image's local path.
*/
export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
(
emoji: Map<string, string>,
repository?: GitHubRepository,
markdownContext?: MarkdownContext
): ReadonlyArray<INodeFilter> => {
(options: ICustomMarkdownFilterOptions): ReadonlyArray<INodeFilter> => {
const { emoji, repository, markdownContext } = options
const filterPipe: Array<INodeFilter> = []
if (repository !== undefined) {
@ -104,15 +106,24 @@ export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
*/
export async function applyNodeFilters(
nodeFilters: ReadonlyArray<INodeFilter>,
parsedMarkdown: string
): Promise<string> {
const mdDoc = new DOMParser().parseFromString(parsedMarkdown, 'text/html')
markdownEmitter: MarkdownEmitter
): Promise<void> {
if (markdownEmitter.latestMarkdown === null || markdownEmitter.disposed) {
return
}
const mdDoc = new DOMParser().parseFromString(
markdownEmitter.latestMarkdown,
'text/html'
)
for (const nodeFilter of nodeFilters) {
await applyNodeFilter(nodeFilter, mdDoc)
if (markdownEmitter.disposed) {
break
}
markdownEmitter.emit(mdDoc.documentElement.innerHTML)
}
return mdDoc.documentElement.innerHTML
}
/**

View file

@ -1,8 +1,13 @@
import { getFileHash } from '../file-system'
import { TokenStore } from '../stores'
import {
getSSHSecretStoreKey,
keepSSHSecretToStore,
} from './ssh-secret-storage'
const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop'
const SSHKeyPassphraseTokenStoreKey = `${appName} - SSH key passphrases`
const SSHKeyPassphraseTokenStoreKey = getSSHSecretStoreKey(
'SSH key passphrases'
)
async function getHashForSSHKey(keyPath: string) {
return getFileHash(keyPath, 'sha256')
@ -19,22 +24,6 @@ export async function getSSHKeyPassphrase(keyPath: string) {
}
}
type SSHKeyPassphraseEntry = {
/** Hash of the SSH key file. */
keyHash: string
/** Passphrase for the SSH key. */
passphrase: string
}
/**
* This map contains the SSH key passphrases that are pending to be stored.
* What this means is that a git operation is currently in progress, and the
* user wanted to store the passphrase for the SSH key, however we don't want
* to store it until we know the git operation finished successfully.
*/
const SSHKeyPassphrasesToStore = new Map<string, SSHKeyPassphraseEntry>()
/**
* Keeps the SSH key passphrase in memory to be stored later if the ongoing git
* operation succeeds.
@ -52,27 +41,13 @@ export async function keepSSHKeyPassphraseToStore(
) {
try {
const keyHash = await getHashForSSHKey(keyPath)
SSHKeyPassphrasesToStore.set(operationGUID, { keyHash, passphrase })
keepSSHSecretToStore(
operationGUID,
SSHKeyPassphraseTokenStoreKey,
keyHash,
passphrase
)
} catch (e) {
log.error('Could not store passphrase for SSH key:', e)
}
}
/** Removes the SSH key passphrase from memory. */
export function removePendingSSHKeyPassphraseToStore(operationGUID: string) {
SSHKeyPassphrasesToStore.delete(operationGUID)
}
/** Stores a pending SSH key passphrase if the operation succeeded. */
export async function storePendingSSHKeyPassphrase(operationGUID: string) {
const entry = SSHKeyPassphrasesToStore.get(operationGUID)
if (entry === undefined) {
return
}
await TokenStore.setItem(
SSHKeyPassphraseTokenStoreKey,
entry.keyHash,
entry.passphrase
)
}

View file

@ -0,0 +1,61 @@
import { TokenStore } from '../stores'
const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop'
export function getSSHSecretStoreKey(name: string) {
return `${appName} - ${name}`
}
type SSHSecretEntry = {
/** Store where this entry will be stored. */
store: string
/** Key used to identify the secret in the store (e.g. username or hash). */
key: string
/** Actual secret to be stored (password). */
secret: string
}
/**
* This map contains the SSH secrets that are pending to be stored. What this
* means is that a git operation is currently in progress, and the user wanted
* to store the passphrase for the SSH key, however we don't want to store it
* until we know the git operation finished successfully.
*/
const SSHSecretsToStore = new Map<string, SSHSecretEntry>()
/**
* Keeps the SSH secret in memory to be stored later if the ongoing git operation
* succeeds.
*
* @param operationGUID A unique identifier for the ongoing git operation. In
* practice, it will always be the trampoline secret for the
* ongoing git operation.
* @param key Key that identifies the SSH secret (e.g. username or key
* hash).
* @param secret Actual SSH secret to store.
*/
export async function keepSSHSecretToStore(
operationGUID: string,
store: string,
key: string,
secret: string
) {
SSHSecretsToStore.set(operationGUID, { store, key, secret })
}
/** Removes the SSH key passphrase from memory. */
export function removePendingSSHSecretToStore(operationGUID: string) {
SSHSecretsToStore.delete(operationGUID)
}
/** Stores a pending SSH key passphrase if the operation succeeded. */
export async function storePendingSSHSecret(operationGUID: string) {
const entry = SSHSecretsToStore.get(operationGUID)
if (entry === undefined) {
return
}
await TokenStore.setItem(entry.store, entry.key, entry.secret)
}

View file

@ -0,0 +1,40 @@
import { TokenStore } from '../stores'
import {
getSSHSecretStoreKey,
keepSSHSecretToStore,
} from './ssh-secret-storage'
const SSHUserPasswordTokenStoreKey = getSSHSecretStoreKey('SSH user password')
/** Retrieves the password for the given SSH username. */
export async function getSSHUserPassword(username: string) {
try {
return TokenStore.getItem(SSHUserPasswordTokenStoreKey, username)
} catch (e) {
log.error('Could not retrieve passphrase for SSH key:', e)
return null
}
}
/**
* Keeps the SSH user password in memory to be stored later if the ongoing git
* operation succeeds.
*
* @param operationGUID A unique identifier for the ongoing git operation. In
* practice, it will always be the trampoline token for the
* ongoing git operation.
* @param username SSH user name. Usually in the form of `user@hostname`.
* @param password Password for the given user.
*/
export async function keepSSHUserPasswordToStore(
operationGUID: string,
username: string,
password: string
) {
keepSSHSecretToStore(
operationGUID,
SSHUserPasswordTokenStoreKey,
username,
password
)
}

View file

@ -1,6 +1,6 @@
import pLimit from 'p-limit'
import QuickLRU from 'quick-lru'
import { IDisposable, Disposable } from 'event-kit'
import { DisposableLike, Disposable } from 'event-kit'
import { IAheadBehind } from '../../models/branch'
import { revSymmetricDifference, getAheadBehind } from '../git'
import { Repository } from '../../models/repository'
@ -76,7 +76,7 @@ export class AheadBehindStore {
from: string,
to: string,
callback: AheadBehindCallback
): IDisposable {
): DisposableLike {
const key = getCacheKey(repository, from, to)
const existing = this.cache.get(key)
const disposable = new Disposable(() => {})

View file

@ -76,6 +76,7 @@ import {
getCurrentWindowZoomFactor,
updatePreferredAppMenuItemLabels,
updateAccounts,
setWindowZoomFactor,
} from '../../ui/main-process-proxy'
import {
API,
@ -158,6 +159,8 @@ import {
appendIgnoreFile,
getRepositoryType,
RepositoryType,
getCommitRangeDiff,
getCommitRangeChangedFiles,
} from '../git'
import {
installGlobalLFSFilters,
@ -203,6 +206,7 @@ import {
getEnum,
getObject,
setObject,
getFloatNumber,
} from '../local-storage'
import { ExternalEditorError, suggestedExternalEditor } from '../editors/shared'
import { ApiRepositoriesStore } from './api-repositories-store'
@ -213,7 +217,10 @@ import {
} from './updates/changes-state'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { BranchPruner } from './helpers/branch-pruner'
import { enableHideWhitespaceInDiffOption } from '../feature-flag'
import {
enableHideWhitespaceInDiffOption,
enableMultiCommitDiffs,
} from '../feature-flag'
import { Banner, BannerType } from '../../models/banner'
import { ComputedAction } from '../../models/computed-action'
import {
@ -570,13 +577,41 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
private initializeZoomFactor = async () => {
const zoomFactor = await getCurrentWindowZoomFactor()
const zoomFactor = await this.getWindowZoomFactor()
if (zoomFactor === undefined) {
return
}
this.onWindowZoomFactorChanged(zoomFactor)
}
/**
* On Windows OS, whenever a user toggles their zoom factor, chromium stores it
* in their `%AppData%/Roaming/GitHub Desktop/Preferences.js` denoted by the
* file path to the application. That file path contains the apps version.
* Thus, on every update, the users set zoom level gets reset as there is not
* defined value for the current app version.
* */
private async getWindowZoomFactor() {
const zoomFactor = await getCurrentWindowZoomFactor()
// One is the default value, we only care about checking the locally stored
// value if it is one because that is the default value after an
// update
if (zoomFactor !== 1 || !__WIN32__) {
return zoomFactor
}
const locallyStoredZoomFactor = getFloatNumber('zoom-factor')
if (
locallyStoredZoomFactor !== undefined &&
locallyStoredZoomFactor !== zoomFactor
) {
setWindowZoomFactor(locallyStoredZoomFactor)
return locallyStoredZoomFactor
}
return zoomFactor
}
private onTokenInvalidated = (endpoint: string) => {
const account = getAccountForEndpoint(this.accounts, endpoint)
@ -799,6 +834,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.windowZoomFactor = zoomFactor
if (zoomFactor !== current) {
setNumber('zoom-factor', zoomFactor)
this.updateResizableConstraints()
this.emitUpdate()
}
@ -1075,7 +1111,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** This shouldn't be called directly. See `Dispatcher`. */
public async _changeCommitSelection(
repository: Repository,
shas: ReadonlyArray<string>
shas: ReadonlyArray<string>,
isContiguous: boolean
): Promise<void> {
const { commitSelection } = this.repositoryStateCache.get(repository)
@ -1088,6 +1125,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.repositoryStateCache.updateCommitSelection(repository, () => ({
shas,
isContiguous,
file: null,
changesetData: { files: [], linesAdded: 0, linesDeleted: 0 },
diff: null,
@ -1102,9 +1140,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
) {
const state = this.repositoryStateCache.get(repository)
let selectedSHA =
state.commitSelection.shas.length === 1
state.commitSelection.shas.length > 0
? state.commitSelection.shas[0]
: null
if (selectedSHA != null) {
const index = commitSHAs.findIndex(sha => sha === selectedSHA)
if (index < 0) {
@ -1115,8 +1154,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
if (state.commitSelection.shas.length === 0 && commitSHAs.length > 0) {
this._changeCommitSelection(repository, [commitSHAs[0]])
if (selectedSHA === null && commitSHAs.length > 0) {
this._changeCommitSelection(repository, [commitSHAs[0]], true)
this._loadChangedFilesForCurrentSelection(repository)
}
}
@ -1371,15 +1410,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
): Promise<void> {
const state = this.repositoryStateCache.get(repository)
const { commitSelection } = state
const currentSHAs = commitSelection.shas
if (currentSHAs.length !== 1) {
// if none or multiple, we don't display a diff
const { shas: currentSHAs, isContiguous } = commitSelection
if (
currentSHAs.length === 0 ||
(currentSHAs.length > 1 && (!enableMultiCommitDiffs() || !isContiguous))
) {
return
}
const gitStore = this.gitStoreCache.get(repository)
const changesetData = await gitStore.performFailableOperation(() =>
getChangedFiles(repository, currentSHAs[0])
currentSHAs.length > 1
? getCommitRangeChangedFiles(repository, currentSHAs)
: getChangedFiles(repository, currentSHAs[0])
)
if (!changesetData) {
return
@ -1390,7 +1433,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
// SHA/path.
if (
commitSelection.shas.length !== currentSHAs.length ||
commitSelection.shas[0] !== currentSHAs[0]
!commitSelection.shas.every((sha, i) => sha === currentSHAs[i])
) {
return
}
@ -1436,7 +1479,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate()
const stateBeforeLoad = this.repositoryStateCache.get(repository)
const shas = stateBeforeLoad.commitSelection.shas
const { shas, isContiguous } = stateBeforeLoad.commitSelection
if (shas.length === 0) {
if (__DEV__) {
@ -1448,24 +1491,35 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
// We do not get a diff when multiple commits selected
if (shas.length > 1) {
if (shas.length > 1 && (!enableMultiCommitDiffs() || !isContiguous)) {
return
}
const diff = await getCommitDiff(
repository,
file,
shas[0],
this.hideWhitespaceInHistoryDiff
)
const diff =
shas.length > 1
? await getCommitRangeDiff(
repository,
file,
shas,
this.hideWhitespaceInHistoryDiff
)
: await getCommitDiff(
repository,
file,
shas[0],
this.hideWhitespaceInHistoryDiff
)
const stateAfterLoad = this.repositoryStateCache.get(repository)
const { shas: shasAfter } = stateAfterLoad.commitSelection
// A whole bunch of things could have happened since we initiated the diff load
if (shasAfter.length !== shas.length || shasAfter[0] !== shas[0]) {
if (
shasAfter.length !== shas.length ||
!shas.every((sha, i) => sha === shasAfter[i])
) {
return
}
if (!stateAfterLoad.commitSelection.file) {
return
}

View file

@ -5,7 +5,7 @@ import { Account } from '../../models/account'
import { AccountsStore } from './accounts-store'
import { GitHubRepository } from '../../models/github-repository'
import { API, getAccountForEndpoint, IAPICheckSuite } from '../api'
import { IDisposable, Disposable } from 'event-kit'
import { DisposableLike, Disposable } from 'event-kit'
import {
ICombinedRefCheck,
IRefCheck,
@ -465,7 +465,7 @@ export class CommitStatusStore {
ref: string,
callback: StatusCallBack,
branchName?: string
): IDisposable {
): DisposableLike {
const key = getCacheKeyForRepository(repository, ref)
const subscription = this.getOrCreateSubscription(
repository,

View file

@ -124,7 +124,7 @@ export class RepositoriesStore extends TypedBaseStore<
)
}
return new GitHubRepository(
const ghRepo = new GitHubRepository(
repo.name,
owner,
repo.id,
@ -137,6 +137,10 @@ export class RepositoriesStore extends TypedBaseStore<
repo.permissions,
parent
)
// Dexie gets confused if we return a non-promise value (e.g. if this function
// didn't need to await for the parent repo or the owner)
return Promise.resolve(ghRepo)
}
private async toRepository(repo: IDatabaseRepository) {

View file

@ -178,6 +178,7 @@ function getInitialRepositoryState(): IRepositoryState {
return {
commitSelection: {
shas: [],
isContiguous: true,
file: null,
changesetData: { files: [], linesAdded: 0, linesDeleted: 0 },
diff: null,

View file

@ -2,12 +2,16 @@ import { getKeyForEndpoint } from '../auth'
import {
getSSHKeyPassphrase,
keepSSHKeyPassphraseToStore,
removePendingSSHKeyPassphraseToStore,
} from '../ssh/ssh-key-passphrase'
import { TokenStore } from '../stores'
import { TrampolineCommandHandler } from './trampoline-command'
import { trampolineUIHelper } from './trampoline-ui-helper'
import { parseAddSSHHostPrompt } from '../ssh/ssh'
import {
getSSHUserPassword,
keepSSHUserPasswordToStore,
} from '../ssh/ssh-user-password'
import { removePendingSSHSecretToStore } from '../ssh/ssh-secret-storage'
async function handleSSHHostAuthenticity(
prompt: string
@ -65,7 +69,7 @@ async function handleSSHKeyPassphrase(
return storedPassphrase
}
const { passphrase, storePassphrase } =
const { secret: passphrase, storeSecret: storePassphrase } =
await trampolineUIHelper.promptSSHKeyPassphrase(keyPath)
// If the user wanted us to remember the passphrase, we'll keep it around to
@ -78,12 +82,39 @@ async function handleSSHKeyPassphrase(
if (passphrase !== undefined && storePassphrase) {
keepSSHKeyPassphraseToStore(operationGUID, keyPath, passphrase)
} else {
removePendingSSHKeyPassphraseToStore(operationGUID)
removePendingSSHSecretToStore(operationGUID)
}
return passphrase ?? ''
}
async function handleSSHUserPassword(operationGUID: string, prompt: string) {
const promptRegex = /^(.+@.+)'s password: $/
const matches = promptRegex.exec(prompt)
if (matches === null || matches.length < 2) {
return undefined
}
const username = matches[1]
const storedPassword = await getSSHUserPassword(username)
if (storedPassword !== null) {
return storedPassword
}
const { secret: password, storeSecret: storePassword } =
await trampolineUIHelper.promptSSHUserPassword(username)
if (password !== undefined && storePassword) {
keepSSHUserPasswordToStore(operationGUID, username, password)
} else {
removePendingSSHSecretToStore(operationGUID)
}
return password ?? ''
}
export const askpassTrampolineHandler: TrampolineCommandHandler =
async command => {
if (command.parameters.length !== 1) {
@ -100,6 +131,10 @@ export const askpassTrampolineHandler: TrampolineCommandHandler =
return handleSSHKeyPassphrase(command.trampolineToken, firstParameter)
}
if (firstParameter.endsWith("'s password: ")) {
return handleSSHUserPassword(command.trampolineToken, firstParameter)
}
const username = command.environmentVariables.get('DESKTOP_USERNAME')
if (username === undefined || username.length === 0) {
return undefined

View file

@ -5,9 +5,9 @@ import { getDesktopTrampolineFilename } from 'desktop-trampoline'
import { TrampolineCommandIdentifier } from '../trampoline/trampoline-command'
import { getSSHEnvironment } from '../ssh/ssh'
import {
removePendingSSHKeyPassphraseToStore,
storePendingSSHKeyPassphrase,
} from '../ssh/ssh-key-passphrase'
removePendingSSHSecretToStore,
storePendingSSHSecret,
} from '../ssh/ssh-secret-storage'
/**
* Allows invoking a function with a set of environment variables to use when
@ -46,11 +46,11 @@ export async function withTrampolineEnv<T>(
...sshEnv,
})
await storePendingSSHKeyPassphrase(token)
await storePendingSSHSecret(token)
return result
} finally {
removePendingSSHKeyPassphraseToStore(token)
removePendingSSHSecretToStore(token)
}
})
}

View file

@ -1,9 +1,9 @@
import { PopupType } from '../../models/popup'
import { Dispatcher } from '../../ui/dispatcher'
type PromptSSHKeyPassphraseResponse = {
readonly passphrase: string | undefined
readonly storePassphrase: boolean
type PromptSSHSecretResponse = {
readonly secret: string | undefined
readonly storeSecret: boolean
}
class TrampolineUIHelper {
@ -34,13 +34,26 @@ class TrampolineUIHelper {
public promptSSHKeyPassphrase(
keyPath: string
): Promise<PromptSSHKeyPassphraseResponse> {
): Promise<PromptSSHSecretResponse> {
return new Promise(resolve => {
this.dispatcher.showPopup({
type: PopupType.SSHKeyPassphrase,
keyPath,
onSubmit: (passphrase, storePassphrase) =>
resolve({ passphrase, storePassphrase }),
resolve({ secret: passphrase, storeSecret: storePassphrase }),
})
})
}
public promptSSHUserPassword(
username: string
): Promise<PromptSSHSecretResponse> {
return new Promise(resolve => {
this.dispatcher.showPopup({
type: PopupType.SSHUserPassword,
username,
onSubmit: (password, storePassword) =>
resolve({ secret: password, storeSecret: storePassword }),
})
})
}

View file

@ -416,6 +416,10 @@ export class AppWindow {
return this.window.webContents.zoomFactor
}
public setWindowZoomFactor(zoomFactor: number) {
this.window.webContents.zoomFactor = zoomFactor
}
/**
* Method to show the save dialog and return the first file path it returns.
*/

View file

@ -515,6 +515,10 @@ app.on('ready', () => {
mainWindow?.getCurrentWindowZoomFactor()
)
ipcMain.on('set-window-zoom-factor', (_, zoomFactor: number) =>
mainWindow?.setWindowZoomFactor(zoomFactor)
)
/**
* An event sent by the renderer asking for a copy of the current
* application menu.

View file

@ -10,7 +10,7 @@ const rootAppDir = Path.resolve(appFolder, '..')
const updateDotExe = Path.resolve(Path.join(rootAppDir, 'Update.exe'))
const exeName = Path.basename(process.execPath)
// A lot of this code was cargo-culted from our Atom comrades:
// A lot of this code was cargo-culted from our Atom collaborators:
// https://github.com/atom/atom/blob/7c9f39e3f1d05ee423e0093e6b83f042ce11c90a/src/main-process/squirrel-update.coffee.
/**

View file

@ -55,4 +55,8 @@ export class CommitIdentity {
public readonly date: Date,
public readonly tzOffset: number = new Date().getTimezoneOffset()
) {}
public toString() {
return `${this.name} <${this.email}>`
}
}

View file

@ -78,6 +78,7 @@ export enum PopupType {
InvalidatedToken,
AddSSHHost,
SSHKeyPassphrase,
SSHUserPassword,
PullRequestChecksFailed,
CICheckRunRerun,
WarnForcePush,
@ -317,6 +318,11 @@ export type Popup =
storePassphrase: boolean
) => void
}
| {
type: PopupType.SSHUserPassword
username: string
onSubmit: (password: string | undefined, storePassword: boolean) => void
}
| {
type: PopupType.PullRequestChecksFailed
repository: RepositoryWithGitHubRepository

View file

@ -154,6 +154,7 @@ import { generateDevReleaseSummary } from '../lib/release-notes'
import { PullRequestReview } from './notifications/pull-request-review'
import { getPullRequestCommitRef } from '../models/pull-request'
import { getRepositoryType } from '../lib/git'
import { SSHUserPassword } from './ssh/ssh-user-password'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -2113,6 +2114,16 @@ export class App extends React.Component<IAppProps, IAppState> {
/>
)
}
case PopupType.SSHUserPassword: {
return (
<SSHUserPassword
key="ssh-user-password"
username={popup.username}
onSubmit={popup.onSubmit}
onDismissed={onPopupDismissedFn}
/>
)
}
case PopupType.PullRequestChecksFailed: {
return (
<PullRequestChecksFailed

View file

@ -3,7 +3,7 @@ import { Octicon, OcticonSymbolType } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import classNames from 'classnames'
import { GitHubRepository } from '../../models/github-repository'
import { IDisposable } from 'event-kit'
import { DisposableLike } from 'event-kit'
import { Dispatcher } from '../dispatcher'
import {
ICombinedRefCheck,
@ -37,7 +37,7 @@ export class CIStatus extends React.PureComponent<
ICIStatusProps,
ICIStatusState
> {
private statusSubscription: IDisposable | null = null
private statusSubscription: DisposableLike | null = null
public constructor(props: ICIStatusProps) {
super(props)

View file

@ -27,6 +27,8 @@ import {
RevealInFileManagerLabel,
OpenWithDefaultProgramLabel,
CopyRelativeFilePathLabel,
CopySelectedPathsLabel,
CopySelectedRelativePathsLabel,
} from '../lib/context-menu'
import { CommitMessage } from './commit-message'
import { ChangedFile } from './changed-file'
@ -51,6 +53,7 @@ import { hasConflictedFiles } from '../../lib/status'
import { createObservableRef } from '../lib/observable-ref'
import { Tooltip, TooltipDirection } from '../lib/tooltip'
import { Popup } from '../../models/popup'
import { EOL } from 'os'
const RowHeight = 29
const StashIcon: OcticonSymbol.OcticonSymbolType = {
@ -426,6 +429,32 @@ export class ChangesList extends React.Component<
}
}
private getCopySelectedPathsMenuItem = (
files: WorkingDirectoryFileChange[]
): IMenuItem => {
return {
label: CopySelectedPathsLabel,
action: () => {
const fullPaths = files.map(file =>
Path.join(this.props.repository.path, file.path)
)
clipboard.writeText(fullPaths.join(EOL))
},
}
}
private getCopySelectedRelativePathsMenuItem = (
files: WorkingDirectoryFileChange[]
): IMenuItem => {
return {
label: CopySelectedRelativePathsLabel,
action: () => {
const paths = files.map(file => Path.normalize(file.path))
clipboard.writeText(paths.join(EOL))
},
}
}
private getRevealInFileManagerMenuItem = (
file: WorkingDirectoryFileChange
): IMenuItem => {
@ -556,15 +585,21 @@ export class ChangesList extends React.Component<
this.props.onIncludeChanged(file.path, false)
)
},
}
},
{ type: 'separator' },
this.getCopySelectedPathsMenuItem(selectedFiles),
this.getCopySelectedRelativePathsMenuItem(selectedFiles)
)
} else {
items.push(
{ type: 'separator' },
this.getCopyPathMenuItem(file),
this.getCopyRelativePathMenuItem(file)
)
}
const enabled = status.kind !== AppFileStatusKind.Deleted
items.push(
{ type: 'separator' },
this.getCopyPathMenuItem(file),
this.getCopyRelativePathMenuItem(file),
{ type: 'separator' },
this.getRevealInFileManagerMenuItem(file),
this.getOpenInExternalEditorMenuItem(file, enabled),

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { GitHubRepository } from '../../models/github-repository'
import { IDisposable } from 'event-kit'
import { DisposableLike } from 'event-kit'
import { Dispatcher } from '../dispatcher'
import {
getCheckRunConclusionAdjective,
@ -62,7 +62,7 @@ export class CICheckRunPopover extends React.PureComponent<
ICICheckRunPopoverProps,
ICICheckRunPopoverState
> {
private statusSubscription: IDisposable | null = null
private statusSubscription: DisposableLike | null = null
public constructor(props: ICICheckRunPopoverProps) {
super(props)

View file

@ -1,4 +1,4 @@
import { Disposable, IDisposable } from 'event-kit'
import { Disposable, DisposableLike } from 'event-kit'
import {
IAPIOrganization,
@ -235,9 +235,10 @@ export class Dispatcher {
*/
public changeCommitSelection(
repository: Repository,
shas: ReadonlyArray<string>
shas: ReadonlyArray<string>,
isContiguous: boolean
): Promise<void> {
return this.appStore._changeCommitSelection(repository, shas)
return this.appStore._changeCommitSelection(repository, shas, isContiguous)
}
/**
@ -2507,7 +2508,7 @@ export class Dispatcher {
ref: string,
callback: StatusCallBack,
branchName?: string
): IDisposable {
): DisposableLike {
return this.commitStatusStore.subscribe(
repository,
ref,
@ -3148,7 +3149,7 @@ export class Dispatcher {
switch (cherryPickResult) {
case CherryPickResult.CompletedWithoutError:
await this.changeCommitSelection(repository, [commits[0].sha])
await this.changeCommitSelection(repository, [commits[0].sha], true)
await this.completeMultiCommitOperation(repository, commits.length)
break
case CherryPickResult.ConflictsEncountered:
@ -3552,7 +3553,11 @@ export class Dispatcher {
// TODO: Look at history back to last retained commit and search for
// squashed commit based on new commit message ... if there is more
// than one, just take the most recent. (not likely?)
await this.changeCommitSelection(repository, [status.currentTip])
await this.changeCommitSelection(
repository,
[status.currentTip],
true
)
}
await this.completeMultiCommitOperation(

View file

@ -174,7 +174,7 @@ export class CommitListItem extends React.PureComponent<
<div className="byline">
<CommitAttribution
gitHubRepository={this.props.gitHubRepository}
commit={commit}
commits={[commit]}
/>
{renderRelativeTime(date)}
</div>

View file

@ -41,7 +41,10 @@ interface ICommitListProps {
readonly emptyListMessage: JSX.Element | string
/** Callback which fires when a commit has been selected in the list */
readonly onCommitsSelected: (commits: ReadonlyArray<Commit>) => void
readonly onCommitsSelected: (
commits: ReadonlyArray<Commit>,
isContiguous: boolean
) => void
/** Callback that fires when a scroll event has occurred */
readonly onScroll: (start: number, end: number) => void
@ -269,10 +272,34 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
// reordering, they will need to do multiple cherry-picks.
// Goal: first commit in history -> first on array
const sorted = [...rows].sort((a, b) => b - a)
const selectedShas = sorted.map(r => this.props.commitSHAs[r])
const selectedCommits = this.lookupCommits(selectedShas)
this.props.onCommitsSelected(selectedCommits)
this.props.onCommitsSelected(selectedCommits, this.isContiguous(sorted))
}
/**
* Accepts a sorted array of numbers in descending order. If the numbers ar
* contiguous order, 4, 3, 2 not 5, 3, 1, returns true.
*
* Defined an array of 0 and 1 are considered contiguous.
*/
private isContiguous(indexes: ReadonlyArray<number>) {
if (indexes.length <= 1) {
return true
}
for (let i = 0; i < indexes.length; i++) {
const current = indexes[i]
if (i + 1 === indexes.length) {
continue
}
if (current - 1 !== indexes[i + 1]) {
return false
}
}
return true
}
// This is required along with onSelectedRangeChanged in the case of a user
@ -281,7 +308,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
const sha = this.props.commitSHAs[row]
const commit = this.props.commitLookup.get(sha)
if (commit) {
this.props.onCommitsSelected([commit])
this.props.onCommitsSelected([commit], true)
}
}

View file

@ -18,10 +18,11 @@ import { TooltippedContent } from '../lib/tooltipped-content'
import { clipboard } from 'electron'
import { TooltipDirection } from '../lib/tooltip'
import { AppFileStatusKind } from '../../models/status'
import _ from 'lodash'
interface ICommitSummaryProps {
readonly repository: Repository
readonly commit: Commit
readonly commits: ReadonlyArray<Commit>
readonly changesetData: IChangesetData
readonly emoji: Map<string, string>
@ -98,17 +99,33 @@ function createState(
isOverflowed: boolean,
props: ICommitSummaryProps
): ICommitSummaryState {
const tokenizer = new Tokenizer(props.emoji, props.repository)
const { emoji, repository, commits } = props
const tokenizer = new Tokenizer(emoji, repository)
const plainTextBody =
commits.length > 1
? commits
.map(
c =>
`${c.shortSha} - ${c.summary}${
c.body.trim() !== '' ? `\n${c.body}` : ''
}`
)
.join('\n\n')
: commits[0].body
const { summary, body } = wrapRichTextCommitMessage(
props.commit.summary,
props.commit.body,
commits[0].summary,
plainTextBody,
tokenizer
)
const avatarUsers = getAvatarUsersForCommit(
props.repository.gitHubRepository,
props.commit
const allAvatarUsers = commits.flatMap(c =>
getAvatarUsersForCommit(repository.gitHubRepository, c)
)
const avatarUsers = _.uniqWith(
allAvatarUsers,
(a, b) => a.email === b.email && a.name === b.name
)
return { isOverflowed, summary, body, avatarUsers }
@ -242,7 +259,12 @@ export class CommitSummary extends React.Component<
}
public componentWillUpdate(nextProps: ICommitSummaryProps) {
if (!messageEquals(nextProps.commit, this.props.commit)) {
if (
nextProps.commits.length !== this.props.commits.length ||
!nextProps.commits.every((nextCommit, i) =>
messageEquals(nextCommit, this.props.commits[i])
)
) {
this.setState(createState(false, nextProps))
}
}
@ -293,9 +315,21 @@ export class CommitSummary extends React.Component<
)
}
public render() {
const shortSHA = this.props.commit.shortSha
private getShaRef = (useShortSha?: boolean) => {
const { commits } = this.props
const oldest = useShortSha ? commits[0].shortSha : commits[0].sha
if (commits.length === 1) {
return oldest
}
const latestCommit = commits.at(-1)
const latest = useShortSha ? latestCommit?.shortSha : latestCommit?.sha
return `${oldest}^..${latest}`
}
public render() {
const className = classNames({
expanded: this.props.isExpanded,
collapsed: !this.props.isExpanded,
@ -306,6 +340,8 @@ export class CommitSummary extends React.Component<
const hasEmptySummary = this.state.summary.length === 0
const commitSummary = hasEmptySummary
? 'Empty commit message'
: this.props.commits.length > 1
? `Viewing the diff of ${this.props.commits.length} commits`
: this.state.summary
const summaryClassNames = classNames('commit-summary-title', {
@ -330,7 +366,7 @@ export class CommitSummary extends React.Component<
<AvatarStack users={this.state.avatarUsers} />
<CommitAttribution
gitHubRepository={this.props.repository.gitHubRepository}
commit={this.props.commit}
commits={this.props.commits}
/>
</li>
@ -346,7 +382,7 @@ export class CommitSummary extends React.Component<
interactive={true}
direction={TooltipDirection.SOUTH}
>
{shortSHA}
{this.getShaRef(true)}
</TooltippedContent>
</li>
@ -382,7 +418,7 @@ export class CommitSummary extends React.Component<
private renderShaTooltip() {
return (
<>
<code>{this.props.commit.sha}</code>
<code>{this.getShaRef()}</code>
<button onClick={this.onCopyShaButtonClick}>Copy</button>
</>
)
@ -390,7 +426,7 @@ export class CommitSummary extends React.Component<
private onCopyShaButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
clipboard.writeText(this.props.commit.sha)
clipboard.writeText(this.getShaRef())
}
private renderChangedFilesDescription = () => {
@ -492,7 +528,7 @@ export class CommitSummary extends React.Component<
}
private renderTags() {
const tags = this.props.commit.tags || []
const tags = this.props.commits.flatMap(c => c.tags) || []
if (tags.length === 0) {
return null

View file

@ -7,7 +7,7 @@ import { Branch, IAheadBehind } from '../../models/branch'
import { IMatches } from '../../lib/fuzzy-find'
import { AheadBehindStore } from '../../lib/stores/ahead-behind-store'
import { Repository } from '../../models/repository'
import { IDisposable } from 'event-kit'
import { DisposableLike } from 'event-kit'
interface ICompareBranchListItemProps {
readonly branch: Branch
@ -50,7 +50,7 @@ export class CompareBranchListItem extends React.Component<
return { aheadBehind, comparisonFrom: from, comparisonTo: to }
}
private aheadBehindSubscription: IDisposable | null = null
private aheadBehindSubscription: DisposableLike | null = null
public constructor(props: ICompareBranchListItemProps) {
super(props)

View file

@ -460,10 +460,14 @@ export class CompareSidebar extends React.Component<
}
}
private onCommitsSelected = (commits: ReadonlyArray<Commit>) => {
private onCommitsSelected = (
commits: ReadonlyArray<Commit>,
isContiguous: boolean
) => {
this.props.dispatcher.changeCommitSelection(
this.props.repository,
commits.map(c => c.sha)
commits.map(c => c.sha),
isContiguous
)
this.loadChangedFilesScheduler.queue(() => {

View file

@ -1,2 +1,2 @@
export { SelectedCommit } from './selected-commit'
export { SelectedCommits } from './selected-commit'
export { CompareSidebar } from './compare'

View file

@ -34,14 +34,15 @@ import { IChangesetData } from '../../lib/git'
import { IConstrainedValue } from '../../lib/app-state'
import { clamp } from '../../lib/clamp'
import { pathExists } from '../lib/path-exists'
import { enableMultiCommitDiffs } from '../../lib/feature-flag'
interface ISelectedCommitProps {
interface ISelectedCommitsProps {
readonly repository: Repository
readonly isLocalRepository: boolean
readonly dispatcher: Dispatcher
readonly emoji: Map<string, string>
readonly selectedCommit: Commit | null
readonly isLocal: boolean
readonly selectedCommits: ReadonlyArray<Commit>
readonly localCommitSHAs: ReadonlyArray<string>
readonly changesetData: IChangesetData
readonly selectedFile: CommittedFileChange | null
readonly currentDiff: IDiff | null
@ -77,27 +78,27 @@ interface ISelectedCommitProps {
/** Called when the user opens the diff options popover */
readonly onDiffOptionsOpened: () => void
/** Whether multiple commits are selected. */
readonly areMultipleCommitsSelected: boolean
/** Whether or not to show the drag overlay */
readonly showDragOverlay: boolean
/** Whether or not the selection of commits is contiguous */
readonly isContiguous: boolean
}
interface ISelectedCommitState {
interface ISelectedCommitsState {
readonly isExpanded: boolean
readonly hideDescriptionBorder: boolean
}
/** The History component. Contains the commit list, commit summary, and diff. */
export class SelectedCommit extends React.Component<
ISelectedCommitProps,
ISelectedCommitState
export class SelectedCommits extends React.Component<
ISelectedCommitsProps,
ISelectedCommitsState
> {
private readonly loadChangedFilesScheduler = new ThrottledScheduler(200)
private historyRef: HTMLDivElement | null = null
public constructor(props: ISelectedCommitProps) {
public constructor(props: ISelectedCommitsProps) {
super(props)
this.state = {
@ -114,16 +115,12 @@ export class SelectedCommit extends React.Component<
this.historyRef = ref
}
public componentWillUpdate(nextProps: ISelectedCommitProps) {
public componentWillUpdate(nextProps: ISelectedCommitsProps) {
// reset isExpanded if we're switching commits.
const currentValue = this.props.selectedCommit
? this.props.selectedCommit.sha
: undefined
const nextValue = nextProps.selectedCommit
? nextProps.selectedCommit.sha
: undefined
const currentValue = this.props.selectedCommits.map(c => c.sha).join('')
const nextValue = nextProps.selectedCommits.map(c => c.sha).join('')
if ((currentValue || nextValue) && currentValue !== nextValue) {
if (currentValue !== nextValue) {
if (this.state.isExpanded) {
this.setState({ isExpanded: false })
}
@ -166,10 +163,10 @@ export class SelectedCommit extends React.Component<
)
}
private renderCommitSummary(commit: Commit) {
private renderCommitSummary(commits: ReadonlyArray<Commit>) {
return (
<CommitSummary
commit={commit}
commits={commits}
changesetData={this.props.changesetData}
emoji={this.props.emoji}
repository={this.props.repository}
@ -250,13 +247,16 @@ export class SelectedCommit extends React.Component<
}
public render() {
const commit = this.props.selectedCommit
const { selectedCommits, isContiguous } = this.props
if (this.props.areMultipleCommitsSelected) {
return this.renderMultipleCommitsSelected()
if (
selectedCommits.length > 1 &&
(!isContiguous || !enableMultiCommitDiffs())
) {
return this.renderMultipleCommitsBlankSlate()
}
if (commit == null) {
if (selectedCommits.length === 0) {
return <NoCommitSelected />
}
@ -265,7 +265,7 @@ export class SelectedCommit extends React.Component<
return (
<div id="history" ref={this.onHistoryRef} className={className}>
{this.renderCommitSummary(commit)}
{this.renderCommitSummary(selectedCommits)}
<div className="commit-details">
<Resizable
width={commitSummaryWidth.value}
@ -291,7 +291,7 @@ export class SelectedCommit extends React.Component<
return <div id="drag-overlay-background"></div>
}
private renderMultipleCommitsSelected(): JSX.Element {
private renderMultipleCommitsBlankSlate(): JSX.Element {
const BlankSlateImage = encodePathAsUrl(
__dirname,
'static/empty-no-commit.svg'
@ -302,11 +302,22 @@ export class SelectedCommit extends React.Component<
<div className="panel blankslate">
<img src={BlankSlateImage} className="blankslate-image" />
<div>
<p>Unable to display diff when multiple commits are selected.</p>
<p>
Unable to display diff when multiple{' '}
{enableMultiCommitDiffs() ? 'non-consecutive ' : ' '}commits are
selected.
</p>
<div>You can:</div>
<ul>
<li>Select a single commit to view a diff.</li>
<li>
Select a single commit{' '}
{enableMultiCommitDiffs()
? 'or a range of consecutive commits '
: ' '}
to view a diff.
</li>
<li>Drag the commits to the branch menu to cherry-pick them.</li>
<li>Drag the commits to squash or reorder them.</li>
<li>Right click on multiple commits to see options.</li>
</ul>
</div>
@ -322,7 +333,14 @@ export class SelectedCommit extends React.Component<
) => {
event.preventDefault()
const fullPath = Path.join(this.props.repository.path, file.path)
const {
selectedCommits,
localCommitSHAs,
repository,
externalEditorLabel,
} = this.props
const fullPath = Path.join(repository.path, file.path)
const fileExistsOnDisk = await pathExists(fullPath)
if (!fileExistsOnDisk) {
showContextualMenu([
@ -339,14 +357,14 @@ export class SelectedCommit extends React.Component<
const extension = Path.extname(file.path)
const isSafeExtension = isSafeFileExtension(extension)
const openInExternalEditor = this.props.externalEditorLabel
? `Open in ${this.props.externalEditorLabel}`
const openInExternalEditor = externalEditorLabel
? `Open in ${externalEditorLabel}`
: DefaultEditorLabel
const items: IMenuItem[] = [
{
label: RevealInFileManagerLabel,
action: () => revealInFileManager(this.props.repository, file.path),
action: () => revealInFileManager(repository, file.path),
enabled: fileExistsOnDisk,
},
{
@ -372,7 +390,7 @@ export class SelectedCommit extends React.Component<
]
let viewOnGitHubLabel = 'View on GitHub'
const gitHubRepository = this.props.repository.gitHubRepository
const gitHubRepository = repository.gitHubRepository
if (
gitHubRepository &&
@ -383,20 +401,19 @@ export class SelectedCommit extends React.Component<
items.push({
label: viewOnGitHubLabel,
action: () => this.onViewOnGitHub(file),
action: () => this.onViewOnGitHub(selectedCommits[0].sha, file),
enabled:
!this.props.isLocal &&
selectedCommits.length === 1 &&
!localCommitSHAs.includes(selectedCommits[0].sha) &&
!!gitHubRepository &&
!!this.props.selectedCommit,
this.props.selectedCommits.length > 0,
})
showContextualMenu(items)
}
private onViewOnGitHub = (file: CommittedFileChange) => {
if (this.props.selectedCommit && this.props.onViewCommitOnGitHub) {
this.props.onViewCommitOnGitHub(this.props.selectedCommit.sha, file.path)
}
private onViewOnGitHub = (sha: string, file: CommittedFileChange) => {
this.props.onViewCommitOnGitHub(sha, file.path)
}
}

View file

@ -25,7 +25,10 @@ export class AvatarStack extends React.Component<IAvatarStackProps, {}> {
const users = this.props.users
for (let i = 0; i < this.props.users.length; i++) {
if (users.length > MaxDisplayedAvatars && i === MaxDisplayedAvatars - 1) {
if (
users.length > MaxDisplayedAvatars + 1 &&
i === MaxDisplayedAvatars - 1
) {
elems.push(<div key="more" className="avatar-more avatar" />)
}
@ -35,7 +38,8 @@ export class AvatarStack extends React.Component<IAvatarStackProps, {}> {
const className = classNames('AvatarStack', {
'AvatarStack--small': true,
'AvatarStack--two': users.length === 2,
'AvatarStack--three-plus': users.length >= MaxDisplayedAvatars,
'AvatarStack--three': users.length === 3,
'AvatarStack--plus': users.length > MaxDisplayedAvatars,
})
return (

View file

@ -7,10 +7,10 @@ import { isWebFlowCommitter } from '../../lib/web-flow-committer'
interface ICommitAttributionProps {
/**
* The commit from where to extract the author, committer
* The commit or commits from where to extract the author, committer
* and co-authors from.
*/
readonly commit: Commit
readonly commits: ReadonlyArray<Commit>
/**
* The GitHub hosted repository that the given commit is
@ -61,24 +61,34 @@ export class CommitAttribution extends React.Component<
}
public render() {
const commit = this.props.commit
const { author, committer, coAuthors } = commit
const { commits } = this.props
// do we need to attribute the committer separately from the author?
const committerAttribution =
!commit.authoredByCommitter &&
!(
this.props.gitHubRepository !== null &&
isWebFlowCommitter(commit, this.props.gitHubRepository)
)
const allAuthors = new Map<string, CommitIdentity | GitAuthor>()
for (const commit of commits) {
const { author, committer, coAuthors } = commit
const authors: Array<CommitIdentity | GitAuthor> = committerAttribution
? [author, committer, ...coAuthors]
: [author, ...coAuthors]
// do we need to attribute the committer separately from the author?
const committerAttribution =
!commit.authoredByCommitter &&
!(
this.props.gitHubRepository !== null &&
isWebFlowCommitter(commit, this.props.gitHubRepository)
)
const authors: Array<CommitIdentity | GitAuthor> = committerAttribution
? [author, committer, ...coAuthors]
: [author, ...coAuthors]
for (const a of authors) {
if (!allAuthors.has(a.toString())) {
allAuthors.set(a.toString(), a)
}
}
}
return (
<span className="commit-attribution-component">
{this.renderAuthors(authors)}
{this.renderAuthors(Array.from(allAuthors.values()))}
</span>
)
}

View file

@ -7,6 +7,12 @@ export const CopyRelativeFilePathLabel = __DARWIN__
? 'Copy Relative File Path'
: 'Copy relative file path'
export const CopySelectedPathsLabel = __DARWIN__ ? 'Copy Paths' : 'Copy paths'
export const CopySelectedRelativePathsLabel = __DARWIN__
? 'Copy Relative Paths'
: 'Copy relative paths'
export const DefaultEditorLabel = __DARWIN__
? 'Open in External Editor'
: 'Open in external editor'

View file

@ -7,14 +7,14 @@ import { Tooltip } from './tooltip'
import { createObservableRef } from './observable-ref'
import { getObjectId } from './object-id'
import { debounce } from 'lodash'
import { parseMarkdown } from '../../lib/markdown-filters/markdown-filter'
import {
MarkdownEmitter,
parseMarkdown,
} from '../../lib/markdown-filters/markdown-filter'
interface ISandboxedMarkdownProps {
/** A string of unparsed markdown to display */
readonly markdown: string
/** Whether the markdown was pre-parsed - assumed false */
readonly isParsed?: boolean
readonly markdown: string | MarkdownEmitter
/** The baseHref of the markdown content for when the markdown has relative links */
readonly baseHref?: string
@ -58,6 +58,7 @@ export class SandboxedMarkdown extends React.PureComponent<
private frameRef: HTMLIFrameElement | null = null
private frameContainingDivRef: HTMLDivElement | null = null
private contentDivRef: HTMLDivElement | null = null
private markdownEmitter?: MarkdownEmitter
/**
* Resize observer used for tracking height changes in the markdown
@ -72,6 +73,18 @@ export class SandboxedMarkdown extends React.PureComponent<
})
}, 100)
/**
* We debounce the markdown updating because it is updated on each custom
* markdown filter. Leading is true so that users will at a minimum see the
* markdown parsed by markedjs while the custom filters are being applied.
* (So instead of being updated, 10+ times it is updated 1 or 2 times.)
*/
private onMarkdownUpdated = debounce(
markdown => this.mountIframeContents(markdown),
10,
{ leading: true }
)
public constructor(props: ISandboxedMarkdownProps) {
super(props)
@ -105,8 +118,27 @@ export class SandboxedMarkdown extends React.PureComponent<
this.frameContainingDivRef = frameContainingDivRef
}
private initializeMarkdownEmitter = () => {
if (this.markdownEmitter !== undefined) {
this.markdownEmitter.dispose()
}
const { emoji, repository, markdownContext } = this.props
this.markdownEmitter =
typeof this.props.markdown !== 'string'
? this.props.markdown
: parseMarkdown(this.props.markdown, {
emoji,
repository,
markdownContext,
})
this.markdownEmitter.onMarkdownUpdated((markdown: string) => {
this.onMarkdownUpdated(markdown)
})
}
public async componentDidMount() {
this.mountIframeContents()
this.initializeMarkdownEmitter()
if (this.frameRef !== null) {
this.setupFrameLoadListeners(this.frameRef)
@ -120,11 +152,12 @@ export class SandboxedMarkdown extends React.PureComponent<
public async componentDidUpdate(prevProps: ISandboxedMarkdownProps) {
// rerender iframe contents if provided markdown changes
if (prevProps.markdown !== this.props.markdown) {
this.mountIframeContents()
this.initializeMarkdownEmitter()
}
}
public componentWillUnmount() {
this.markdownEmitter?.dispose()
this.resizeObserver.disconnect()
document.removeEventListener('scroll', this.onDocumentScroll)
}
@ -288,23 +321,13 @@ export class SandboxedMarkdown extends React.PureComponent<
/**
* Populates the mounted iframe with HTML generated from the provided markdown
*/
private async mountIframeContents() {
private async mountIframeContents(markdown: string) {
if (this.frameRef === null) {
return
}
const styleSheet = await this.getInlineStyleSheet()
const { emoji, repository, markdownContext } = this.props
const filteredHTML =
this.props.isParsed === true
? this.props.markdown
: await parseMarkdown(this.props.markdown, {
emoji,
repository,
markdownContext,
})
const src = `
<html>
<head>
@ -313,7 +336,7 @@ export class SandboxedMarkdown extends React.PureComponent<
</head>
<body class="markdown-body">
<div id="content">
${filteredHTML}
${markdown}
</div>
</body>
</html>

View file

@ -155,6 +155,9 @@ export const getCurrentWindowZoomFactor = invokeProxy(
0
)
/** Tell the main process to set the current window's zoom factor */
export const setWindowZoomFactor = sendProxy('set-window-zoom-factor', 1)
/** Tell the main process to check for app updates */
export const checkForUpdates = invokeProxy('check-for-updates', 1)

View file

@ -7,7 +7,7 @@ import { Changes, ChangesSidebar } from './changes'
import { NoChanges } from './changes/no-changes'
import { MultipleSelection } from './changes/multiple-selection'
import { FilesChangedBadge } from './changes/files-changed-badge'
import { SelectedCommit, CompareSidebar } from './history'
import { SelectedCommits, CompareSidebar } from './history'
import { Resizable } from './resizable'
import { TabBar } from './tab-bar'
import {
@ -372,31 +372,29 @@ export class RepositoryView extends React.Component<
}
private renderContentForHistory(): JSX.Element {
const { commitSelection } = this.props.state
const { commitSelection, commitLookup, localCommitSHAs } = this.props.state
const { changesetData, file, diff, shas, isContiguous } = commitSelection
const sha =
commitSelection.shas.length === 1 ? commitSelection.shas[0] : null
const selectedCommit =
sha != null ? this.props.state.commitLookup.get(sha) || null : null
const isLocal =
selectedCommit != null &&
this.props.state.localCommitSHAs.includes(selectedCommit.sha)
const { changesetData, file, diff } = commitSelection
const selectedCommits = []
for (const sha of shas) {
const commit = commitLookup.get(sha)
if (commit !== undefined) {
selectedCommits.push(commit)
}
}
const showDragOverlay = dragAndDropManager.isDragOfTypeInProgress(
DragType.Commit
)
return (
<SelectedCommit
<SelectedCommits
repository={this.props.repository}
isLocalRepository={this.props.state.remote === null}
dispatcher={this.props.dispatcher}
selectedCommit={selectedCommit}
isLocal={isLocal}
selectedCommits={selectedCommits}
isContiguous={isContiguous}
localCommitSHAs={localCommitSHAs}
changesetData={changesetData}
selectedFile={file}
currentDiff={diff}
@ -411,7 +409,6 @@ export class RepositoryView extends React.Component<
onOpenBinaryFile={this.onOpenBinaryFile}
onChangeImageDiffType={this.onChangeImageDiffType}
onDiffOptionsOpened={this.onDiffOptionsOpened}
areMultipleCommitsSelected={commitSelection.shas.length > 1}
showDragOverlay={showDragOverlay}
/>
)

View file

@ -0,0 +1,99 @@
import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Row } from '../lib/row'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { TextBox } from '../lib/text-box'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
interface ISSHUserPasswordProps {
readonly username: string
readonly onSubmit: (
password: string | undefined,
storePassword: boolean
) => void
readonly onDismissed: () => void
}
interface ISSHUserPasswordState {
readonly password: string
readonly rememberPassword: boolean
}
/**
* Dialog prompts the user the password of an SSH user.
*/
export class SSHUserPassword extends React.Component<
ISSHUserPasswordProps,
ISSHUserPasswordState
> {
public constructor(props: ISSHUserPasswordProps) {
super(props)
this.state = { password: '', rememberPassword: false }
}
public render() {
return (
<Dialog
id="ssh-user-password"
type="normal"
title="SSH User Password"
dismissable={false}
onSubmit={this.onSubmit}
onDismissed={this.props.onDismissed}
>
<DialogContent>
<Row>
<TextBox
label={`Enter password for '${this.props.username}':`}
value={this.state.password}
type="password"
onValueChanged={this.onValueChanged}
/>
</Row>
<Row>
<Checkbox
label="Remember password"
value={
this.state.rememberPassword
? CheckboxValue.On
: CheckboxValue.Off
}
onChange={this.onRememberPasswordChanged}
/>
</Row>
</DialogContent>
<DialogFooter>
<OkCancelButtonGroup
onCancelButtonClick={this.onCancel}
okButtonDisabled={this.state.password.length === 0}
/>
</DialogFooter>
</Dialog>
)
}
private onRememberPasswordChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
this.setState({ rememberPassword: event.currentTarget.checked })
}
private onValueChanged = (value: string) => {
this.setState({ password: value })
}
private submit(password: string | undefined, storePassword: boolean) {
const { onSubmit, onDismissed } = this.props
onSubmit(password, storePassword)
onDismissed()
}
private onSubmit = () => {
this.submit(this.state.password, this.state.rememberPassword)
}
private onCancel = () => {
this.submit(undefined, false)
}
}

View file

@ -11,7 +11,11 @@
min-width: 36px;
}
&.AvatarStack--three-plus {
&.AvatarStack--three {
min-width: 30px;
}
&.AvatarStack--plus {
min-width: 46px;
}
@ -28,10 +32,14 @@
min-width: 25px;
}
&.AvatarStack--three-plus {
&.AvatarStack--three {
min-width: 30px;
}
&.AvatarStack--plus {
min-width: 40px;
}
.avatar.avatar-more {
&::before,
&::after {
@ -66,6 +74,13 @@
background: var(--box-alt-background-color);
}
.avatar-container:nth-child(n + 5) {
.avatar {
display: none;
opacity: 0;
}
}
.avatar {
position: relative;
z-index: 2;
@ -92,13 +107,6 @@
img {
border-radius: 50%;
}
// stylelint-enable selector-max-type
// Account for 4+ avatars
&:nth-child(n + 4) {
display: none;
opacity: 0;
}
}
&:hover {
@ -106,9 +114,11 @@
margin-right: 3px;
}
.avatar:nth-child(n + 4) {
display: flex;
opacity: 1;
.avatar-container:nth-child(n + 5) {
.avatar {
display: flex;
opacity: 1;
}
}
.avatar-more {
@ -121,6 +131,7 @@
z-index: 1;
margin-right: 0;
background: $gray-100;
width: 10px !important;
&::before,
&::after {

View file

@ -25,7 +25,7 @@
&.expanded {
.commit-summary-description-scroll-view {
max-height: none;
max-height: 400px;
overflow: auto;
&:before {

View file

@ -398,10 +398,10 @@ devtron@^1.4.0:
highlight.js "^9.3.0"
humanize-plus "^1.8.1"
dexie@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-2.0.4.tgz#6027a5e05879424e8f9979d8c14e7420f27e3a11"
integrity sha512-aQ/s1U2wHxwBKRrt2Z/mwFNHMQWhESerFsMYzE+5P5OsIe5o1kgpFMWkzKTtkvkyyEni6mWr/T4HUJuY9xIHLA==
dexie@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.2.tgz#fa6f2a3c0d6ed0766f8d97a03720056f88fe0e01"
integrity sha512-q5dC3HPmir2DERlX+toCBbHQXW5MsyrFqPFcovkH9N2S/UW/H3H5AWAB6iEOExeraAu+j+zRDG+zg/D7YhH0qg==
dom-classlist@^1.0.1:
version "1.0.1"

View file

@ -4,6 +4,20 @@
"[Fixed] Fix crash launching the app on macOS High Sierra - #14712",
"[Fixed] Terminate all GitHub Desktop processes on Windows when the app is closed - #14733. Thanks @tsvetilian-ty!"
],
"3.0.2-beta4": [
"[Improved] Add support for SSH password prompts when accessing repositories - #14676",
"[Fixed] Fix Markdown syntax highlighting - #14710",
"[Fixed] Fix issue with some repositories not being properly persisted - #14748"
],
"3.0.2-beta3": [
"[Fixed] Terminate all GitHub Desktop processes on Windows when the app is closed - #14733. Thanks @tsvetilian-ty!"
],
"3.0.2-beta2": [
"[Fixed] Fix crash launching the app on macOS High Sierra - #14712"
],
"3.0.2-beta1": [
"[Added] Add support for Aptana Studio - #14669. Thanks @tsvetilian-ty!"
],
"3.0.1": [
"[Added] Add support for PyCharm Community Edition on Windows - #14411. Thanks @tsvetilian-ty!",
"[Added] Add support for highlighting .mjs/.cjs/.mts/.cts files as JavaScript/TypeScript - #14481. Thanks @j-f1!",

View file

@ -11,7 +11,7 @@ These are good teams to start with for general communication and questions. (Mem
| Team | Purpose |
|:--|:--|
| `@desktop/maintainers` | The people designing, developing, and driving GitHub Desktop. Includes all groups below. |
| `@desktop/comrades` | Community members with a track record of activity in the Desktop project |
| `@desktop/collaborators` | Community members with a track record of activity in the Desktop project |
## Special-purpose Teams

View file

@ -43,6 +43,7 @@ These editors are currently supported:
- [Brackets](http://brackets.io/)
- [Notepad++](https://notepad-plus-plus.org/)
- [RStudio](https://rstudio.com/)
- [Aptana Studio](http://www.aptana.com/)
These are defined in a list at the top of the file:
@ -233,6 +234,7 @@ These editors are currently supported:
- [Android Studio](https://developer.android.com/studio)
- [JetBrains Rider](https://www.jetbrains.com/rider/)
- [Nova](https://nova.app/)
- [Aptana Studio](http://www.aptana.com/)
These are defined in a list at the top of the file:

View file

@ -116,7 +116,7 @@
"@types/electron-winstaller": "^4.0.0",
"@types/eslint": "^8.4.1",
"@types/estree": "^0.0.49",
"@types/event-kit": "^1.2.28",
"@types/event-kit": "^2.4.1",
"@types/express": "^4.11.0",
"@types/fs-extra": "^7.0.0",
"@types/fuzzaldrin-plus": "^0.0.1",

View file

@ -3,6 +3,7 @@ import * as HTTPS from 'https'
export interface IAPIPR {
readonly title: string
readonly body: string
readonly headRefName: string
}
type GraphQLResponse = {
@ -49,6 +50,7 @@ export function fetchPR(id: number): Promise<IAPIPR | null> {
pullRequest(number: ${id}) {
title
body
headRefName
}
}
}

View file

@ -37,6 +37,26 @@ function capitalized(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
/**
* Finds a release note in the PR body, which is under the 'Release notes'
* section, preceded by a 'Notes:' title.
*
* @param body Body of the PR to parse
* @returns The release note if it exist, null if it's explicitly marked to
* not have a release note (with no-notes), and undefined if there
* is no 'Release notes' section at all.
*/
export function findReleaseNote(body: string): string | null | undefined {
const re = /^Notes: (.+)$/gm
const matches = re.exec(body)
if (!matches || matches.length < 2) {
return undefined
}
const note = matches[1].replace(/\.$/, '')
return note === 'no-notes' ? null : note
}
export function findIssueRef(body: string): string {
let issueRef = ''
@ -55,7 +75,12 @@ export function findIssueRef(body: string): string {
return issueRef
}
function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string {
function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string | null {
let attribution = ''
if (commit.owner !== OfficialOwner) {
attribution = `. Thanks @${commit.owner}!`
}
let type = PlaceholderChangeType
const description = capitalized(pr.title)
@ -67,9 +92,12 @@ function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string {
issueRef = ` #${commit.prID}`
}
let attribution = ''
if (commit.owner !== OfficialOwner) {
attribution = `. Thanks @${commit.owner}!`
// Use release note from PR body if defined
const releaseNote = findReleaseNote(pr.body)
if (releaseNote !== undefined) {
return releaseNote === null
? null
: `${releaseNote} -${issueRef}${attribution}`
}
return `[${type}] ${description} -${issueRef}${attribution}`
@ -86,9 +114,15 @@ export async function convertToChangelogFormat(
if (!pr) {
throw new Error(`Unable to get PR from API: ${commit.prID}`)
}
// Skip release PRs
if (pr.headRefName.startsWith('releases/')) {
continue
}
const entry = getChangelogEntry(commit, pr)
entries.push(entry)
if (entry !== null) {
entries.push(entry)
}
} catch (e) {
console.warn('Unable to parse line, using the full message.', e)

View file

@ -1,4 +1,4 @@
import { findIssueRef } from '../parser'
import { findIssueRef, findReleaseNote } from '../parser'
describe('changelog/parser', () => {
describe('findIssueRef', () => {
@ -54,4 +54,55 @@ quam vel augue.`
expect(findIssueRef(body)).toBe(' #2314')
})
})
describe('findReleaseNote', () => {
it('detected release note at the end of the body', () => {
const body = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
quam vel augue.
Notes: [Fixed] Fix lorem impsum dolor sit amet
`
expect(findReleaseNote(body)).toBe(
'[Fixed] Fix lorem impsum dolor sit amet'
)
})
it('removes dot at the end of release note', () => {
const body = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
quam vel augue.
Notes: [Fixed] Fix lorem impsum dolor sit amet.
`
expect(findReleaseNote(body)).toBe(
'[Fixed] Fix lorem impsum dolor sit amet'
)
})
it('detected no release notes wanted for the PR', () => {
const body = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
quam vel augue.
Notes: no-notes
`
expect(findReleaseNote(body)).toBeNull()
})
it('detected no release notes were added to the PR', () => {
const body = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
quam vel augue.`
expect(findReleaseNote(body)).toBeUndefined()
})
})
})

View file

@ -51,6 +51,20 @@ async function getLatestRelease(options: {
return latestTag instanceof SemVer ? latestTag.raw : latestTag
}
async function createReleaseBranch(version: string): Promise<void> {
try {
const versionBranch = `releases/${version}`
const currentBranch = (
await sh('git', 'rev-parse', '--abbrev-ref', 'HEAD')
).trim()
if (currentBranch !== versionBranch) {
await sh('git', 'checkout', '-b', versionBranch)
}
} catch (error) {
console.log(`Failed to create release branch: ${error}`)
}
}
/** Converts a string to Channel type if possible */
function parseChannel(arg: string): Channel {
if (arg === 'production' || arg === 'beta' || arg === 'test') {
@ -115,6 +129,10 @@ export async function run(args: ReadonlyArray<string>): Promise<void> {
})
const nextVersion = getNextVersionNumber(previousVersion, channel)
console.log(`Creating release branch for "${nextVersion}"...`)
createReleaseBranch(nextVersion)
console.log(`Done!`)
console.log(`Setting app version to "${nextVersion}" in app/package.json...`)
try {

View file

@ -960,10 +960,10 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
"@types/event-kit@^1.2.28":
version "1.2.32"
resolved "https://registry.yarnpkg.com/@types/event-kit/-/event-kit-1.2.32.tgz#068cbdc69e8c969afae8c9f6e3a51ea4b1b5522e"
integrity sha512-v+dvA/8Uqp5OfLkd8PRPCZgIWyfz2n14yZdyHvMkZG3Kl4d5K/7son3w18p9bh8zXx3FeT5/DZnu3cM8dWh3sg==
"@types/event-kit@^2.4.1":
version "2.4.1"
resolved "https://registry.yarnpkg.com/@types/event-kit/-/event-kit-2.4.1.tgz#cc00a9b80bae9a387ea60d5c9031b5eb490cfa34"
integrity sha512-ZwGAHGQSj+ZRmqueYyjfIrXRfwLd5A2Z0mfzpP40M9F+BlbUI0v7qsVVFHcWNTE+rq5TLzHeFhEGwFp1zZBSUQ==
"@types/events@*":
version "1.2.0"