diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf910c649b..86d1a83fa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,19 +32,7 @@ jobs: uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} - - - 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- + cache: yarn # This step can be removed as soon as official Windows arm64 builds are published: # https://github.com/nodejs/build/issues/2450#issuecomment-705853342 diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index bc17c4bcd8..9898c5e974 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -508,6 +508,15 @@ export interface IAPIPullRequestReview { | '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. */ 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(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(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(response) + } catch (e) { + log.debug( + `failed fetching PR comments for ${owner}/${name}/pulls/${prNumber}`, + e + ) + return [] + } + } + /** * Get the combined status for the given ref. */ diff --git a/app/src/lib/editors/darwin.ts b/app/src/lib/editors/darwin.ts index 7634e7482e..76c475145e 100644 --- a/app/src/lib/editors/darwin.ts +++ b/app/src/lib/editors/darwin.ts @@ -71,6 +71,10 @@ const editors: IDarwinExternalEditor[] = [ name: 'PyCharm Community Edition', bundleIdentifiers: ['com.jetbrains.pycharm.ce'], }, + { + name: 'DataSpell', + bundleIdentifiers: ['com.jetbrains.DataSpell'], + }, { name: 'RubyMine', bundleIdentifiers: ['com.jetbrains.RubyMine'], diff --git a/app/src/lib/editors/linux.ts b/app/src/lib/editors/linux.ts index 0670548b44..d7a0308b3c 100644 --- a/app/src/lib/editors/linux.ts +++ b/app/src/lib/editors/linux.ts @@ -23,9 +23,26 @@ const editors: ILinuxExternalEditor[] = [ name: 'Neovim', 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', - 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)', @@ -33,7 +50,11 @@ const editors: ILinuxExternalEditor[] = [ }, { 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', @@ -63,17 +84,69 @@ const editors: ILinuxExternalEditor[] = [ paths: ['/usr/bin/lite-xl'], }, { - name: 'Jetbrains PhpStorm', - paths: ['/snap/bin/phpstorm'], + name: 'JetBrains PhpStorm', + paths: [ + '/snap/bin/phpstorm', + '.local/share/JetBrains/Toolbox/scripts/phpstorm', + ], }, { - name: 'Jetbrains WebStorm', - paths: ['/snap/bin/webstorm'], + name: 'JetBrains 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', 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 { diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts index afdc6c09ba..90ac080f7f 100644 --- a/app/src/lib/editors/win32.ts +++ b/app/src/lib/editors/win32.ts @@ -471,6 +471,14 @@ const editors: WindowsExternalEditor[] = [ displayNamePrefix: 'Fleet ', 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( diff --git a/app/src/lib/stores/alive-store.ts b/app/src/lib/stores/alive-store.ts index e8639ee2d4..a77cd3eec5 100644 --- a/app/src/lib/stores/alive-store.ts +++ b/app/src/lib/stores/alive-store.ts @@ -28,13 +28,23 @@ export interface IDesktopPullRequestReviewSubmitAliveEvent { readonly pull_request_number: number readonly state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' 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. */ export type DesktopAliveEvent = | IDesktopChecksFailedAliveEvent | IDesktopPullRequestReviewSubmitAliveEvent + | IDesktopPullRequestCommentAliveEvent interface IAliveSubscription { readonly account: Account readonly subscription: Subscription @@ -247,7 +257,11 @@ export class AliveStore { } 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) } } diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 6a2e8b5260..e25b741eb1 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -7300,8 +7300,7 @@ export class AppStore extends TypedBaseStore { private onPullRequestReviewSubmitNotification = async ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - review: ValidNotificationPullRequestReview, - numberOfComments: number + review: ValidNotificationPullRequestReview ) => { const selectedRepository = this.selectedRepository ?? (await this._selectRepository(repository)) @@ -7322,7 +7321,6 @@ export class AppStore extends TypedBaseStore { review, pullRequest, repository, - numberOfComments, }) } diff --git a/app/src/lib/stores/notifications-debug-store.ts b/app/src/lib/stores/notifications-debug-store.ts new file mode 100644 index 0000000000..65869cbf82 --- /dev/null +++ b/app/src/lib/stores/notifications-debug-store.ts @@ -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(), + }) + } +} diff --git a/app/src/lib/stores/notifications-store.ts b/app/src/lib/stores/notifications-store.ts index b5c61ab29d..93bb4e2742 100644 --- a/app/src/lib/stores/notifications-store.ts +++ b/app/src/lib/stores/notifications-store.ts @@ -48,8 +48,7 @@ type OnChecksFailedCallback = ( type OnPullRequestReviewSubmitCallback = ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - review: ValidNotificationPullRequestReview, - numberOfComments: number + review: ValidNotificationPullRequestReview ) => void /** @@ -105,6 +104,12 @@ export class NotificationsStore { public onNotificationEventReceived: NotificationCallback = async (event, id, userInfo) => this.handleAliveEvent(userInfo, true) + public simulateAliveEvent(event: DesktopAliveEvent) { + if (__DEV__) { + this.handleAliveEvent(event, false) + } + } + private async handleAliveEvent( e: DesktopAliveEvent, skipNotification: boolean @@ -175,12 +180,7 @@ export class NotificationsStore { const onClick = () => { this.statsStore.recordPullRequestReviewNotificationClicked(review.state) - this.onPullRequestReviewSubmitCallback?.( - repository, - pullRequest, - review, - event.number_of_comments - ) + this.onPullRequestReviewSubmitCallback?.(repository, pullRequest, review) } if (skipNotification) { diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts index 75a889f5f6..bef7e254ea 100644 --- a/app/src/main-process/main.ts +++ b/app/src/main-process/main.ts @@ -729,7 +729,13 @@ function createWindow() { 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) { try { diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 7b21a8ca24..abdb78ff7e 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -89,6 +89,7 @@ export enum PopupType { StartPullRequest = 'StartPullRequest', Error = 'Error', InstallingUpdate = 'InstallingUpdate', + TestNotifications = 'TestNotifications', } interface IBasePopup { @@ -361,7 +362,6 @@ export type PopupDetail = repository: RepositoryWithGitHubRepository pullRequest: PullRequest review: ValidNotificationPullRequestReview - numberOfComments: number shouldCheckoutBranch: boolean shouldChangeRepository: boolean } @@ -389,5 +389,9 @@ export type PopupDetail = | { type: PopupType.InstallingUpdate } + | { + type: PopupType.TestNotifications + repository: RepositoryWithGitHubRepository + } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index bbe2be7374..4ec2feda18 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -148,7 +148,6 @@ import { WarnForcePushDialog } from './multi-commit-operation/dialog/warn-force- import { clamp } from '../lib/clamp' import { generateRepositoryListContextMenu } from './repositories-list/repository-list-item-context-menu' import * as ipcRenderer from '../lib/ipc-renderer' -import { showNotification } from '../lib/notifications/show-notification' import { DiscardChangesRetryDialog } from './discard-changes/discard-changes-retry-dialog' import { generateDevReleaseSummary } from '../lib/release-notes' import { PullRequestReview } from './notifications/pull-request-review' @@ -164,6 +163,8 @@ import { uuid } from '../lib/uuid' import { InstallingUpdate } from './installing-update/installing-update' import { enableStackedPopups } from '../lib/feature-flag' import { DialogStackContext } from './dialog' +import { TestNotifications } from './test-notifications/test-notifications' +import { NotificationsDebugStore } from '../lib/stores/notifications-debug-store' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -185,6 +186,7 @@ interface IAppProps { readonly issuesStore: IssuesStore readonly gitHubUserStore: GitHubUserStore readonly aheadBehindStore: AheadBehindStore + readonly notificationsDebugStore: NotificationsDebugStore readonly startTime: number } @@ -489,10 +491,19 @@ export class App extends React.Component { return } - showNotification({ - title: 'Test notification', - body: 'Click here! This is a test notification', - onClick: () => this.props.dispatcher.showPopup({ type: PopupType.About }), + // if current repository is not repository with github repository, return + const repository = this.getRepository() + if ( + 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 { case PopupType.PullRequestReview: { return ( { /> ) } + case PopupType.TestNotifications: { + return ( + + ) + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } diff --git a/app/src/ui/changes/commit-message-avatar.tsx b/app/src/ui/changes/commit-message-avatar.tsx index a015c2e394..0b4830f858 100644 --- a/app/src/ui/changes/commit-message-avatar.tsx +++ b/app/src/ui/changes/commit-message-avatar.tsx @@ -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 { Select } from '../lib/select' import { Button } from '../lib/button' @@ -73,10 +71,17 @@ export class CommitMessageAvatar extends React.Component< public render() { return (
-
- {this.props.warningBadgeVisible && this.renderWarningBadge()} + {this.props.warningBadgeVisible && ( + + )} + + {!this.props.warningBadgeVisible && ( -
+ )} + {this.state.isPopoverOpen && this.renderPopover()}
) @@ -108,7 +113,7 @@ export class CommitMessageAvatar extends React.Component< }) } - private onAvatarClick = (event: React.FormEvent) => { + private onAvatarClick = (event: React.FormEvent) => { if (this.props.warningBadgeVisible === false) { return } diff --git a/app/src/ui/dialog/dialog.tsx b/app/src/ui/dialog/dialog.tsx index ff7b39598d..94840c9802 100644 --- a/app/src/ui/dialog/dialog.tsx +++ b/app/src/ui/dialog/dialog.tsx @@ -383,7 +383,7 @@ export class Dialog extends React.Component { * 4. Any remaining button * */ - private focusFirstSuitableChild() { + public focusFirstSuitableChild() { const dialog = this.dialogElement if (dialog === null) { diff --git a/app/src/ui/history/commit-summary.tsx b/app/src/ui/history/commit-summary.tsx index 904f195617..e342225d71 100644 --- a/app/src/ui/history/commit-summary.tsx +++ b/app/src/ui/history/commit-summary.tsx @@ -536,6 +536,7 @@ export class CommitSummary extends React.Component< let filesAdded = 0 let filesModified = 0 let filesRemoved = 0 + let filesRenamed = 0 for (const file of this.props.changesetData.files) { switch (file.status.kind) { case AppFileStatusKind.New: @@ -547,9 +548,14 @@ export class CommitSummary extends React.Component< case AppFileStatusKind.Deleted: filesRemoved += 1 break + case AppFileStatusKind.Renamed: + filesRenamed += 1 } } + const hasFileDescription = + filesAdded + filesModified + filesRemoved + filesRenamed > 0 + const filesLongDescription = ( <> {filesAdded > 0 ? ( @@ -579,6 +585,15 @@ export class CommitSummary extends React.Component< {filesRemoved} deleted ) : null} + {filesRenamed > 0 ? ( + + + {filesRenamed} renamed + + ) : null} ) @@ -586,7 +601,9 @@ export class CommitSummary extends React.Component< 0 ? filesLongDescription : undefined} + tooltip={ + fileCount > 0 && hasFileDescription ? filesLongDescription : undefined + } > {filesShortDescription} diff --git a/app/src/ui/index.tsx b/app/src/ui/index.tsx index caa09e662f..7e8a7f1234 100644 --- a/app/src/ui/index.tsx +++ b/app/src/ui/index.tsx @@ -79,6 +79,7 @@ import { NotificationsStore } from '../lib/stores/notifications-store' import * as ipcRenderer from '../lib/ipc-renderer' import { migrateRendererGUID } from '../lib/get-renderer-guid' import { initializeRendererNotificationHandler } from '../lib/notifications/notification-handler' +import { NotificationsDebugStore } from '../lib/stores/notifications-debug-store' if (__DEV__) { installDevGlobals() @@ -267,6 +268,12 @@ const notificationsStore = new NotificationsStore( statsStore ) +const notificationsDebugStore = new NotificationsDebugStore( + accountsStore, + notificationsStore, + pullRequestCoordinator +) + const appStore = new AppStore( gitHubUserStore, cloningRepositoriesStore, @@ -353,6 +360,7 @@ ReactDOM.render( issuesStore={issuesStore} gitHubUserStore={gitHubUserStore} aheadBehindStore={aheadBehindStore} + notificationsDebugStore={notificationsDebugStore} startTime={startTime} />, document.getElementById('desktop-app-container')! diff --git a/app/src/ui/notifications/pull-request-comment-like.tsx b/app/src/ui/notifications/pull-request-comment-like.tsx new file mode 100644 index 0000000000..e99e458b5f --- /dev/null +++ b/app/src/ui/notifications/pull-request-comment-like.tsx @@ -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 + 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 + + 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 { + public render() { + const { title, pullRequestNumber } = this.props.pullRequest + + const header = ( +
+ {this.renderPullRequestIcon()} + + {title}{' '} + #{pullRequestNumber}{' '} + +
+ ) + + return ( + + +
+ {this.renderTimelineItem()} + {this.renderCommentBubble()} +
+
+ {this.props.renderFooterContent()} +
+ ) + } + + 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 ( +
+ {this.renderDashedTimelineLine('top')} +
+ + {this.renderReviewIcon()} +
+ + {user.login} + {' '} + {eventVerb} your pull request{' '} + + {relativeReviewDate} + +
+
+ {bottomLine} +
+ ) + } + + private shouldRenderCommentBubble() { + return this.props.body !== '' + } + + private renderCommentBubble() { + if (!this.shouldRenderCommentBubble()) { + return null + } + + return ( +
+
{this.renderReviewBody()}
+ {this.renderDashedTimelineLine('bottom')} +
+ ) + } + + private renderDashedTimelineLine(type: 'top' | 'bottom') { + return ( + + {/* Need to use 0.5 for X to prevent nearest neighbour filtering causing + the line to appear semi-transparent. */} + + + ) + } + + private onMarkdownLinkClicked = (url: string) => { + this.props.dispatcher.openInBrowser(url) + } + + private renderReviewBody() { + const { body, emoji, pullRequest } = this.props + const { base } = pullRequest + + return ( + + ) + } + + private renderPullRequestIcon = () => { + const { pullRequest } = this.props + + const cls = classNames('pull-request-icon', { + draft: pullRequest.draft, + }) + + return ( + + ) + } + + private renderReviewIcon = () => { + const { eventIconSymbol, eventIconClass } = this.props + + return ( +
+ +
+ ) + } +} diff --git a/app/src/ui/notifications/pull-request-review.tsx b/app/src/ui/notifications/pull-request-review.tsx index d98303a76b..d0714aae50 100644 --- a/app/src/ui/notifications/pull-request-review.tsx +++ b/app/src/ui/notifications/pull-request-review.tsx @@ -1,24 +1,17 @@ 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 { 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 { RepositoryWithGitHubRepository } from '../../models/repository' -import { SandboxedMarkdown } from '../lib/sandboxed-markdown' import { getPullRequestReviewStateIcon, getVerbForPullRequestReview, } from './pull-request-review-helpers' 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 { getStealthEmailForUser } from '../../lib/email' +import { PullRequestCommentLike } from './pull-request-comment-like' interface IPullRequestReviewProps { readonly dispatcher: Dispatcher @@ -26,7 +19,6 @@ interface IPullRequestReviewProps { readonly repository: RepositoryWithGitHubRepository readonly pullRequest: PullRequest readonly review: ValidNotificationPullRequestReview - readonly numberOfComments: number /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ readonly emoji: Map @@ -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< IPullRequestReviewProps, @@ -62,115 +54,42 @@ export class PullRequestReview extends React.Component< } public render() { - const { title, pullRequestNumber } = this.props.pullRequest + const { + dispatcher, + accounts, + repository, + pullRequest, + emoji, + review, + onSubmit, + onDismissed, + } = this.props - const header = ( -
- {this.renderPullRequestIcon()} - - {title}{' '} - #{pullRequestNumber}{' '} - -
- ) + const icon = getPullRequestReviewStateIcon(review.state) return ( - - -
- {this.renderTimelineItem()} - {this.renderCommentBubble()} -
-
- {this.renderFooterContent()} -
+ ) } - private renderTimelineItem() { - 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 ( -
- {this.renderDashedTimelineLine('top')} -
- - {this.renderReviewIcon()} -
- - {review.user.login} - {' '} - {verb} your pull request{' '} - - {relativeReviewDate} - -
-
- {bottomLine} -
- ) - } - - private shouldRenderCommentBubble() { - return this.props.review.body !== '' - } - - private renderCommentBubble() { - if (!this.shouldRenderCommentBubble()) { - return null - } - - return ( -
-
{this.renderReviewBody()}
- {this.renderDashedTimelineLine('bottom')} -
- ) - } - - private renderDashedTimelineLine(type: 'top' | 'bottom') { - return ( - - {/* Need to use 0.5 for X to prevent nearest neighbour filtering causing - the line to appear semi-transparent. */} - - - ) - } - - private renderFooterContent() { + private renderFooterContent = () => { const { review, shouldChangeRepository, shouldCheckoutBranch } = this.props 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 ( - - ) - } - - private renderPullRequestIcon = () => { - const { pullRequest } = this.props - - const cls = classNames('pull-request-icon', { - draft: pullRequest.draft, - }) - - return ( - - ) - } - - private renderReviewIcon = () => { - const { review } = this.props - - const icon = getPullRequestReviewStateIcon(review.state) - return ( -
- -
- ) - } - private onSubmit = async (event: React.MouseEvent) => { event.preventDefault() diff --git a/app/src/ui/sign-in/sign-in.tsx b/app/src/ui/sign-in/sign-in.tsx index 9ffe0e6321..0f9689dfc5 100644 --- a/app/src/ui/sign-in/sign-in.tsx +++ b/app/src/ui/sign-in/sign-in.tsx @@ -41,6 +41,8 @@ const SignInWithBrowserTitle = __DARWIN__ const DefaultTitle = 'Sign in' export class SignIn extends React.Component { + private readonly dialogRef = React.createRef() + public constructor(props: ISignInProps) { super(props) @@ -52,6 +54,18 @@ export class SignIn extends React.Component { } } + 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) { if (nextProps.signInState !== this.props.signInState) { if ( @@ -161,6 +175,7 @@ export class SignIn extends React.Component { ) @@ -324,6 +339,7 @@ export class SignIn extends React.Component { onDismissed={this.onDismissed} onSubmit={this.onSubmit} loading={state.loading} + ref={this.dialogRef} > {errors} {this.renderStep()} diff --git a/app/src/ui/test-notifications/test-notifications.tsx b/app/src/ui/test-notifications/test-notifications.tsx new file mode 100644 index 0000000000..070a16c3a8 --- /dev/null +++ b/app/src/ui/test-notifications/test-notifications.tsx @@ -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 +} + +const TestNotificationFlows: ReadonlyArray = [ + { + 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 + readonly reviews: ReadonlyArray + readonly comments: ReadonlyArray +} + +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 ( +
+ {leftAccessory &&
{leftAccessory}
} +
{children}
+ {html_url && ( +
+ +
+ )} +
+ ) + } + + private onExternalLinkClick = (e: React.MouseEvent) => { + 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 ( + + ) + } + + 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 ( +
+

Select the type of notification to display:

+
+ {this.renderNotificationType( + TestNotificationType.PullRequestReview + )} + {this.renderNotificationType( + TestNotificationType.PullRequestReviewComment + )} +
+
+ ) + } + + const currentStep = this.state.selectedFlow.steps.at( + this.state.stepResults.size + ) + + if (currentStep === undefined) { + return

Done!

+ } + + 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 + } + + const { pullRequests } = this.state + + if (pullRequests.length === 0) { + return

No pull requests found

+ } + + return ( +
+ Pull requests: + +
+ ) + } + + 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 + } + + const { reviews } = this.state + + if (reviews.length === 0) { + return

No reviews found

+ } + + return ( +
+ Reviews: + +
+ ) + } + + 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 + } + + const { comments } = this.state + + if (comments.length === 0) { + return

No comments found

+ } + + return ( +
+ Comments: + +
+ ) + } + + 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 ( + + {comment.body} +
+ by {comment.user.login} +
+ ) + } + + private renderPullRequestReviewRow = (row: number) => { + const review = this.state.reviews[row] + + return ( + + {review.body || Review without body} +
+ by {review.user.login} +
+ ) + } + + private renderReviewStateIcon = ( + state: ValidNotificationPullRequestReviewState + ) => { + const icon = getPullRequestReviewStateIcon(state) + return ( +
+ +
+ ) + } + + 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 ( + + + #{pullRequest.pullRequestNumber} + {pullRequest.draft ? ' (Draft)' : ''}: + {' '} + {pullRequest.title}
+ by {pullRequest.author} +
+ ) + } + + private renderPullRequestStateIcon = ( + pullRequest: PullRequest + ): JSX.Element => { + return ( + + ) + } + + public render() { + return ( + + + {this.renderCurrentStep()} + + + + + ) + } + + private onBack = (event: React.MouseEvent) => { + 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 = new Map( + stepResults + ) + newStepResults.delete(lastStep) + + this.setState( + { + stepResults: newStepResults as TestNotificationStepResultMap, + }, + () => { + this.prepareForNextStep() + } + ) + } +} diff --git a/app/styles/_ui.scss b/app/styles/_ui.scss index f43653c03a..522b6b341c 100644 --- a/app/styles/_ui.scss +++ b/app/styles/_ui.scss @@ -95,7 +95,6 @@ @import 'ui/check-runs/_ci-check-run-popover'; @import 'ui/check-runs/ci-check-run-job-steps'; @import 'ui/_pull-request-checks-failed'; -@import 'ui/_pull-request-review'; @import 'ui/_sandboxed-markdown'; @import 'ui/_pull-request-quick-view'; @import 'ui/discard-changes-retry'; diff --git a/app/styles/ui/_commit-message-avatar.scss b/app/styles/ui/_commit-message-avatar.scss index 1302bdf96c..bb32cd47f5 100644 --- a/app/styles/ui/_commit-message-avatar.scss +++ b/app/styles/ui/_commit-message-avatar.scss @@ -2,6 +2,27 @@ // With this, the popover's absolute position will be relative to its parent 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 { background-color: var(--commit-warning-badge-background-color); border: var(--commit-warning-badge-border-color) 1px solid; diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index 3669ea9fd1..8613a0f617 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -22,6 +22,8 @@ @import 'dialogs/unreachable-commits'; @import 'dialogs/open-pull-request'; @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 // to the bottom of elements (with the exception of the last child). This to diff --git a/app/styles/ui/_pull-request-review.scss b/app/styles/ui/dialogs/_pull-request-comment-like.scss similarity index 96% rename from app/styles/ui/_pull-request-review.scss rename to app/styles/ui/dialogs/_pull-request-comment-like.scss index 99cd84c151..e6247a138d 100644 --- a/app/styles/ui/_pull-request-review.scss +++ b/app/styles/ui/dialogs/_pull-request-comment-like.scss @@ -1,4 +1,5 @@ -#pull-request-review { +#pull-request-review, +#pull-request-comment { --avatar-size: 40px; min-width: 500px; @@ -6,7 +7,7 @@ height: unset; } - .pull-request-review-dialog-header { + .pull-request-comment-like-dialog-header { display: flex; flex-direction: row; align-items: center; @@ -44,7 +45,7 @@ max-height: 300px; overflow: auto; - .review-container { + .comment-container { .timeline-line { width: 1px; height: 24px; @@ -103,7 +104,7 @@ .link-button-component { color: unset; - &.reviewer { + &.author { font-weight: bold; } } diff --git a/app/styles/ui/dialogs/_test-notifications.scss b/app/styles/ui/dialogs/_test-notifications.scss new file mode 100644 index 0000000000..00cb1cfa01 --- /dev/null +++ b/app/styles/ui/dialogs/_test-notifications.scss @@ -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); + } +} diff --git a/app/styles/ui/window/_tooltips.scss b/app/styles/ui/window/_tooltips.scss index 27f84b7439..ca924e6d91 100644 --- a/app/styles/ui/window/_tooltips.scss +++ b/app/styles/ui/window/_tooltips.scss @@ -193,6 +193,10 @@ body > .tooltip, color: var(--color-deleted); } + .files-renamed-icon { + color: var(--color-renamed); + } + .octicon { margin-right: var(--spacing-third); vertical-align: bottom; // For some reason, `bottom` places the text in the middle diff --git a/app/test/unit/app-test.tsx b/app/test/unit/app-test.tsx index 095dbd2ca1..3a2610d9f5 100644 --- a/app/test/unit/app-test.tsx +++ b/app/test/unit/app-test.tsx @@ -32,6 +32,7 @@ import { CommitStatusStore } from '../../src/lib/stores/commit-status-store' import { AheadBehindStore } from '../../src/lib/stores/ahead-behind-store' import { AliveStore } from '../../src/lib/stores/alive-store' import { NotificationsStore } from '../../src/lib/stores/notifications-store' +import { NotificationsDebugStore } from '../../src/lib/stores/notifications-debug-store' describe('App', () => { let appStore: AppStore @@ -41,6 +42,7 @@ describe('App', () => { let githubUserStore: GitHubUserStore let issuesStore: IssuesStore let aheadBehindStore: AheadBehindStore + let notificationsDebugStore: NotificationsDebugStore beforeEach(async () => { const db = new TestGitHubUserDatabase() @@ -86,6 +88,12 @@ describe('App', () => { ) notificationsStore.setNotificationsEnabled(false) + notificationsDebugStore = new NotificationsDebugStore( + accountsStore, + notificationsStore, + pullRequestCoordinator + ) + appStore = new AppStore( githubUserStore, new CloningRepositoriesStore(), @@ -117,6 +125,7 @@ describe('App', () => { issuesStore={issuesStore} gitHubUserStore={githubUserStore} aheadBehindStore={aheadBehindStore} + notificationsDebugStore={notificationsDebugStore} startTime={0} /> ) as unknown as React.Component diff --git a/changelog.json b/changelog.json index fa41241bc5..2bd3675baa 100644 --- a/changelog.json +++ b/changelog.json @@ -1,6 +1,9 @@ { "releases": { "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": [ "[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!", diff --git a/docs/technical/editor-integration.md b/docs/technical/editor-integration.md index 6cdb0386d4..ddba8b72b6 100644 --- a/docs/technical/editor-integration.md +++ b/docs/technical/editor-integration.md @@ -45,6 +45,7 @@ These editors are currently supported: - [RStudio](https://rstudio.com/) - [Aptana Studio](http://www.aptana.com/) - [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: @@ -272,6 +273,7 @@ These editors are currently supported: - [Emacs](https://www.gnu.org/software/emacs/) - [Lite XL](https://lite-xl.com/) - [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: diff --git a/package.json b/package.json index 5e0e53586b..e5ac739268 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "jest-diff": "^25.0.0", "jest-extended": "^0.11.2", "jest-localstorage-mock": "^2.3.0", - "jszip": "^3.7.1", + "jszip": "^3.8.0", "klaw-sync": "^3.0.0", "legal-eagle": "0.16.0", "mini-css-extract-plugin": "^2.5.3", diff --git a/yarn.lock b/yarn.lock index a9dab1caa2..2e46820054 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5245,9 +5245,9 @@ htmlparser2@^6.1.0: entities "^2.0.0" http-cache-semantics@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" - integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== http-errors@1.8.1: version "1.8.1" @@ -6558,10 +6558,10 @@ jsx-ast-utils@^3.3.2: array-includes "^3.1.5" object.assign "^4.1.3" -jszip@^3.7.1: - version "3.7.1" - resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.1.tgz#bd63401221c15625a1228c556ca8a68da6fda3d9" - integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg== +jszip@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.8.0.tgz#a2ac3c33fe96a76489765168213655850254d51b" + integrity sha512-cnpQrXvFSLdsR9KR5/x7zdf6c3m8IhZfZzSblFEHSqBaVwD2nvJ4CuCKLyvKvwBgZm08CgfSoiTBQLm5WW9hGw== dependencies: lie "~3.3.0" pako "~1.0.2"