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", "deep-equal": "^1.0.1",
"desktop-notifications": "^0.2.2", "desktop-notifications": "^0.2.2",
"desktop-trampoline": "desktop/desktop-trampoline#v0.9.8", "desktop-trampoline": "desktop/desktop-trampoline#v0.9.8",
"dexie": "^2.0.0", "dexie": "^3.2.2",
"dompurify": "^2.3.3", "dompurify": "^2.3.3",
"dugite": "^1.109.0", "dugite": "^1.109.0",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",

View file

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

View file

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

View file

@ -553,6 +553,20 @@ export interface ICommitSelection {
/** The commits currently selected in the app */ /** The commits currently selected in the app */
readonly shas: ReadonlyArray<string> 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 */ /** The changeset data associated with the selected commit */
readonly changesetData: IChangesetData readonly changesetData: IChangesetData

View file

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

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie' import Dexie, { Transaction } from 'dexie'
import { BaseDatabase } from './base-database' import { BaseDatabase } from './base-database'
export interface IIssue { 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 // Clear deprecated localStorage keys, we compute the since parameter
// using the database now. // using the database now.
clearDeprecatedKeys() clearDeprecatedKeys()

View file

@ -1,4 +1,4 @@
import Dexie from 'dexie' import Dexie, { Transaction } from 'dexie'
import { BaseDatabase } from './base-database' import { BaseDatabase } from './base-database'
import { WorkflowPreferences } from '../../models/workflow-preferences' import { WorkflowPreferences } from '../../models/workflow-preferences'
import { assertNonNullable } from '../fatal-error' 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. * 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>( const table = transaction.table<IDatabaseGitHubRepository, number>(
'gitHubRepositories' 'gitHubRepositories'
) )
@ -164,7 +164,7 @@ function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) {
}) })
} }
async function ensureNoUndefinedParentID(tx: Dexie.Transaction) { async function ensureNoUndefinedParentID(tx: Transaction) {
return tx return tx
.table<IDatabaseGitHubRepository, number>('gitHubRepositories') .table<IDatabaseGitHubRepository, number>('gitHubRepositories')
.toCollection() .toCollection()
@ -185,7 +185,7 @@ async function ensureNoUndefinedParentID(tx: Dexie.Transaction) {
* (https://github.com/desktop/desktop/pull/1242). This scenario ought to be * (https://github.com/desktop/desktop/pull/1242). This scenario ought to be
* incredibly unlikely. * incredibly unlikely.
*/ */
async function createOwnerKey(tx: Dexie.Transaction) { async function createOwnerKey(tx: Transaction) {
const ownersTable = tx.table<IDatabaseOwner, number>('owners') const ownersTable = tx.table<IDatabaseOwner, number>('owners')
const ghReposTable = tx.table<IDatabaseGitHubRepository, number>( const ghReposTable = tx.table<IDatabaseGitHubRepository, number>(
'gitHubRepositories' 'gitHubRepositories'

View file

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

View file

@ -299,6 +299,15 @@ const editors: WindowsExternalEditor[] = [
displayNamePrefix: 'SlickEdit', displayNamePrefix: 'SlickEdit',
publisher: 'SlickEdit Inc.', 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', name: 'JetBrains Webstorm',
registryKeys: registryKeysForJetBrainsIDE('WebStorm'), registryKeys: registryKeysForJetBrainsIDE('WebStorm'),

View file

@ -172,3 +172,8 @@ export function enablePullRequestReviewNotifications(): boolean {
export function enableReRunFailedAndSingleCheckJobs(): boolean { export function enableReRunFailedAndSingleCheckJobs(): boolean {
return true 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. */ /** 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 * Get a list of files which have recorded changes in the index as compared to

View file

@ -7,6 +7,7 @@ import {
WorkingDirectoryFileChange, WorkingDirectoryFileChange,
FileChange, FileChange,
AppFileStatusKind, AppFileStatusKind,
CommittedFileChange,
} from '../../models/status' } from '../../models/status'
import { import {
DiffType, DiffType,
@ -27,6 +28,10 @@ import { getOldPathOrDefault } from '../get-old-path'
import { getCaptures } from '../helpers/regex' import { getCaptures } from '../helpers/regex'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import { forceUnwrap } from '../fatal-error' 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 * 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) 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 * 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 * 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( async function getImageDiff(
repository: Repository, repository: Repository,
file: FileChange, file: FileChange,
commitish: string oldestCommitish: string
): Promise<IImageDiff> { ): Promise<IImageDiff> {
let current: Image | undefined = undefined let current: Image | undefined = undefined
let previous: Image | undefined = undefined let previous: Image | undefined = undefined
@ -232,7 +432,7 @@ async function getImageDiff(
} else { } else {
// File status can't be conflicted for a file in a commit // File status can't be conflicted for a file in a commit
if (file.status.kind !== AppFileStatusKind.Deleted) { 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 // File status can't be conflicted for a file in a commit
@ -247,7 +447,7 @@ async function getImageDiff(
previous = await getBlobImage( previous = await getBlobImage(
repository, repository,
getOldPathOrDefault(file), getOldPathOrDefault(file),
`${commitish}^` `${oldestCommitish}^`
) )
} }
} }
@ -263,7 +463,7 @@ export async function convertDiff(
repository: Repository, repository: Repository,
file: FileChange, file: FileChange,
diff: IRawDiff, diff: IRawDiff,
commitish: string, oldestCommitish: string,
lineEndingsChange?: LineEndingsChange lineEndingsChange?: LineEndingsChange
): Promise<IDiff> { ): Promise<IDiff> {
const extension = Path.extname(file.path).toLowerCase() const extension = Path.extname(file.path).toLowerCase()
@ -275,7 +475,7 @@ export async function convertDiff(
kind: DiffType.Binary, kind: DiffType.Binary,
} }
} else { } else {
return getImageDiff(repository, file, commitish) return getImageDiff(repository, file, oldestCommitish)
} }
} }
@ -370,7 +570,7 @@ function buildDiff(
buffer: Buffer, buffer: Buffer,
repository: Repository, repository: Repository,
file: FileChange, file: FileChange,
commitish: string, oldestCommitish: string,
lineEndingsChange?: LineEndingsChange lineEndingsChange?: LineEndingsChange
): Promise<IDiff> { ): Promise<IDiff> {
if (!isValidBuffer(buffer)) { if (!isValidBuffer(buffer)) {
@ -396,7 +596,7 @@ function buildDiff(
return Promise.resolve(largeTextDiff) 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 * Map the raw status text from Git to an app-friendly value
* shamelessly borrowed from GitHub Desktop (Windows) * shamelessly borrowed from GitHub Desktop (Windows)
*/ */
function mapStatus( export function mapStatus(
rawStatus: string, rawStatus: string,
oldPath?: string oldPath?: string
): PlainFileStatus | CopiedOrRenamedFileStatus | UntrackedFileStatus { ): PlainFileStatus | CopiedOrRenamedFileStatus | UntrackedFileStatus {

View file

@ -76,6 +76,7 @@ export type RequestChannels = {
'set-native-theme-source': (themeName: ThemeSource) => void 'set-native-theme-source': (themeName: ThemeSource) => void
'focus-window': () => void 'focus-window': () => void
'notification-event': NotificationCallback<DesktopAliveEvent> '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 * provided `defaultValue` if the key doesn't exist or if the value cannot be
* converted into a number * converted into a number
* *
@ -77,6 +77,34 @@ export function getNumber(
return value 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 * Set the provided key in local storage to a numeric value, or update the
* existing value if a key is already defined. * existing value if a key is already defined.

View file

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

View file

@ -1,5 +1,4 @@
import memoizeOne from 'memoize-one' import memoizeOne from 'memoize-one'
import { GitHubRepository } from '../../models/github-repository'
import { EmojiFilter } from './emoji-filter' import { EmojiFilter } from './emoji-filter'
import { IssueLinkFilter } from './issue-link-filter' import { IssueLinkFilter } from './issue-link-filter'
import { IssueMentionFilter } from './issue-mention-filter' import { IssueMentionFilter } from './issue-mention-filter'
@ -13,6 +12,8 @@ import {
isIssueClosingContext, isIssueClosingContext,
} from './close-keyword-filter' } from './close-keyword-filter'
import { CommitMentionLinkFilter } from './commit-mention-link-filter' import { CommitMentionLinkFilter } from './commit-mention-link-filter'
import { MarkdownEmitter } from './markdown-filter'
import { GitHubRepository } from '../../models/github-repository'
export interface INodeFilter { export interface INodeFilter {
/** /**
@ -37,19 +38,20 @@ export interface INodeFilter {
filter(node: Node): Promise<ReadonlyArray<Node> | null> 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 * 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 * because they will be applied in the order they are entered in the returned
* array. This is important as some filters impact others. * 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( export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
( (options: ICustomMarkdownFilterOptions): ReadonlyArray<INodeFilter> => {
emoji: Map<string, string>, const { emoji, repository, markdownContext } = options
repository?: GitHubRepository,
markdownContext?: MarkdownContext
): ReadonlyArray<INodeFilter> => {
const filterPipe: Array<INodeFilter> = [] const filterPipe: Array<INodeFilter> = []
if (repository !== undefined) { if (repository !== undefined) {
@ -104,15 +106,24 @@ export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
*/ */
export async function applyNodeFilters( export async function applyNodeFilters(
nodeFilters: ReadonlyArray<INodeFilter>, nodeFilters: ReadonlyArray<INodeFilter>,
parsedMarkdown: string markdownEmitter: MarkdownEmitter
): Promise<string> { ): Promise<void> {
const mdDoc = new DOMParser().parseFromString(parsedMarkdown, 'text/html') if (markdownEmitter.latestMarkdown === null || markdownEmitter.disposed) {
return
}
const mdDoc = new DOMParser().parseFromString(
markdownEmitter.latestMarkdown,
'text/html'
)
for (const nodeFilter of nodeFilters) { for (const nodeFilter of nodeFilters) {
await applyNodeFilter(nodeFilter, mdDoc) 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 { getFileHash } from '../file-system'
import { TokenStore } from '../stores' import { TokenStore } from '../stores'
import {
getSSHSecretStoreKey,
keepSSHSecretToStore,
} from './ssh-secret-storage'
const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop' const SSHKeyPassphraseTokenStoreKey = getSSHSecretStoreKey(
const SSHKeyPassphraseTokenStoreKey = `${appName} - SSH key passphrases` 'SSH key passphrases'
)
async function getHashForSSHKey(keyPath: string) { async function getHashForSSHKey(keyPath: string) {
return getFileHash(keyPath, 'sha256') 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 * Keeps the SSH key passphrase in memory to be stored later if the ongoing git
* operation succeeds. * operation succeeds.
@ -52,27 +41,13 @@ export async function keepSSHKeyPassphraseToStore(
) { ) {
try { try {
const keyHash = await getHashForSSHKey(keyPath) const keyHash = await getHashForSSHKey(keyPath)
SSHKeyPassphrasesToStore.set(operationGUID, { keyHash, passphrase }) keepSSHSecretToStore(
operationGUID,
SSHKeyPassphraseTokenStoreKey,
keyHash,
passphrase
)
} catch (e) { } catch (e) {
log.error('Could not store passphrase for SSH key:', 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 pLimit from 'p-limit'
import QuickLRU from 'quick-lru' import QuickLRU from 'quick-lru'
import { IDisposable, Disposable } from 'event-kit' import { DisposableLike, Disposable } from 'event-kit'
import { IAheadBehind } from '../../models/branch' import { IAheadBehind } from '../../models/branch'
import { revSymmetricDifference, getAheadBehind } from '../git' import { revSymmetricDifference, getAheadBehind } from '../git'
import { Repository } from '../../models/repository' import { Repository } from '../../models/repository'
@ -76,7 +76,7 @@ export class AheadBehindStore {
from: string, from: string,
to: string, to: string,
callback: AheadBehindCallback callback: AheadBehindCallback
): IDisposable { ): DisposableLike {
const key = getCacheKey(repository, from, to) const key = getCacheKey(repository, from, to)
const existing = this.cache.get(key) const existing = this.cache.get(key)
const disposable = new Disposable(() => {}) const disposable = new Disposable(() => {})

View file

@ -76,6 +76,7 @@ import {
getCurrentWindowZoomFactor, getCurrentWindowZoomFactor,
updatePreferredAppMenuItemLabels, updatePreferredAppMenuItemLabels,
updateAccounts, updateAccounts,
setWindowZoomFactor,
} from '../../ui/main-process-proxy' } from '../../ui/main-process-proxy'
import { import {
API, API,
@ -158,6 +159,8 @@ import {
appendIgnoreFile, appendIgnoreFile,
getRepositoryType, getRepositoryType,
RepositoryType, RepositoryType,
getCommitRangeDiff,
getCommitRangeChangedFiles,
} from '../git' } from '../git'
import { import {
installGlobalLFSFilters, installGlobalLFSFilters,
@ -203,6 +206,7 @@ import {
getEnum, getEnum,
getObject, getObject,
setObject, setObject,
getFloatNumber,
} from '../local-storage' } from '../local-storage'
import { ExternalEditorError, suggestedExternalEditor } from '../editors/shared' import { ExternalEditorError, suggestedExternalEditor } from '../editors/shared'
import { ApiRepositoriesStore } from './api-repositories-store' import { ApiRepositoriesStore } from './api-repositories-store'
@ -213,7 +217,10 @@ import {
} from './updates/changes-state' } from './updates/changes-state'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution' import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { BranchPruner } from './helpers/branch-pruner' import { BranchPruner } from './helpers/branch-pruner'
import { enableHideWhitespaceInDiffOption } from '../feature-flag' import {
enableHideWhitespaceInDiffOption,
enableMultiCommitDiffs,
} from '../feature-flag'
import { Banner, BannerType } from '../../models/banner' import { Banner, BannerType } from '../../models/banner'
import { ComputedAction } from '../../models/computed-action' import { ComputedAction } from '../../models/computed-action'
import { import {
@ -570,13 +577,41 @@ export class AppStore extends TypedBaseStore<IAppState> {
} }
private initializeZoomFactor = async () => { private initializeZoomFactor = async () => {
const zoomFactor = await getCurrentWindowZoomFactor() const zoomFactor = await this.getWindowZoomFactor()
if (zoomFactor === undefined) { if (zoomFactor === undefined) {
return return
} }
this.onWindowZoomFactorChanged(zoomFactor) 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) => { private onTokenInvalidated = (endpoint: string) => {
const account = getAccountForEndpoint(this.accounts, endpoint) const account = getAccountForEndpoint(this.accounts, endpoint)
@ -799,6 +834,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.windowZoomFactor = zoomFactor this.windowZoomFactor = zoomFactor
if (zoomFactor !== current) { if (zoomFactor !== current) {
setNumber('zoom-factor', zoomFactor)
this.updateResizableConstraints() this.updateResizableConstraints()
this.emitUpdate() this.emitUpdate()
} }
@ -1075,7 +1111,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** This shouldn't be called directly. See `Dispatcher`. */ /** This shouldn't be called directly. See `Dispatcher`. */
public async _changeCommitSelection( public async _changeCommitSelection(
repository: Repository, repository: Repository,
shas: ReadonlyArray<string> shas: ReadonlyArray<string>,
isContiguous: boolean
): Promise<void> { ): Promise<void> {
const { commitSelection } = this.repositoryStateCache.get(repository) const { commitSelection } = this.repositoryStateCache.get(repository)
@ -1088,6 +1125,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.repositoryStateCache.updateCommitSelection(repository, () => ({ this.repositoryStateCache.updateCommitSelection(repository, () => ({
shas, shas,
isContiguous,
file: null, file: null,
changesetData: { files: [], linesAdded: 0, linesDeleted: 0 }, changesetData: { files: [], linesAdded: 0, linesDeleted: 0 },
diff: null, diff: null,
@ -1102,9 +1140,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
) { ) {
const state = this.repositoryStateCache.get(repository) const state = this.repositoryStateCache.get(repository)
let selectedSHA = let selectedSHA =
state.commitSelection.shas.length === 1 state.commitSelection.shas.length > 0
? state.commitSelection.shas[0] ? state.commitSelection.shas[0]
: null : null
if (selectedSHA != null) { if (selectedSHA != null) {
const index = commitSHAs.findIndex(sha => sha === selectedSHA) const index = commitSHAs.findIndex(sha => sha === selectedSHA)
if (index < 0) { if (index < 0) {
@ -1115,8 +1154,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
} }
} }
if (state.commitSelection.shas.length === 0 && commitSHAs.length > 0) { if (selectedSHA === null && commitSHAs.length > 0) {
this._changeCommitSelection(repository, [commitSHAs[0]]) this._changeCommitSelection(repository, [commitSHAs[0]], true)
this._loadChangedFilesForCurrentSelection(repository) this._loadChangedFilesForCurrentSelection(repository)
} }
} }
@ -1371,15 +1410,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
): Promise<void> { ): Promise<void> {
const state = this.repositoryStateCache.get(repository) const state = this.repositoryStateCache.get(repository)
const { commitSelection } = state const { commitSelection } = state
const currentSHAs = commitSelection.shas const { shas: currentSHAs, isContiguous } = commitSelection
if (currentSHAs.length !== 1) { if (
// if none or multiple, we don't display a diff currentSHAs.length === 0 ||
(currentSHAs.length > 1 && (!enableMultiCommitDiffs() || !isContiguous))
) {
return return
} }
const gitStore = this.gitStoreCache.get(repository) const gitStore = this.gitStoreCache.get(repository)
const changesetData = await gitStore.performFailableOperation(() => const changesetData = await gitStore.performFailableOperation(() =>
getChangedFiles(repository, currentSHAs[0]) currentSHAs.length > 1
? getCommitRangeChangedFiles(repository, currentSHAs)
: getChangedFiles(repository, currentSHAs[0])
) )
if (!changesetData) { if (!changesetData) {
return return
@ -1390,7 +1433,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
// SHA/path. // SHA/path.
if ( if (
commitSelection.shas.length !== currentSHAs.length || commitSelection.shas.length !== currentSHAs.length ||
commitSelection.shas[0] !== currentSHAs[0] !commitSelection.shas.every((sha, i) => sha === currentSHAs[i])
) { ) {
return return
} }
@ -1436,7 +1479,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate() this.emitUpdate()
const stateBeforeLoad = this.repositoryStateCache.get(repository) const stateBeforeLoad = this.repositoryStateCache.get(repository)
const shas = stateBeforeLoad.commitSelection.shas const { shas, isContiguous } = stateBeforeLoad.commitSelection
if (shas.length === 0) { if (shas.length === 0) {
if (__DEV__) { 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 && (!enableMultiCommitDiffs() || !isContiguous)) {
if (shas.length > 1) {
return return
} }
const diff = await getCommitDiff( const diff =
repository, shas.length > 1
file, ? await getCommitRangeDiff(
shas[0], repository,
this.hideWhitespaceInHistoryDiff file,
) shas,
this.hideWhitespaceInHistoryDiff
)
: await getCommitDiff(
repository,
file,
shas[0],
this.hideWhitespaceInHistoryDiff
)
const stateAfterLoad = this.repositoryStateCache.get(repository) const stateAfterLoad = this.repositoryStateCache.get(repository)
const { shas: shasAfter } = stateAfterLoad.commitSelection const { shas: shasAfter } = stateAfterLoad.commitSelection
// A whole bunch of things could have happened since we initiated the diff load // 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 return
} }
if (!stateAfterLoad.commitSelection.file) { if (!stateAfterLoad.commitSelection.file) {
return return
} }

View file

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

View file

@ -124,7 +124,7 @@ export class RepositoriesStore extends TypedBaseStore<
) )
} }
return new GitHubRepository( const ghRepo = new GitHubRepository(
repo.name, repo.name,
owner, owner,
repo.id, repo.id,
@ -137,6 +137,10 @@ export class RepositoriesStore extends TypedBaseStore<
repo.permissions, repo.permissions,
parent 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) { private async toRepository(repo: IDatabaseRepository) {

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { PopupType } from '../../models/popup' import { PopupType } from '../../models/popup'
import { Dispatcher } from '../../ui/dispatcher' import { Dispatcher } from '../../ui/dispatcher'
type PromptSSHKeyPassphraseResponse = { type PromptSSHSecretResponse = {
readonly passphrase: string | undefined readonly secret: string | undefined
readonly storePassphrase: boolean readonly storeSecret: boolean
} }
class TrampolineUIHelper { class TrampolineUIHelper {
@ -34,13 +34,26 @@ class TrampolineUIHelper {
public promptSSHKeyPassphrase( public promptSSHKeyPassphrase(
keyPath: string keyPath: string
): Promise<PromptSSHKeyPassphraseResponse> { ): Promise<PromptSSHSecretResponse> {
return new Promise(resolve => { return new Promise(resolve => {
this.dispatcher.showPopup({ this.dispatcher.showPopup({
type: PopupType.SSHKeyPassphrase, type: PopupType.SSHKeyPassphrase,
keyPath, keyPath,
onSubmit: (passphrase, storePassphrase) => 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 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. * 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() 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 * An event sent by the renderer asking for a copy of the current
* application menu. * application menu.

View file

@ -10,7 +10,7 @@ const rootAppDir = Path.resolve(appFolder, '..')
const updateDotExe = Path.resolve(Path.join(rootAppDir, 'Update.exe')) const updateDotExe = Path.resolve(Path.join(rootAppDir, 'Update.exe'))
const exeName = Path.basename(process.execPath) 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. // 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 date: Date,
public readonly tzOffset: number = new Date().getTimezoneOffset() public readonly tzOffset: number = new Date().getTimezoneOffset()
) {} ) {}
public toString() {
return `${this.name} <${this.email}>`
}
} }

View file

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

View file

@ -154,6 +154,7 @@ import { generateDevReleaseSummary } from '../lib/release-notes'
import { PullRequestReview } from './notifications/pull-request-review' import { PullRequestReview } from './notifications/pull-request-review'
import { getPullRequestCommitRef } from '../models/pull-request' import { getPullRequestCommitRef } from '../models/pull-request'
import { getRepositoryType } from '../lib/git' import { getRepositoryType } from '../lib/git'
import { SSHUserPassword } from './ssh/ssh-user-password'
const MinuteInMilliseconds = 1000 * 60 const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 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: { case PopupType.PullRequestChecksFailed: {
return ( return (
<PullRequestChecksFailed <PullRequestChecksFailed

View file

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

View file

@ -27,6 +27,8 @@ import {
RevealInFileManagerLabel, RevealInFileManagerLabel,
OpenWithDefaultProgramLabel, OpenWithDefaultProgramLabel,
CopyRelativeFilePathLabel, CopyRelativeFilePathLabel,
CopySelectedPathsLabel,
CopySelectedRelativePathsLabel,
} from '../lib/context-menu' } from '../lib/context-menu'
import { CommitMessage } from './commit-message' import { CommitMessage } from './commit-message'
import { ChangedFile } from './changed-file' import { ChangedFile } from './changed-file'
@ -51,6 +53,7 @@ import { hasConflictedFiles } from '../../lib/status'
import { createObservableRef } from '../lib/observable-ref' import { createObservableRef } from '../lib/observable-ref'
import { Tooltip, TooltipDirection } from '../lib/tooltip' import { Tooltip, TooltipDirection } from '../lib/tooltip'
import { Popup } from '../../models/popup' import { Popup } from '../../models/popup'
import { EOL } from 'os'
const RowHeight = 29 const RowHeight = 29
const StashIcon: OcticonSymbol.OcticonSymbolType = { 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 = ( private getRevealInFileManagerMenuItem = (
file: WorkingDirectoryFileChange file: WorkingDirectoryFileChange
): IMenuItem => { ): IMenuItem => {
@ -556,15 +585,21 @@ export class ChangesList extends React.Component<
this.props.onIncludeChanged(file.path, false) 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 const enabled = status.kind !== AppFileStatusKind.Deleted
items.push( items.push(
{ type: 'separator' },
this.getCopyPathMenuItem(file),
this.getCopyRelativePathMenuItem(file),
{ type: 'separator' }, { type: 'separator' },
this.getRevealInFileManagerMenuItem(file), this.getRevealInFileManagerMenuItem(file),
this.getOpenInExternalEditorMenuItem(file, enabled), this.getOpenInExternalEditorMenuItem(file, enabled),

View file

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

View file

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

View file

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

View file

@ -41,7 +41,10 @@ interface ICommitListProps {
readonly emptyListMessage: JSX.Element | string readonly emptyListMessage: JSX.Element | string
/** Callback which fires when a commit has been selected in the list */ /** 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 */ /** Callback that fires when a scroll event has occurred */
readonly onScroll: (start: number, end: number) => void 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. // reordering, they will need to do multiple cherry-picks.
// Goal: first commit in history -> first on array // Goal: first commit in history -> first on array
const sorted = [...rows].sort((a, b) => b - a) const sorted = [...rows].sort((a, b) => b - a)
const selectedShas = sorted.map(r => this.props.commitSHAs[r]) const selectedShas = sorted.map(r => this.props.commitSHAs[r])
const selectedCommits = this.lookupCommits(selectedShas) 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 // 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 sha = this.props.commitSHAs[row]
const commit = this.props.commitLookup.get(sha) const commit = this.props.commitLookup.get(sha)
if (commit) { 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 { clipboard } from 'electron'
import { TooltipDirection } from '../lib/tooltip' import { TooltipDirection } from '../lib/tooltip'
import { AppFileStatusKind } from '../../models/status' import { AppFileStatusKind } from '../../models/status'
import _ from 'lodash'
interface ICommitSummaryProps { interface ICommitSummaryProps {
readonly repository: Repository readonly repository: Repository
readonly commit: Commit readonly commits: ReadonlyArray<Commit>
readonly changesetData: IChangesetData readonly changesetData: IChangesetData
readonly emoji: Map<string, string> readonly emoji: Map<string, string>
@ -98,17 +99,33 @@ function createState(
isOverflowed: boolean, isOverflowed: boolean,
props: ICommitSummaryProps props: ICommitSummaryProps
): ICommitSummaryState { ): 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( const { summary, body } = wrapRichTextCommitMessage(
props.commit.summary, commits[0].summary,
props.commit.body, plainTextBody,
tokenizer tokenizer
) )
const avatarUsers = getAvatarUsersForCommit( const allAvatarUsers = commits.flatMap(c =>
props.repository.gitHubRepository, getAvatarUsersForCommit(repository.gitHubRepository, c)
props.commit )
const avatarUsers = _.uniqWith(
allAvatarUsers,
(a, b) => a.email === b.email && a.name === b.name
) )
return { isOverflowed, summary, body, avatarUsers } return { isOverflowed, summary, body, avatarUsers }
@ -242,7 +259,12 @@ export class CommitSummary extends React.Component<
} }
public componentWillUpdate(nextProps: ICommitSummaryProps) { 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)) this.setState(createState(false, nextProps))
} }
} }
@ -293,9 +315,21 @@ export class CommitSummary extends React.Component<
) )
} }
public render() { private getShaRef = (useShortSha?: boolean) => {
const shortSHA = this.props.commit.shortSha 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({ const className = classNames({
expanded: this.props.isExpanded, expanded: this.props.isExpanded,
collapsed: !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 hasEmptySummary = this.state.summary.length === 0
const commitSummary = hasEmptySummary const commitSummary = hasEmptySummary
? 'Empty commit message' ? 'Empty commit message'
: this.props.commits.length > 1
? `Viewing the diff of ${this.props.commits.length} commits`
: this.state.summary : this.state.summary
const summaryClassNames = classNames('commit-summary-title', { const summaryClassNames = classNames('commit-summary-title', {
@ -330,7 +366,7 @@ export class CommitSummary extends React.Component<
<AvatarStack users={this.state.avatarUsers} /> <AvatarStack users={this.state.avatarUsers} />
<CommitAttribution <CommitAttribution
gitHubRepository={this.props.repository.gitHubRepository} gitHubRepository={this.props.repository.gitHubRepository}
commit={this.props.commit} commits={this.props.commits}
/> />
</li> </li>
@ -346,7 +382,7 @@ export class CommitSummary extends React.Component<
interactive={true} interactive={true}
direction={TooltipDirection.SOUTH} direction={TooltipDirection.SOUTH}
> >
{shortSHA} {this.getShaRef(true)}
</TooltippedContent> </TooltippedContent>
</li> </li>
@ -382,7 +418,7 @@ export class CommitSummary extends React.Component<
private renderShaTooltip() { private renderShaTooltip() {
return ( return (
<> <>
<code>{this.props.commit.sha}</code> <code>{this.getShaRef()}</code>
<button onClick={this.onCopyShaButtonClick}>Copy</button> <button onClick={this.onCopyShaButtonClick}>Copy</button>
</> </>
) )
@ -390,7 +426,7 @@ export class CommitSummary extends React.Component<
private onCopyShaButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => { private onCopyShaButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault() e.preventDefault()
clipboard.writeText(this.props.commit.sha) clipboard.writeText(this.getShaRef())
} }
private renderChangedFilesDescription = () => { private renderChangedFilesDescription = () => {
@ -492,7 +528,7 @@ export class CommitSummary extends React.Component<
} }
private renderTags() { private renderTags() {
const tags = this.props.commit.tags || [] const tags = this.props.commits.flatMap(c => c.tags) || []
if (tags.length === 0) { if (tags.length === 0) {
return null return null

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,12 @@ export const CopyRelativeFilePathLabel = __DARWIN__
? 'Copy Relative File Path' ? 'Copy Relative File Path'
: '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__ export const DefaultEditorLabel = __DARWIN__
? 'Open in External Editor' ? 'Open in External Editor'
: 'Open in external editor' : 'Open in external editor'

View file

@ -7,14 +7,14 @@ import { Tooltip } from './tooltip'
import { createObservableRef } from './observable-ref' import { createObservableRef } from './observable-ref'
import { getObjectId } from './object-id' import { getObjectId } from './object-id'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import { parseMarkdown } from '../../lib/markdown-filters/markdown-filter' import {
MarkdownEmitter,
parseMarkdown,
} from '../../lib/markdown-filters/markdown-filter'
interface ISandboxedMarkdownProps { interface ISandboxedMarkdownProps {
/** A string of unparsed markdown to display */ /** A string of unparsed markdown to display */
readonly markdown: string readonly markdown: string | MarkdownEmitter
/** Whether the markdown was pre-parsed - assumed false */
readonly isParsed?: boolean
/** The baseHref of the markdown content for when the markdown has relative links */ /** The baseHref of the markdown content for when the markdown has relative links */
readonly baseHref?: string readonly baseHref?: string
@ -58,6 +58,7 @@ export class SandboxedMarkdown extends React.PureComponent<
private frameRef: HTMLIFrameElement | null = null private frameRef: HTMLIFrameElement | null = null
private frameContainingDivRef: HTMLDivElement | null = null private frameContainingDivRef: HTMLDivElement | null = null
private contentDivRef: HTMLDivElement | null = null private contentDivRef: HTMLDivElement | null = null
private markdownEmitter?: MarkdownEmitter
/** /**
* Resize observer used for tracking height changes in the markdown * Resize observer used for tracking height changes in the markdown
@ -72,6 +73,18 @@ export class SandboxedMarkdown extends React.PureComponent<
}) })
}, 100) }, 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) { public constructor(props: ISandboxedMarkdownProps) {
super(props) super(props)
@ -105,8 +118,27 @@ export class SandboxedMarkdown extends React.PureComponent<
this.frameContainingDivRef = frameContainingDivRef 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() { public async componentDidMount() {
this.mountIframeContents() this.initializeMarkdownEmitter()
if (this.frameRef !== null) { if (this.frameRef !== null) {
this.setupFrameLoadListeners(this.frameRef) this.setupFrameLoadListeners(this.frameRef)
@ -120,11 +152,12 @@ export class SandboxedMarkdown extends React.PureComponent<
public async componentDidUpdate(prevProps: ISandboxedMarkdownProps) { public async componentDidUpdate(prevProps: ISandboxedMarkdownProps) {
// rerender iframe contents if provided markdown changes // rerender iframe contents if provided markdown changes
if (prevProps.markdown !== this.props.markdown) { if (prevProps.markdown !== this.props.markdown) {
this.mountIframeContents() this.initializeMarkdownEmitter()
} }
} }
public componentWillUnmount() { public componentWillUnmount() {
this.markdownEmitter?.dispose()
this.resizeObserver.disconnect() this.resizeObserver.disconnect()
document.removeEventListener('scroll', this.onDocumentScroll) 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 * Populates the mounted iframe with HTML generated from the provided markdown
*/ */
private async mountIframeContents() { private async mountIframeContents(markdown: string) {
if (this.frameRef === null) { if (this.frameRef === null) {
return return
} }
const styleSheet = await this.getInlineStyleSheet() 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 = ` const src = `
<html> <html>
<head> <head>
@ -313,7 +336,7 @@ export class SandboxedMarkdown extends React.PureComponent<
</head> </head>
<body class="markdown-body"> <body class="markdown-body">
<div id="content"> <div id="content">
${filteredHTML} ${markdown}
</div> </div>
</body> </body>
</html> </html>

View file

@ -155,6 +155,9 @@ export const getCurrentWindowZoomFactor = invokeProxy(
0 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 */ /** Tell the main process to check for app updates */
export const checkForUpdates = invokeProxy('check-for-updates', 1) 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 { NoChanges } from './changes/no-changes'
import { MultipleSelection } from './changes/multiple-selection' import { MultipleSelection } from './changes/multiple-selection'
import { FilesChangedBadge } from './changes/files-changed-badge' import { FilesChangedBadge } from './changes/files-changed-badge'
import { SelectedCommit, CompareSidebar } from './history' import { SelectedCommits, CompareSidebar } from './history'
import { Resizable } from './resizable' import { Resizable } from './resizable'
import { TabBar } from './tab-bar' import { TabBar } from './tab-bar'
import { import {
@ -372,31 +372,29 @@ export class RepositoryView extends React.Component<
} }
private renderContentForHistory(): JSX.Element { 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 = const selectedCommits = []
commitSelection.shas.length === 1 ? commitSelection.shas[0] : null for (const sha of shas) {
const commit = commitLookup.get(sha)
const selectedCommit = if (commit !== undefined) {
sha != null ? this.props.state.commitLookup.get(sha) || null : null selectedCommits.push(commit)
}
const isLocal = }
selectedCommit != null &&
this.props.state.localCommitSHAs.includes(selectedCommit.sha)
const { changesetData, file, diff } = commitSelection
const showDragOverlay = dragAndDropManager.isDragOfTypeInProgress( const showDragOverlay = dragAndDropManager.isDragOfTypeInProgress(
DragType.Commit DragType.Commit
) )
return ( return (
<SelectedCommit <SelectedCommits
repository={this.props.repository} repository={this.props.repository}
isLocalRepository={this.props.state.remote === null} isLocalRepository={this.props.state.remote === null}
dispatcher={this.props.dispatcher} dispatcher={this.props.dispatcher}
selectedCommit={selectedCommit} selectedCommits={selectedCommits}
isLocal={isLocal} isContiguous={isContiguous}
localCommitSHAs={localCommitSHAs}
changesetData={changesetData} changesetData={changesetData}
selectedFile={file} selectedFile={file}
currentDiff={diff} currentDiff={diff}
@ -411,7 +409,6 @@ export class RepositoryView extends React.Component<
onOpenBinaryFile={this.onOpenBinaryFile} onOpenBinaryFile={this.onOpenBinaryFile}
onChangeImageDiffType={this.onChangeImageDiffType} onChangeImageDiffType={this.onChangeImageDiffType}
onDiffOptionsOpened={this.onDiffOptionsOpened} onDiffOptionsOpened={this.onDiffOptionsOpened}
areMultipleCommitsSelected={commitSelection.shas.length > 1}
showDragOverlay={showDragOverlay} 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; min-width: 36px;
} }
&.AvatarStack--three-plus { &.AvatarStack--three {
min-width: 30px;
}
&.AvatarStack--plus {
min-width: 46px; min-width: 46px;
} }
@ -28,10 +32,14 @@
min-width: 25px; min-width: 25px;
} }
&.AvatarStack--three-plus { &.AvatarStack--three {
min-width: 30px; min-width: 30px;
} }
&.AvatarStack--plus {
min-width: 40px;
}
.avatar.avatar-more { .avatar.avatar-more {
&::before, &::before,
&::after { &::after {
@ -66,6 +74,13 @@
background: var(--box-alt-background-color); background: var(--box-alt-background-color);
} }
.avatar-container:nth-child(n + 5) {
.avatar {
display: none;
opacity: 0;
}
}
.avatar { .avatar {
position: relative; position: relative;
z-index: 2; z-index: 2;
@ -92,13 +107,6 @@
img { img {
border-radius: 50%; border-radius: 50%;
} }
// stylelint-enable selector-max-type
// Account for 4+ avatars
&:nth-child(n + 4) {
display: none;
opacity: 0;
}
} }
&:hover { &:hover {
@ -106,9 +114,11 @@
margin-right: 3px; margin-right: 3px;
} }
.avatar:nth-child(n + 4) { .avatar-container:nth-child(n + 5) {
display: flex; .avatar {
opacity: 1; display: flex;
opacity: 1;
}
} }
.avatar-more { .avatar-more {
@ -121,6 +131,7 @@
z-index: 1; z-index: 1;
margin-right: 0; margin-right: 0;
background: $gray-100; background: $gray-100;
width: 10px !important;
&::before, &::before,
&::after { &::after {

View file

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

View file

@ -398,10 +398,10 @@ devtron@^1.4.0:
highlight.js "^9.3.0" highlight.js "^9.3.0"
humanize-plus "^1.8.1" humanize-plus "^1.8.1"
dexie@^2.0.0: dexie@^3.2.2:
version "2.0.4" version "3.2.2"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-2.0.4.tgz#6027a5e05879424e8f9979d8c14e7420f27e3a11" resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.2.tgz#fa6f2a3c0d6ed0766f8d97a03720056f88fe0e01"
integrity sha512-aQ/s1U2wHxwBKRrt2Z/mwFNHMQWhESerFsMYzE+5P5OsIe5o1kgpFMWkzKTtkvkyyEni6mWr/T4HUJuY9xIHLA== integrity sha512-q5dC3HPmir2DERlX+toCBbHQXW5MsyrFqPFcovkH9N2S/UW/H3H5AWAB6iEOExeraAu+j+zRDG+zg/D7YhH0qg==
dom-classlist@^1.0.1: dom-classlist@^1.0.1:
version "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] 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!" "[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": [ "3.0.1": [
"[Added] Add support for PyCharm Community Edition on Windows - #14411. Thanks @tsvetilian-ty!", "[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!", "[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 | | Team | Purpose |
|:--|:--| |:--|:--|
| `@desktop/maintainers` | The people designing, developing, and driving GitHub Desktop. Includes all groups below. | | `@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 ## Special-purpose Teams

View file

@ -43,6 +43,7 @@ These editors are currently supported:
- [Brackets](http://brackets.io/) - [Brackets](http://brackets.io/)
- [Notepad++](https://notepad-plus-plus.org/) - [Notepad++](https://notepad-plus-plus.org/)
- [RStudio](https://rstudio.com/) - [RStudio](https://rstudio.com/)
- [Aptana Studio](http://www.aptana.com/)
These are defined in a list at the top of the file: 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) - [Android Studio](https://developer.android.com/studio)
- [JetBrains Rider](https://www.jetbrains.com/rider/) - [JetBrains Rider](https://www.jetbrains.com/rider/)
- [Nova](https://nova.app/) - [Nova](https://nova.app/)
- [Aptana Studio](http://www.aptana.com/)
These are defined in a list at the top of the file: 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/electron-winstaller": "^4.0.0",
"@types/eslint": "^8.4.1", "@types/eslint": "^8.4.1",
"@types/estree": "^0.0.49", "@types/estree": "^0.0.49",
"@types/event-kit": "^1.2.28", "@types/event-kit": "^2.4.1",
"@types/express": "^4.11.0", "@types/express": "^4.11.0",
"@types/fs-extra": "^7.0.0", "@types/fs-extra": "^7.0.0",
"@types/fuzzaldrin-plus": "^0.0.1", "@types/fuzzaldrin-plus": "^0.0.1",

View file

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

View file

@ -37,6 +37,26 @@ function capitalized(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1) 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 { export function findIssueRef(body: string): string {
let issueRef = '' let issueRef = ''
@ -55,7 +75,12 @@ export function findIssueRef(body: string): string {
return issueRef 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 let type = PlaceholderChangeType
const description = capitalized(pr.title) const description = capitalized(pr.title)
@ -67,9 +92,12 @@ function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string {
issueRef = ` #${commit.prID}` issueRef = ` #${commit.prID}`
} }
let attribution = '' // Use release note from PR body if defined
if (commit.owner !== OfficialOwner) { const releaseNote = findReleaseNote(pr.body)
attribution = `. Thanks @${commit.owner}!` if (releaseNote !== undefined) {
return releaseNote === null
? null
: `${releaseNote} -${issueRef}${attribution}`
} }
return `[${type}] ${description} -${issueRef}${attribution}` return `[${type}] ${description} -${issueRef}${attribution}`
@ -86,9 +114,15 @@ export async function convertToChangelogFormat(
if (!pr) { if (!pr) {
throw new Error(`Unable to get PR from API: ${commit.prID}`) 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) const entry = getChangelogEntry(commit, pr)
entries.push(entry) if (entry !== null) {
entries.push(entry)
}
} catch (e) { } catch (e) {
console.warn('Unable to parse line, using the full message.', 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('changelog/parser', () => {
describe('findIssueRef', () => { describe('findIssueRef', () => {
@ -54,4 +54,55 @@ quam vel augue.`
expect(findIssueRef(body)).toBe(' #2314') 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 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 */ /** Converts a string to Channel type if possible */
function parseChannel(arg: string): Channel { function parseChannel(arg: string): Channel {
if (arg === 'production' || arg === 'beta' || arg === 'test') { 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) 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...`) console.log(`Setting app version to "${nextVersion}" in app/package.json...`)
try { try {

View file

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