mirror of
https://github.com/desktop/desktop
synced 2024-07-17 11:08:04 +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",
|
||||
"desktop-notifications": "^0.2.2",
|
||||
"desktop-trampoline": "desktop/desktop-trampoline#v0.9.8",
|
||||
"dexie": "^2.0.0",
|
||||
"dexie": "^3.2.2",
|
||||
"dompurify": "^2.3.3",
|
||||
"dugite": "^1.109.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
|
|
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 {
|
||||
lines: string[]
|
||||
line: number
|
||||
lookAhead: (n: number) => string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -635,7 +635,11 @@ onmessage = async (ev: MessageEvent) => {
|
|||
continue
|
||||
}
|
||||
|
||||
const lineCtx = { lines, line: ix }
|
||||
const lineCtx = {
|
||||
lines,
|
||||
line: ix,
|
||||
lookAhead: (n: number) => lines[ix + n],
|
||||
}
|
||||
const lineStream = new StringStream(line, tabSize, lineCtx)
|
||||
|
||||
while (!lineStream.eol()) {
|
||||
|
|
|
@ -553,6 +553,20 @@ export interface ICommitSelection {
|
|||
/** The commits currently selected in the app */
|
||||
readonly shas: ReadonlyArray<string>
|
||||
|
||||
/**
|
||||
* Whether the a selection of commits are group of adjacent to each other.
|
||||
* Example: Given these are indexes of sha's in history, 3, 4, 5, 6 is contiguous as
|
||||
* opposed to 3, 5, 8.
|
||||
*
|
||||
* Technically order does not matter, but shas are stored in order.
|
||||
*
|
||||
* Contiguous selections can be diffed. Non-contiguous selections can be
|
||||
* cherry-picked, reordered, or squashed.
|
||||
*
|
||||
* Assumed that a selections of zero or one commit are contiguous.
|
||||
* */
|
||||
readonly isContiguous: boolean
|
||||
|
||||
/** The changeset data associated with the selected commit */
|
||||
readonly changesetData: IChangesetData
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Dexie from 'dexie'
|
||||
import Dexie, { Transaction } from 'dexie'
|
||||
|
||||
export abstract class BaseDatabase extends Dexie {
|
||||
private schemaVersion: number | undefined
|
||||
|
@ -23,7 +23,7 @@ export abstract class BaseDatabase extends Dexie {
|
|||
protected async conditionalVersion(
|
||||
version: number,
|
||||
schema: { [key: string]: string | null },
|
||||
upgrade?: (t: Dexie.Transaction) => Promise<void>
|
||||
upgrade?: (t: Transaction) => Promise<void>
|
||||
) {
|
||||
if (this.schemaVersion != null && this.schemaVersion < version) {
|
||||
return
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Dexie from 'dexie'
|
||||
import Dexie, { Transaction } from 'dexie'
|
||||
import { BaseDatabase } from './base-database'
|
||||
|
||||
export interface IIssue {
|
||||
|
@ -37,7 +37,7 @@ export class IssuesDatabase extends BaseDatabase {
|
|||
}
|
||||
}
|
||||
|
||||
function clearIssues(transaction: Dexie.Transaction) {
|
||||
function clearIssues(transaction: Transaction) {
|
||||
// Clear deprecated localStorage keys, we compute the since parameter
|
||||
// using the database now.
|
||||
clearDeprecatedKeys()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Dexie from 'dexie'
|
||||
import Dexie, { Transaction } from 'dexie'
|
||||
import { BaseDatabase } from './base-database'
|
||||
import { WorkflowPreferences } from '../../models/workflow-preferences'
|
||||
import { assertNonNullable } from '../fatal-error'
|
||||
|
@ -144,7 +144,7 @@ export class RepositoriesDatabase extends BaseDatabase {
|
|||
/**
|
||||
* Remove any duplicate GitHub repositories that have the same owner and name.
|
||||
*/
|
||||
function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) {
|
||||
function removeDuplicateGitHubRepositories(transaction: Transaction) {
|
||||
const table = transaction.table<IDatabaseGitHubRepository, number>(
|
||||
'gitHubRepositories'
|
||||
)
|
||||
|
@ -164,7 +164,7 @@ function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) {
|
|||
})
|
||||
}
|
||||
|
||||
async function ensureNoUndefinedParentID(tx: Dexie.Transaction) {
|
||||
async function ensureNoUndefinedParentID(tx: Transaction) {
|
||||
return tx
|
||||
.table<IDatabaseGitHubRepository, number>('gitHubRepositories')
|
||||
.toCollection()
|
||||
|
@ -185,7 +185,7 @@ async function ensureNoUndefinedParentID(tx: Dexie.Transaction) {
|
|||
* (https://github.com/desktop/desktop/pull/1242). This scenario ought to be
|
||||
* incredibly unlikely.
|
||||
*/
|
||||
async function createOwnerKey(tx: Dexie.Transaction) {
|
||||
async function createOwnerKey(tx: Transaction) {
|
||||
const ownersTable = tx.table<IDatabaseOwner, number>('owners')
|
||||
const ghReposTable = tx.table<IDatabaseGitHubRepository, number>(
|
||||
'gitHubRepositories'
|
||||
|
|
|
@ -23,6 +23,10 @@ const editors: IDarwinExternalEditor[] = [
|
|||
name: 'Atom',
|
||||
bundleIdentifiers: ['com.github.atom'],
|
||||
},
|
||||
{
|
||||
name: 'Aptana Studio',
|
||||
bundleIdentifiers: ['aptana.studio'],
|
||||
},
|
||||
{
|
||||
name: 'MacVim',
|
||||
bundleIdentifiers: ['org.vim.MacVim'],
|
||||
|
|
|
@ -299,6 +299,15 @@ const editors: WindowsExternalEditor[] = [
|
|||
displayNamePrefix: 'SlickEdit',
|
||||
publisher: 'SlickEdit Inc.',
|
||||
},
|
||||
{
|
||||
name: 'Aptana Studio 3',
|
||||
registryKeys: [
|
||||
Wow64LocalMachineUninstallKey('{2D6C1116-78C6-469C-9923-3E549218773F}'),
|
||||
],
|
||||
executableShimPaths: [['AptanaStudio3.exe']],
|
||||
displayNamePrefix: 'Aptana Studio',
|
||||
publisher: 'Appcelerator',
|
||||
},
|
||||
{
|
||||
name: 'JetBrains Webstorm',
|
||||
registryKeys: registryKeysForJetBrainsIDE('WebStorm'),
|
||||
|
|
|
@ -172,3 +172,8 @@ export function enablePullRequestReviewNotifications(): boolean {
|
|||
export function enableReRunFailedAndSingleCheckJobs(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Should we enable displaying multi commit diffs. This also switches diff logic from one commit */
|
||||
export function enableMultiCommitDiffs(): boolean {
|
||||
return enableDevelopmentFeatures()
|
||||
}
|
||||
|
|
|
@ -68,7 +68,7 @@ function getNoRenameIndexStatus(status: string): NoRenameIndexStatus {
|
|||
}
|
||||
|
||||
/** The SHA for the null tree. */
|
||||
const NullTreeSHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
||||
export const NullTreeSHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
||||
|
||||
/**
|
||||
* Get a list of files which have recorded changes in the index as compared to
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
WorkingDirectoryFileChange,
|
||||
FileChange,
|
||||
AppFileStatusKind,
|
||||
CommittedFileChange,
|
||||
} from '../../models/status'
|
||||
import {
|
||||
DiffType,
|
||||
|
@ -27,6 +28,10 @@ import { getOldPathOrDefault } from '../get-old-path'
|
|||
import { getCaptures } from '../helpers/regex'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { forceUnwrap } from '../fatal-error'
|
||||
import { git } from './core'
|
||||
import { NullTreeSHA } from './diff-index'
|
||||
import { GitError } from 'dugite'
|
||||
import { mapStatus } from './log'
|
||||
|
||||
/**
|
||||
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
|
||||
|
@ -133,6 +138,201 @@ export async function getCommitDiff(
|
|||
return buildDiff(output, repository, file, commitish)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the difference between two commits for a file
|
||||
*
|
||||
*/
|
||||
export async function getCommitRangeDiff(
|
||||
repository: Repository,
|
||||
file: FileChange,
|
||||
commits: ReadonlyArray<string>,
|
||||
hideWhitespaceInDiff: boolean = false,
|
||||
useNullTreeSHA: boolean = false
|
||||
): Promise<IDiff> {
|
||||
if (commits.length === 0) {
|
||||
throw new Error('No commits to diff...')
|
||||
}
|
||||
|
||||
const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${commits[0]}^`
|
||||
const latestCommit = commits.at(-1) ?? '' // can't be undefined since commits.length > 0
|
||||
const args = [
|
||||
'diff',
|
||||
oldestCommitRef,
|
||||
latestCommit,
|
||||
...(hideWhitespaceInDiff ? ['-w'] : []),
|
||||
'--patch-with-raw',
|
||||
'-z',
|
||||
'--no-color',
|
||||
'--',
|
||||
file.path,
|
||||
]
|
||||
|
||||
if (
|
||||
file.status.kind === AppFileStatusKind.Renamed ||
|
||||
file.status.kind === AppFileStatusKind.Copied
|
||||
) {
|
||||
args.push(file.status.oldPath)
|
||||
}
|
||||
|
||||
const result = await git(args, repository.path, 'getCommitsDiff', {
|
||||
maxBuffer: Infinity,
|
||||
expectedErrors: new Set([GitError.BadRevision]),
|
||||
})
|
||||
|
||||
// This should only happen if the oldest commit does not have a parent (ex:
|
||||
// initial commit of a branch) and therefore `SHA^` is not a valid reference.
|
||||
// In which case, we will retry with the null tree sha.
|
||||
if (result.gitError === GitError.BadRevision && useNullTreeSHA === false) {
|
||||
return getCommitRangeDiff(
|
||||
repository,
|
||||
file,
|
||||
commits,
|
||||
hideWhitespaceInDiff,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
return buildDiff(
|
||||
Buffer.from(result.combinedOutput),
|
||||
repository,
|
||||
file,
|
||||
latestCommit
|
||||
)
|
||||
}
|
||||
|
||||
export async function getCommitRangeChangedFiles(
|
||||
repository: Repository,
|
||||
shas: ReadonlyArray<string>,
|
||||
useNullTreeSHA: boolean = false
|
||||
): Promise<{
|
||||
files: ReadonlyArray<CommittedFileChange>
|
||||
linesAdded: number
|
||||
linesDeleted: number
|
||||
}> {
|
||||
if (shas.length === 0) {
|
||||
throw new Error('No commits to diff...')
|
||||
}
|
||||
|
||||
const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${shas[0]}^`
|
||||
const latestCommitRef = shas.at(-1) ?? '' // can't be undefined since shas.length > 0
|
||||
const baseArgs = [
|
||||
'diff',
|
||||
oldestCommitRef,
|
||||
latestCommitRef,
|
||||
'-C',
|
||||
'-M',
|
||||
'-z',
|
||||
'--raw',
|
||||
'--numstat',
|
||||
'--',
|
||||
]
|
||||
|
||||
const result = await git(
|
||||
baseArgs,
|
||||
repository.path,
|
||||
'getCommitRangeChangedFiles',
|
||||
{
|
||||
expectedErrors: new Set([GitError.BadRevision]),
|
||||
}
|
||||
)
|
||||
|
||||
// This should only happen if the oldest commit does not have a parent (ex:
|
||||
// initial commit of a branch) and therefore `SHA^` is not a valid reference.
|
||||
// In which case, we will retry with the null tree sha.
|
||||
if (result.gitError === GitError.BadRevision && useNullTreeSHA === false) {
|
||||
const useNullTreeSHA = true
|
||||
return getCommitRangeChangedFiles(repository, shas, useNullTreeSHA)
|
||||
}
|
||||
|
||||
return parseChangedFilesAndNumStat(
|
||||
result.combinedOutput,
|
||||
`${oldestCommitRef}..${latestCommitRef}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses output of diff flags -z --raw --numstat.
|
||||
*
|
||||
* Given the -z flag the new lines are separated by \0 character (left them as
|
||||
* new lines below for ease of reading)
|
||||
*
|
||||
* For modified, added, deleted, untracked:
|
||||
* 100644 100644 5716ca5 db3c77d M
|
||||
* file_one_path
|
||||
* :100644 100644 0835e4f 28096ea M
|
||||
* file_two_path
|
||||
* 1 0 file_one_path
|
||||
* 1 0 file_two_path
|
||||
*
|
||||
* For copied or renamed:
|
||||
* 100644 100644 5716ca5 db3c77d M
|
||||
* file_one_original_path
|
||||
* file_one_new_path
|
||||
* :100644 100644 0835e4f 28096ea M
|
||||
* file_two_original_path
|
||||
* file_two_new_path
|
||||
* 1 0
|
||||
* file_one_original_path
|
||||
* file_one_new_path
|
||||
* 1 0
|
||||
* file_two_original_path
|
||||
* file_two_new_path
|
||||
*/
|
||||
function parseChangedFilesAndNumStat(stdout: string, committish: string) {
|
||||
const lines = stdout.split('\0')
|
||||
// Remove the trailing empty line
|
||||
lines.splice(-1, 1)
|
||||
|
||||
const files: CommittedFileChange[] = []
|
||||
let linesAdded = 0
|
||||
let linesDeleted = 0
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split('\t')
|
||||
|
||||
if (parts.length === 1) {
|
||||
const statusParts = parts[0].split(' ')
|
||||
const statusText = statusParts.at(-1) ?? ''
|
||||
let oldPath: string | undefined = undefined
|
||||
|
||||
if (
|
||||
statusText.length > 0 &&
|
||||
(statusText[0] === 'R' || statusText[0] === 'C')
|
||||
) {
|
||||
oldPath = lines[++i]
|
||||
}
|
||||
|
||||
const status = mapStatus(statusText, oldPath)
|
||||
const path = lines[++i]
|
||||
|
||||
files.push(new CommittedFileChange(path, status, committish))
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
const [added, deleted, file] = parts
|
||||
|
||||
if (added === '-' || deleted === '-') {
|
||||
continue
|
||||
}
|
||||
|
||||
linesAdded += parseInt(added, 10)
|
||||
linesDeleted += parseInt(deleted, 10)
|
||||
|
||||
// If a file is not renamed or copied, the file name is with the
|
||||
// add/deleted lines other wise the 2 files names are the next two lines
|
||||
if (file === '' && lines[i + 1].split('\t').length === 1) {
|
||||
i = i + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
linesAdded,
|
||||
linesDeleted,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the diff for a file within the repository working directory. The file will be
|
||||
* compared against HEAD if it's tracked, if not it'll be compared to an empty file meaning
|
||||
|
@ -198,7 +398,7 @@ export async function getWorkingDirectoryDiff(
|
|||
async function getImageDiff(
|
||||
repository: Repository,
|
||||
file: FileChange,
|
||||
commitish: string
|
||||
oldestCommitish: string
|
||||
): Promise<IImageDiff> {
|
||||
let current: Image | undefined = undefined
|
||||
let previous: Image | undefined = undefined
|
||||
|
@ -232,7 +432,7 @@ async function getImageDiff(
|
|||
} else {
|
||||
// File status can't be conflicted for a file in a commit
|
||||
if (file.status.kind !== AppFileStatusKind.Deleted) {
|
||||
current = await getBlobImage(repository, file.path, commitish)
|
||||
current = await getBlobImage(repository, file.path, oldestCommitish)
|
||||
}
|
||||
|
||||
// File status can't be conflicted for a file in a commit
|
||||
|
@ -247,7 +447,7 @@ async function getImageDiff(
|
|||
previous = await getBlobImage(
|
||||
repository,
|
||||
getOldPathOrDefault(file),
|
||||
`${commitish}^`
|
||||
`${oldestCommitish}^`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -263,7 +463,7 @@ export async function convertDiff(
|
|||
repository: Repository,
|
||||
file: FileChange,
|
||||
diff: IRawDiff,
|
||||
commitish: string,
|
||||
oldestCommitish: string,
|
||||
lineEndingsChange?: LineEndingsChange
|
||||
): Promise<IDiff> {
|
||||
const extension = Path.extname(file.path).toLowerCase()
|
||||
|
@ -275,7 +475,7 @@ export async function convertDiff(
|
|||
kind: DiffType.Binary,
|
||||
}
|
||||
} else {
|
||||
return getImageDiff(repository, file, commitish)
|
||||
return getImageDiff(repository, file, oldestCommitish)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -370,7 +570,7 @@ function buildDiff(
|
|||
buffer: Buffer,
|
||||
repository: Repository,
|
||||
file: FileChange,
|
||||
commitish: string,
|
||||
oldestCommitish: string,
|
||||
lineEndingsChange?: LineEndingsChange
|
||||
): Promise<IDiff> {
|
||||
if (!isValidBuffer(buffer)) {
|
||||
|
@ -396,7 +596,7 @@ function buildDiff(
|
|||
return Promise.resolve(largeTextDiff)
|
||||
}
|
||||
|
||||
return convertDiff(repository, file, diff, commitish, lineEndingsChange)
|
||||
return convertDiff(repository, file, diff, oldestCommitish, lineEndingsChange)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -22,7 +22,7 @@ import { enableLineChangesInCommit } from '../feature-flag'
|
|||
* Map the raw status text from Git to an app-friendly value
|
||||
* shamelessly borrowed from GitHub Desktop (Windows)
|
||||
*/
|
||||
function mapStatus(
|
||||
export function mapStatus(
|
||||
rawStatus: string,
|
||||
oldPath?: string
|
||||
): PlainFileStatus | CopiedOrRenamedFileStatus | UntrackedFileStatus {
|
||||
|
|
|
@ -76,6 +76,7 @@ export type RequestChannels = {
|
|||
'set-native-theme-source': (themeName: ThemeSource) => void
|
||||
'focus-window': () => void
|
||||
'notification-event': NotificationCallback<DesktopAliveEvent>
|
||||
'set-window-zoom-factor': (zoomFactor: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -50,7 +50,7 @@ export function setBoolean(key: string, value: boolean) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Retrieve a `number` value from a given local storage entry if found, or the
|
||||
* Retrieve a integer number value from a given local storage entry if found, or the
|
||||
* provided `defaultValue` if the key doesn't exist or if the value cannot be
|
||||
* converted into a number
|
||||
*
|
||||
|
@ -77,6 +77,34 @@ export function getNumber(
|
|||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a floating point number value from a given local storage entry if
|
||||
* found, or the provided `defaultValue` if the key doesn't exist or if the
|
||||
* value cannot be converted into a number
|
||||
*
|
||||
* @param key local storage entry to read
|
||||
* @param defaultValue fallback value if unable to find key or valid value
|
||||
*/
|
||||
export function getFloatNumber(key: string): number | undefined
|
||||
export function getFloatNumber(key: string, defaultValue: number): number
|
||||
export function getFloatNumber(
|
||||
key: string,
|
||||
defaultValue?: number
|
||||
): number | undefined {
|
||||
const numberAsText = localStorage.getItem(key)
|
||||
|
||||
if (numberAsText === null || numberAsText.length === 0) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const value = parseFloat(numberAsText)
|
||||
if (isNaN(value)) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the provided key in local storage to a numeric value, or update the
|
||||
* existing value if a key is already defined.
|
||||
|
|
|
@ -1,28 +1,60 @@
|
|||
import DOMPurify from 'dompurify'
|
||||
import { Disposable, Emitter } from 'event-kit'
|
||||
import { marked } from 'marked'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import {
|
||||
applyNodeFilters,
|
||||
buildCustomMarkDownNodeFilterPipe,
|
||||
MarkdownContext,
|
||||
ICustomMarkdownFilterOptions,
|
||||
} from './node-filter'
|
||||
|
||||
interface ICustomMarkdownFilterOptions {
|
||||
emoji: Map<string, string>
|
||||
repository?: GitHubRepository
|
||||
markdownContext?: MarkdownContext
|
||||
/**
|
||||
* The MarkdownEmitter extends the Emitter functionality to be able to keep
|
||||
* track of the last emitted value and return it upon subscription.
|
||||
*/
|
||||
export class MarkdownEmitter extends Emitter {
|
||||
public constructor(private markdown: null | string = null) {
|
||||
super()
|
||||
}
|
||||
|
||||
public onMarkdownUpdated(handler: (value: string) => void): Disposable {
|
||||
if (this.markdown !== null) {
|
||||
handler(this.markdown)
|
||||
}
|
||||
return super.on('markdown', handler)
|
||||
}
|
||||
|
||||
public emit(value: string): void {
|
||||
this.markdown = value
|
||||
super.emit('markdown', value)
|
||||
}
|
||||
|
||||
public get latestMarkdown() {
|
||||
return this.markdown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes string of markdown and runs it through the MarkedJs parser with github
|
||||
* flavored flags enabled followed by running that through domPurify, and lastly
|
||||
* if custom markdown options are provided, it applies the custom markdown
|
||||
* flavored flags followed by sanitization with domPurify.
|
||||
*
|
||||
* If custom markdown options are provided, it applies the custom markdown
|
||||
* filters.
|
||||
*
|
||||
* Rely `repository` custom markdown option:
|
||||
* - TeamMentionFilter
|
||||
* - MentionFilter
|
||||
* - CommitMentionFilter
|
||||
* - CommitMentionLinkFilter
|
||||
*
|
||||
* Rely `markdownContext` custom markdown option:
|
||||
* - IssueMentionFilter
|
||||
* - IssueLinkFilter
|
||||
* - CloseKeyWordFilter
|
||||
*/
|
||||
export async function parseMarkdown(
|
||||
export function parseMarkdown(
|
||||
markdown: string,
|
||||
customMarkdownOptions?: ICustomMarkdownFilterOptions
|
||||
) {
|
||||
): MarkdownEmitter {
|
||||
const parsedMarkdown = marked(markdown, {
|
||||
// https://marked.js.org/using_advanced If true, use approved GitHub
|
||||
// Flavored Markdown (GFM) specification.
|
||||
|
@ -33,27 +65,26 @@ export async function parseMarkdown(
|
|||
breaks: true,
|
||||
})
|
||||
|
||||
const sanitizedHTML = DOMPurify.sanitize(parsedMarkdown)
|
||||
const sanitizedMarkdown = DOMPurify.sanitize(parsedMarkdown)
|
||||
const markdownEmitter = new MarkdownEmitter(sanitizedMarkdown)
|
||||
|
||||
return customMarkdownOptions !== undefined
|
||||
? await applyCustomMarkdownFilters(sanitizedHTML, customMarkdownOptions)
|
||||
: sanitizedHTML
|
||||
if (customMarkdownOptions !== undefined) {
|
||||
applyCustomMarkdownFilters(markdownEmitter, customMarkdownOptions)
|
||||
}
|
||||
|
||||
return markdownEmitter
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies custom markdown filters to parsed markdown html. This is done
|
||||
* through converting the markdown html into a DOM document and then
|
||||
* traversing the nodes to apply custom filters such as emoji, issue, username
|
||||
* mentions, etc.
|
||||
* mentions, etc. (Expects a markdownEmitter with an initial markdown value)
|
||||
*/
|
||||
function applyCustomMarkdownFilters(
|
||||
parsedMarkdown: string,
|
||||
markdownEmitter: MarkdownEmitter,
|
||||
options: ICustomMarkdownFilterOptions
|
||||
): Promise<string> {
|
||||
const nodeFilters = buildCustomMarkDownNodeFilterPipe(
|
||||
options.emoji,
|
||||
options.repository,
|
||||
options.markdownContext
|
||||
)
|
||||
return applyNodeFilters(nodeFilters, parsedMarkdown)
|
||||
): void {
|
||||
const nodeFilters = buildCustomMarkDownNodeFilterPipe(options)
|
||||
applyNodeFilters(nodeFilters, markdownEmitter)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import memoizeOne from 'memoize-one'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { EmojiFilter } from './emoji-filter'
|
||||
import { IssueLinkFilter } from './issue-link-filter'
|
||||
import { IssueMentionFilter } from './issue-mention-filter'
|
||||
|
@ -13,6 +12,8 @@ import {
|
|||
isIssueClosingContext,
|
||||
} from './close-keyword-filter'
|
||||
import { CommitMentionLinkFilter } from './commit-mention-link-filter'
|
||||
import { MarkdownEmitter } from './markdown-filter'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
|
||||
export interface INodeFilter {
|
||||
/**
|
||||
|
@ -37,19 +38,20 @@ export interface INodeFilter {
|
|||
filter(node: Node): Promise<ReadonlyArray<Node> | null>
|
||||
}
|
||||
|
||||
export interface ICustomMarkdownFilterOptions {
|
||||
emoji: Map<string, string>
|
||||
repository?: GitHubRepository
|
||||
markdownContext?: MarkdownContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an array of node filters to apply to markdown html. Referring to it as pipe
|
||||
* because they will be applied in the order they are entered in the returned
|
||||
* array. This is important as some filters impact others.
|
||||
*
|
||||
* @param emoji Map from the emoji shortcut (e.g., :+1:) to the image's local path.
|
||||
*/
|
||||
export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
|
||||
(
|
||||
emoji: Map<string, string>,
|
||||
repository?: GitHubRepository,
|
||||
markdownContext?: MarkdownContext
|
||||
): ReadonlyArray<INodeFilter> => {
|
||||
(options: ICustomMarkdownFilterOptions): ReadonlyArray<INodeFilter> => {
|
||||
const { emoji, repository, markdownContext } = options
|
||||
const filterPipe: Array<INodeFilter> = []
|
||||
|
||||
if (repository !== undefined) {
|
||||
|
@ -104,15 +106,24 @@ export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
|
|||
*/
|
||||
export async function applyNodeFilters(
|
||||
nodeFilters: ReadonlyArray<INodeFilter>,
|
||||
parsedMarkdown: string
|
||||
): Promise<string> {
|
||||
const mdDoc = new DOMParser().parseFromString(parsedMarkdown, 'text/html')
|
||||
markdownEmitter: MarkdownEmitter
|
||||
): Promise<void> {
|
||||
if (markdownEmitter.latestMarkdown === null || markdownEmitter.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
const mdDoc = new DOMParser().parseFromString(
|
||||
markdownEmitter.latestMarkdown,
|
||||
'text/html'
|
||||
)
|
||||
|
||||
for (const nodeFilter of nodeFilters) {
|
||||
await applyNodeFilter(nodeFilter, mdDoc)
|
||||
if (markdownEmitter.disposed) {
|
||||
break
|
||||
}
|
||||
markdownEmitter.emit(mdDoc.documentElement.innerHTML)
|
||||
}
|
||||
|
||||
return mdDoc.documentElement.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { getFileHash } from '../file-system'
|
||||
import { TokenStore } from '../stores'
|
||||
import {
|
||||
getSSHSecretStoreKey,
|
||||
keepSSHSecretToStore,
|
||||
} from './ssh-secret-storage'
|
||||
|
||||
const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop'
|
||||
const SSHKeyPassphraseTokenStoreKey = `${appName} - SSH key passphrases`
|
||||
const SSHKeyPassphraseTokenStoreKey = getSSHSecretStoreKey(
|
||||
'SSH key passphrases'
|
||||
)
|
||||
|
||||
async function getHashForSSHKey(keyPath: string) {
|
||||
return getFileHash(keyPath, 'sha256')
|
||||
|
@ -19,22 +24,6 @@ export async function getSSHKeyPassphrase(keyPath: string) {
|
|||
}
|
||||
}
|
||||
|
||||
type SSHKeyPassphraseEntry = {
|
||||
/** Hash of the SSH key file. */
|
||||
keyHash: string
|
||||
|
||||
/** Passphrase for the SSH key. */
|
||||
passphrase: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This map contains the SSH key passphrases that are pending to be stored.
|
||||
* What this means is that a git operation is currently in progress, and the
|
||||
* user wanted to store the passphrase for the SSH key, however we don't want
|
||||
* to store it until we know the git operation finished successfully.
|
||||
*/
|
||||
const SSHKeyPassphrasesToStore = new Map<string, SSHKeyPassphraseEntry>()
|
||||
|
||||
/**
|
||||
* Keeps the SSH key passphrase in memory to be stored later if the ongoing git
|
||||
* operation succeeds.
|
||||
|
@ -52,27 +41,13 @@ export async function keepSSHKeyPassphraseToStore(
|
|||
) {
|
||||
try {
|
||||
const keyHash = await getHashForSSHKey(keyPath)
|
||||
SSHKeyPassphrasesToStore.set(operationGUID, { keyHash, passphrase })
|
||||
keepSSHSecretToStore(
|
||||
operationGUID,
|
||||
SSHKeyPassphraseTokenStoreKey,
|
||||
keyHash,
|
||||
passphrase
|
||||
)
|
||||
} catch (e) {
|
||||
log.error('Could not store passphrase for SSH key:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes the SSH key passphrase from memory. */
|
||||
export function removePendingSSHKeyPassphraseToStore(operationGUID: string) {
|
||||
SSHKeyPassphrasesToStore.delete(operationGUID)
|
||||
}
|
||||
|
||||
/** Stores a pending SSH key passphrase if the operation succeeded. */
|
||||
export async function storePendingSSHKeyPassphrase(operationGUID: string) {
|
||||
const entry = SSHKeyPassphrasesToStore.get(operationGUID)
|
||||
if (entry === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
await TokenStore.setItem(
|
||||
SSHKeyPassphraseTokenStoreKey,
|
||||
entry.keyHash,
|
||||
entry.passphrase
|
||||
)
|
||||
}
|
||||
|
|
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 QuickLRU from 'quick-lru'
|
||||
import { IDisposable, Disposable } from 'event-kit'
|
||||
import { DisposableLike, Disposable } from 'event-kit'
|
||||
import { IAheadBehind } from '../../models/branch'
|
||||
import { revSymmetricDifference, getAheadBehind } from '../git'
|
||||
import { Repository } from '../../models/repository'
|
||||
|
@ -76,7 +76,7 @@ export class AheadBehindStore {
|
|||
from: string,
|
||||
to: string,
|
||||
callback: AheadBehindCallback
|
||||
): IDisposable {
|
||||
): DisposableLike {
|
||||
const key = getCacheKey(repository, from, to)
|
||||
const existing = this.cache.get(key)
|
||||
const disposable = new Disposable(() => {})
|
||||
|
|
|
@ -76,6 +76,7 @@ import {
|
|||
getCurrentWindowZoomFactor,
|
||||
updatePreferredAppMenuItemLabels,
|
||||
updateAccounts,
|
||||
setWindowZoomFactor,
|
||||
} from '../../ui/main-process-proxy'
|
||||
import {
|
||||
API,
|
||||
|
@ -158,6 +159,8 @@ import {
|
|||
appendIgnoreFile,
|
||||
getRepositoryType,
|
||||
RepositoryType,
|
||||
getCommitRangeDiff,
|
||||
getCommitRangeChangedFiles,
|
||||
} from '../git'
|
||||
import {
|
||||
installGlobalLFSFilters,
|
||||
|
@ -203,6 +206,7 @@ import {
|
|||
getEnum,
|
||||
getObject,
|
||||
setObject,
|
||||
getFloatNumber,
|
||||
} from '../local-storage'
|
||||
import { ExternalEditorError, suggestedExternalEditor } from '../editors/shared'
|
||||
import { ApiRepositoriesStore } from './api-repositories-store'
|
||||
|
@ -213,7 +217,10 @@ import {
|
|||
} from './updates/changes-state'
|
||||
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
|
||||
import { BranchPruner } from './helpers/branch-pruner'
|
||||
import { enableHideWhitespaceInDiffOption } from '../feature-flag'
|
||||
import {
|
||||
enableHideWhitespaceInDiffOption,
|
||||
enableMultiCommitDiffs,
|
||||
} from '../feature-flag'
|
||||
import { Banner, BannerType } from '../../models/banner'
|
||||
import { ComputedAction } from '../../models/computed-action'
|
||||
import {
|
||||
|
@ -570,13 +577,41 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
|
||||
private initializeZoomFactor = async () => {
|
||||
const zoomFactor = await getCurrentWindowZoomFactor()
|
||||
const zoomFactor = await this.getWindowZoomFactor()
|
||||
if (zoomFactor === undefined) {
|
||||
return
|
||||
}
|
||||
this.onWindowZoomFactorChanged(zoomFactor)
|
||||
}
|
||||
|
||||
/**
|
||||
* On Windows OS, whenever a user toggles their zoom factor, chromium stores it
|
||||
* in their `%AppData%/Roaming/GitHub Desktop/Preferences.js` denoted by the
|
||||
* file path to the application. That file path contains the apps version.
|
||||
* Thus, on every update, the users set zoom level gets reset as there is not
|
||||
* defined value for the current app version.
|
||||
* */
|
||||
private async getWindowZoomFactor() {
|
||||
const zoomFactor = await getCurrentWindowZoomFactor()
|
||||
// One is the default value, we only care about checking the locally stored
|
||||
// value if it is one because that is the default value after an
|
||||
// update
|
||||
if (zoomFactor !== 1 || !__WIN32__) {
|
||||
return zoomFactor
|
||||
}
|
||||
|
||||
const locallyStoredZoomFactor = getFloatNumber('zoom-factor')
|
||||
if (
|
||||
locallyStoredZoomFactor !== undefined &&
|
||||
locallyStoredZoomFactor !== zoomFactor
|
||||
) {
|
||||
setWindowZoomFactor(locallyStoredZoomFactor)
|
||||
return locallyStoredZoomFactor
|
||||
}
|
||||
|
||||
return zoomFactor
|
||||
}
|
||||
|
||||
private onTokenInvalidated = (endpoint: string) => {
|
||||
const account = getAccountForEndpoint(this.accounts, endpoint)
|
||||
|
||||
|
@ -799,6 +834,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
this.windowZoomFactor = zoomFactor
|
||||
|
||||
if (zoomFactor !== current) {
|
||||
setNumber('zoom-factor', zoomFactor)
|
||||
this.updateResizableConstraints()
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
@ -1075,7 +1111,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _changeCommitSelection(
|
||||
repository: Repository,
|
||||
shas: ReadonlyArray<string>
|
||||
shas: ReadonlyArray<string>,
|
||||
isContiguous: boolean
|
||||
): Promise<void> {
|
||||
const { commitSelection } = this.repositoryStateCache.get(repository)
|
||||
|
||||
|
@ -1088,6 +1125,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
this.repositoryStateCache.updateCommitSelection(repository, () => ({
|
||||
shas,
|
||||
isContiguous,
|
||||
file: null,
|
||||
changesetData: { files: [], linesAdded: 0, linesDeleted: 0 },
|
||||
diff: null,
|
||||
|
@ -1102,9 +1140,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
) {
|
||||
const state = this.repositoryStateCache.get(repository)
|
||||
let selectedSHA =
|
||||
state.commitSelection.shas.length === 1
|
||||
state.commitSelection.shas.length > 0
|
||||
? state.commitSelection.shas[0]
|
||||
: null
|
||||
|
||||
if (selectedSHA != null) {
|
||||
const index = commitSHAs.findIndex(sha => sha === selectedSHA)
|
||||
if (index < 0) {
|
||||
|
@ -1115,8 +1154,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
if (state.commitSelection.shas.length === 0 && commitSHAs.length > 0) {
|
||||
this._changeCommitSelection(repository, [commitSHAs[0]])
|
||||
if (selectedSHA === null && commitSHAs.length > 0) {
|
||||
this._changeCommitSelection(repository, [commitSHAs[0]], true)
|
||||
this._loadChangedFilesForCurrentSelection(repository)
|
||||
}
|
||||
}
|
||||
|
@ -1371,15 +1410,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
): Promise<void> {
|
||||
const state = this.repositoryStateCache.get(repository)
|
||||
const { commitSelection } = state
|
||||
const currentSHAs = commitSelection.shas
|
||||
if (currentSHAs.length !== 1) {
|
||||
// if none or multiple, we don't display a diff
|
||||
const { shas: currentSHAs, isContiguous } = commitSelection
|
||||
if (
|
||||
currentSHAs.length === 0 ||
|
||||
(currentSHAs.length > 1 && (!enableMultiCommitDiffs() || !isContiguous))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
const changesetData = await gitStore.performFailableOperation(() =>
|
||||
getChangedFiles(repository, currentSHAs[0])
|
||||
currentSHAs.length > 1
|
||||
? getCommitRangeChangedFiles(repository, currentSHAs)
|
||||
: getChangedFiles(repository, currentSHAs[0])
|
||||
)
|
||||
if (!changesetData) {
|
||||
return
|
||||
|
@ -1390,7 +1433,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
// SHA/path.
|
||||
if (
|
||||
commitSelection.shas.length !== currentSHAs.length ||
|
||||
commitSelection.shas[0] !== currentSHAs[0]
|
||||
!commitSelection.shas.every((sha, i) => sha === currentSHAs[i])
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
@ -1436,7 +1479,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
this.emitUpdate()
|
||||
|
||||
const stateBeforeLoad = this.repositoryStateCache.get(repository)
|
||||
const shas = stateBeforeLoad.commitSelection.shas
|
||||
const { shas, isContiguous } = stateBeforeLoad.commitSelection
|
||||
|
||||
if (shas.length === 0) {
|
||||
if (__DEV__) {
|
||||
|
@ -1448,24 +1491,35 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
// We do not get a diff when multiple commits selected
|
||||
if (shas.length > 1) {
|
||||
if (shas.length > 1 && (!enableMultiCommitDiffs() || !isContiguous)) {
|
||||
return
|
||||
}
|
||||
|
||||
const diff = await getCommitDiff(
|
||||
repository,
|
||||
file,
|
||||
shas[0],
|
||||
this.hideWhitespaceInHistoryDiff
|
||||
)
|
||||
const diff =
|
||||
shas.length > 1
|
||||
? await getCommitRangeDiff(
|
||||
repository,
|
||||
file,
|
||||
shas,
|
||||
this.hideWhitespaceInHistoryDiff
|
||||
)
|
||||
: await getCommitDiff(
|
||||
repository,
|
||||
file,
|
||||
shas[0],
|
||||
this.hideWhitespaceInHistoryDiff
|
||||
)
|
||||
|
||||
const stateAfterLoad = this.repositoryStateCache.get(repository)
|
||||
const { shas: shasAfter } = stateAfterLoad.commitSelection
|
||||
// A whole bunch of things could have happened since we initiated the diff load
|
||||
if (shasAfter.length !== shas.length || shasAfter[0] !== shas[0]) {
|
||||
if (
|
||||
shasAfter.length !== shas.length ||
|
||||
!shas.every((sha, i) => sha === shasAfter[i])
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!stateAfterLoad.commitSelection.file) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Account } from '../../models/account'
|
|||
import { AccountsStore } from './accounts-store'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { API, getAccountForEndpoint, IAPICheckSuite } from '../api'
|
||||
import { IDisposable, Disposable } from 'event-kit'
|
||||
import { DisposableLike, Disposable } from 'event-kit'
|
||||
import {
|
||||
ICombinedRefCheck,
|
||||
IRefCheck,
|
||||
|
@ -465,7 +465,7 @@ export class CommitStatusStore {
|
|||
ref: string,
|
||||
callback: StatusCallBack,
|
||||
branchName?: string
|
||||
): IDisposable {
|
||||
): DisposableLike {
|
||||
const key = getCacheKeyForRepository(repository, ref)
|
||||
const subscription = this.getOrCreateSubscription(
|
||||
repository,
|
||||
|
|
|
@ -124,7 +124,7 @@ export class RepositoriesStore extends TypedBaseStore<
|
|||
)
|
||||
}
|
||||
|
||||
return new GitHubRepository(
|
||||
const ghRepo = new GitHubRepository(
|
||||
repo.name,
|
||||
owner,
|
||||
repo.id,
|
||||
|
@ -137,6 +137,10 @@ export class RepositoriesStore extends TypedBaseStore<
|
|||
repo.permissions,
|
||||
parent
|
||||
)
|
||||
|
||||
// Dexie gets confused if we return a non-promise value (e.g. if this function
|
||||
// didn't need to await for the parent repo or the owner)
|
||||
return Promise.resolve(ghRepo)
|
||||
}
|
||||
|
||||
private async toRepository(repo: IDatabaseRepository) {
|
||||
|
|
|
@ -178,6 +178,7 @@ function getInitialRepositoryState(): IRepositoryState {
|
|||
return {
|
||||
commitSelection: {
|
||||
shas: [],
|
||||
isContiguous: true,
|
||||
file: null,
|
||||
changesetData: { files: [], linesAdded: 0, linesDeleted: 0 },
|
||||
diff: null,
|
||||
|
|
|
@ -2,12 +2,16 @@ import { getKeyForEndpoint } from '../auth'
|
|||
import {
|
||||
getSSHKeyPassphrase,
|
||||
keepSSHKeyPassphraseToStore,
|
||||
removePendingSSHKeyPassphraseToStore,
|
||||
} from '../ssh/ssh-key-passphrase'
|
||||
import { TokenStore } from '../stores'
|
||||
import { TrampolineCommandHandler } from './trampoline-command'
|
||||
import { trampolineUIHelper } from './trampoline-ui-helper'
|
||||
import { parseAddSSHHostPrompt } from '../ssh/ssh'
|
||||
import {
|
||||
getSSHUserPassword,
|
||||
keepSSHUserPasswordToStore,
|
||||
} from '../ssh/ssh-user-password'
|
||||
import { removePendingSSHSecretToStore } from '../ssh/ssh-secret-storage'
|
||||
|
||||
async function handleSSHHostAuthenticity(
|
||||
prompt: string
|
||||
|
@ -65,7 +69,7 @@ async function handleSSHKeyPassphrase(
|
|||
return storedPassphrase
|
||||
}
|
||||
|
||||
const { passphrase, storePassphrase } =
|
||||
const { secret: passphrase, storeSecret: storePassphrase } =
|
||||
await trampolineUIHelper.promptSSHKeyPassphrase(keyPath)
|
||||
|
||||
// If the user wanted us to remember the passphrase, we'll keep it around to
|
||||
|
@ -78,12 +82,39 @@ async function handleSSHKeyPassphrase(
|
|||
if (passphrase !== undefined && storePassphrase) {
|
||||
keepSSHKeyPassphraseToStore(operationGUID, keyPath, passphrase)
|
||||
} else {
|
||||
removePendingSSHKeyPassphraseToStore(operationGUID)
|
||||
removePendingSSHSecretToStore(operationGUID)
|
||||
}
|
||||
|
||||
return passphrase ?? ''
|
||||
}
|
||||
|
||||
async function handleSSHUserPassword(operationGUID: string, prompt: string) {
|
||||
const promptRegex = /^(.+@.+)'s password: $/
|
||||
|
||||
const matches = promptRegex.exec(prompt)
|
||||
if (matches === null || matches.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const username = matches[1]
|
||||
|
||||
const storedPassword = await getSSHUserPassword(username)
|
||||
if (storedPassword !== null) {
|
||||
return storedPassword
|
||||
}
|
||||
|
||||
const { secret: password, storeSecret: storePassword } =
|
||||
await trampolineUIHelper.promptSSHUserPassword(username)
|
||||
|
||||
if (password !== undefined && storePassword) {
|
||||
keepSSHUserPasswordToStore(operationGUID, username, password)
|
||||
} else {
|
||||
removePendingSSHSecretToStore(operationGUID)
|
||||
}
|
||||
|
||||
return password ?? ''
|
||||
}
|
||||
|
||||
export const askpassTrampolineHandler: TrampolineCommandHandler =
|
||||
async command => {
|
||||
if (command.parameters.length !== 1) {
|
||||
|
@ -100,6 +131,10 @@ export const askpassTrampolineHandler: TrampolineCommandHandler =
|
|||
return handleSSHKeyPassphrase(command.trampolineToken, firstParameter)
|
||||
}
|
||||
|
||||
if (firstParameter.endsWith("'s password: ")) {
|
||||
return handleSSHUserPassword(command.trampolineToken, firstParameter)
|
||||
}
|
||||
|
||||
const username = command.environmentVariables.get('DESKTOP_USERNAME')
|
||||
if (username === undefined || username.length === 0) {
|
||||
return undefined
|
||||
|
|
|
@ -5,9 +5,9 @@ import { getDesktopTrampolineFilename } from 'desktop-trampoline'
|
|||
import { TrampolineCommandIdentifier } from '../trampoline/trampoline-command'
|
||||
import { getSSHEnvironment } from '../ssh/ssh'
|
||||
import {
|
||||
removePendingSSHKeyPassphraseToStore,
|
||||
storePendingSSHKeyPassphrase,
|
||||
} from '../ssh/ssh-key-passphrase'
|
||||
removePendingSSHSecretToStore,
|
||||
storePendingSSHSecret,
|
||||
} from '../ssh/ssh-secret-storage'
|
||||
|
||||
/**
|
||||
* Allows invoking a function with a set of environment variables to use when
|
||||
|
@ -46,11 +46,11 @@ export async function withTrampolineEnv<T>(
|
|||
...sshEnv,
|
||||
})
|
||||
|
||||
await storePendingSSHKeyPassphrase(token)
|
||||
await storePendingSSHSecret(token)
|
||||
|
||||
return result
|
||||
} finally {
|
||||
removePendingSSHKeyPassphraseToStore(token)
|
||||
removePendingSSHSecretToStore(token)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { PopupType } from '../../models/popup'
|
||||
import { Dispatcher } from '../../ui/dispatcher'
|
||||
|
||||
type PromptSSHKeyPassphraseResponse = {
|
||||
readonly passphrase: string | undefined
|
||||
readonly storePassphrase: boolean
|
||||
type PromptSSHSecretResponse = {
|
||||
readonly secret: string | undefined
|
||||
readonly storeSecret: boolean
|
||||
}
|
||||
|
||||
class TrampolineUIHelper {
|
||||
|
@ -34,13 +34,26 @@ class TrampolineUIHelper {
|
|||
|
||||
public promptSSHKeyPassphrase(
|
||||
keyPath: string
|
||||
): Promise<PromptSSHKeyPassphraseResponse> {
|
||||
): Promise<PromptSSHSecretResponse> {
|
||||
return new Promise(resolve => {
|
||||
this.dispatcher.showPopup({
|
||||
type: PopupType.SSHKeyPassphrase,
|
||||
keyPath,
|
||||
onSubmit: (passphrase, storePassphrase) =>
|
||||
resolve({ passphrase, storePassphrase }),
|
||||
resolve({ secret: passphrase, storeSecret: storePassphrase }),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public promptSSHUserPassword(
|
||||
username: string
|
||||
): Promise<PromptSSHSecretResponse> {
|
||||
return new Promise(resolve => {
|
||||
this.dispatcher.showPopup({
|
||||
type: PopupType.SSHUserPassword,
|
||||
username,
|
||||
onSubmit: (password, storePassword) =>
|
||||
resolve({ secret: password, storeSecret: storePassword }),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -416,6 +416,10 @@ export class AppWindow {
|
|||
return this.window.webContents.zoomFactor
|
||||
}
|
||||
|
||||
public setWindowZoomFactor(zoomFactor: number) {
|
||||
this.window.webContents.zoomFactor = zoomFactor
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to show the save dialog and return the first file path it returns.
|
||||
*/
|
||||
|
|
|
@ -515,6 +515,10 @@ app.on('ready', () => {
|
|||
mainWindow?.getCurrentWindowZoomFactor()
|
||||
)
|
||||
|
||||
ipcMain.on('set-window-zoom-factor', (_, zoomFactor: number) =>
|
||||
mainWindow?.setWindowZoomFactor(zoomFactor)
|
||||
)
|
||||
|
||||
/**
|
||||
* An event sent by the renderer asking for a copy of the current
|
||||
* application menu.
|
||||
|
|
|
@ -10,7 +10,7 @@ const rootAppDir = Path.resolve(appFolder, '..')
|
|||
const updateDotExe = Path.resolve(Path.join(rootAppDir, 'Update.exe'))
|
||||
const exeName = Path.basename(process.execPath)
|
||||
|
||||
// A lot of this code was cargo-culted from our Atom comrades:
|
||||
// A lot of this code was cargo-culted from our Atom collaborators:
|
||||
// https://github.com/atom/atom/blob/7c9f39e3f1d05ee423e0093e6b83f042ce11c90a/src/main-process/squirrel-update.coffee.
|
||||
|
||||
/**
|
||||
|
|
|
@ -55,4 +55,8 @@ export class CommitIdentity {
|
|||
public readonly date: Date,
|
||||
public readonly tzOffset: number = new Date().getTimezoneOffset()
|
||||
) {}
|
||||
|
||||
public toString() {
|
||||
return `${this.name} <${this.email}>`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,6 +78,7 @@ export enum PopupType {
|
|||
InvalidatedToken,
|
||||
AddSSHHost,
|
||||
SSHKeyPassphrase,
|
||||
SSHUserPassword,
|
||||
PullRequestChecksFailed,
|
||||
CICheckRunRerun,
|
||||
WarnForcePush,
|
||||
|
@ -317,6 +318,11 @@ export type Popup =
|
|||
storePassphrase: boolean
|
||||
) => void
|
||||
}
|
||||
| {
|
||||
type: PopupType.SSHUserPassword
|
||||
username: string
|
||||
onSubmit: (password: string | undefined, storePassword: boolean) => void
|
||||
}
|
||||
| {
|
||||
type: PopupType.PullRequestChecksFailed
|
||||
repository: RepositoryWithGitHubRepository
|
||||
|
|
|
@ -154,6 +154,7 @@ import { generateDevReleaseSummary } from '../lib/release-notes'
|
|||
import { PullRequestReview } from './notifications/pull-request-review'
|
||||
import { getPullRequestCommitRef } from '../models/pull-request'
|
||||
import { getRepositoryType } from '../lib/git'
|
||||
import { SSHUserPassword } from './ssh/ssh-user-password'
|
||||
|
||||
const MinuteInMilliseconds = 1000 * 60
|
||||
const HourInMilliseconds = MinuteInMilliseconds * 60
|
||||
|
@ -2113,6 +2114,16 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.SSHUserPassword: {
|
||||
return (
|
||||
<SSHUserPassword
|
||||
key="ssh-user-password"
|
||||
username={popup.username}
|
||||
onSubmit={popup.onSubmit}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.PullRequestChecksFailed: {
|
||||
return (
|
||||
<PullRequestChecksFailed
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Octicon, OcticonSymbolType } from '../octicons'
|
|||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import classNames from 'classnames'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { IDisposable } from 'event-kit'
|
||||
import { DisposableLike } from 'event-kit'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import {
|
||||
ICombinedRefCheck,
|
||||
|
@ -37,7 +37,7 @@ export class CIStatus extends React.PureComponent<
|
|||
ICIStatusProps,
|
||||
ICIStatusState
|
||||
> {
|
||||
private statusSubscription: IDisposable | null = null
|
||||
private statusSubscription: DisposableLike | null = null
|
||||
|
||||
public constructor(props: ICIStatusProps) {
|
||||
super(props)
|
||||
|
|
|
@ -27,6 +27,8 @@ import {
|
|||
RevealInFileManagerLabel,
|
||||
OpenWithDefaultProgramLabel,
|
||||
CopyRelativeFilePathLabel,
|
||||
CopySelectedPathsLabel,
|
||||
CopySelectedRelativePathsLabel,
|
||||
} from '../lib/context-menu'
|
||||
import { CommitMessage } from './commit-message'
|
||||
import { ChangedFile } from './changed-file'
|
||||
|
@ -51,6 +53,7 @@ import { hasConflictedFiles } from '../../lib/status'
|
|||
import { createObservableRef } from '../lib/observable-ref'
|
||||
import { Tooltip, TooltipDirection } from '../lib/tooltip'
|
||||
import { Popup } from '../../models/popup'
|
||||
import { EOL } from 'os'
|
||||
|
||||
const RowHeight = 29
|
||||
const StashIcon: OcticonSymbol.OcticonSymbolType = {
|
||||
|
@ -426,6 +429,32 @@ export class ChangesList extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private getCopySelectedPathsMenuItem = (
|
||||
files: WorkingDirectoryFileChange[]
|
||||
): IMenuItem => {
|
||||
return {
|
||||
label: CopySelectedPathsLabel,
|
||||
action: () => {
|
||||
const fullPaths = files.map(file =>
|
||||
Path.join(this.props.repository.path, file.path)
|
||||
)
|
||||
clipboard.writeText(fullPaths.join(EOL))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private getCopySelectedRelativePathsMenuItem = (
|
||||
files: WorkingDirectoryFileChange[]
|
||||
): IMenuItem => {
|
||||
return {
|
||||
label: CopySelectedRelativePathsLabel,
|
||||
action: () => {
|
||||
const paths = files.map(file => Path.normalize(file.path))
|
||||
clipboard.writeText(paths.join(EOL))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private getRevealInFileManagerMenuItem = (
|
||||
file: WorkingDirectoryFileChange
|
||||
): IMenuItem => {
|
||||
|
@ -556,15 +585,21 @@ export class ChangesList extends React.Component<
|
|||
this.props.onIncludeChanged(file.path, false)
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
this.getCopySelectedPathsMenuItem(selectedFiles),
|
||||
this.getCopySelectedRelativePathsMenuItem(selectedFiles)
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
{ type: 'separator' },
|
||||
this.getCopyPathMenuItem(file),
|
||||
this.getCopyRelativePathMenuItem(file)
|
||||
)
|
||||
}
|
||||
|
||||
const enabled = status.kind !== AppFileStatusKind.Deleted
|
||||
items.push(
|
||||
{ type: 'separator' },
|
||||
this.getCopyPathMenuItem(file),
|
||||
this.getCopyRelativePathMenuItem(file),
|
||||
{ type: 'separator' },
|
||||
this.getRevealInFileManagerMenuItem(file),
|
||||
this.getOpenInExternalEditorMenuItem(file, enabled),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { IDisposable } from 'event-kit'
|
||||
import { DisposableLike } from 'event-kit'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import {
|
||||
getCheckRunConclusionAdjective,
|
||||
|
@ -62,7 +62,7 @@ export class CICheckRunPopover extends React.PureComponent<
|
|||
ICICheckRunPopoverProps,
|
||||
ICICheckRunPopoverState
|
||||
> {
|
||||
private statusSubscription: IDisposable | null = null
|
||||
private statusSubscription: DisposableLike | null = null
|
||||
|
||||
public constructor(props: ICICheckRunPopoverProps) {
|
||||
super(props)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Disposable, IDisposable } from 'event-kit'
|
||||
import { Disposable, DisposableLike } from 'event-kit'
|
||||
|
||||
import {
|
||||
IAPIOrganization,
|
||||
|
@ -235,9 +235,10 @@ export class Dispatcher {
|
|||
*/
|
||||
public changeCommitSelection(
|
||||
repository: Repository,
|
||||
shas: ReadonlyArray<string>
|
||||
shas: ReadonlyArray<string>,
|
||||
isContiguous: boolean
|
||||
): Promise<void> {
|
||||
return this.appStore._changeCommitSelection(repository, shas)
|
||||
return this.appStore._changeCommitSelection(repository, shas, isContiguous)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2507,7 +2508,7 @@ export class Dispatcher {
|
|||
ref: string,
|
||||
callback: StatusCallBack,
|
||||
branchName?: string
|
||||
): IDisposable {
|
||||
): DisposableLike {
|
||||
return this.commitStatusStore.subscribe(
|
||||
repository,
|
||||
ref,
|
||||
|
@ -3148,7 +3149,7 @@ export class Dispatcher {
|
|||
|
||||
switch (cherryPickResult) {
|
||||
case CherryPickResult.CompletedWithoutError:
|
||||
await this.changeCommitSelection(repository, [commits[0].sha])
|
||||
await this.changeCommitSelection(repository, [commits[0].sha], true)
|
||||
await this.completeMultiCommitOperation(repository, commits.length)
|
||||
break
|
||||
case CherryPickResult.ConflictsEncountered:
|
||||
|
@ -3552,7 +3553,11 @@ export class Dispatcher {
|
|||
// TODO: Look at history back to last retained commit and search for
|
||||
// squashed commit based on new commit message ... if there is more
|
||||
// than one, just take the most recent. (not likely?)
|
||||
await this.changeCommitSelection(repository, [status.currentTip])
|
||||
await this.changeCommitSelection(
|
||||
repository,
|
||||
[status.currentTip],
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
await this.completeMultiCommitOperation(
|
||||
|
|
|
@ -174,7 +174,7 @@ export class CommitListItem extends React.PureComponent<
|
|||
<div className="byline">
|
||||
<CommitAttribution
|
||||
gitHubRepository={this.props.gitHubRepository}
|
||||
commit={commit}
|
||||
commits={[commit]}
|
||||
/>
|
||||
{renderRelativeTime(date)}
|
||||
</div>
|
||||
|
|
|
@ -41,7 +41,10 @@ interface ICommitListProps {
|
|||
readonly emptyListMessage: JSX.Element | string
|
||||
|
||||
/** Callback which fires when a commit has been selected in the list */
|
||||
readonly onCommitsSelected: (commits: ReadonlyArray<Commit>) => void
|
||||
readonly onCommitsSelected: (
|
||||
commits: ReadonlyArray<Commit>,
|
||||
isContiguous: boolean
|
||||
) => void
|
||||
|
||||
/** Callback that fires when a scroll event has occurred */
|
||||
readonly onScroll: (start: number, end: number) => void
|
||||
|
@ -269,10 +272,34 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
// reordering, they will need to do multiple cherry-picks.
|
||||
// Goal: first commit in history -> first on array
|
||||
const sorted = [...rows].sort((a, b) => b - a)
|
||||
|
||||
const selectedShas = sorted.map(r => this.props.commitSHAs[r])
|
||||
const selectedCommits = this.lookupCommits(selectedShas)
|
||||
this.props.onCommitsSelected(selectedCommits)
|
||||
this.props.onCommitsSelected(selectedCommits, this.isContiguous(sorted))
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a sorted array of numbers in descending order. If the numbers ar
|
||||
* contiguous order, 4, 3, 2 not 5, 3, 1, returns true.
|
||||
*
|
||||
* Defined an array of 0 and 1 are considered contiguous.
|
||||
*/
|
||||
private isContiguous(indexes: ReadonlyArray<number>) {
|
||||
if (indexes.length <= 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (let i = 0; i < indexes.length; i++) {
|
||||
const current = indexes[i]
|
||||
if (i + 1 === indexes.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (current - 1 !== indexes[i + 1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// This is required along with onSelectedRangeChanged in the case of a user
|
||||
|
@ -281,7 +308,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
const sha = this.props.commitSHAs[row]
|
||||
const commit = this.props.commitLookup.get(sha)
|
||||
if (commit) {
|
||||
this.props.onCommitsSelected([commit])
|
||||
this.props.onCommitsSelected([commit], true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,10 +18,11 @@ import { TooltippedContent } from '../lib/tooltipped-content'
|
|||
import { clipboard } from 'electron'
|
||||
import { TooltipDirection } from '../lib/tooltip'
|
||||
import { AppFileStatusKind } from '../../models/status'
|
||||
import _ from 'lodash'
|
||||
|
||||
interface ICommitSummaryProps {
|
||||
readonly repository: Repository
|
||||
readonly commit: Commit
|
||||
readonly commits: ReadonlyArray<Commit>
|
||||
readonly changesetData: IChangesetData
|
||||
readonly emoji: Map<string, string>
|
||||
|
||||
|
@ -98,17 +99,33 @@ function createState(
|
|||
isOverflowed: boolean,
|
||||
props: ICommitSummaryProps
|
||||
): ICommitSummaryState {
|
||||
const tokenizer = new Tokenizer(props.emoji, props.repository)
|
||||
const { emoji, repository, commits } = props
|
||||
const tokenizer = new Tokenizer(emoji, repository)
|
||||
|
||||
const plainTextBody =
|
||||
commits.length > 1
|
||||
? commits
|
||||
.map(
|
||||
c =>
|
||||
`${c.shortSha} - ${c.summary}${
|
||||
c.body.trim() !== '' ? `\n${c.body}` : ''
|
||||
}`
|
||||
)
|
||||
.join('\n\n')
|
||||
: commits[0].body
|
||||
|
||||
const { summary, body } = wrapRichTextCommitMessage(
|
||||
props.commit.summary,
|
||||
props.commit.body,
|
||||
commits[0].summary,
|
||||
plainTextBody,
|
||||
tokenizer
|
||||
)
|
||||
|
||||
const avatarUsers = getAvatarUsersForCommit(
|
||||
props.repository.gitHubRepository,
|
||||
props.commit
|
||||
const allAvatarUsers = commits.flatMap(c =>
|
||||
getAvatarUsersForCommit(repository.gitHubRepository, c)
|
||||
)
|
||||
const avatarUsers = _.uniqWith(
|
||||
allAvatarUsers,
|
||||
(a, b) => a.email === b.email && a.name === b.name
|
||||
)
|
||||
|
||||
return { isOverflowed, summary, body, avatarUsers }
|
||||
|
@ -242,7 +259,12 @@ export class CommitSummary extends React.Component<
|
|||
}
|
||||
|
||||
public componentWillUpdate(nextProps: ICommitSummaryProps) {
|
||||
if (!messageEquals(nextProps.commit, this.props.commit)) {
|
||||
if (
|
||||
nextProps.commits.length !== this.props.commits.length ||
|
||||
!nextProps.commits.every((nextCommit, i) =>
|
||||
messageEquals(nextCommit, this.props.commits[i])
|
||||
)
|
||||
) {
|
||||
this.setState(createState(false, nextProps))
|
||||
}
|
||||
}
|
||||
|
@ -293,9 +315,21 @@ export class CommitSummary extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const shortSHA = this.props.commit.shortSha
|
||||
private getShaRef = (useShortSha?: boolean) => {
|
||||
const { commits } = this.props
|
||||
const oldest = useShortSha ? commits[0].shortSha : commits[0].sha
|
||||
|
||||
if (commits.length === 1) {
|
||||
return oldest
|
||||
}
|
||||
|
||||
const latestCommit = commits.at(-1)
|
||||
const latest = useShortSha ? latestCommit?.shortSha : latestCommit?.sha
|
||||
|
||||
return `${oldest}^..${latest}`
|
||||
}
|
||||
|
||||
public render() {
|
||||
const className = classNames({
|
||||
expanded: this.props.isExpanded,
|
||||
collapsed: !this.props.isExpanded,
|
||||
|
@ -306,6 +340,8 @@ export class CommitSummary extends React.Component<
|
|||
const hasEmptySummary = this.state.summary.length === 0
|
||||
const commitSummary = hasEmptySummary
|
||||
? 'Empty commit message'
|
||||
: this.props.commits.length > 1
|
||||
? `Viewing the diff of ${this.props.commits.length} commits`
|
||||
: this.state.summary
|
||||
|
||||
const summaryClassNames = classNames('commit-summary-title', {
|
||||
|
@ -330,7 +366,7 @@ export class CommitSummary extends React.Component<
|
|||
<AvatarStack users={this.state.avatarUsers} />
|
||||
<CommitAttribution
|
||||
gitHubRepository={this.props.repository.gitHubRepository}
|
||||
commit={this.props.commit}
|
||||
commits={this.props.commits}
|
||||
/>
|
||||
</li>
|
||||
|
||||
|
@ -346,7 +382,7 @@ export class CommitSummary extends React.Component<
|
|||
interactive={true}
|
||||
direction={TooltipDirection.SOUTH}
|
||||
>
|
||||
{shortSHA}
|
||||
{this.getShaRef(true)}
|
||||
</TooltippedContent>
|
||||
</li>
|
||||
|
||||
|
@ -382,7 +418,7 @@ export class CommitSummary extends React.Component<
|
|||
private renderShaTooltip() {
|
||||
return (
|
||||
<>
|
||||
<code>{this.props.commit.sha}</code>
|
||||
<code>{this.getShaRef()}</code>
|
||||
<button onClick={this.onCopyShaButtonClick}>Copy</button>
|
||||
</>
|
||||
)
|
||||
|
@ -390,7 +426,7 @@ export class CommitSummary extends React.Component<
|
|||
|
||||
private onCopyShaButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
clipboard.writeText(this.props.commit.sha)
|
||||
clipboard.writeText(this.getShaRef())
|
||||
}
|
||||
|
||||
private renderChangedFilesDescription = () => {
|
||||
|
@ -492,7 +528,7 @@ export class CommitSummary extends React.Component<
|
|||
}
|
||||
|
||||
private renderTags() {
|
||||
const tags = this.props.commit.tags || []
|
||||
const tags = this.props.commits.flatMap(c => c.tags) || []
|
||||
|
||||
if (tags.length === 0) {
|
||||
return null
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Branch, IAheadBehind } from '../../models/branch'
|
|||
import { IMatches } from '../../lib/fuzzy-find'
|
||||
import { AheadBehindStore } from '../../lib/stores/ahead-behind-store'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { IDisposable } from 'event-kit'
|
||||
import { DisposableLike } from 'event-kit'
|
||||
|
||||
interface ICompareBranchListItemProps {
|
||||
readonly branch: Branch
|
||||
|
@ -50,7 +50,7 @@ export class CompareBranchListItem extends React.Component<
|
|||
return { aheadBehind, comparisonFrom: from, comparisonTo: to }
|
||||
}
|
||||
|
||||
private aheadBehindSubscription: IDisposable | null = null
|
||||
private aheadBehindSubscription: DisposableLike | null = null
|
||||
|
||||
public constructor(props: ICompareBranchListItemProps) {
|
||||
super(props)
|
||||
|
|
|
@ -460,10 +460,14 @@ export class CompareSidebar extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private onCommitsSelected = (commits: ReadonlyArray<Commit>) => {
|
||||
private onCommitsSelected = (
|
||||
commits: ReadonlyArray<Commit>,
|
||||
isContiguous: boolean
|
||||
) => {
|
||||
this.props.dispatcher.changeCommitSelection(
|
||||
this.props.repository,
|
||||
commits.map(c => c.sha)
|
||||
commits.map(c => c.sha),
|
||||
isContiguous
|
||||
)
|
||||
|
||||
this.loadChangedFilesScheduler.queue(() => {
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export { SelectedCommit } from './selected-commit'
|
||||
export { SelectedCommits } from './selected-commit'
|
||||
export { CompareSidebar } from './compare'
|
||||
|
|
|
@ -34,14 +34,15 @@ import { IChangesetData } from '../../lib/git'
|
|||
import { IConstrainedValue } from '../../lib/app-state'
|
||||
import { clamp } from '../../lib/clamp'
|
||||
import { pathExists } from '../lib/path-exists'
|
||||
import { enableMultiCommitDiffs } from '../../lib/feature-flag'
|
||||
|
||||
interface ISelectedCommitProps {
|
||||
interface ISelectedCommitsProps {
|
||||
readonly repository: Repository
|
||||
readonly isLocalRepository: boolean
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly emoji: Map<string, string>
|
||||
readonly selectedCommit: Commit | null
|
||||
readonly isLocal: boolean
|
||||
readonly selectedCommits: ReadonlyArray<Commit>
|
||||
readonly localCommitSHAs: ReadonlyArray<string>
|
||||
readonly changesetData: IChangesetData
|
||||
readonly selectedFile: CommittedFileChange | null
|
||||
readonly currentDiff: IDiff | null
|
||||
|
@ -77,27 +78,27 @@ interface ISelectedCommitProps {
|
|||
/** Called when the user opens the diff options popover */
|
||||
readonly onDiffOptionsOpened: () => void
|
||||
|
||||
/** Whether multiple commits are selected. */
|
||||
readonly areMultipleCommitsSelected: boolean
|
||||
|
||||
/** Whether or not to show the drag overlay */
|
||||
readonly showDragOverlay: boolean
|
||||
|
||||
/** Whether or not the selection of commits is contiguous */
|
||||
readonly isContiguous: boolean
|
||||
}
|
||||
|
||||
interface ISelectedCommitState {
|
||||
interface ISelectedCommitsState {
|
||||
readonly isExpanded: boolean
|
||||
readonly hideDescriptionBorder: boolean
|
||||
}
|
||||
|
||||
/** The History component. Contains the commit list, commit summary, and diff. */
|
||||
export class SelectedCommit extends React.Component<
|
||||
ISelectedCommitProps,
|
||||
ISelectedCommitState
|
||||
export class SelectedCommits extends React.Component<
|
||||
ISelectedCommitsProps,
|
||||
ISelectedCommitsState
|
||||
> {
|
||||
private readonly loadChangedFilesScheduler = new ThrottledScheduler(200)
|
||||
private historyRef: HTMLDivElement | null = null
|
||||
|
||||
public constructor(props: ISelectedCommitProps) {
|
||||
public constructor(props: ISelectedCommitsProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
|
@ -114,16 +115,12 @@ export class SelectedCommit extends React.Component<
|
|||
this.historyRef = ref
|
||||
}
|
||||
|
||||
public componentWillUpdate(nextProps: ISelectedCommitProps) {
|
||||
public componentWillUpdate(nextProps: ISelectedCommitsProps) {
|
||||
// reset isExpanded if we're switching commits.
|
||||
const currentValue = this.props.selectedCommit
|
||||
? this.props.selectedCommit.sha
|
||||
: undefined
|
||||
const nextValue = nextProps.selectedCommit
|
||||
? nextProps.selectedCommit.sha
|
||||
: undefined
|
||||
const currentValue = this.props.selectedCommits.map(c => c.sha).join('')
|
||||
const nextValue = nextProps.selectedCommits.map(c => c.sha).join('')
|
||||
|
||||
if ((currentValue || nextValue) && currentValue !== nextValue) {
|
||||
if (currentValue !== nextValue) {
|
||||
if (this.state.isExpanded) {
|
||||
this.setState({ isExpanded: false })
|
||||
}
|
||||
|
@ -166,10 +163,10 @@ export class SelectedCommit extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderCommitSummary(commit: Commit) {
|
||||
private renderCommitSummary(commits: ReadonlyArray<Commit>) {
|
||||
return (
|
||||
<CommitSummary
|
||||
commit={commit}
|
||||
commits={commits}
|
||||
changesetData={this.props.changesetData}
|
||||
emoji={this.props.emoji}
|
||||
repository={this.props.repository}
|
||||
|
@ -250,13 +247,16 @@ export class SelectedCommit extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
const commit = this.props.selectedCommit
|
||||
const { selectedCommits, isContiguous } = this.props
|
||||
|
||||
if (this.props.areMultipleCommitsSelected) {
|
||||
return this.renderMultipleCommitsSelected()
|
||||
if (
|
||||
selectedCommits.length > 1 &&
|
||||
(!isContiguous || !enableMultiCommitDiffs())
|
||||
) {
|
||||
return this.renderMultipleCommitsBlankSlate()
|
||||
}
|
||||
|
||||
if (commit == null) {
|
||||
if (selectedCommits.length === 0) {
|
||||
return <NoCommitSelected />
|
||||
}
|
||||
|
||||
|
@ -265,7 +265,7 @@ export class SelectedCommit extends React.Component<
|
|||
|
||||
return (
|
||||
<div id="history" ref={this.onHistoryRef} className={className}>
|
||||
{this.renderCommitSummary(commit)}
|
||||
{this.renderCommitSummary(selectedCommits)}
|
||||
<div className="commit-details">
|
||||
<Resizable
|
||||
width={commitSummaryWidth.value}
|
||||
|
@ -291,7 +291,7 @@ export class SelectedCommit extends React.Component<
|
|||
return <div id="drag-overlay-background"></div>
|
||||
}
|
||||
|
||||
private renderMultipleCommitsSelected(): JSX.Element {
|
||||
private renderMultipleCommitsBlankSlate(): JSX.Element {
|
||||
const BlankSlateImage = encodePathAsUrl(
|
||||
__dirname,
|
||||
'static/empty-no-commit.svg'
|
||||
|
@ -302,11 +302,22 @@ export class SelectedCommit extends React.Component<
|
|||
<div className="panel blankslate">
|
||||
<img src={BlankSlateImage} className="blankslate-image" />
|
||||
<div>
|
||||
<p>Unable to display diff when multiple commits are selected.</p>
|
||||
<p>
|
||||
Unable to display diff when multiple{' '}
|
||||
{enableMultiCommitDiffs() ? 'non-consecutive ' : ' '}commits are
|
||||
selected.
|
||||
</p>
|
||||
<div>You can:</div>
|
||||
<ul>
|
||||
<li>Select a single commit to view a diff.</li>
|
||||
<li>
|
||||
Select a single commit{' '}
|
||||
{enableMultiCommitDiffs()
|
||||
? 'or a range of consecutive commits '
|
||||
: ' '}
|
||||
to view a diff.
|
||||
</li>
|
||||
<li>Drag the commits to the branch menu to cherry-pick them.</li>
|
||||
<li>Drag the commits to squash or reorder them.</li>
|
||||
<li>Right click on multiple commits to see options.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -322,7 +333,14 @@ export class SelectedCommit extends React.Component<
|
|||
) => {
|
||||
event.preventDefault()
|
||||
|
||||
const fullPath = Path.join(this.props.repository.path, file.path)
|
||||
const {
|
||||
selectedCommits,
|
||||
localCommitSHAs,
|
||||
repository,
|
||||
externalEditorLabel,
|
||||
} = this.props
|
||||
|
||||
const fullPath = Path.join(repository.path, file.path)
|
||||
const fileExistsOnDisk = await pathExists(fullPath)
|
||||
if (!fileExistsOnDisk) {
|
||||
showContextualMenu([
|
||||
|
@ -339,14 +357,14 @@ export class SelectedCommit extends React.Component<
|
|||
const extension = Path.extname(file.path)
|
||||
|
||||
const isSafeExtension = isSafeFileExtension(extension)
|
||||
const openInExternalEditor = this.props.externalEditorLabel
|
||||
? `Open in ${this.props.externalEditorLabel}`
|
||||
const openInExternalEditor = externalEditorLabel
|
||||
? `Open in ${externalEditorLabel}`
|
||||
: DefaultEditorLabel
|
||||
|
||||
const items: IMenuItem[] = [
|
||||
{
|
||||
label: RevealInFileManagerLabel,
|
||||
action: () => revealInFileManager(this.props.repository, file.path),
|
||||
action: () => revealInFileManager(repository, file.path),
|
||||
enabled: fileExistsOnDisk,
|
||||
},
|
||||
{
|
||||
|
@ -372,7 +390,7 @@ export class SelectedCommit extends React.Component<
|
|||
]
|
||||
|
||||
let viewOnGitHubLabel = 'View on GitHub'
|
||||
const gitHubRepository = this.props.repository.gitHubRepository
|
||||
const gitHubRepository = repository.gitHubRepository
|
||||
|
||||
if (
|
||||
gitHubRepository &&
|
||||
|
@ -383,20 +401,19 @@ export class SelectedCommit extends React.Component<
|
|||
|
||||
items.push({
|
||||
label: viewOnGitHubLabel,
|
||||
action: () => this.onViewOnGitHub(file),
|
||||
action: () => this.onViewOnGitHub(selectedCommits[0].sha, file),
|
||||
enabled:
|
||||
!this.props.isLocal &&
|
||||
selectedCommits.length === 1 &&
|
||||
!localCommitSHAs.includes(selectedCommits[0].sha) &&
|
||||
!!gitHubRepository &&
|
||||
!!this.props.selectedCommit,
|
||||
this.props.selectedCommits.length > 0,
|
||||
})
|
||||
|
||||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private onViewOnGitHub = (file: CommittedFileChange) => {
|
||||
if (this.props.selectedCommit && this.props.onViewCommitOnGitHub) {
|
||||
this.props.onViewCommitOnGitHub(this.props.selectedCommit.sha, file.path)
|
||||
}
|
||||
private onViewOnGitHub = (sha: string, file: CommittedFileChange) => {
|
||||
this.props.onViewCommitOnGitHub(sha, file.path)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,10 @@ export class AvatarStack extends React.Component<IAvatarStackProps, {}> {
|
|||
const users = this.props.users
|
||||
|
||||
for (let i = 0; i < this.props.users.length; i++) {
|
||||
if (users.length > MaxDisplayedAvatars && i === MaxDisplayedAvatars - 1) {
|
||||
if (
|
||||
users.length > MaxDisplayedAvatars + 1 &&
|
||||
i === MaxDisplayedAvatars - 1
|
||||
) {
|
||||
elems.push(<div key="more" className="avatar-more avatar" />)
|
||||
}
|
||||
|
||||
|
@ -35,7 +38,8 @@ export class AvatarStack extends React.Component<IAvatarStackProps, {}> {
|
|||
const className = classNames('AvatarStack', {
|
||||
'AvatarStack--small': true,
|
||||
'AvatarStack--two': users.length === 2,
|
||||
'AvatarStack--three-plus': users.length >= MaxDisplayedAvatars,
|
||||
'AvatarStack--three': users.length === 3,
|
||||
'AvatarStack--plus': users.length > MaxDisplayedAvatars,
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
@ -7,10 +7,10 @@ import { isWebFlowCommitter } from '../../lib/web-flow-committer'
|
|||
|
||||
interface ICommitAttributionProps {
|
||||
/**
|
||||
* The commit from where to extract the author, committer
|
||||
* The commit or commits from where to extract the author, committer
|
||||
* and co-authors from.
|
||||
*/
|
||||
readonly commit: Commit
|
||||
readonly commits: ReadonlyArray<Commit>
|
||||
|
||||
/**
|
||||
* The GitHub hosted repository that the given commit is
|
||||
|
@ -61,24 +61,34 @@ export class CommitAttribution extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
const commit = this.props.commit
|
||||
const { author, committer, coAuthors } = commit
|
||||
const { commits } = this.props
|
||||
|
||||
// do we need to attribute the committer separately from the author?
|
||||
const committerAttribution =
|
||||
!commit.authoredByCommitter &&
|
||||
!(
|
||||
this.props.gitHubRepository !== null &&
|
||||
isWebFlowCommitter(commit, this.props.gitHubRepository)
|
||||
)
|
||||
const allAuthors = new Map<string, CommitIdentity | GitAuthor>()
|
||||
for (const commit of commits) {
|
||||
const { author, committer, coAuthors } = commit
|
||||
|
||||
const authors: Array<CommitIdentity | GitAuthor> = committerAttribution
|
||||
? [author, committer, ...coAuthors]
|
||||
: [author, ...coAuthors]
|
||||
// do we need to attribute the committer separately from the author?
|
||||
const committerAttribution =
|
||||
!commit.authoredByCommitter &&
|
||||
!(
|
||||
this.props.gitHubRepository !== null &&
|
||||
isWebFlowCommitter(commit, this.props.gitHubRepository)
|
||||
)
|
||||
|
||||
const authors: Array<CommitIdentity | GitAuthor> = committerAttribution
|
||||
? [author, committer, ...coAuthors]
|
||||
: [author, ...coAuthors]
|
||||
|
||||
for (const a of authors) {
|
||||
if (!allAuthors.has(a.toString())) {
|
||||
allAuthors.set(a.toString(), a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="commit-attribution-component">
|
||||
{this.renderAuthors(authors)}
|
||||
{this.renderAuthors(Array.from(allAuthors.values()))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,12 @@ export const CopyRelativeFilePathLabel = __DARWIN__
|
|||
? 'Copy Relative File Path'
|
||||
: 'Copy relative file path'
|
||||
|
||||
export const CopySelectedPathsLabel = __DARWIN__ ? 'Copy Paths' : 'Copy paths'
|
||||
|
||||
export const CopySelectedRelativePathsLabel = __DARWIN__
|
||||
? 'Copy Relative Paths'
|
||||
: 'Copy relative paths'
|
||||
|
||||
export const DefaultEditorLabel = __DARWIN__
|
||||
? 'Open in External Editor'
|
||||
: 'Open in external editor'
|
||||
|
|
|
@ -7,14 +7,14 @@ import { Tooltip } from './tooltip'
|
|||
import { createObservableRef } from './observable-ref'
|
||||
import { getObjectId } from './object-id'
|
||||
import { debounce } from 'lodash'
|
||||
import { parseMarkdown } from '../../lib/markdown-filters/markdown-filter'
|
||||
import {
|
||||
MarkdownEmitter,
|
||||
parseMarkdown,
|
||||
} from '../../lib/markdown-filters/markdown-filter'
|
||||
|
||||
interface ISandboxedMarkdownProps {
|
||||
/** A string of unparsed markdown to display */
|
||||
readonly markdown: string
|
||||
|
||||
/** Whether the markdown was pre-parsed - assumed false */
|
||||
readonly isParsed?: boolean
|
||||
readonly markdown: string | MarkdownEmitter
|
||||
|
||||
/** The baseHref of the markdown content for when the markdown has relative links */
|
||||
readonly baseHref?: string
|
||||
|
@ -58,6 +58,7 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
private frameRef: HTMLIFrameElement | null = null
|
||||
private frameContainingDivRef: HTMLDivElement | null = null
|
||||
private contentDivRef: HTMLDivElement | null = null
|
||||
private markdownEmitter?: MarkdownEmitter
|
||||
|
||||
/**
|
||||
* Resize observer used for tracking height changes in the markdown
|
||||
|
@ -72,6 +73,18 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
})
|
||||
}, 100)
|
||||
|
||||
/**
|
||||
* We debounce the markdown updating because it is updated on each custom
|
||||
* markdown filter. Leading is true so that users will at a minimum see the
|
||||
* markdown parsed by markedjs while the custom filters are being applied.
|
||||
* (So instead of being updated, 10+ times it is updated 1 or 2 times.)
|
||||
*/
|
||||
private onMarkdownUpdated = debounce(
|
||||
markdown => this.mountIframeContents(markdown),
|
||||
10,
|
||||
{ leading: true }
|
||||
)
|
||||
|
||||
public constructor(props: ISandboxedMarkdownProps) {
|
||||
super(props)
|
||||
|
||||
|
@ -105,8 +118,27 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
this.frameContainingDivRef = frameContainingDivRef
|
||||
}
|
||||
|
||||
private initializeMarkdownEmitter = () => {
|
||||
if (this.markdownEmitter !== undefined) {
|
||||
this.markdownEmitter.dispose()
|
||||
}
|
||||
const { emoji, repository, markdownContext } = this.props
|
||||
this.markdownEmitter =
|
||||
typeof this.props.markdown !== 'string'
|
||||
? this.props.markdown
|
||||
: parseMarkdown(this.props.markdown, {
|
||||
emoji,
|
||||
repository,
|
||||
markdownContext,
|
||||
})
|
||||
|
||||
this.markdownEmitter.onMarkdownUpdated((markdown: string) => {
|
||||
this.onMarkdownUpdated(markdown)
|
||||
})
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
this.mountIframeContents()
|
||||
this.initializeMarkdownEmitter()
|
||||
|
||||
if (this.frameRef !== null) {
|
||||
this.setupFrameLoadListeners(this.frameRef)
|
||||
|
@ -120,11 +152,12 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
public async componentDidUpdate(prevProps: ISandboxedMarkdownProps) {
|
||||
// rerender iframe contents if provided markdown changes
|
||||
if (prevProps.markdown !== this.props.markdown) {
|
||||
this.mountIframeContents()
|
||||
this.initializeMarkdownEmitter()
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.markdownEmitter?.dispose()
|
||||
this.resizeObserver.disconnect()
|
||||
document.removeEventListener('scroll', this.onDocumentScroll)
|
||||
}
|
||||
|
@ -288,23 +321,13 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
/**
|
||||
* Populates the mounted iframe with HTML generated from the provided markdown
|
||||
*/
|
||||
private async mountIframeContents() {
|
||||
private async mountIframeContents(markdown: string) {
|
||||
if (this.frameRef === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const styleSheet = await this.getInlineStyleSheet()
|
||||
|
||||
const { emoji, repository, markdownContext } = this.props
|
||||
const filteredHTML =
|
||||
this.props.isParsed === true
|
||||
? this.props.markdown
|
||||
: await parseMarkdown(this.props.markdown, {
|
||||
emoji,
|
||||
repository,
|
||||
markdownContext,
|
||||
})
|
||||
|
||||
const src = `
|
||||
<html>
|
||||
<head>
|
||||
|
@ -313,7 +336,7 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
</head>
|
||||
<body class="markdown-body">
|
||||
<div id="content">
|
||||
${filteredHTML}
|
||||
${markdown}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -155,6 +155,9 @@ export const getCurrentWindowZoomFactor = invokeProxy(
|
|||
0
|
||||
)
|
||||
|
||||
/** Tell the main process to set the current window's zoom factor */
|
||||
export const setWindowZoomFactor = sendProxy('set-window-zoom-factor', 1)
|
||||
|
||||
/** Tell the main process to check for app updates */
|
||||
export const checkForUpdates = invokeProxy('check-for-updates', 1)
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Changes, ChangesSidebar } from './changes'
|
|||
import { NoChanges } from './changes/no-changes'
|
||||
import { MultipleSelection } from './changes/multiple-selection'
|
||||
import { FilesChangedBadge } from './changes/files-changed-badge'
|
||||
import { SelectedCommit, CompareSidebar } from './history'
|
||||
import { SelectedCommits, CompareSidebar } from './history'
|
||||
import { Resizable } from './resizable'
|
||||
import { TabBar } from './tab-bar'
|
||||
import {
|
||||
|
@ -372,31 +372,29 @@ export class RepositoryView extends React.Component<
|
|||
}
|
||||
|
||||
private renderContentForHistory(): JSX.Element {
|
||||
const { commitSelection } = this.props.state
|
||||
const { commitSelection, commitLookup, localCommitSHAs } = this.props.state
|
||||
const { changesetData, file, diff, shas, isContiguous } = commitSelection
|
||||
|
||||
const sha =
|
||||
commitSelection.shas.length === 1 ? commitSelection.shas[0] : null
|
||||
|
||||
const selectedCommit =
|
||||
sha != null ? this.props.state.commitLookup.get(sha) || null : null
|
||||
|
||||
const isLocal =
|
||||
selectedCommit != null &&
|
||||
this.props.state.localCommitSHAs.includes(selectedCommit.sha)
|
||||
|
||||
const { changesetData, file, diff } = commitSelection
|
||||
const selectedCommits = []
|
||||
for (const sha of shas) {
|
||||
const commit = commitLookup.get(sha)
|
||||
if (commit !== undefined) {
|
||||
selectedCommits.push(commit)
|
||||
}
|
||||
}
|
||||
|
||||
const showDragOverlay = dragAndDropManager.isDragOfTypeInProgress(
|
||||
DragType.Commit
|
||||
)
|
||||
|
||||
return (
|
||||
<SelectedCommit
|
||||
<SelectedCommits
|
||||
repository={this.props.repository}
|
||||
isLocalRepository={this.props.state.remote === null}
|
||||
dispatcher={this.props.dispatcher}
|
||||
selectedCommit={selectedCommit}
|
||||
isLocal={isLocal}
|
||||
selectedCommits={selectedCommits}
|
||||
isContiguous={isContiguous}
|
||||
localCommitSHAs={localCommitSHAs}
|
||||
changesetData={changesetData}
|
||||
selectedFile={file}
|
||||
currentDiff={diff}
|
||||
|
@ -411,7 +409,6 @@ export class RepositoryView extends React.Component<
|
|||
onOpenBinaryFile={this.onOpenBinaryFile}
|
||||
onChangeImageDiffType={this.onChangeImageDiffType}
|
||||
onDiffOptionsOpened={this.onDiffOptionsOpened}
|
||||
areMultipleCommitsSelected={commitSelection.shas.length > 1}
|
||||
showDragOverlay={showDragOverlay}
|
||||
/>
|
||||
)
|
||||
|
|
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;
|
||||
}
|
||||
|
||||
&.AvatarStack--three-plus {
|
||||
&.AvatarStack--three {
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
&.AvatarStack--plus {
|
||||
min-width: 46px;
|
||||
}
|
||||
|
||||
|
@ -28,10 +32,14 @@
|
|||
min-width: 25px;
|
||||
}
|
||||
|
||||
&.AvatarStack--three-plus {
|
||||
&.AvatarStack--three {
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
&.AvatarStack--plus {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.avatar.avatar-more {
|
||||
&::before,
|
||||
&::after {
|
||||
|
@ -66,6 +74,13 @@
|
|||
background: var(--box-alt-background-color);
|
||||
}
|
||||
|
||||
.avatar-container:nth-child(n + 5) {
|
||||
.avatar {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
@ -92,13 +107,6 @@
|
|||
img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
// stylelint-enable selector-max-type
|
||||
|
||||
// Account for 4+ avatars
|
||||
&:nth-child(n + 4) {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -106,9 +114,11 @@
|
|||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.avatar:nth-child(n + 4) {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
.avatar-container:nth-child(n + 5) {
|
||||
.avatar {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-more {
|
||||
|
@ -121,6 +131,7 @@
|
|||
z-index: 1;
|
||||
margin-right: 0;
|
||||
background: $gray-100;
|
||||
width: 10px !important;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
&.expanded {
|
||||
.commit-summary-description-scroll-view {
|
||||
max-height: none;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
|
||||
&:before {
|
||||
|
|
|
@ -398,10 +398,10 @@ devtron@^1.4.0:
|
|||
highlight.js "^9.3.0"
|
||||
humanize-plus "^1.8.1"
|
||||
|
||||
dexie@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/dexie/-/dexie-2.0.4.tgz#6027a5e05879424e8f9979d8c14e7420f27e3a11"
|
||||
integrity sha512-aQ/s1U2wHxwBKRrt2Z/mwFNHMQWhESerFsMYzE+5P5OsIe5o1kgpFMWkzKTtkvkyyEni6mWr/T4HUJuY9xIHLA==
|
||||
dexie@^3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.2.tgz#fa6f2a3c0d6ed0766f8d97a03720056f88fe0e01"
|
||||
integrity sha512-q5dC3HPmir2DERlX+toCBbHQXW5MsyrFqPFcovkH9N2S/UW/H3H5AWAB6iEOExeraAu+j+zRDG+zg/D7YhH0qg==
|
||||
|
||||
dom-classlist@^1.0.1:
|
||||
version "1.0.1"
|
||||
|
|
|
@ -4,6 +4,20 @@
|
|||
"[Fixed] Fix crash launching the app on macOS High Sierra - #14712",
|
||||
"[Fixed] Terminate all GitHub Desktop processes on Windows when the app is closed - #14733. Thanks @tsvetilian-ty!"
|
||||
],
|
||||
"3.0.2-beta4": [
|
||||
"[Improved] Add support for SSH password prompts when accessing repositories - #14676",
|
||||
"[Fixed] Fix Markdown syntax highlighting - #14710",
|
||||
"[Fixed] Fix issue with some repositories not being properly persisted - #14748"
|
||||
],
|
||||
"3.0.2-beta3": [
|
||||
"[Fixed] Terminate all GitHub Desktop processes on Windows when the app is closed - #14733. Thanks @tsvetilian-ty!"
|
||||
],
|
||||
"3.0.2-beta2": [
|
||||
"[Fixed] Fix crash launching the app on macOS High Sierra - #14712"
|
||||
],
|
||||
"3.0.2-beta1": [
|
||||
"[Added] Add support for Aptana Studio - #14669. Thanks @tsvetilian-ty!"
|
||||
],
|
||||
"3.0.1": [
|
||||
"[Added] Add support for PyCharm Community Edition on Windows - #14411. Thanks @tsvetilian-ty!",
|
||||
"[Added] Add support for highlighting .mjs/.cjs/.mts/.cts files as JavaScript/TypeScript - #14481. Thanks @j-f1!",
|
||||
|
|
|
@ -11,7 +11,7 @@ These are good teams to start with for general communication and questions. (Mem
|
|||
| Team | Purpose |
|
||||
|:--|:--|
|
||||
| `@desktop/maintainers` | The people designing, developing, and driving GitHub Desktop. Includes all groups below. |
|
||||
| `@desktop/comrades` | Community members with a track record of activity in the Desktop project |
|
||||
| `@desktop/collaborators` | Community members with a track record of activity in the Desktop project |
|
||||
|
||||
## Special-purpose Teams
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ These editors are currently supported:
|
|||
- [Brackets](http://brackets.io/)
|
||||
- [Notepad++](https://notepad-plus-plus.org/)
|
||||
- [RStudio](https://rstudio.com/)
|
||||
- [Aptana Studio](http://www.aptana.com/)
|
||||
|
||||
These are defined in a list at the top of the file:
|
||||
|
||||
|
@ -233,6 +234,7 @@ These editors are currently supported:
|
|||
- [Android Studio](https://developer.android.com/studio)
|
||||
- [JetBrains Rider](https://www.jetbrains.com/rider/)
|
||||
- [Nova](https://nova.app/)
|
||||
- [Aptana Studio](http://www.aptana.com/)
|
||||
|
||||
These are defined in a list at the top of the file:
|
||||
|
||||
|
|
|
@ -116,7 +116,7 @@
|
|||
"@types/electron-winstaller": "^4.0.0",
|
||||
"@types/eslint": "^8.4.1",
|
||||
"@types/estree": "^0.0.49",
|
||||
"@types/event-kit": "^1.2.28",
|
||||
"@types/event-kit": "^2.4.1",
|
||||
"@types/express": "^4.11.0",
|
||||
"@types/fs-extra": "^7.0.0",
|
||||
"@types/fuzzaldrin-plus": "^0.0.1",
|
||||
|
|
|
@ -3,6 +3,7 @@ import * as HTTPS from 'https'
|
|||
export interface IAPIPR {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly headRefName: string
|
||||
}
|
||||
|
||||
type GraphQLResponse = {
|
||||
|
@ -49,6 +50,7 @@ export function fetchPR(id: number): Promise<IAPIPR | null> {
|
|||
pullRequest(number: ${id}) {
|
||||
title
|
||||
body
|
||||
headRefName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,26 @@ function capitalized(str: string): string {
|
|||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a release note in the PR body, which is under the 'Release notes'
|
||||
* section, preceded by a 'Notes:' title.
|
||||
*
|
||||
* @param body Body of the PR to parse
|
||||
* @returns The release note if it exist, null if it's explicitly marked to
|
||||
* not have a release note (with no-notes), and undefined if there
|
||||
* is no 'Release notes' section at all.
|
||||
*/
|
||||
export function findReleaseNote(body: string): string | null | undefined {
|
||||
const re = /^Notes: (.+)$/gm
|
||||
const matches = re.exec(body)
|
||||
if (!matches || matches.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const note = matches[1].replace(/\.$/, '')
|
||||
return note === 'no-notes' ? null : note
|
||||
}
|
||||
|
||||
export function findIssueRef(body: string): string {
|
||||
let issueRef = ''
|
||||
|
||||
|
@ -55,7 +75,12 @@ export function findIssueRef(body: string): string {
|
|||
return issueRef
|
||||
}
|
||||
|
||||
function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string {
|
||||
function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string | null {
|
||||
let attribution = ''
|
||||
if (commit.owner !== OfficialOwner) {
|
||||
attribution = `. Thanks @${commit.owner}!`
|
||||
}
|
||||
|
||||
let type = PlaceholderChangeType
|
||||
const description = capitalized(pr.title)
|
||||
|
||||
|
@ -67,9 +92,12 @@ function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string {
|
|||
issueRef = ` #${commit.prID}`
|
||||
}
|
||||
|
||||
let attribution = ''
|
||||
if (commit.owner !== OfficialOwner) {
|
||||
attribution = `. Thanks @${commit.owner}!`
|
||||
// Use release note from PR body if defined
|
||||
const releaseNote = findReleaseNote(pr.body)
|
||||
if (releaseNote !== undefined) {
|
||||
return releaseNote === null
|
||||
? null
|
||||
: `${releaseNote} -${issueRef}${attribution}`
|
||||
}
|
||||
|
||||
return `[${type}] ${description} -${issueRef}${attribution}`
|
||||
|
@ -86,9 +114,15 @@ export async function convertToChangelogFormat(
|
|||
if (!pr) {
|
||||
throw new Error(`Unable to get PR from API: ${commit.prID}`)
|
||||
}
|
||||
// Skip release PRs
|
||||
if (pr.headRefName.startsWith('releases/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const entry = getChangelogEntry(commit, pr)
|
||||
entries.push(entry)
|
||||
if (entry !== null) {
|
||||
entries.push(entry)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Unable to parse line, using the full message.', e)
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { findIssueRef } from '../parser'
|
||||
import { findIssueRef, findReleaseNote } from '../parser'
|
||||
|
||||
describe('changelog/parser', () => {
|
||||
describe('findIssueRef', () => {
|
||||
|
@ -54,4 +54,55 @@ quam vel augue.`
|
|||
expect(findIssueRef(body)).toBe(' #2314')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findReleaseNote', () => {
|
||||
it('detected release note at the end of the body', () => {
|
||||
const body = `
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
|
||||
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
|
||||
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
|
||||
quam vel augue.
|
||||
|
||||
Notes: [Fixed] Fix lorem impsum dolor sit amet
|
||||
`
|
||||
expect(findReleaseNote(body)).toBe(
|
||||
'[Fixed] Fix lorem impsum dolor sit amet'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes dot at the end of release note', () => {
|
||||
const body = `
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
|
||||
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
|
||||
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
|
||||
quam vel augue.
|
||||
|
||||
Notes: [Fixed] Fix lorem impsum dolor sit amet.
|
||||
`
|
||||
expect(findReleaseNote(body)).toBe(
|
||||
'[Fixed] Fix lorem impsum dolor sit amet'
|
||||
)
|
||||
})
|
||||
|
||||
it('detected no release notes wanted for the PR', () => {
|
||||
const body = `
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
|
||||
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
|
||||
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
|
||||
quam vel augue.
|
||||
|
||||
Notes: no-notes
|
||||
`
|
||||
expect(findReleaseNote(body)).toBeNull()
|
||||
})
|
||||
|
||||
it('detected no release notes were added to the PR', () => {
|
||||
const body = `
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sollicitudin turpis
|
||||
tempor euismod fermentum. Nullam hendrerit neque eget risus faucibus volutpat. Donec
|
||||
ultrices, orci quis auctor ultrices, nulla lacus gravida lectus, non rutrum dolor
|
||||
quam vel augue.`
|
||||
expect(findReleaseNote(body)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -51,6 +51,20 @@ async function getLatestRelease(options: {
|
|||
return latestTag instanceof SemVer ? latestTag.raw : latestTag
|
||||
}
|
||||
|
||||
async function createReleaseBranch(version: string): Promise<void> {
|
||||
try {
|
||||
const versionBranch = `releases/${version}`
|
||||
const currentBranch = (
|
||||
await sh('git', 'rev-parse', '--abbrev-ref', 'HEAD')
|
||||
).trim()
|
||||
if (currentBranch !== versionBranch) {
|
||||
await sh('git', 'checkout', '-b', versionBranch)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Failed to create release branch: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts a string to Channel type if possible */
|
||||
function parseChannel(arg: string): Channel {
|
||||
if (arg === 'production' || arg === 'beta' || arg === 'test') {
|
||||
|
@ -115,6 +129,10 @@ export async function run(args: ReadonlyArray<string>): Promise<void> {
|
|||
})
|
||||
const nextVersion = getNextVersionNumber(previousVersion, channel)
|
||||
|
||||
console.log(`Creating release branch for "${nextVersion}"...`)
|
||||
createReleaseBranch(nextVersion)
|
||||
console.log(`Done!`)
|
||||
|
||||
console.log(`Setting app version to "${nextVersion}" in app/package.json...`)
|
||||
|
||||
try {
|
||||
|
|
|
@ -960,10 +960,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
|
||||
integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
|
||||
|
||||
"@types/event-kit@^1.2.28":
|
||||
version "1.2.32"
|
||||
resolved "https://registry.yarnpkg.com/@types/event-kit/-/event-kit-1.2.32.tgz#068cbdc69e8c969afae8c9f6e3a51ea4b1b5522e"
|
||||
integrity sha512-v+dvA/8Uqp5OfLkd8PRPCZgIWyfz2n14yZdyHvMkZG3Kl4d5K/7son3w18p9bh8zXx3FeT5/DZnu3cM8dWh3sg==
|
||||
"@types/event-kit@^2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/event-kit/-/event-kit-2.4.1.tgz#cc00a9b80bae9a387ea60d5c9031b5eb490cfa34"
|
||||
integrity sha512-ZwGAHGQSj+ZRmqueYyjfIrXRfwLd5A2Z0mfzpP40M9F+BlbUI0v7qsVVFHcWNTE+rq5TLzHeFhEGwFp1zZBSUQ==
|
||||
|
||||
"@types/events@*":
|
||||
version "1.2.0"
|
||||
|
|
Loading…
Reference in a new issue