mirror of
https://github.com/desktop/desktop
synced 2024-08-28 12:40:55 +00:00
Merge branch 'development' into releases/3.0.2
This commit is contained in:
commit
7975b5b857
16
.github/no-response.yml
vendored
16
.github/no-response.yml
vendored
|
@ -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 haven’t gotten a response to our questions above. With only the information
|
|
||||||
that is currently in the issue, we don’t have enough information to take
|
|
||||||
action. We’re going to close this but don’t 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
32
.github/workflows/no-response.yml
vendored
Normal 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 haven’t gotten a response to our questions above. With only the
|
||||||
|
information that is currently in the issue, we don’t have enough
|
||||||
|
information to take action. We’re going to close this but don’t
|
||||||
|
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
|
|
@ -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",
|
||||||
|
|
1
app/src/highlighter/globals.d.ts
vendored
1
app/src/highlighter/globals.d.ts
vendored
|
@ -19,6 +19,7 @@ declare namespace CodeMirror {
|
||||||
interface StringStreamContext {
|
interface StringStreamContext {
|
||||||
lines: string[]
|
lines: string[]
|
||||||
line: number
|
line: number
|
||||||
|
lookAhead: (n: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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()) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
61
app/src/lib/ssh/ssh-secret-storage.ts
Normal file
61
app/src/lib/ssh/ssh-secret-storage.ts
Normal 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)
|
||||||
|
}
|
40
app/src/lib/ssh/ssh-user-password.ts
Normal file
40
app/src/lib/ssh/ssh-user-password.ts
Normal 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
|
||||||
|
)
|
||||||
|
}
|
|
@ -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(() => {})
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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}>`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
export { SelectedCommit } from './selected-commit'
|
export { SelectedCommits } from './selected-commit'
|
||||||
export { CompareSidebar } from './compare'
|
export { CompareSidebar } from './compare'
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
99
app/src/ui/ssh/ssh-user-password.tsx
Normal file
99
app/src/ui/ssh/ssh-user-password.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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!",
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue