Merge branch 'development' into releases/3.1.7-beta1

This commit is contained in:
Sergio Padrino 2023-02-15 15:23:08 +01:00 committed by GitHub
commit ccddcaa46e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1456 additions and 225 deletions

View file

@ -32,19 +32,7 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: yarn
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v3
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
${{ runner.os }}-yarn-
# This step can be removed as soon as official Windows arm64 builds are published: # This step can be removed as soon as official Windows arm64 builds are published:
# https://github.com/nodejs/build/issues/2450#issuecomment-705853342 # https://github.com/nodejs/build/issues/2450#issuecomment-705853342

View file

@ -508,6 +508,15 @@ export interface IAPIPullRequestReview {
| 'CHANGES_REQUESTED' | 'CHANGES_REQUESTED'
} }
/** Represents both issue comments and PR review comments */
export interface IAPIComment {
readonly id: number
readonly body: string
readonly html_url: string
readonly user: IAPIIdentity
readonly created_at: string
}
/** The metadata about a GitHub server. */ /** The metadata about a GitHub server. */
export interface IServerMetadata { export interface IServerMetadata {
/** /**
@ -1047,6 +1056,64 @@ export class API {
} }
} }
/** Fetches all reviews from a given pull request. */
public async fetchPullRequestReviews(
owner: string,
name: string,
prNumber: string
) {
try {
const path = `/repos/${owner}/${name}/pulls/${prNumber}/reviews`
const response = await this.request('GET', path)
return await parsedResponse<IAPIPullRequestReview[]>(response)
} catch (e) {
log.debug(
`failed fetching PR reviews for ${owner}/${name}/pulls/${prNumber}`,
e
)
return []
}
}
/** Fetches all review comments from a given pull request. */
public async fetchPullRequestReviewComments(
owner: string,
name: string,
prNumber: string,
reviewId: string
) {
try {
const path = `/repos/${owner}/${name}/pulls/${prNumber}/reviews/${reviewId}/comments`
const response = await this.request('GET', path)
return await parsedResponse<IAPIComment[]>(response)
} catch (e) {
log.debug(
`failed fetching PR review comments for ${owner}/${name}/pulls/${prNumber}`,
e
)
return []
}
}
/** Fetches all comments from a given pull request. */
public async fetchPullRequestComments(
owner: string,
name: string,
prNumber: string
) {
try {
const path = `/repos/${owner}/${name}/pulls/${prNumber}/comments`
const response = await this.request('GET', path)
return await parsedResponse<IAPIComment[]>(response)
} catch (e) {
log.debug(
`failed fetching PR comments for ${owner}/${name}/pulls/${prNumber}`,
e
)
return []
}
}
/** /**
* Get the combined status for the given ref. * Get the combined status for the given ref.
*/ */

View file

@ -71,6 +71,10 @@ const editors: IDarwinExternalEditor[] = [
name: 'PyCharm Community Edition', name: 'PyCharm Community Edition',
bundleIdentifiers: ['com.jetbrains.pycharm.ce'], bundleIdentifiers: ['com.jetbrains.pycharm.ce'],
}, },
{
name: 'DataSpell',
bundleIdentifiers: ['com.jetbrains.DataSpell'],
},
{ {
name: 'RubyMine', name: 'RubyMine',
bundleIdentifiers: ['com.jetbrains.RubyMine'], bundleIdentifiers: ['com.jetbrains.RubyMine'],

View file

@ -23,9 +23,26 @@ const editors: ILinuxExternalEditor[] = [
name: 'Neovim', name: 'Neovim',
paths: ['/usr/bin/nvim'], paths: ['/usr/bin/nvim'],
}, },
{
name: 'Neovim-Qt',
paths: ['/usr/bin/nvim-qt'],
},
{
name: 'Neovide',
paths: ['/usr/bin/neovide'],
},
{
name: 'gVim',
paths: ['/usr/bin/gvim'],
},
{ {
name: 'Visual Studio Code', name: 'Visual Studio Code',
paths: ['/usr/share/code/bin/code', '/snap/bin/code', '/usr/bin/code'], paths: [
'/usr/share/code/bin/code',
'/snap/bin/code',
'/usr/bin/code',
'/mnt/c/Program Files/Microsoft VS Code/bin/code',
],
}, },
{ {
name: 'Visual Studio Code (Insiders)', name: 'Visual Studio Code (Insiders)',
@ -33,7 +50,11 @@ const editors: ILinuxExternalEditor[] = [
}, },
{ {
name: 'VSCodium', name: 'VSCodium',
paths: ['/usr/bin/codium', '/var/lib/flatpak/app/com.vscodium.codium'], paths: [
'/usr/bin/codium',
'/var/lib/flatpak/app/com.vscodium.codium',
'/usr/share/vscodium-bin/bin/codium',
],
}, },
{ {
name: 'Sublime Text', name: 'Sublime Text',
@ -63,17 +84,69 @@ const editors: ILinuxExternalEditor[] = [
paths: ['/usr/bin/lite-xl'], paths: ['/usr/bin/lite-xl'],
}, },
{ {
name: 'Jetbrains PhpStorm', name: 'JetBrains PhpStorm',
paths: ['/snap/bin/phpstorm'], paths: [
'/snap/bin/phpstorm',
'.local/share/JetBrains/Toolbox/scripts/phpstorm',
],
}, },
{ {
name: 'Jetbrains WebStorm', name: 'JetBrains WebStorm',
paths: ['/snap/bin/webstorm'], paths: [
'/snap/bin/webstorm',
'.local/share/JetBrains/Toolbox/scripts/webstorm',
],
},
{
name: 'IntelliJ IDEA',
paths: ['/snap/bin/idea', '.local/share/JetBrains/Toolbox/scripts/idea'],
},
{
name: 'JetBrains PyCharm',
paths: [
'/snap/bin/pycharm',
'.local/share/JetBrains/Toolbox/scripts/pycharm',
],
},
{
name: 'Android Studio',
paths: [
'/snap/bin/studio',
'.local/share/JetBrains/Toolbox/scripts/studio',
],
}, },
{ {
name: 'Emacs', name: 'Emacs',
paths: ['/snap/bin/emacs', '/usr/local/bin/emacs', '/usr/bin/emacs'], paths: ['/snap/bin/emacs', '/usr/local/bin/emacs', '/usr/bin/emacs'],
}, },
{
name: 'Kate',
paths: ['/usr/bin/kate'],
},
{
name: 'GEdit',
paths: ['/usr/bin/gedit'],
},
{
name: 'GNOME Text Editor',
paths: ['/usr/bin/gnome-text-editor'],
},
{
name: 'GNOME Builder',
paths: ['/usr/bin/gnome-builder'],
},
{
name: 'Notepadqq',
paths: ['/usr/bin/notepadqq'],
},
{
name: 'Geany',
paths: ['/usr/bin/geany'],
},
{
name: 'Mousepad',
paths: ['/usr/bin/mousepad'],
},
] ]
async function getAvailablePath(paths: string[]): Promise<string | null> { async function getAvailablePath(paths: string[]): Promise<string | null> {

View file

@ -471,6 +471,14 @@ const editors: WindowsExternalEditor[] = [
displayNamePrefix: 'Fleet ', displayNamePrefix: 'Fleet ',
publishers: ['JetBrains s.r.o.'], publishers: ['JetBrains s.r.o.'],
}, },
{
name: 'JetBrains DataSpell',
registryKeys: registryKeysForJetBrainsIDE('DataSpell'),
executableShimPaths: executableShimPathsForJetBrainsIDE('dataspell'),
jetBrainsToolboxScriptName: 'dataspell',
displayNamePrefix: 'DataSpell ',
publishers: ['JetBrains s.r.o.'],
},
] ]
function getKeyOrEmpty( function getKeyOrEmpty(

View file

@ -28,13 +28,23 @@ export interface IDesktopPullRequestReviewSubmitAliveEvent {
readonly pull_request_number: number readonly pull_request_number: number
readonly state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' readonly state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED'
readonly review_id: string readonly review_id: string
readonly number_of_comments: number }
export interface IDesktopPullRequestCommentAliveEvent {
readonly type: 'pr-comment'
readonly subtype: 'review-comment' | 'issue-comment'
readonly timestamp: number
readonly owner: string
readonly repo: string
readonly pull_request_number: number
readonly comment_id: string
} }
/** Represents an Alive event relevant to Desktop. */ /** Represents an Alive event relevant to Desktop. */
export type DesktopAliveEvent = export type DesktopAliveEvent =
| IDesktopChecksFailedAliveEvent | IDesktopChecksFailedAliveEvent
| IDesktopPullRequestReviewSubmitAliveEvent | IDesktopPullRequestReviewSubmitAliveEvent
| IDesktopPullRequestCommentAliveEvent
interface IAliveSubscription { interface IAliveSubscription {
readonly account: Account readonly account: Account
readonly subscription: Subscription<AliveStore> readonly subscription: Subscription<AliveStore>
@ -247,7 +257,11 @@ export class AliveStore {
} }
const data = event.data as any as DesktopAliveEvent const data = event.data as any as DesktopAliveEvent
if (data.type === 'pr-checks-failed' || data.type === 'pr-review-submit') { if (
data.type === 'pr-checks-failed' ||
data.type === 'pr-review-submit' ||
data.type === 'pr-comment'
) {
this.emitter.emit(this.ALIVE_EVENT_RECEIVED_EVENT, data) this.emitter.emit(this.ALIVE_EVENT_RECEIVED_EVENT, data)
} }
} }

View file

@ -7300,8 +7300,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private onPullRequestReviewSubmitNotification = async ( private onPullRequestReviewSubmitNotification = async (
repository: RepositoryWithGitHubRepository, repository: RepositoryWithGitHubRepository,
pullRequest: PullRequest, pullRequest: PullRequest,
review: ValidNotificationPullRequestReview, review: ValidNotificationPullRequestReview
numberOfComments: number
) => { ) => {
const selectedRepository = const selectedRepository =
this.selectedRepository ?? (await this._selectRepository(repository)) this.selectedRepository ?? (await this._selectRepository(repository))
@ -7322,7 +7321,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
review, review,
pullRequest, pullRequest,
repository, repository,
numberOfComments,
}) })
} }

View file

@ -0,0 +1,151 @@
import { GitHubRepository } from '../../models/github-repository'
import { PullRequest } from '../../models/pull-request'
import { RepositoryWithGitHubRepository } from '../../models/repository'
import { API, IAPIComment } from '../api'
import {
isValidNotificationPullRequestReview,
ValidNotificationPullRequestReview,
} from '../valid-notification-pull-request-review'
import { AccountsStore } from './accounts-store'
import { NotificationsStore } from './notifications-store'
import { PullRequestCoordinator } from './pull-request-coordinator'
/**
* This class allows the TestNotifications dialog to fetch real data to simulate
* notifications.
*/
export class NotificationsDebugStore {
public constructor(
private readonly accountsStore: AccountsStore,
private readonly notificationsStore: NotificationsStore,
private readonly pullRequestCoordinator: PullRequestCoordinator
) {}
private async getAccountForRepository(repository: GitHubRepository) {
const { endpoint } = repository
const accounts = await this.accountsStore.getAll()
return accounts.find(a => a.endpoint === endpoint) ?? null
}
private async getAPIForRepository(repository: GitHubRepository) {
const account = await this.getAccountForRepository(repository)
if (account === null) {
return null
}
return API.fromAccount(account)
}
/** Fetch all pull requests for the given repository. */
public async getPullRequests(repository: RepositoryWithGitHubRepository) {
return this.pullRequestCoordinator.getAllPullRequests(repository)
}
/** Fetch all reviews for the given pull request. */
public async getPullRequestReviews(
repository: RepositoryWithGitHubRepository,
pullRequestNumber: number
) {
const api = await this.getAPIForRepository(repository.gitHubRepository)
if (api === null) {
return []
}
const ghRepository = repository.gitHubRepository
const reviews = await api.fetchPullRequestReviews(
ghRepository.owner.login,
ghRepository.name,
pullRequestNumber.toString()
)
return reviews.filter(isValidNotificationPullRequestReview)
}
/** Fetch all comments (issue and review comments) for the given pull request. */
public async getPullRequestComments(
repository: RepositoryWithGitHubRepository,
pullRequestNumber: number
) {
const api = await this.getAPIForRepository(repository.gitHubRepository)
if (api === null) {
return []
}
const ghRepository = repository.gitHubRepository
const issueComments = await api.fetchPullRequestComments(
ghRepository.owner.login,
ghRepository.name,
pullRequestNumber.toString()
)
const issueCommentIds = new Set(issueComments.map(c => c.id))
// Fetch review comments of type COMMENTED and with no body
const allReviews = await api.fetchPullRequestReviews(
ghRepository.owner.login,
ghRepository.name,
pullRequestNumber.toString()
)
const commentedReviewsWithNoBody = allReviews.filter(
review => review.state === 'COMMENTED' && !review.body
)
const allReviewComments = await Promise.all(
commentedReviewsWithNoBody.map(review =>
api.fetchPullRequestReviewComments(
ghRepository.owner.login,
ghRepository.name,
pullRequestNumber.toString(),
review.id.toString()
)
)
)
// Only reviews with one comment, and that comment is not an issue comment
const singleReviewComments = allReviewComments
.flatMap(comments => (comments.length === 1 ? comments : []))
.filter(comment => !issueCommentIds.has(comment.id))
return [...issueComments, ...singleReviewComments]
}
/** Simulate a notification for the given pull request review. */
public simulatePullRequestReviewNotification(
repository: GitHubRepository,
pullRequest: PullRequest,
review: ValidNotificationPullRequestReview
) {
this.notificationsStore.simulateAliveEvent({
type: 'pr-review-submit',
timestamp: new Date(review.submitted_at).getTime(),
owner: repository.owner.login,
repo: repository.name,
pull_request_number: pullRequest.pullRequestNumber,
state: review.state,
review_id: review.id.toString(),
})
}
/** Simulate a notification for the given pull request comment. */
public simulatePullRequestCommentNotification(
repository: GitHubRepository,
pullRequest: PullRequest,
comment: IAPIComment,
isIssueComment: boolean
) {
this.notificationsStore.simulateAliveEvent({
type: 'pr-comment',
subtype: isIssueComment ? 'issue-comment' : 'review-comment',
timestamp: new Date(comment.created_at).getTime(),
owner: repository.owner.login,
repo: repository.name,
pull_request_number: pullRequest.pullRequestNumber,
comment_id: comment.id.toString(),
})
}
}

View file

