diff --git a/.github/no-response.yml b/.github/no-response.yml deleted file mode 100644 index f182b078b0..0000000000 --- a/.github/no-response.yml +++ /dev/null @@ -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. diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 0000000000..44d543dc91 --- /dev/null +++ b/.github/workflows/no-response.yml @@ -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 diff --git a/app/package.json b/app/package.json index 8a11ea27a5..49f4851283 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/highlighter/globals.d.ts b/app/src/highlighter/globals.d.ts index f17b1b2349..bdefb21dfb 100644 --- a/app/src/highlighter/globals.d.ts +++ b/app/src/highlighter/globals.d.ts @@ -19,6 +19,7 @@ declare namespace CodeMirror { interface StringStreamContext { lines: string[] line: number + lookAhead: (n: number) => string } /** diff --git a/app/src/highlighter/index.ts b/app/src/highlighter/index.ts index 76fc1ddf0a..63885be640 100644 --- a/app/src/highlighter/index.ts +++ b/app/src/highlighter/index.ts @@ -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()) { diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 4f6e6233c9..d02694c324 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -553,6 +553,20 @@ export interface ICommitSelection { /** The commits currently selected in the app */ readonly shas: ReadonlyArray + /** + * 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 diff --git a/app/src/lib/databases/base-database.ts b/app/src/lib/databases/base-database.ts index 1189901eea..c1ccfe2c08 100644 --- a/app/src/lib/databases/base-database.ts +++ b/app/src/lib/databases/base-database.ts @@ -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 + upgrade?: (t: Transaction) => Promise ) { if (this.schemaVersion != null && this.schemaVersion < version) { return diff --git a/app/src/lib/databases/issues-database.ts b/app/src/lib/databases/issues-database.ts index 4eafd591d5..1bf8c95225 100644 --- a/app/src/lib/databases/issues-database.ts +++ b/app/src/lib/databases/issues-database.ts @@ -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() diff --git a/app/src/lib/databases/repositories-database.ts b/app/src/lib/databases/repositories-database.ts index 36b6e7fbf8..76fbebd833 100644 --- a/app/src/lib/databases/repositories-database.ts +++ b/app/src/lib/databases/repositories-database.ts @@ -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( 'gitHubRepositories' ) @@ -164,7 +164,7 @@ function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) { }) } -async function ensureNoUndefinedParentID(tx: Dexie.Transaction) { +async function ensureNoUndefinedParentID(tx: Transaction) { return tx .table('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('owners') const ghReposTable = tx.table( 'gitHubRepositories' diff --git a/app/src/lib/editors/darwin.ts b/app/src/lib/editors/darwin.ts index 03c47d3790..15dc96f2d1 100644 --- a/app/src/lib/editors/darwin.ts +++ b/app/src/lib/editors/darwin.ts @@ -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'], diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts index b8b2698548..d24776e5ee 100644 --- a/app/src/lib/editors/win32.ts +++ b/app/src/lib/editors/win32.ts @@ -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'), diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index 11d5d9ca55..67d44dacb1 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -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() +} diff --git a/app/src/lib/git/diff-index.ts b/app/src/lib/git/diff-index.ts index 599b425c7d..e8b6fd5351 100644 --- a/app/src/lib/git/diff-index.ts +++ b/app/src/lib/git/diff-index.ts @@ -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 diff --git a/app/src/lib/git/diff.ts b/app/src/lib/git/diff.ts index 729954e199..b2e0080a07 100644 --- a/app/src/lib/git/diff.ts +++ b/app/src/lib/git/diff.ts @@ -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, + hideWhitespaceInDiff: boolean = false, + useNullTreeSHA: boolean = false +): Promise { + 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, + useNullTreeSHA: boolean = false +): Promise<{ + files: ReadonlyArray + 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 { 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 { 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 { 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) } /** diff --git a/app/src/lib/git/log.ts b/app/src/lib/git/log.ts index 0ae17d33cd..0de475dc8f 100644 --- a/app/src/lib/git/log.ts +++ b/app/src/lib/git/log.ts @@ -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 { diff --git a/app/src/lib/ipc-shared.ts b/app/src/lib/ipc-shared.ts index 477e69765f..07ffdb7cc0 100644 --- a/app/src/lib/ipc-shared.ts +++ b/app/src/lib/ipc-shared.ts @@ -76,6 +76,7 @@ export type RequestChannels = { 'set-native-theme-source': (themeName: ThemeSource) => void 'focus-window': () => void 'notification-event': NotificationCallback + 'set-window-zoom-factor': (zoomFactor: number) => void } /** diff --git a/app/src/lib/local-storage.ts b/app/src/lib/local-storage.ts index 16f1eb2dc3..efc6c40bba 100644 --- a/app/src/lib/local-storage.ts +++ b/app/src/lib/local-storage.ts @@ -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. diff --git a/app/src/lib/markdown-filters/markdown-filter.ts b/app/src/lib/markdown-filters/markdown-filter.ts index d954d7104f..0151622616 100644 --- a/app/src/lib/markdown-filters/markdown-filter.ts +++ b/app/src/lib/markdown-filters/markdown-filter.ts @@ -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 - 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 { - const nodeFilters = buildCustomMarkDownNodeFilterPipe( - options.emoji, - options.repository, - options.markdownContext - ) - return applyNodeFilters(nodeFilters, parsedMarkdown) +): void { + const nodeFilters = buildCustomMarkDownNodeFilterPipe(options) + applyNodeFilters(nodeFilters, markdownEmitter) } diff --git a/app/src/lib/markdown-filters/node-filter.ts b/app/src/lib/markdown-filters/node-filter.ts index 4870aa39af..83ad978260 100644 --- a/app/src/lib/markdown-filters/node-filter.ts +++ b/app/src/lib/markdown-filters/node-filter.ts @@ -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 | null> } +export interface ICustomMarkdownFilterOptions { + emoji: Map + 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, - repository?: GitHubRepository, - markdownContext?: MarkdownContext - ): ReadonlyArray => { + (options: ICustomMarkdownFilterOptions): ReadonlyArray => { + const { emoji, repository, markdownContext } = options const filterPipe: Array = [] if (repository !== undefined) { @@ -104,15 +106,24 @@ export const buildCustomMarkDownNodeFilterPipe = memoizeOne( */ export async function applyNodeFilters( nodeFilters: ReadonlyArray, - parsedMarkdown: string -): Promise { - const mdDoc = new DOMParser().parseFromString(parsedMarkdown, 'text/html') + markdownEmitter: MarkdownEmitter +): Promise { + 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 } /** diff --git a/app/src/lib/ssh/ssh-key-passphrase.ts b/app/src/lib/ssh/ssh-key-passphrase.ts index 3259b48cb1..7782a1f530 100644 --- a/app/src/lib/ssh/ssh-key-passphrase.ts +++ b/app/src/lib/ssh/ssh-key-passphrase.ts @@ -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() - /** * 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 - ) -} diff --git a/app/src/lib/ssh/ssh-secret-storage.ts b/app/src/lib/ssh/ssh-secret-storage.ts new file mode 100644 index 0000000000..7f651591f5 --- /dev/null +++ b/app/src/lib/ssh/ssh-secret-storage.ts @@ -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() + +/** + * 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) +} diff --git a/app/src/lib/ssh/ssh-user-password.ts b/app/src/lib/ssh/ssh-user-password.ts new file mode 100644 index 0000000000..4bb25c4301 --- /dev/null +++ b/app/src/lib/ssh/ssh-user-password.ts @@ -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 + ) +} diff --git a/app/src/lib/stores/ahead-behind-store.ts b/app/src/lib/stores/ahead-behind-store.ts index c541af9315..12dbc4d219 100644 --- a/app/src/lib/stores/ahead-behind-store.ts +++ b/app/src/lib/stores/ahead-behind-store.ts @@ -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(() => {}) diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 4a3aec66f1..3bc0ff1659 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -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 { } 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 { this.windowZoomFactor = zoomFactor if (zoomFactor !== current) { + setNumber('zoom-factor', zoomFactor) this.updateResizableConstraints() this.emitUpdate() } @@ -1075,7 +1111,8 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _changeCommitSelection( repository: Repository, - shas: ReadonlyArray + shas: ReadonlyArray, + isContiguous: boolean ): Promise { const { commitSelection } = this.repositoryStateCache.get(repository) @@ -1088,6 +1125,7 @@ export class AppStore extends TypedBaseStore { 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 { ) { 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 { } } - 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 { ): Promise { 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 { // 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 { 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 { } } - // 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 } diff --git a/app/src/lib/stores/commit-status-store.ts b/app/src/lib/stores/commit-status-store.ts index a3f1f913ad..a43ab95bc1 100644 --- a/app/src/lib/stores/commit-status-store.ts +++ b/app/src/lib/stores/commit-status-store.ts @@ -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, diff --git a/app/src/lib/stores/repositories-store.ts b/app/src/lib/stores/repositories-store.ts index 3de0de6475..466d70c920 100644 --- a/app/src/lib/stores/repositories-store.ts +++ b/app/src/lib/stores/repositories-store.ts @@ -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) { diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index c1cb5f1770..d7e2586657 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -178,6 +178,7 @@ function getInitialRepositoryState(): IRepositoryState { return { commitSelection: { shas: [], + isContiguous: true, file: null, changesetData: { files: [], linesAdded: 0, linesDeleted: 0 }, diff: null, diff --git a/app/src/lib/trampoline/trampoline-askpass-handler.ts b/app/src/lib/trampoline/trampoline-askpass-handler.ts index 01ee12eccd..68d6cc62a9 100644 --- a/app/src/lib/trampoline/trampoline-askpass-handler.ts +++ b/app/src/lib/trampoline/trampoline-askpass-handler.ts @@ -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 diff --git a/app/src/lib/trampoline/trampoline-environment.ts b/app/src/lib/trampoline/trampoline-environment.ts index 222e18252b..e111a67196 100644 --- a/app/src/lib/trampoline/trampoline-environment.ts +++ b/app/src/lib/trampoline/trampoline-environment.ts @@ -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( ...sshEnv, }) - await storePendingSSHKeyPassphrase(token) + await storePendingSSHSecret(token) return result } finally { - removePendingSSHKeyPassphraseToStore(token) + removePendingSSHSecretToStore(token) } }) } diff --git a/app/src/lib/trampoline/trampoline-ui-helper.ts b/app/src/lib/trampoline/trampoline-ui-helper.ts index 52ea64098c..7e6fe66d5e 100644 --- a/app/src/lib/trampoline/trampoline-ui-helper.ts +++ b/app/src/lib/trampoline/trampoline-ui-helper.ts @@ -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 { + ): Promise { 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 { + return new Promise(resolve => { + this.dispatcher.showPopup({ + type: PopupType.SSHUserPassword, + username, + onSubmit: (password, storePassword) => + resolve({ secret: password, storeSecret: storePassword }), }) }) } diff --git a/app/src/main-process/app-window.ts b/app/src/main-process/app-window.ts index 5d03367530..54fff826f0 100644 --- a/app/src/main-process/app-window.ts +++ b/app/src/main-process/app-window.ts @@ -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. */ diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts index e66839461f..114480145f 100644 --- a/app/src/main-process/main.ts +++ b/app/src/main-process/main.ts @@ -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. diff --git a/app/src/main-process/squirrel-updater.ts b/app/src/main-process/squirrel-updater.ts index 154f97dca8..eb0ef380a4 100644 --- a/app/src/main-process/squirrel-updater.ts +++ b/app/src/main-process/squirrel-updater.ts @@ -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. /** diff --git a/app/src/models/commit-identity.ts b/app/src/models/commit-identity.ts index d5c9246848..d66d681631 100644 --- a/app/src/models/commit-identity.ts +++ b/app/src/models/commit-identity.ts @@ -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}>` + } } diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index a312292002..fdf1058b3a 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -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 diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 4e0d24fcf5..6140020c25 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -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 { /> ) } + case PopupType.SSHUserPassword: { + return ( + + ) + } case PopupType.PullRequestChecksFailed: { return ( { - private statusSubscription: IDisposable | null = null + private statusSubscription: DisposableLike | null = null public constructor(props: ICIStatusProps) { super(props) diff --git a/app/src/ui/changes/changes-list.tsx b/app/src/ui/changes/changes-list.tsx index a7e0869605..9970ca7b23 100644 --- a/app/src/ui/changes/changes-list.tsx +++ b/app/src/ui/changes/changes-list.tsx @@ -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), diff --git a/app/src/ui/check-runs/ci-check-run-popover.tsx b/app/src/ui/check-runs/ci-check-run-popover.tsx index 695c7e7837..9a1a47dcdb 100644 --- a/app/src/ui/check-runs/ci-check-run-popover.tsx +++ b/app/src/ui/check-runs/ci-check-run-popover.tsx @@ -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) diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 4d86966124..dfa8a15813 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -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 + shas: ReadonlyArray, + isContiguous: boolean ): Promise { - 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( diff --git a/app/src/ui/history/commit-list-item.tsx b/app/src/ui/history/commit-list-item.tsx index c93a14af26..374a299173 100644 --- a/app/src/ui/history/commit-list-item.tsx +++ b/app/src/ui/history/commit-list-item.tsx @@ -174,7 +174,7 @@ export class CommitListItem extends React.PureComponent<
{renderRelativeTime(date)}
diff --git a/app/src/ui/history/commit-list.tsx b/app/src/ui/history/commit-list.tsx index af78a79d4e..3943ed78ee 100644 --- a/app/src/ui/history/commit-list.tsx +++ b/app/src/ui/history/commit-list.tsx @@ -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) => void + readonly onCommitsSelected: ( + commits: ReadonlyArray, + 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 { // 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) { + 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 { const sha = this.props.commitSHAs[row] const commit = this.props.commitLookup.get(sha) if (commit) { - this.props.onCommitsSelected([commit]) + this.props.onCommitsSelected([commit], true) } } diff --git a/app/src/ui/history/commit-summary.tsx b/app/src/ui/history/commit-summary.tsx index 8eef26f5b6..f16c83a66a 100644 --- a/app/src/ui/history/commit-summary.tsx +++ b/app/src/ui/history/commit-summary.tsx @@ -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 readonly changesetData: IChangesetData readonly emoji: Map @@ -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< @@ -346,7 +382,7 @@ export class CommitSummary extends React.Component< interactive={true} direction={TooltipDirection.SOUTH} > - {shortSHA} + {this.getShaRef(true)} @@ -382,7 +418,7 @@ export class CommitSummary extends React.Component< private renderShaTooltip() { return ( <> - {this.props.commit.sha} + {this.getShaRef()} ) @@ -390,7 +426,7 @@ export class CommitSummary extends React.Component< private onCopyShaButtonClick = (e: React.MouseEvent) => { 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 diff --git a/app/src/ui/history/compare-branch-list-item.tsx b/app/src/ui/history/compare-branch-list-item.tsx index 30a01d7195..4adb6949f5 100644 --- a/app/src/ui/history/compare-branch-list-item.tsx +++ b/app/src/ui/history/compare-branch-list-item.tsx @@ -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) diff --git a/app/src/ui/history/compare.tsx b/app/src/ui/history/compare.tsx index c2fb522e29..7d74d46edd 100644 --- a/app/src/ui/history/compare.tsx +++ b/app/src/ui/history/compare.tsx @@ -460,10 +460,14 @@ export class CompareSidebar extends React.Component< } } - private onCommitsSelected = (commits: ReadonlyArray) => { + private onCommitsSelected = ( + commits: ReadonlyArray, + isContiguous: boolean + ) => { this.props.dispatcher.changeCommitSelection( this.props.repository, - commits.map(c => c.sha) + commits.map(c => c.sha), + isContiguous ) this.loadChangedFilesScheduler.queue(() => { diff --git a/app/src/ui/history/index.ts b/app/src/ui/history/index.ts index 5314aa21a1..7603ced06a 100644 --- a/app/src/ui/history/index.ts +++ b/app/src/ui/history/index.ts @@ -1,2 +1,2 @@ -export { SelectedCommit } from './selected-commit' +export { SelectedCommits } from './selected-commit' export { CompareSidebar } from './compare' diff --git a/app/src/ui/history/selected-commit.tsx b/app/src/ui/history/selected-commit.tsx index 9b004a0538..3b31b63dff 100644 --- a/app/src/ui/history/selected-commit.tsx +++ b/app/src/ui/history/selected-commit.tsx @@ -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 - readonly selectedCommit: Commit | null - readonly isLocal: boolean + readonly selectedCommits: ReadonlyArray + readonly localCommitSHAs: ReadonlyArray 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) { return ( 1 && + (!isContiguous || !enableMultiCommitDiffs()) + ) { + return this.renderMultipleCommitsBlankSlate() } - if (commit == null) { + if (selectedCommits.length === 0) { return } @@ -265,7 +265,7 @@ export class SelectedCommit extends React.Component< return (
- {this.renderCommitSummary(commit)} + {this.renderCommitSummary(selectedCommits)}
} - 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<
-

Unable to display diff when multiple commits are selected.

+

+ Unable to display diff when multiple{' '} + {enableMultiCommitDiffs() ? 'non-consecutive ' : ' '}commits are + selected. +

You can:
    -
  • Select a single commit to view a diff.
  • +
  • + Select a single commit{' '} + {enableMultiCommitDiffs() + ? 'or a range of consecutive commits ' + : ' '} + to view a diff. +
  • Drag the commits to the branch menu to cherry-pick them.
  • +
  • Drag the commits to squash or reorder them.
  • Right click on multiple commits to see options.
@@ -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) } } diff --git a/app/src/ui/lib/avatar-stack.tsx b/app/src/ui/lib/avatar-stack.tsx index 4b3d3da4b6..f0681292ef 100644 --- a/app/src/ui/lib/avatar-stack.tsx +++ b/app/src/ui/lib/avatar-stack.tsx @@ -25,7 +25,10 @@ export class AvatarStack extends React.Component { 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(
) } @@ -35,7 +38,8 @@ export class AvatarStack extends React.Component { 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 ( diff --git a/app/src/ui/lib/commit-attribution.tsx b/app/src/ui/lib/commit-attribution.tsx index 2a7c775b94..d0fbc40980 100644 --- a/app/src/ui/lib/commit-attribution.tsx +++ b/app/src/ui/lib/commit-attribution.tsx @@ -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 /** * 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() + for (const commit of commits) { + const { author, committer, coAuthors } = commit - const authors: Array = 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 = committerAttribution + ? [author, committer, ...coAuthors] + : [author, ...coAuthors] + + for (const a of authors) { + if (!allAuthors.has(a.toString())) { + allAuthors.set(a.toString(), a) + } + } + } return ( - {this.renderAuthors(authors)} + {this.renderAuthors(Array.from(allAuthors.values()))} ) } diff --git a/app/src/ui/lib/context-menu.ts b/app/src/ui/lib/context-menu.ts index c92d86aee3..6f96ecb139 100644 --- a/app/src/ui/lib/context-menu.ts +++ b/app/src/ui/lib/context-menu.ts @@ -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' diff --git a/app/src/ui/lib/sandboxed-markdown.tsx b/app/src/ui/lib/sandboxed-markdown.tsx index c5d1839029..561f46a09c 100644 --- a/app/src/ui/lib/sandboxed-markdown.tsx +++ b/app/src/ui/lib/sandboxed-markdown.tsx @@ -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 = ` @@ -313,7 +336,7 @@ export class SandboxedMarkdown extends React.PureComponent<
- ${filteredHTML} + ${markdown}
diff --git a/app/src/ui/main-process-proxy.ts b/app/src/ui/main-process-proxy.ts index 2fa5dee7bb..710c04378c 100644 --- a/app/src/ui/main-process-proxy.ts +++ b/app/src/ui/main-process-proxy.ts @@ -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) diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index c47874c6bf..9d9ac732c0 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -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 ( - 1} showDragOverlay={showDragOverlay} /> ) diff --git a/app/src/ui/ssh/ssh-user-password.tsx b/app/src/ui/ssh/ssh-user-password.tsx new file mode 100644 index 0000000000..c78b9fe175 --- /dev/null +++ b/app/src/ui/ssh/ssh-user-password.tsx @@ -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 ( + + + + + + + + + + + + + + ) + } + + private onRememberPasswordChanged = ( + event: React.FormEvent + ) => { + 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) + } +} diff --git a/app/styles/ui/_avatar-stack.scss b/app/styles/ui/_avatar-stack.scss index 1b79570ead..52bfedf5d9 100644 --- a/app/styles/ui/_avatar-stack.scss +++ b/app/styles/ui/_avatar-stack.scss @@ -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 { diff --git a/app/styles/ui/history/_commit-summary.scss b/app/styles/ui/history/_commit-summary.scss index 3b5b31ac84..704ba0762e 100644 --- a/app/styles/ui/history/_commit-summary.scss +++ b/app/styles/ui/history/_commit-summary.scss @@ -25,7 +25,7 @@ &.expanded { .commit-summary-description-scroll-view { - max-height: none; + max-height: 400px; overflow: auto; &:before { diff --git a/app/yarn.lock b/app/yarn.lock index 5b0c6db1e3..7c80ee90e7 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -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" diff --git a/changelog.json b/changelog.json index 4903f7c107..e1388ed14c 100644 --- a/changelog.json +++ b/changelog.json @@ -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!", diff --git a/docs/process/teams.md b/docs/process/teams.md index ecaa8e0885..adde6c63ff 100644 --- a/docs/process/teams.md +++ b/docs/process/teams.md @@ -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 diff --git a/docs/technical/editor-integration.md b/docs/technical/editor-integration.md index f8ed551741..b6b3084ba5 100644 --- a/docs/technical/editor-integration.md +++ b/docs/technical/editor-integration.md @@ -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: diff --git a/package.json b/package.json index 12e0162338..bc585167be 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/script/changelog/api.ts b/script/changelog/api.ts index 9cd75d7522..f40f5e51f9 100644 --- a/script/changelog/api.ts +++ b/script/changelog/api.ts @@ -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 { pullRequest(number: ${id}) { title body + headRefName } } } diff --git a/script/changelog/parser.ts b/script/changelog/parser.ts index 9151c4beab..d7afb1f648 100644 --- a/script/changelog/parser.ts +++ b/script/changelog/parser.ts @@ -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) diff --git a/script/changelog/test/parser-test.ts b/script/changelog/test/parser-test.ts index 2d04249a2d..4b771b0c8a 100644 --- a/script/changelog/test/parser-test.ts +++ b/script/changelog/test/parser-test.ts @@ -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() + }) + }) }) diff --git a/script/draft-release/run.ts b/script/draft-release/run.ts index 9aa30be162..fdbfc4b706 100644 --- a/script/draft-release/run.ts +++ b/script/draft-release/run.ts @@ -51,6 +51,20 @@ async function getLatestRelease(options: { return latestTag instanceof SemVer ? latestTag.raw : latestTag } +async function createReleaseBranch(version: string): Promise { + 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): Promise { }) 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 { diff --git a/yarn.lock b/yarn.lock index d9e6bee636..b9f0b26949 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"