@ -48,8 +48,7 @@ type OnChecksFailedCallback = (
type OnPullRequestReviewSubmitCallback = ( type OnPullRequestReviewSubmitCallback = (
repository: RepositoryWithGitHubRepository, repository: RepositoryWithGitHubRepository,
pullRequest: PullRequest, pullRequest: PullRequest,
review: ValidNotificationPullRequestReview, review: ValidNotificationPullRequestReview
numberOfComments: number
) => void ) => void
/** /**
@ -105,6 +104,12 @@ export class NotificationsStore {
public onNotificationEventReceived: NotificationCallback<DesktopAliveEvent> = public onNotificationEventReceived: NotificationCallback<DesktopAliveEvent> =
async (event, id, userInfo) => this.handleAliveEvent(userInfo, true) async (event, id, userInfo) => this.handleAliveEvent(userInfo, true)
public simulateAliveEvent(event: DesktopAliveEvent) {
if (__DEV__) {
this.handleAliveEvent(event, false)
}
}
private async handleAliveEvent( private async handleAliveEvent(
e: DesktopAliveEvent, e: DesktopAliveEvent,
skipNotification: boolean skipNotification: boolean
@ -175,12 +180,7 @@ export class NotificationsStore {
const onClick = () => { const onClick = () => {
this.statsStore.recordPullRequestReviewNotificationClicked(review.state) this.statsStore.recordPullRequestReviewNotificationClicked(review.state)
this.onPullRequestReviewSubmitCallback?.( this.onPullRequestReviewSubmitCallback?.(repository, pullRequest, review)
repository,
pullRequest,
review,
event.number_of_comments
)
} }
if (skipNotification) { if (skipNotification) {

View file

@ -729,7 +729,13 @@ function createWindow() {
electron: '>=1.2.1', electron: '>=1.2.1',
} }
const extensions = [REACT_DEVELOPER_TOOLS, ChromeLens] const axeDevTools = {
id: 'lhdoppojpmngadmnindnejefpokejbdd',
electron: '>=1.2.1',
Permissions: ['tabs', 'debugger'],
}
const extensions = [REACT_DEVELOPER_TOOLS, ChromeLens, axeDevTools]
for (const extension of extensions) { for (const extension of extensions) {
try { try {

View file

@ -89,6 +89,7 @@ export enum PopupType {
StartPullRequest = 'StartPullRequest', StartPullRequest = 'StartPullRequest',
Error = 'Error', Error = 'Error',
InstallingUpdate = 'InstallingUpdate', InstallingUpdate = 'InstallingUpdate',
TestNotifications = 'TestNotifications',
} }
interface IBasePopup { interface IBasePopup {
@ -361,7 +362,6 @@ export type PopupDetail =
repository: RepositoryWithGitHubRepository repository: RepositoryWithGitHubRepository
pullRequest: PullRequest pullRequest: PullRequest
review: ValidNotificationPullRequestReview review: ValidNotificationPullRequestReview
numberOfComments: number
shouldCheckoutBranch: boolean shouldCheckoutBranch: boolean
shouldChangeRepository: boolean shouldChangeRepository: boolean
} }
@ -389,5 +389,9 @@ export type PopupDetail =
| { | {
type: PopupType.InstallingUpdate type: PopupType.InstallingUpdate
} }
| {
type: PopupType.TestNotifications
repository: RepositoryWithGitHubRepository
}
export type Popup = IBasePopup & PopupDetail export type Popup = IBasePopup & PopupDetail

View file

@ -148,7 +148,6 @@ import { WarnForcePushDialog } from './multi-commit-operation/dialog/warn-force-
import { clamp } from '../lib/clamp' import { clamp } from '../lib/clamp'
import { generateRepositoryListContextMenu } from './repositories-list/repository-list-item-context-menu' import { generateRepositoryListContextMenu } from './repositories-list/repository-list-item-context-menu'
import * as ipcRenderer from '../lib/ipc-renderer' import * as ipcRenderer from '../lib/ipc-renderer'
import { showNotification } from '../lib/notifications/show-notification'
import { DiscardChangesRetryDialog } from './discard-changes/discard-changes-retry-dialog' import { DiscardChangesRetryDialog } from './discard-changes/discard-changes-retry-dialog'
import { generateDevReleaseSummary } from '../lib/release-notes' import { generateDevReleaseSummary } from '../lib/release-notes'
import { PullRequestReview } from './notifications/pull-request-review' import { PullRequestReview } from './notifications/pull-request-review'
@ -164,6 +163,8 @@ import { uuid } from '../lib/uuid'
import { InstallingUpdate } from './installing-update/installing-update' import { InstallingUpdate } from './installing-update/installing-update'
import { enableStackedPopups } from '../lib/feature-flag' import { enableStackedPopups } from '../lib/feature-flag'
import { DialogStackContext } from './dialog' import { DialogStackContext } from './dialog'
import { TestNotifications } from './test-notifications/test-notifications'
import { NotificationsDebugStore } from '../lib/stores/notifications-debug-store'
const MinuteInMilliseconds = 1000 * 60 const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60 const HourInMilliseconds = MinuteInMilliseconds * 60
@ -185,6 +186,7 @@ interface IAppProps {
readonly issuesStore: IssuesStore readonly issuesStore: IssuesStore
readonly gitHubUserStore: GitHubUserStore readonly gitHubUserStore: GitHubUserStore
readonly aheadBehindStore: AheadBehindStore readonly aheadBehindStore: AheadBehindStore
readonly notificationsDebugStore: NotificationsDebugStore
readonly startTime: number readonly startTime: number
} }
@ -489,10 +491,19 @@ export class App extends React.Component<IAppProps, IAppState> {
return return
} }
showNotification({ // if current repository is not repository with github repository, return
title: 'Test notification', const repository = this.getRepository()
body: 'Click here! This is a test notification', if (
onClick: () => this.props.dispatcher.showPopup({ type: PopupType.About }), repository == null ||
repository instanceof CloningRepository ||
!isRepositoryWithGitHubRepository(repository)
) {
return
}
this.props.dispatcher.showPopup({
type: PopupType.TestNotifications,
repository,
}) })
} }
@ -2262,14 +2273,13 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.PullRequestReview: { case PopupType.PullRequestReview: {
return ( return (
<PullRequestReview <PullRequestReview
key="pull-request-checks-failed" key="pull-request-review"
dispatcher={this.props.dispatcher} dispatcher={this.props.dispatcher}
shouldCheckoutBranch={popup.shouldCheckoutBranch} shouldCheckoutBranch={popup.shouldCheckoutBranch}
shouldChangeRepository={popup.shouldChangeRepository} shouldChangeRepository={popup.shouldChangeRepository}
repository={popup.repository} repository={popup.repository}
pullRequest={popup.pullRequest} pullRequest={popup.pullRequest}
review={popup.review} review={popup.review}
numberOfComments={popup.numberOfComments}
emoji={this.state.emoji} emoji={this.state.emoji}
accounts={this.state.accounts} accounts={this.state.accounts}
onSubmit={onPopupDismissedFn} onSubmit={onPopupDismissedFn}
@ -2374,6 +2384,17 @@ export class App extends React.Component<IAppProps, IAppState> {
/> />
) )
} }
case PopupType.TestNotifications: {
return (
<TestNotifications
key="test-notifications"
dispatcher={this.props.dispatcher}
notificationsDebugStore={this.props.notificationsDebugStore}
repository={popup.repository}
onDismissed={onPopupDismissedFn}
/>
)
}
default: default:
return assertNever(popup, `Unknown popup type: ${popup}`) return assertNever(popup, `Unknown popup type: ${popup}`)
} }

View file

@ -1,5 +1,3 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React from 'react' import React from 'react'
import { Select } from '../lib/select' import { Select } from '../lib/select'
import { Button } from '../lib/button' import { Button } from '../lib/button'
@ -73,10 +71,17 @@ export class CommitMessageAvatar extends React.Component<
public render() { public render() {
return ( return (
<div className="commit-message-avatar-component"> <div className="commit-message-avatar-component">
<div onClick={this.onAvatarClick}> {this.props.warningBadgeVisible && (
{this.props.warningBadgeVisible && this.renderWarningBadge()} <Button className="avatar-button" onClick={this.onAvatarClick}>
{this.renderWarningBadge()}
<Avatar user={this.props.user} title={this.props.title} /> <Avatar user={this.props.user} title={this.props.title} />
</div> </Button>
)}
{!this.props.warningBadgeVisible && (
<Avatar user={this.props.user} title={this.props.title} />
)}
{this.state.isPopoverOpen && this.renderPopover()} {this.state.isPopoverOpen && this.renderPopover()}
</div> </div>
) )
@ -108,7 +113,7 @@ export class CommitMessageAvatar extends React.Component<
}) })
} }
private onAvatarClick = (event: React.FormEvent<HTMLDivElement>) => { private onAvatarClick = (event: React.FormEvent<HTMLButtonElement>) => {
if (this.props.warningBadgeVisible === false) { if (this.props.warningBadgeVisible === false) {
return return
} }

View file

@ -383,7 +383,7 @@ export class Dialog extends React.Component<IDialogProps, IDialogState> {
* 4. Any remaining button * 4. Any remaining button
* *
*/ */
private focusFirstSuitableChild() { public focusFirstSuitableChild() {
const dialog = this.dialogElement const dialog = this.dialogElement
if (dialog === null) { if (dialog === null) {

View file

@ -536,6 +536,7 @@ export class CommitSummary extends React.Component<
let filesAdded = 0 let filesAdded = 0
let filesModified = 0 let filesModified = 0
let filesRemoved = 0 let filesRemoved = 0
let filesRenamed = 0
for (const file of this.props.changesetData.files) { for (const file of this.props.changesetData.files) {
switch (file.status.kind) { switch (file.status.kind) {
case AppFileStatusKind.New: case AppFileStatusKind.New:
@ -547,9 +548,14 @@ export class CommitSummary extends React.Component<
case AppFileStatusKind.Deleted: case AppFileStatusKind.Deleted:
filesRemoved += 1 filesRemoved += 1
break break
case AppFileStatusKind.Renamed:
filesRenamed += 1
} }
} }
const hasFileDescription =
filesAdded + filesModified + filesRemoved + filesRenamed > 0
const filesLongDescription = ( const filesLongDescription = (
<> <>
{filesAdded > 0 ? ( {filesAdded > 0 ? (
@ -579,6 +585,15 @@ export class CommitSummary extends React.Component<
{filesRemoved} deleted {filesRemoved} deleted
</span> </span>
) : null} ) : null}
{filesRenamed > 0 ? (
<span>
<Octicon
className="files-renamed-icon"
symbol={OcticonSymbol.diffRenamed}
/>
{filesRenamed} renamed
</span>
) : null}
</> </>
) )
@ -586,7 +601,9 @@ export class CommitSummary extends React.Component<
<TooltippedContent <TooltippedContent
className="commit-summary-meta-item without-truncation" className="commit-summary-meta-item without-truncation"
tooltipClassName="changed-files-description-tooltip" tooltipClassName="changed-files-description-tooltip"
tooltip={fileCount > 0 ? filesLongDescription : undefined} tooltip={
fileCount > 0 && hasFileDescription ? filesLongDescription : undefined
}
> >
<Octicon symbol={OcticonSymbol.diff} /> <Octicon symbol={OcticonSymbol.diff} />
{filesShortDescription} {filesShortDescription}

View file

@ -79,6 +79,7 @@ import { NotificationsStore } from '../lib/stores/notifications-store'
import * as ipcRenderer from '../lib/ipc-renderer' import * as ipcRenderer from '../lib/ipc-renderer'
import { migrateRendererGUID } from '../lib/get-renderer-guid' import { migrateRendererGUID } from '../lib/get-renderer-guid'
import { initializeRendererNotificationHandler } from '../lib/notifications/notification-handler' import { initializeRendererNotificationHandler } from '../lib/notifications/notification-handler'
import { NotificationsDebugStore } from '../lib/stores/notifications-debug-store'
if (__DEV__) { if (__DEV__) {
installDevGlobals() installDevGlobals()
@ -267,6 +268,12 @@ const notificationsStore = new NotificationsStore(
statsStore statsStore
) )
const notificationsDebugStore = new NotificationsDebugStore(
accountsStore,
notificationsStore,
pullRequestCoordinator
)
const appStore = new AppStore( const appStore = new AppStore(
gitHubUserStore, gitHubUserStore,
cloningRepositoriesStore, cloningRepositoriesStore,
@ -353,6 +360,7 @@ ReactDOM.render(
issuesStore={issuesStore} issuesStore={issuesStore}
gitHubUserStore={gitHubUserStore} gitHubUserStore={gitHubUserStore}
aheadBehindStore={aheadBehindStore} aheadBehindStore={aheadBehindStore}
notificationsDebugStore={notificationsDebugStore}
startTime={startTime} startTime={startTime}
/>, />,
document.getElementById('desktop-app-container')! document.getElementById('desktop-app-container')!

View file

@ -0,0 +1,200 @@
import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { PullRequest } from '../../models/pull-request'
import { Dispatcher } from '../dispatcher'
import { Account } from '../../models/account'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { OcticonSymbolType } from '../octicons/octicons.generated'
import { RepositoryWithGitHubRepository } from '../../models/repository'
import { SandboxedMarkdown } from '../lib/sandboxed-markdown'
import { LinkButton } from '../lib/link-button'
import classNames from 'classnames'
import { Avatar } from '../lib/avatar'
import { formatRelative } from '../../lib/format-relative'
import { getStealthEmailForUser } from '../../lib/email'
import { IAPIIdentity } from '../../lib/api'
interface IPullRequestCommentLikeProps {
readonly dispatcher: Dispatcher
readonly accounts: ReadonlyArray<Account>
readonly repository: RepositoryWithGitHubRepository
readonly pullRequest: PullRequest
readonly eventDate: Date
readonly eventVerb: string
readonly eventIconSymbol: OcticonSymbolType
readonly eventIconClass: string
readonly externalURL: string
readonly user: IAPIIdentity
readonly body: string
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
readonly switchingToPullRequest: boolean
readonly renderFooterContent: () => JSX.Element
readonly onSubmit: () => void
readonly onDismissed: () => void
}
/**
* Dialog to show a pull request review.
*/
export abstract class PullRequestCommentLike extends React.Component<IPullRequestCommentLikeProps> {
public render() {
const { title, pullRequestNumber } = this.props.pullRequest
const header = (
<div className="pull-request-comment-like-dialog-header">
{this.renderPullRequestIcon()}
<span className="pr-title">
<span className="pr-title">{title}</span>{' '}
<span className="pr-number">#{pullRequestNumber}</span>{' '}
</span>
</div>
)
return (
<Dialog
id="pull-request-review"
type="normal"
title={header}
dismissable={false}
onSubmit={this.props.onSubmit}
onDismissed={this.props.onDismissed}
loading={this.props.switchingToPullRequest}
>
<DialogContent>
<div className="comment-container">
{this.renderTimelineItem()}
{this.renderCommentBubble()}
</div>
</DialogContent>
<DialogFooter>{this.props.renderFooterContent()}</DialogFooter>
</Dialog>
)
}
private renderTimelineItem() {
const { user, repository, eventDate, eventVerb, externalURL } = this.props
const { endpoint } = repository.gitHubRepository
const userAvatar = {
name: user.login,
email: getStealthEmailForUser(user.id, user.login, endpoint),
avatarURL: user.avatar_url,
endpoint: endpoint,
}
const bottomLine = this.shouldRenderCommentBubble()
? null
: this.renderDashedTimelineLine('bottom')
const timelineItemClass = classNames('timeline-item', {
'with-comment': this.shouldRenderCommentBubble(),
})
const diff = eventDate.getTime() - Date.now()
const relativeReviewDate = formatRelative(diff)
return (
<div className="timeline-item-container">
{this.renderDashedTimelineLine('top')}
<div className={timelineItemClass}>
<Avatar user={userAvatar} title={null} size={40} />
{this.renderReviewIcon()}
<div className="summary">
<LinkButton uri={user.html_url} className="author">
{user.login}
</LinkButton>{' '}
{eventVerb} your pull request{' '}
<LinkButton uri={externalURL} className="submission-date">
{relativeReviewDate}
</LinkButton>
</div>
</div>
{bottomLine}
</div>
)
}
private shouldRenderCommentBubble() {
return this.props.body !== ''
}
private renderCommentBubble() {
if (!this.shouldRenderCommentBubble()) {
return null
}
return (
<div className="comment-bubble-container">
<div className="comment-bubble">{this.renderReviewBody()}</div>
{this.renderDashedTimelineLine('bottom')}
</div>
)
}
private renderDashedTimelineLine(type: 'top' | 'bottom') {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={`timeline-line ${type}`}
>
{/* Need to use 0.5 for X to prevent nearest neighbour filtering causing
the line to appear semi-transparent. */}
<line x1="0.5" y1="0" x2="0.5" y2="100%" />
</svg>
)
}
private onMarkdownLinkClicked = (url: string) => {
this.props.dispatcher.openInBrowser(url)
}
private renderReviewBody() {
const { body, emoji, pullRequest } = this.props
const { base } = pullRequest
return (
<SandboxedMarkdown
markdown={body}
emoji={emoji}
baseHref={base.gitHubRepository.htmlURL ?? undefined}
repository={base.gitHubRepository}
onMarkdownLinkClicked={this.onMarkdownLinkClicked}
markdownContext={'PullRequestComment'}
/>
)
}
private renderPullRequestIcon = () => {
const { pullRequest } = this.props
const cls = classNames('pull-request-icon', {
draft: pullRequest.draft,
})
return (
<Octicon
className={cls}
symbol={
pullRequest.draft
? OcticonSymbol.gitPullRequestDraft
: OcticonSymbol.gitPullRequest
}
/>
)
}
private renderReviewIcon = () => {
const { eventIconSymbol, eventIconClass } = this.props
return (
<div className={classNames('review-icon-container', eventIconClass)}>
<Octicon symbol={eventIconSymbol} />
</div>
)
}
}

View file

@ -1,24 +1,17 @@
import * as React from 'react' import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Row } from '../lib/row' import { Row } from '../lib/row'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { PullRequest } from '../../models/pull-request' import { PullRequest } from '../../models/pull-request'
import { Dispatcher } from '../dispatcher' import { Dispatcher } from '../dispatcher'
import { Account } from '../../models/account' import { Account } from '../../models/account'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { RepositoryWithGitHubRepository } from '../../models/repository' import { RepositoryWithGitHubRepository } from '../../models/repository'
import { SandboxedMarkdown } from '../lib/sandboxed-markdown'
import { import {
getPullRequestReviewStateIcon, getPullRequestReviewStateIcon,
getVerbForPullRequestReview, getVerbForPullRequestReview,
} from './pull-request-review-helpers' } from './pull-request-review-helpers'
import { LinkButton } from '../lib/link-button' import { LinkButton } from '../lib/link-button'
import classNames from 'classnames'
import { Avatar } from '../lib/avatar'
import { formatRelative } from '../../lib/format-relative'
import { ValidNotificationPullRequestReview } from '../../lib/valid-notification-pull-request-review' import { ValidNotificationPullRequestReview } from '../../lib/valid-notification-pull-request-review'
import { getStealthEmailForUser } from '../../lib/email' import { PullRequestCommentLike } from './pull-request-comment-like'
interface IPullRequestReviewProps { interface IPullRequestReviewProps {
readonly dispatcher: Dispatcher readonly dispatcher: Dispatcher
@ -26,7 +19,6 @@ interface IPullRequestReviewProps {
readonly repository: RepositoryWithGitHubRepository readonly repository: RepositoryWithGitHubRepository
readonly pullRequest: PullRequest readonly pullRequest: PullRequest
readonly review: ValidNotificationPullRequestReview readonly review: ValidNotificationPullRequestReview
readonly numberOfComments: number
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string> readonly emoji: Map<string, string>
@ -47,7 +39,7 @@ interface IPullRequestReviewState {
} }
/** /**
* Dialog to show the result of a CI check run. * Dialog to show a pull request review.
*/ */
export class PullRequestReview extends React.Component< export class PullRequestReview extends React.Component<
IPullRequestReviewProps, IPullRequestReviewProps,
@ -62,115 +54,42 @@ export class PullRequestReview extends React.Component<
} }
public render() { public render() {
const { title, pullRequestNumber } = this.props.pullRequest const {
dispatcher,
accounts,
repository,
pullRequest,
emoji,
review,
onSubmit,
onDismissed,
} = this.props
const header = ( const icon = getPullRequestReviewStateIcon(review.state)
<div className="pull-request-review-dialog-header">
{this.renderPullRequestIcon()}
<span className="pr-title">
<span className="pr-title">{title}</span>{' '}
<span className="pr-number">#{pullRequestNumber}</span>{' '}
</span>
</div>
)
return ( return (
<Dialog <PullRequestCommentLike
id="pull-request-review" dispatcher={dispatcher}
type="normal" accounts={accounts}
title={header} repository={repository}
dismissable={false} pullRequest={pullRequest}
onSubmit={this.props.onSubmit} emoji={emoji}
onDismissed={this.props.onDismissed} eventDate={new Date(review.submitted_at)}
loading={this.state.switchingToPullRequest} eventVerb={getVerbForPullRequestReview(review)}
> eventIconSymbol={icon.symbol}
<DialogContent> eventIconClass={icon.className}
<div className="review-container"> externalURL={review.html_url}
{this.renderTimelineItem()} user={review.user}
{this.renderCommentBubble()} body={review.body}
</div> switchingToPullRequest={this.state.switchingToPullRequest}
</DialogContent> renderFooterContent={this.renderFooterContent}
<DialogFooter>{this.renderFooterContent()}</DialogFooter> onSubmit={onSubmit}
</Dialog> onDismissed={onDismissed}
/>
) )
} }
private renderTimelineItem() { private renderFooterContent = () => {
const { review, repository } = this.props
const { user } = review
const { endpoint } = repository.gitHubRepository
const verb = getVerbForPullRequestReview(review)
const userAvatar = {
name: user.login,
email: getStealthEmailForUser(user.id, user.login, endpoint),
avatarURL: user.avatar_url,
endpoint: endpoint,
}
const bottomLine = this.shouldRenderCommentBubble()
? null
: this.renderDashedTimelineLine('bottom')
const timelineItemClass = classNames('timeline-item', {
'with-comment': this.shouldRenderCommentBubble(),
})
const submittedAt = new Date(review.submitted_at)
const diff = submittedAt.getTime() - Date.now()
const relativeReviewDate = formatRelative(diff)
return (
<div className="timeline-item-container">
{this.renderDashedTimelineLine('top')}
<div className={timelineItemClass}>
<Avatar user={userAvatar} title={null} size={40} />
{this.renderReviewIcon()}
<div className="summary">
<LinkButton uri={review.user.html_url} className="reviewer">
{review.user.login}
</LinkButton>{' '}
{verb} your pull request{' '}
<LinkButton uri={review.html_url} className="submission-date">
{relativeReviewDate}
</LinkButton>
</div>
</div>
{bottomLine}
</div>
)
}
private shouldRenderCommentBubble() {
return this.props.review.body !== ''
}
private renderCommentBubble() {
if (!this.shouldRenderCommentBubble()) {
return null
}
return (
<div className="comment-bubble-container">
<div className="comment-bubble">{this.renderReviewBody()}</div>
{this.renderDashedTimelineLine('bottom')}
</div>
)
}
private renderDashedTimelineLine(type: 'top' | 'bottom') {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={`timeline-line ${type}`}
>
{/* Need to use 0.5 for X to prevent nearest neighbour filtering causing
the line to appear semi-transparent. */}
<line x1="0.5" y1="0" x2="0.5" y2="100%" />
</svg>
)
}
private renderFooterContent() {
const { review, shouldChangeRepository, shouldCheckoutBranch } = this.props const { review, shouldChangeRepository, shouldCheckoutBranch } = this.props
const isApprovedReview = review.state === 'APPROVED' const isApprovedReview = review.state === 'APPROVED'
@ -213,56 +132,6 @@ export class PullRequestReview extends React.Component<
) )
} }
private onMarkdownLinkClicked = (url: string) => {
this.props.dispatcher.openInBrowser(url)
}
private renderReviewBody() {
const { review, emoji, pullRequest } = this.props
const { base } = pullRequest
return (
<SandboxedMarkdown
markdown={review.body}
emoji={emoji}
baseHref={base.gitHubRepository.htmlURL ?? undefined}
repository={base.gitHubRepository}
onMarkdownLinkClicked={this.onMarkdownLinkClicked}
markdownContext={'PullRequestComment'}
/>
)
}
private renderPullRequestIcon = () => {
const { pullRequest } = this.props
const cls = classNames('pull-request-icon', {
draft: pullRequest.draft,
})
return (
<Octicon
className={cls}
symbol={
pullRequest.draft
? OcticonSymbol.gitPullRequestDraft
: OcticonSymbol.gitPullRequest
}
/>
)
}
private renderReviewIcon = () => {
const { review } = this.props
const icon = getPullRequestReviewStateIcon(review.state)
return (
<div className={classNames('review-icon-container', icon.className)}>
<Octicon symbol={icon.symbol} />
</div>
)
}
private onSubmit = async (event: React.MouseEvent<HTMLButtonElement>) => { private onSubmit = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault() event.preventDefault()

View file

@ -41,6 +41,8 @@ const SignInWithBrowserTitle = __DARWIN__
const DefaultTitle = 'Sign in' const DefaultTitle = 'Sign in'
export class SignIn extends React.Component<ISignInProps, ISignInState> { export class SignIn extends React.Component<ISignInProps, ISignInState> {
private readonly dialogRef = React.createRef<Dialog>()
public constructor(props: ISignInProps) { public constructor(props: ISignInProps) {
super(props) super(props)
@ -52,6 +54,18 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
} }
} }
public componentDidUpdate(prevProps: ISignInProps) {
// Whenever the sign in step changes we replace the dialog contents which
// means we need to re-focus the first suitable child element as it's
// essentially a "new" dialog we're showing only the dialog component itself
// doesn't know that.
if (prevProps.signInState !== null && this.props.signInState !== null) {
if (prevProps.signInState.kind !== this.props.signInState.kind) {
this.dialogRef.current?.focusFirstSuitableChild()
}
}
}
public componentWillReceiveProps(nextProps: ISignInProps) { public componentWillReceiveProps(nextProps: ISignInProps) {
if (nextProps.signInState !== this.props.signInState) { if (nextProps.signInState !== this.props.signInState) {
if ( if (
@ -161,6 +175,7 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
<OkCancelButtonGroup <OkCancelButtonGroup
okButtonText={primaryButtonText} okButtonText={primaryButtonText}
okButtonDisabled={disableSubmit} okButtonDisabled={disableSubmit}
onCancelButtonClick={this.onDismissed}
/> />
</DialogFooter> </DialogFooter>
) )
@ -324,6 +339,7 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
onDismissed={this.onDismissed} onDismissed={this.onDismissed}
onSubmit={this.onSubmit} onSubmit={this.onSubmit}
loading={state.loading} loading={state.loading}
ref={this.dialogRef}
> >
{errors} {errors}
{this.renderStep()} {this.renderStep()}

View file

@ -0,0 +1,666 @@
import classNames from 'classnames'
import React from 'react'
import { getHTMLURL, IAPIComment } from '../../lib/api'
import { assertNever } from '../../lib/fatal-error'
import { NotificationsDebugStore } from '../../lib/stores/notifications-debug-store'
import {
ValidNotificationPullRequestReview,
ValidNotificationPullRequestReviewState,
} from '../../lib/valid-notification-pull-request-review'
import { PullRequest } from '../../models/pull-request'
import { RepositoryWithGitHubRepository } from '../../models/repository'
import {
Dialog,
DialogContent,
DialogFooter,
OkCancelButtonGroup,
} from '../dialog'
import { DialogHeader } from '../dialog/header'
import { Dispatcher } from '../dispatcher'
import { Button } from '../lib/button'
import { List } from '../lib/list'
import { Loading } from '../lib/loading'
import { getPullRequestReviewStateIcon } from '../notifications/pull-request-review-helpers'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
enum TestNotificationType {
PullRequestReview,
PullRequestReviewComment,
}
enum TestNotificationStepKind {
SelectPullRequest,
SelectPullRequestReview,
SelectPullRequestComment,
}
type TestNotificationFlow = {
readonly type: TestNotificationType
readonly steps: ReadonlyArray<TestNotificationStepKind>
}
const TestNotificationFlows: ReadonlyArray<TestNotificationFlow> = [
{
type: TestNotificationType.PullRequestReview,
steps: [
TestNotificationStepKind.SelectPullRequest,
TestNotificationStepKind.SelectPullRequestReview,
],
},
{
type: TestNotificationType.PullRequestReviewComment,
steps: [
TestNotificationStepKind.SelectPullRequest,
TestNotificationStepKind.SelectPullRequestComment,
],
},
]
type TestNotificationStepSelectPullRequestResult = {
readonly kind: TestNotificationStepKind.SelectPullRequest
readonly pullRequest: PullRequest
}
type TestNotificationStepSelectPullRequestReviewResult = {
readonly kind: TestNotificationStepKind.SelectPullRequestReview
readonly review: ValidNotificationPullRequestReview
}
type TestNotificationStepSelectPullRequestCommentResult = {
readonly kind: TestNotificationStepKind.SelectPullRequestComment
readonly comment: IAPIComment
readonly isIssueComment: boolean
}
type TestNotificationStepResultMap = Map<
TestNotificationStepKind.SelectPullRequest,
TestNotificationStepSelectPullRequestResult
> &
Map<
TestNotificationStepKind.SelectPullRequestReview,
TestNotificationStepSelectPullRequestReviewResult
> &
Map<
TestNotificationStepKind.SelectPullRequestComment,
TestNotificationStepSelectPullRequestCommentResult
>
interface ITestNotificationsState {
readonly selectedFlow: TestNotificationFlow | null
readonly stepResults: TestNotificationStepResultMap
readonly loading: boolean
readonly pullRequests: ReadonlyArray<PullRequest>
readonly reviews: ReadonlyArray<ValidNotificationPullRequestReview>
readonly comments: ReadonlyArray<IAPIComment>
}
interface ITestNotificationsProps {
readonly dispatcher: Dispatcher
readonly notificationsDebugStore: NotificationsDebugStore
readonly repository: RepositoryWithGitHubRepository
readonly onDismissed: () => void
}
class TestNotificationItemRowContent extends React.Component<{
readonly leftAccessory?: JSX.Element
readonly html_url?: string
readonly dispatcher: Dispatcher
}> {
public render() {
const { leftAccessory, html_url, children } = this.props
return (
<div className="row-content">
{leftAccessory && <div className="left-accessory">{leftAccessory}</div>}
<div className="main-content">{children}</div>
{html_url && (
<div className="right-accessory">
<Button onClick={this.onExternalLinkClick}>
<Octicon symbol={OcticonSymbol.linkExternal} />
</Button>
</div>
)}
</div>
)
}
private onExternalLinkClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const { dispatcher, html_url } = this.props
if (html_url === undefined) {
return
}
e.stopPropagation()
dispatcher.openInBrowser(html_url)
}
}
export class TestNotifications extends React.Component<
ITestNotificationsProps,
ITestNotificationsState
> {
public constructor(props: ITestNotificationsProps) {
super(props)
this.state = {
selectedFlow: null,
stepResults: new Map(),
loading: false,
pullRequests: [],
reviews: [],
comments: [],
}
}
private onDismissed = () => {
this.props.dispatcher.closePopup()
}
private renderNotificationType = (
type: TestNotificationType
): JSX.Element => {
const title =
type === TestNotificationType.PullRequestReview
? 'Pull Request Review'
: 'Pull Request Review Comment'
return (
<Button onClick={this.getOnNotificationTypeClick(type)}>{title}</Button>
)
}
private getOnNotificationTypeClick = (type: TestNotificationType) => () => {
const selectedFlow =
TestNotificationFlows.find(f => f.type === type) ?? null
this.setState(
{
selectedFlow,
},
() => {
this.prepareForNextStep()
}
)
}
private doFinalAction() {
const selectedFlow = this.state.selectedFlow
if (selectedFlow === null) {
return
}
switch (selectedFlow.type) {
case TestNotificationType.PullRequestReview: {
const pullRequestNumber = this.getPullRequest()
const review = this.getReview()
if (pullRequestNumber === null || review === null) {
return
}
this.props.notificationsDebugStore.simulatePullRequestReviewNotification(
this.props.repository.gitHubRepository,
pullRequestNumber,
review
)
break
}
case TestNotificationType.PullRequestReviewComment: {
const pullRequest = this.getPullRequest()
const commentInfo = this.getCommentInfo()
if (pullRequest === null || commentInfo === null) {
return
}
const { comment, isIssueComment } = commentInfo
this.props.notificationsDebugStore.simulatePullRequestCommentNotification(
this.props.repository.gitHubRepository,
pullRequest,
comment,
isIssueComment
)
break
}
default:
assertNever(selectedFlow.type, `Unknown flow type: ${selectedFlow}`)
}
}
private prepareForNextStep() {
const nextStep = this.state.selectedFlow?.steps[this.state.stepResults.size]
if (nextStep === undefined) {
this.doFinalAction()
this.back()
return
}
switch (nextStep) {
case TestNotificationStepKind.SelectPullRequest: {
this.setState({
loading: true,
})
this.props.notificationsDebugStore
.getPullRequests(this.props.repository)
.then(pullRequests => {
this.setState({
pullRequests,
loading: false,
})
})
break
}
case TestNotificationStepKind.SelectPullRequestReview: {
this.setState({
loading: true,
})
const pullRequest = this.getPullRequest()
if (pullRequest === null) {
return
}
this.props.notificationsDebugStore
.getPullRequestReviews(
this.props.repository,
pullRequest.pullRequestNumber
)
.then(reviews => {
this.setState({
reviews,
loading: false,
})
})
break
}
case TestNotificationStepKind.SelectPullRequestComment: {
this.setState({
loading: true,
})
const pullRequest = this.getPullRequest()
if (pullRequest === null) {
return
}
this.props.notificationsDebugStore
.getPullRequestComments(
this.props.repository,
pullRequest.pullRequestNumber
)
.then(comments => {
this.setState({
comments,
loading: false,
})
})
break
}
default:
assertNever(nextStep, `Unknown step: ${nextStep}`)
}
}
private getPullRequest(): PullRequest | null {
const pullRequestResult = this.state.stepResults.get(
TestNotificationStepKind.SelectPullRequest
)
if (pullRequestResult === undefined) {
return null
}
return pullRequestResult.pullRequest
}
private getReview(): ValidNotificationPullRequestReview | null {
const reviewResult = this.state.stepResults.get(
TestNotificationStepKind.SelectPullRequestReview
)
if (reviewResult === undefined) {
return null
}
return reviewResult.review
}
private getCommentInfo() {
const commentResult = this.state.stepResults.get(
TestNotificationStepKind.SelectPullRequestComment
)
if (commentResult === undefined) {
return null
}
return {
comment: commentResult.comment,
isIssueComment: commentResult.isIssueComment,
}
}
private renderCurrentStep() {
if (this.state.selectedFlow === null) {
return (
<div>
<p>Select the type of notification to display:</p>
<div className="notification-type-list">
{this.renderNotificationType(
TestNotificationType.PullRequestReview
)}
{this.renderNotificationType(
TestNotificationType.PullRequestReviewComment
)}
</div>
</div>
)
}
const currentStep = this.state.selectedFlow.steps.at(
this.state.stepResults.size
)
if (currentStep === undefined) {
return <p>Done!</p>
}
switch (currentStep) {
case TestNotificationStepKind.SelectPullRequest:
return this.renderSelectPullRequest()
case TestNotificationStepKind.SelectPullRequestReview:
return this.renderSelectPullRequestReview()
case TestNotificationStepKind.SelectPullRequestComment:
return this.renderSelectPullRequestComment()
default:
return assertNever(currentStep, `Unknown step: ${currentStep}`)
}
}
private renderSelectPullRequest() {
if (this.state.loading) {
return <Loading />
}
const { pullRequests } = this.state
if (pullRequests.length === 0) {
return <p>No pull requests found</p>
}
return (
<div>
Pull requests:
<List
rowHeight={40}
rowCount={pullRequests.length}
rowRenderer={this.renderPullRequestRow}
selectedRows={[]}
onRowClick={this.onPullRequestRowClick}
/>
</div>
)
}
private onPullRequestRowClick = (row: number) => {
const pullRequest = this.state.pullRequests[row]
const stepResults = this.state.stepResults
stepResults.set(TestNotificationStepKind.SelectPullRequest, {
kind: TestNotificationStepKind.SelectPullRequest,
pullRequest: pullRequest,
})
this.setState(
{
stepResults,
},
() => {
this.prepareForNextStep()
}
)
}
private renderSelectPullRequestReview() {
if (this.state.loading) {
return <Loading />
}
const { reviews } = this.state
if (reviews.length === 0) {
return <p>No reviews found</p>
}
return (
<div>
Reviews:
<List
rowHeight={40}
rowCount={reviews.length}
rowRenderer={this.renderPullRequestReviewRow}
selectedRows={[]}
onRowClick={this.onPullRequestReviewRowClick}
/>
</div>
)
}
private onPullRequestReviewRowClick = (row: number) => {
const review = this.state.reviews[row]
const stepResults = this.state.stepResults
stepResults.set(TestNotificationStepKind.SelectPullRequestReview, {
kind: TestNotificationStepKind.SelectPullRequestReview,
review: review,
})
this.setState(
{
stepResults,
},
() => {
this.prepareForNextStep()
}
)
}
private renderSelectPullRequestComment() {
if (this.state.loading) {
return <Loading />
}
const { comments } = this.state
if (comments.length === 0) {
return <p>No comments found</p>
}
return (
<div>
Comments:
<List
rowHeight={40}
rowCount={comments.length}
rowRenderer={this.renderPullRequestCommentRow}
selectedRows={[]}
onRowClick={this.onPullRequestCommentRowClick}
/>
</div>
)
}
private onPullRequestCommentRowClick = (row: number) => {
const comment = this.state.comments[row]
const stepResults = this.state.stepResults
stepResults.set(TestNotificationStepKind.SelectPullRequestComment, {
kind: TestNotificationStepKind.SelectPullRequestComment,
comment: comment,
isIssueComment: comment.html_url.includes('#issuecomment-'),
})
this.setState(
{
stepResults,
},
() => {
this.prepareForNextStep()
}
)
}
private renderPullRequestCommentRow = (row: number) => {
const comment = this.state.comments[row]
return (
<TestNotificationItemRowContent
dispatcher={this.props.dispatcher}
html_url={comment.html_url}
leftAccessory={this.renderReviewStateIcon('COMMENTED')}
>
{comment.body}
<br />
by <i>{comment.user.login}</i>
</TestNotificationItemRowContent>
)
}
private renderPullRequestReviewRow = (row: number) => {
const review = this.state.reviews[row]
return (
<TestNotificationItemRowContent
dispatcher={this.props.dispatcher}
html_url={review.html_url}
leftAccessory={this.renderReviewStateIcon(review.state)}
>
{review.body || <i>Review without body</i>}
<br />
by <i>{review.user.login}</i>
</TestNotificationItemRowContent>
)
}
private renderReviewStateIcon = (
state: ValidNotificationPullRequestReviewState
) => {
const icon = getPullRequestReviewStateIcon(state)
return (
<div className={classNames('review-icon-container', icon.className)}>
<Octicon symbol={icon.symbol} />
</div>
)
}
private renderPullRequestRow = (row: number) => {
const pullRequest = this.state.pullRequests[row]
const repository = this.props.repository.gitHubRepository
const endpointHtmlUrl = getHTMLURL(repository.endpoint)
const htmlURL = `${endpointHtmlUrl}/${repository.owner.login}/${repository.name}/pull/${pullRequest.pullRequestNumber}`
return (
<TestNotificationItemRowContent
dispatcher={this.props.dispatcher}
html_url={htmlURL}
leftAccessory={this.renderPullRequestStateIcon(pullRequest)}
>
<b>
#{pullRequest.pullRequestNumber}
{pullRequest.draft ? ' (Draft)' : ''}:
</b>{' '}
{pullRequest.title} <br />
by <i>{pullRequest.author}</i>
</TestNotificationItemRowContent>
)
}
private renderPullRequestStateIcon = (
pullRequest: PullRequest
): JSX.Element => {
return (
<Octicon
className={pullRequest.draft ? 'pr-draft-icon' : 'pr-icon'}
symbol={
pullRequest.draft
? OcticonSymbol.gitPullRequestDraft
: OcticonSymbol.gitPullRequest
}
/>
)
}
public render() {
return (
<Dialog
id="test-notifications"
onSubmit={this.onDismissed}
dismissable={true}
onDismissed={this.onDismissed}
>
<DialogHeader
title="Test Notifications"
dismissable={true}
onDismissed={this.onDismissed}
/>
<DialogContent>{this.renderCurrentStep()}</DialogContent>
<DialogFooter>
<OkCancelButtonGroup
okButtonText="Close"
okButtonDisabled={false}
cancelButtonDisabled={false}
cancelButtonVisible={this.state.selectedFlow !== null}
cancelButtonText="Back"
onCancelButtonClick={this.onBack}
/>
</DialogFooter>
</Dialog>
)
}
private onBack = (event: React.MouseEvent<HTMLButtonElement>) => {
this.back()
event.preventDefault()
}
private back() {
const { selectedFlow, stepResults } = this.state
if (selectedFlow === null) {
return
}
if (stepResults.size === 0) {
this.setState(
{
selectedFlow: null,
stepResults: new Map(),
},
() => {
this.prepareForNextStep()
}
)
}
const steps = selectedFlow.steps
const lastStep = steps.at(stepResults.size - 1)
if (lastStep === undefined) {
return
}
const newStepResults: Map<TestNotificationStepKind, any> = new Map(
stepResults
)
newStepResults.delete(lastStep)
this.setState(
{
stepResults: newStepResults as TestNotificationStepResultMap,
},
() => {
this.prepareForNextStep()
}
)
}
}

View file

@ -95,7 +95,6 @@
@import 'ui/check-runs/_ci-check-run-popover'; @import 'ui/check-runs/_ci-check-run-popover';
@import 'ui/check-runs/ci-check-run-job-steps'; @import 'ui/check-runs/ci-check-run-job-steps';
@import 'ui/_pull-request-checks-failed'; @import 'ui/_pull-request-checks-failed';
@import 'ui/_pull-request-review';
@import 'ui/_sandboxed-markdown'; @import 'ui/_sandboxed-markdown';
@import 'ui/_pull-request-quick-view'; @import 'ui/_pull-request-quick-view';
@import 'ui/discard-changes-retry'; @import 'ui/discard-changes-retry';

View file

@ -2,6 +2,27 @@
// With this, the popover's absolute position will be relative to its parent // With this, the popover's absolute position will be relative to its parent
position: relative; position: relative;
.avatar-button {
// override default button styles
overflow: inherit;
text-overflow: inherit;
white-space: inherit;
font-family: inherit;
font-size: inherit;
padding: inherit;
border: none;
height: inherit;
color: inherit;
background-color: none;
border-radius: 50%;
margin-right: var(--spacing-half);
.avatar {
margin-right: 0;
}
}
.warning-badge { .warning-badge {
background-color: var(--commit-warning-badge-background-color); background-color: var(--commit-warning-badge-background-color);
border: var(--commit-warning-badge-border-color) 1px solid; border: var(--commit-warning-badge-border-color) 1px solid;

View file

@ -22,6 +22,8 @@
@import 'dialogs/unreachable-commits'; @import 'dialogs/unreachable-commits';
@import 'dialogs/open-pull-request'; @import 'dialogs/open-pull-request';
@import 'dialogs/installing-update'; @import 'dialogs/installing-update';
@import 'dialogs/test-notifications';
@import 'dialogs/pull-request-comment-like';
// The styles herein attempt to follow a flow where margins are only applied // The styles herein attempt to follow a flow where margins are only applied
// to the bottom of elements (with the exception of the last child). This to // to the bottom of elements (with the exception of the last child). This to

View file

@ -1,4 +1,5 @@
#pull-request-review { #pull-request-review,
#pull-request-comment {
--avatar-size: 40px; --avatar-size: 40px;
min-width: 500px; min-width: 500px;
@ -6,7 +7,7 @@
height: unset; height: unset;
} }
.pull-request-review-dialog-header { .pull-request-comment-like-dialog-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -44,7 +45,7 @@
max-height: 300px; max-height: 300px;
overflow: auto; overflow: auto;
.review-container { .comment-container {
.timeline-line { .timeline-line {
width: 1px; width: 1px;
height: 24px; height: 24px;
@ -103,7 +104,7 @@
.link-button-component { .link-button-component {
color: unset; color: unset;
&.reviewer { &.author {
font-weight: bold; font-weight: bold;
} }
} }

View file

@ -0,0 +1,75 @@
#test-notifications {
.notification-type-list {
display: flex;
flex-direction: column;
row-gap: var(--spacing-half);
}
.list {
height: 200px;
.row-content {
width: 100%;
display: flex;
flex-direction: row;
.left-accessory {
flex-grow: 0;
flex-shrink: 0;
align-self: center;
margin-right: 10px;
}
.main-content {
flex-grow: 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.right-accessory {
flex-grow: 0;
flex-shrink: 0;
align-self: center;
margin-left: 10px;
}
}
}
.review-icon-container {
border-radius: 50%;
width: 30px;
height: 30px;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
.octicon {
width: 16px;
height: 16px;
text-align: center;
}
&.pr-review-approved {
color: var(--pr-approved-icon-color);
background-color: var(--pr-approved-icon-background-color);
}
&.pr-review-changes-requested {
color: var(--pr-changes-requested-icon-color);
background-color: var(--pr-changes-requested-icon-background-color);
}
&.pr-review-commented {
color: var(--pr-commented-icon-color);
background-color: var(--pr-commented-icon-background-color);
}
}
.pr-icon {
color: var(--pr-open-icon-color);
}
.pr-draft-icon {
color: var(--pr-draft-icon-color);
}
}

View file

@ -193,6 +193,10 @@ body > .tooltip,
color: var(--color-deleted); color: var(--color-deleted);
} }
.files-renamed-icon {
color: var(--color-renamed);
}
.octicon { .octicon {
margin-right: var(--spacing-third); margin-right: var(--spacing-third);
vertical-align: bottom; // For some reason, `bottom` places the text in the middle vertical-align: bottom; // For some reason, `bottom` places the text in the middle

View file

@ -32,6 +32,7 @@ import { CommitStatusStore } from '../../src/lib/stores/commit-status-store'
import { AheadBehindStore } from '../../src/lib/stores/ahead-behind-store' import { AheadBehindStore } from '../../src/lib/stores/ahead-behind-store'
import { AliveStore } from '../../src/lib/stores/alive-store' import { AliveStore } from '../../src/lib/stores/alive-store'
import { NotificationsStore } from '../../src/lib/stores/notifications-store' import { NotificationsStore } from '../../src/lib/stores/notifications-store'
import { NotificationsDebugStore } from '../../src/lib/stores/notifications-debug-store'
describe('App', () => { describe('App', () => {
let appStore: AppStore let appStore: AppStore
@ -41,6 +42,7 @@ describe('App', () => {
let githubUserStore: GitHubUserStore let githubUserStore: GitHubUserStore
let issuesStore: IssuesStore let issuesStore: IssuesStore
let aheadBehindStore: AheadBehindStore let aheadBehindStore: AheadBehindStore
let notificationsDebugStore: NotificationsDebugStore
beforeEach(async () => { beforeEach(async () => {
const db = new TestGitHubUserDatabase() const db = new TestGitHubUserDatabase()
@ -86,6 +88,12 @@ describe('App', () => {
) )
notificationsStore.setNotificationsEnabled(false) notificationsStore.setNotificationsEnabled(false)
notificationsDebugStore = new NotificationsDebugStore(
accountsStore,
notificationsStore,
pullRequestCoordinator
)
appStore = new AppStore( appStore = new AppStore(
githubUserStore, githubUserStore,
new CloningRepositoriesStore(), new CloningRepositoriesStore(),
@ -117,6 +125,7 @@ describe('App', () => {
issuesStore={issuesStore} issuesStore={issuesStore}
gitHubUserStore={githubUserStore} gitHubUserStore={githubUserStore}
aheadBehindStore={aheadBehindStore} aheadBehindStore={aheadBehindStore}
notificationsDebugStore={notificationsDebugStore}
startTime={0} startTime={0}
/> />
) as unknown as React.Component<any, any> ) as unknown as React.Component<any, any>

View file

@ -1,6 +1,9 @@
{ {
"releases": { "releases": {
"3.1.7-beta1": ["[Improved] Upgrade embedded Git to 2.39.2"], "3.1.7-beta1": ["[Improved] Upgrade embedded Git to 2.39.2"],
"3.1.6": [
"[Improved] Upgrade embedded Git to 2.39.1 and Git LFS to 3.3.0 - #15915"
],
"3.1.6-beta2": [ "3.1.6-beta2": [
"[Fixed] Fix crash launching the app on Apple silicon devices - #16011", "[Fixed] Fix crash launching the app on Apple silicon devices - #16011",
"[Fixed] Trim leading and trailing whitespaces in URLs of repository remotes - #15821. Thanks @Shivareddy-Aluri!", "[Fixed] Trim leading and trailing whitespaces in URLs of repository remotes - #15821. Thanks @Shivareddy-Aluri!",

View file

@ -45,6 +45,7 @@ These editors are currently supported:
- [RStudio](https://rstudio.com/) - [RStudio](https://rstudio.com/)
- [Aptana Studio](http://www.aptana.com/) - [Aptana Studio](http://www.aptana.com/)
- [JetBrains Fleet](https://www.jetbrains.com/fleet/) - [JetBrains Fleet](https://www.jetbrains.com/fleet/)
- [JetBrains DataSpell](https://www.jetbrains.com/dataspell/)
These are defined in a list at the top of the file: These are defined in a list at the top of the file:
@ -272,6 +273,7 @@ These editors are currently supported:
- [Emacs](https://www.gnu.org/software/emacs/) - [Emacs](https://www.gnu.org/software/emacs/)
- [Lite XL](https://lite-xl.com/) - [Lite XL](https://lite-xl.com/)
- [JetBrains Fleet](https://www.jetbrains.com/fleet/) - [JetBrains Fleet](https://www.jetbrains.com/fleet/)
- [JetBrains DataSpell](https://www.jetbrains.com/dataspell/)
These are defined in a list at the top of the file: These are defined in a list at the top of the file:

View file

@ -85,7 +85,7 @@
"jest-diff": "^25.0.0", "jest-diff": "^25.0.0",
"jest-extended": "^0.11.2", "jest-extended": "^0.11.2",
"jest-localstorage-mock": "^2.3.0", "jest-localstorage-mock": "^2.3.0",
"jszip": "^3.7.1", "jszip": "^3.8.0",
"klaw-sync": "^3.0.0", "klaw-sync": "^3.0.0",
"legal-eagle": "0.16.0", "legal-eagle": "0.16.0",
"mini-css-extract-plugin": "^2.5.3", "mini-css-extract-plugin": "^2.5.3",

View file

@ -5245,9 +5245,9 @@ htmlparser2@^6.1.0:
entities "^2.0.0" entities "^2.0.0"
http-cache-semantics@^4.0.0: http-cache-semantics@^4.0.0:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
http-errors@1.8.1: http-errors@1.8.1:
version "1.8.1" version "1.8.1"
@ -6558,10 +6558,10 @@ jsx-ast-utils@^3.3.2:
array-includes "^3.1.5" array-includes "^3.1.5"
object.assign "^4.1.3" object.assign "^4.1.3"
jszip@^3.7.1: jszip@^3.8.0:
version "3.7.1" version "3.8.0"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.1.tgz#bd63401221c15625a1228c556ca8a68da6fda3d9" resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.8.0.tgz#a2ac3c33fe96a76489765168213655850254d51b"
integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg== integrity sha512-cnpQrXvFSLdsR9KR5/x7zdf6c3m8IhZfZzSblFEHSqBaVwD2nvJ4CuCKLyvKvwBgZm08CgfSoiTBQLm5WW9hGw==
dependencies: dependencies:
lie "~3.3.0" lie "~3.3.0"
pako "~1.0.2" pako "~1.0.2"