Merge branch 'development' into private-alive-subscriptions

This commit is contained in:
Sergio Padrino 2022-01-12 18:23:50 +01:00
commit d0da30aa89
24 changed files with 723 additions and 82 deletions

View file

@ -15,6 +15,7 @@ import username from 'username'
import { GitProtocol } from './remote-parsing'
import { Emitter } from 'event-kit'
import JSZip from 'jszip'
import { updateEndpointVersion } from './endpoint-capabilities'
const envEndpoint = process.env['DESKTOP_GITHUB_DOTCOM_API_ENDPOINT']
const envHTMLURL = process.env['DESKTOP_GITHUB_DOTCOM_HTML_URL']
@ -80,14 +81,8 @@ if (!ClientID || !ClientID.length || !ClientSecret || !ClientSecret.length) {
type GitHubAccountType = 'User' | 'Organization'
/** The OAuth scopes we want to request from GitHub.com. */
const DotComOAuthScopes = ['repo', 'user', 'workflow']
/**
* The OAuth scopes we want to request from GitHub
* Enterprise.
*/
const EnterpriseOAuthScopes = ['repo', 'user']
/** The OAuth scopes we want to request */
const oauthScopes = ['repo', 'user', 'workflow']
enum HttpStatusCode {
NotModified = 304,
@ -1312,6 +1307,8 @@ export class API {
API.emitTokenInvalidated(this.endpoint)
}
tryUpdateEndpointVersionFromResponse(this.endpoint, response)
return response
}
@ -1451,7 +1448,7 @@ export async function createAuthorization(
'POST',
'authorizations',
{
scopes: getOAuthScopesForEndpoint(endpoint),
scopes: oauthScopes,
client_id: ClientID,
client_secret: ClientSecret,
note: note,
@ -1464,6 +1461,8 @@ export async function createAuthorization(
}
)
tryUpdateEndpointVersionFromResponse(endpoint, response)
try {
const result = await parsedResponse<IAPIAuthorization>(response)
if (result) {
@ -1570,6 +1569,8 @@ export async function fetchMetadata(
'Content-Type': 'application/json',
})
tryUpdateEndpointVersionFromResponse(endpoint, response)
const result = await parsedResponse<IServerMetadata>(response)
if (!result || result.verifiable_password_authentication === undefined) {
return null
@ -1679,7 +1680,7 @@ export function getOAuthAuthorizationURL(
state: string
): string {
const urlBase = getHTMLURL(endpoint)
const scopes = getOAuthScopesForEndpoint(endpoint)
const scopes = oauthScopes
const scope = encodeURIComponent(scopes.join(' '))
return `${urlBase}/login/oauth/authorize?client_id=${ClientID}&scope=${scope}&state=${state}`
}
@ -1701,6 +1702,8 @@ export async function requestOAuthToken(
code: code,
}
)
tryUpdateEndpointVersionFromResponse(endpoint, response)
const result = await parsedResponse<IAPIAccessToken>(response)
return result.access_token
} catch (e) {
@ -1709,8 +1712,12 @@ export async function requestOAuthToken(
}
}
function getOAuthScopesForEndpoint(endpoint: string) {
return endpoint === getDotComAPIEndpoint()
? DotComOAuthScopes
: EnterpriseOAuthScopes
function tryUpdateEndpointVersionFromResponse(
endpoint: string,
response: Response
) {
const gheVersion = response.headers.get('x-github-enterprise-version')
if (gheVersion !== null) {
updateEndpointVersion(endpoint, gheVersion)
}
}

View file

@ -0,0 +1,155 @@
import * as semver from 'semver'
import { getDotComAPIEndpoint } from './api'
import { assertNonNullable } from './fatal-error'
export type VersionConstraint = {
/** Whether this constrain will be satisfied when using GitHub.com */
dotcom: boolean
/**
* Whether this constrain will be satisfied when using GitHub AE
* Supports specifying a version constraint as a SemVer Range (ex: >= 3.1.0)
*/
ae: boolean | string
/**
* Whether this constrain will be satisfied when using GitHub Enterprise
* Server. Supports specifying a version constraint as a SemVer Range (ex: >=
* 3.1.0)
*/
es: boolean | string
}
/**
* If we're connected to a GHES instance but it doesn't report a version
* number (either because of corporate proxies that strip the version
* header or because GHES stops sending the version header in the future)
* we'll assume it's this version.
*
* This should correspond loosely with the oldest supported GHES series and
* needs to be updated manually.
*/
const assumedGHESVersion = new semver.SemVer('3.1.0')
/**
* If we're connected to a GHAE instance we won't know its version number
* since it doesn't report that so we'll use this substitute GHES equivalent
* version number.
*
* This should correspond loosely with the most recent GHES series and
* needs to be updated manually.
*/
const assumedGHAEVersion = new semver.SemVer('3.2.0')
/** Stores raw x-github-enterprise-version headers keyed on endpoint */
const rawVersionCache = new Map<string, string>()
/** Stores parsed x-github-enterprise-version headers keyed on endpoint */
const versionCache = new Map<string, semver.SemVer | null>()
/** Get the cache key for a given endpoint address */
const endpointVersionKey = (ep: string) => `endpoint-version:${ep}`
/**
* Whether or not the given endpoint URI matches GitHub.com's
*
* I.e. https://api.github.com/
*
* Most often used to check if an endpoint _isn't_ GitHub.com meaning it's
* either GitHub Enterprise Server or GitHub AE
*/
export const isDotCom = (ep: string) => ep === getDotComAPIEndpoint()
/**
* Whether or not the given endpoint URI appears to point to a GitHub AE
* instance
*/
export const isGHAE = (ep: string) =>
/^https:\/\/[a-z0-9-]+\.ghe\.com$/i.test(ep)
/**
* Whether or not the given endpoint URI appears to point to a GitHub Enterprise
* Server instance
*/
export const isGHES = (ep: string) => !isDotCom(ep) && !isGHAE(ep)
function getEndpointVersion(endpoint: string) {
const key = endpointVersionKey(endpoint)
const cached = versionCache.get(key)
if (cached !== undefined) {
return cached
}
const raw = localStorage.getItem(key)
const parsed = raw === null ? null : semver.parse(raw)
if (parsed !== null) {
versionCache.set(key, parsed)
}
return parsed
}
/**
* Update the known version number for a given endpoint
*/
export function updateEndpointVersion(endpoint: string, version: string) {
const key = endpointVersionKey(endpoint)
if (rawVersionCache.get(key) !== version) {
const parsed = semver.parse(version)
localStorage.setItem(key, version)
rawVersionCache.set(key, version)
versionCache.set(key, parsed)
}
}
function checkConstraint(
epConstraint: string | boolean,
epMatchesType: boolean,
epVersion?: semver.SemVer
) {
// Denial of endpoint type regardless of version
if (epConstraint === false) {
return false
}
// Approval of endpoint type regardless of version
if (epConstraint === true) {
return epMatchesType
}
// Version number constraint
assertNonNullable(epVersion, `Need to provide a version to compare against`)
return epMatchesType && semver.satisfies(epVersion, epConstraint)
}
/**
* Returns a predicate which verifies whether a given endpoint matches the
* provided constraints.
*
* Note: NOT meant for direct consumption, only exported for testability reasons.
* Consumers should use the various `supports*` methods instead.
*/
export const endpointSatisfies = (
{ dotcom, ae, es }: VersionConstraint,
getVersion = getEndpointVersion
) => (ep: string) =>
checkConstraint(dotcom, isDotCom(ep)) ||
checkConstraint(ae, isGHAE(ep), assumedGHAEVersion) ||
checkConstraint(es, isGHES(ep), getVersion(ep) ?? assumedGHESVersion)
/**
* Whether or not the endpoint supports the internal GitHub Enterprise Server
* avatars API
*/
export const supportsAvatarsAPI = endpointSatisfies({
dotcom: false,
ae: '>= 3.0.0',
es: '>= 3.0.0',
})
export const supportsRerunningChecks = endpointSatisfies({
dotcom: true,
ae: '>= 3.4.0',
es: '>= 3.4.0',
})

View file

@ -4,9 +4,5 @@
* considered a hard limit, i.e. older versions of GitHub Enterprise
* might (and probably do) work just fine but this should be a fairly
* recent version that we can safely say that we'll work well with.
*
* I picked the current minimum (2.8) because it was the version
* running on our internal GitHub Enterprise instance at the time
* we implemented Enterprise sign in (desktop/desktop#664)
*/
export const minimumSupportedEnterpriseVersion = '2.8.0'
export const minimumSupportedEnterpriseVersion = '3.0.0'

View file

@ -0,0 +1,145 @@
import { INodeFilter } from './node-filter'
import * as FSE from 'fs-extra'
import { escapeRegExp } from '../helpers/regex'
import uri2path from 'file-uri-to-path'
/**
* The Emoji Markdown filter will take a text node and create multiple text and
* image nodes by inserting emoji images using base64 data uri where emoji
* references are in the text node.
*
* Example: A text node of "That is great! :+1: Good Job!"
* Becomes three nodes: "That is great! ",<img src="data uri for :+1:>, " Good Job!"
*
* Notes: We are taking the emoji file paths and creating the base 64 data URI
* because this is to be injected into a sandboxed markdown parser were we will
* no longer have access to the local file paths.
*/
export class EmojiFilter implements INodeFilter {
private readonly emojiRegex: RegExp
private readonly emojiFilePath: Map<string, string>
private readonly emojiBase64URICache: Map<string, string> = new Map()
/**
* @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path.
*/
public constructor(emojiFilePath: Map<string, string>) {
this.emojiFilePath = emojiFilePath
this.emojiRegex = this.buildEmojiRegExp(emojiFilePath)
}
/**
* Emoji filter iterates on all text nodes that are not inside a pre or code tag.
*/
public createFilterTreeWalker(doc: Document): TreeWalker {
return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
return node.parentNode !== null &&
['CODE', 'PRE'].includes(node.parentNode.nodeName)
? NodeFilter.FILTER_SKIP
: NodeFilter.FILTER_ACCEPT
},
})
}
/**
* Takes a text node and creates multiple text and image nodes by inserting
* emoji image nodes using base64 data uri where emoji references are.
*
* Example: A text node of "That is great! :+1: Good Job!" Becomes three
* nodes: ["That is great! ",<img src="data uri for :+1:>, " Good Job!"]
*
* Note: Emoji filter requires text nodes; otherwise we may inadvertently replace non text elements.
*/
public async filter(node: Node): Promise<ReadonlyArray<Node> | null> {
let text = node.textContent
if (
node.nodeType !== node.TEXT_NODE ||
text === null ||
!text.includes(':')
) {
return null
}
const emojiMatches = text.match(this.emojiRegex)
if (emojiMatches === null) {
return null
}
const nodes = new Array<Text | HTMLImageElement>()
for (let i = 0; i < emojiMatches.length; i++) {
const emojiKey = emojiMatches[i]
const emojiPath = this.emojiFilePath.get(emojiKey)
if (emojiPath === undefined) {
continue
}
const emojiImg = await this.createEmojiNode(emojiPath)
if (emojiImg === null) {
continue
}
const emojiPosition = text.indexOf(emojiKey)
const textBeforeEmoji = text.slice(0, emojiPosition)
const textNodeBeforeEmoji = document.createTextNode(textBeforeEmoji)
nodes.push(textNodeBeforeEmoji)
nodes.push(emojiImg)
text = text.slice(emojiPosition + emojiKey.length)
}
if (text !== '') {
const trailingTextNode = document.createTextNode(text)
nodes.push(trailingTextNode)
}
return nodes
}
/**
* Method to build an emoji image node to insert in place of the emoji ref.
* If we fail to create the image element, returns null.
*/
private async createEmojiNode(
emojiPath: string
): Promise<HTMLImageElement | null> {
try {
const dataURI = await this.getBase64FromImageUrl(emojiPath)
const emojiImg = new Image()
emojiImg.classList.add('emoji')
emojiImg.src = dataURI
return emojiImg
} catch (e) {}
return null
}
/**
* Method to obtain an images base 64 data uri from it's file path.
* - It checks cache, if not, reads from file, then stores in cache.
*/
private async getBase64FromImageUrl(filePath: string): Promise<string> {
const cached = this.emojiBase64URICache.get(filePath)
if (cached !== undefined) {
return cached
}
const imageBuffer = await FSE.readFile(uri2path(filePath))
const b64src = imageBuffer.toString('base64')
const uri = `data:image/png;base64,${b64src}`
this.emojiBase64URICache.set(filePath, uri)
return uri
}
/**
* Builds a regular expression that is looking for all group of characters
* that represents any emoji ref (or map key) in the provided map.
*
* @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path.
*/
private buildEmojiRegExp(emoji: Map<string, string>): RegExp {
const emojiGroups = [...emoji.keys()]
.map(emoji => escapeRegExp(emoji))
.join('|')
return new RegExp('(' + emojiGroups + ')', 'g')
}
}

View file

@ -0,0 +1,89 @@
import memoizeOne from 'memoize-one'
import { EmojiFilter } from './emoji-filter'
export interface INodeFilter {
/**
* Creates a document tree walker filtered to the nodes relevant to the node filter.
*
* Examples:
* 1) An Emoji filter operates on all text nodes, but not inside pre or code tags.
* 2) The issue mention filter operates on all text nodes, but not inside pre, code, or anchor tags
*/
createFilterTreeWalker(doc: Document): TreeWalker
/**
* This filter accepts a document node and searches for it's pattern within it.
*
* If found, returns an array of nodes to replace the node with.
* Example: [Node(contents before match), Node(match replacement), Node(contents after match)]
* If not found, returns null
*
* This is asynchronous as some filters have data must be fetched or, like in
* emoji, the conversion to base 64 data uri is asynchronous
* */
filter(node: Node): Promise<ReadonlyArray<Node> | null>
}
/**
* Builds an array of node filters to apply to markdown html. Referring to it as pipe
* because they will be applied in the order they are entered in the returned
* array. This is important as some filters impact others.
*
* @param emoji Map from the emoji shortcut (e.g., :+1:) to the image's local path.
*/
export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
(emoji: Map<string, string>): ReadonlyArray<INodeFilter> => [
new EmojiFilter(emoji),
]
)
/**
* Method takes an array of node filters and applies them to a markdown string.
*
* It converts the markdown string into a DOM Document. Then, iterates over each
* provided filter. Each filter will have method to create a tree walker to
* limit the document nodes relative to the filter's purpose. Then, it will
* replace any affected node with the node(s) generated by the node filter. If a
* node is not impacted, it is not replace.
*/
export async function applyNodeFilters(
nodeFilters: ReadonlyArray<INodeFilter>,
parsedMarkdown: string
): Promise<string> {
const mdDoc = new DOMParser().parseFromString(parsedMarkdown, 'text/html')
for (const nodeFilter of nodeFilters) {
await applyNodeFilter(nodeFilter, mdDoc)
}
return mdDoc.documentElement.innerHTML
}
/**
* Method uses a NodeFilter to replace any nodes that match the filters tree
* walker and filter change criteria.
*
* Note: This mutates; it does not return a changed copy of the DOM Document
* provided.
*/
async function applyNodeFilter(
nodeFilter: INodeFilter,
mdDoc: Document
): Promise<void> {
const walker = nodeFilter.createFilterTreeWalker(mdDoc)
let textNode = walker.nextNode()
while (textNode !== null) {
const replacementNodes = await nodeFilter.filter(textNode)
const currentNode = textNode
textNode = walker.nextNode()
if (replacementNodes === null) {
continue
}
for (const replacementNode of replacementNodes) {
currentNode.parentNode?.insertBefore(replacementNode, currentNode)
}
currentNode.parentNode?.removeChild(currentNode)
}
}

View file

@ -225,7 +225,7 @@ export type Popup =
| {
type: PopupType.PushRejectedDueToMissingWorkflowScope
rejectedPath: string
repository: Repository
repository: RepositoryWithGitHubRepository
}
| {
type: PopupType.SAMLReauthRequired

View file

@ -1148,13 +1148,7 @@ export class App extends React.Component<IAppProps, IAppState> {
private viewRepositoryOnGitHub() {
const repository = this.getRepository()
if (repository instanceof Repository) {
const url = getGitHubHtmlUrl(repository)
if (url) {
this.props.dispatcher.openInBrowser(url)
}
}
this.viewOnGitHub(repository)
}
/** Returns the URL to the current repository if hosted on GitHub */
@ -2324,6 +2318,7 @@ export class App extends React.Component<IAppProps, IAppState> {
this.state.askForConfirmationOnRepositoryRemoval
}
onRemoveRepository={this.removeRepository}
onViewOnGitHub={this.viewOnGitHub}
onOpenInShell={this.openInShell}
onShowRepository={this.showRepository}
onOpenInExternalEditor={this.openInExternalEditor}
@ -2334,6 +2329,20 @@ export class App extends React.Component<IAppProps, IAppState> {
)
}
private viewOnGitHub = (
repository: Repository | CloningRepository | null
) => {
if (!(repository instanceof Repository)) {
return
}
const url = getGitHubHtmlUrl(repository)
if (url) {
this.props.dispatcher.openInBrowser(url)
}
}
private openInShell = (repository: Repository | CloningRepository) => {
if (!(repository instanceof Repository)) {
return
@ -2577,6 +2586,7 @@ export class App extends React.Component<IAppProps, IAppState> {
this.state.currentOnboardingTutorialStep === TutorialStep.CreateBranch
}
showCIStatusPopover={this.state.showCIStatusPopover}
emoji={this.state.emoji}
/>
)
}

View file

@ -46,6 +46,9 @@ interface IBranchesContainerProps {
/** Are we currently loading pull requests? */
readonly isLoadingPullRequests: boolean
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
}
interface IBranchesContainerState {
@ -125,6 +128,7 @@ export class BranchesContainer extends React.Component<
return (
<PullRequestQuickView
dispatcher={this.props.dispatcher}
emoji={this.props.emoji}
pullRequest={pr}
pullRequestItemTop={prListItemTop}
onMouseEnter={this.onMouseEnterPullRequestQuickView}

View file

@ -767,7 +767,7 @@ export class CommitMessage extends React.Component<
}
public render() {
const className = classNames({
const className = classNames('commit-message-component', {
'with-action-bar': this.isActionBarEnabled,
'with-co-authors': this.isCoAuthorInputVisible,
})
@ -786,7 +786,6 @@ export class CommitMessage extends React.Component<
return (
<div
id="commit-message"
role="group"
aria-label="Create commit"
className={className}

View file

@ -34,7 +34,7 @@ export const CommitWarning: React.FunctionComponent<{
readonly icon: CommitWarningIcon
}> = props => {
return (
<div id="commit-warning" onContextMenu={ignoreContextMenu}>
<div className="commit-warning-component" onContextMenu={ignoreContextMenu}>
<div className="warning-icon-container">{renderIcon(props.icon)}</div>
<div className="warning-message">{props.children}</div>
</div>

View file

@ -12,17 +12,14 @@ import {
} from '../../lib/ci-checks/ci-checks'
import { Octicon, syncClockwise } from '../octicons'
import { Button } from '../lib/button'
import {
APICheckConclusion,
getDotComAPIEndpoint,
IAPIWorkflowJobStep,
} from '../../lib/api'
import { APICheckConclusion, IAPIWorkflowJobStep } from '../../lib/api'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import { CICheckRunList } from './ci-check-run-list'
import { encodePathAsUrl } from '../../lib/path'
import { PopupType } from '../../models/popup'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { Donut } from '../donut'
import { supportsRerunningChecks } from '../../lib/endpoint-capabilities'
const BlankSlateImage = encodePathAsUrl(
__dirname,
@ -222,7 +219,7 @@ export class CICheckRunPopover extends React.PureComponent<
private renderRerunButton = () => {
const { checkRuns } = this.state
if (this.props.repository.endpoint !== getDotComAPIEndpoint()) {
if (!supportsRerunningChecks(this.props.repository.endpoint)) {
return null
}

View file

@ -16,7 +16,6 @@ import {
Repository,
isRepositoryWithGitHubRepository,
} from '../../models/repository'
import { getDotComAPIEndpoint } from '../../lib/api'
import { hasWritePermission } from '../../models/github-repository'
import { RetryActionType } from '../../models/retry-actions'
import { parseFilesToBeOverwritten } from '../lib/parse-files-to-be-overwritten'
@ -448,12 +447,7 @@ export async function refusedWorkflowUpdate(
return error
}
if (repository.gitHubRepository === null) {
return error
}
// DotCom only for now.
if (repository.gitHubRepository.endpoint !== getDotComAPIEndpoint()) {
if (!isRepositoryWithGitHubRepository(repository)) {
return error
}

View file

@ -3,9 +3,13 @@ import * as FSE from 'fs-extra'
import * as Path from 'path'
import marked from 'marked'
import DOMPurify from 'dompurify'
import {
applyNodeFilters,
buildCustomMarkDownNodeFilterPipe,
} from '../../lib/markdown-filters/node-filter'
interface ISandboxedMarkdownProps {
/** A string of unparsed markdownm to display */
/** A string of unparsed markdown to display */
readonly markdown: string
/** The baseHref of the markdown content for when the markdown has relative links */
@ -19,6 +23,12 @@ interface ISandboxedMarkdownProps {
* this will not fire.
*/
readonly onMarkdownLinkClicked?: (url: string) => void
/** A callback for after the markdown has been parsed and the contents have
* been mounted to the iframe */
readonly onMarkdownParsed?: () => void
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
}
/**
@ -142,6 +152,7 @@ export class SandboxedMarkdown extends React.PureComponent<
// to prevent scrollbar/content cut off.
const divHeight = docEl.clientHeight + 50
this.frameContainingDivRef.style.height = `${divHeight}px`
this.props.onMarkdownParsed?.()
}
/**
@ -189,11 +200,19 @@ export class SandboxedMarkdown extends React.PureComponent<
const styleSheet = await this.getInlineStyleSheet()
const parsedMarkdown = marked(this.props.markdown ?? '', {
// https://marked.js.org/using_advanced If true, use approved GitHub
// Flavored Markdown (GFM) specification.
gfm: true,
// https://marked.js.org/using_advanced, If true, add <br> on a single
// line break (copies GitHub behavior on comments, but not on rendered
// markdown files). Requires gfm be true.
breaks: true,
})
const sanitizedHTML = DOMPurify.sanitize(parsedMarkdown)
const filteredHTML = await this.applyCustomMarkdownFilters(sanitizedHTML)
const src = `
<html>
<head>
@ -202,8 +221,8 @@ export class SandboxedMarkdown extends React.PureComponent<
</head>
<body class="markdown-body">
<div id="content">
${sanitizedHTML}
</div
${filteredHTML}
</div>
</body>
</html>
`
@ -224,6 +243,17 @@ export class SandboxedMarkdown extends React.PureComponent<
this.frameRef.src = `data:text/html;charset=utf-8;base64,${b64src}`
}
/**
* Applies custom markdown filters to parsed markdown html. This is done
* through converting the markdown html into a DOM document and then
* traversing the nodes to apply custom filters such as emoji, issue, username
* mentions, etc.
*/
private applyCustomMarkdownFilters(parsedMarkdown: string): Promise<string> {
const nodeFilters = buildCustomMarkDownNodeFilterPipe(this.props.emoji)
return applyNodeFilters(nodeFilters, parsedMarkdown)
}
public render() {
return (
<div

View file

@ -14,6 +14,11 @@ import classNames from 'classnames'
* body and 56 for header)
*/
const maxQuickViewHeight = 556
/**
* This is currently statically defined so not bothering to attain it from dom
* searching.
*/
const heightPRListItem = 47
interface IPullRequestQuickViewProps {
readonly dispatcher: Dispatcher
@ -26,25 +31,32 @@ interface IPullRequestQuickViewProps {
/** When mouse leaves the PR quick view */
readonly onMouseLeave: () => void
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
}
interface IPullRequestQuickViewState {
readonly position: React.CSSProperties | undefined
readonly top: number
}
export class PullRequestQuickView extends React.Component<
IPullRequestQuickViewProps,
IPullRequestQuickViewState
> {
private quickViewRef: HTMLDivElement | null = null
private quickViewRef = React.createRef<HTMLDivElement>()
private get quickViewHeight(): number {
return this.quickViewRef.current?.clientHeight ?? maxQuickViewHeight
}
public constructor(props: IPullRequestQuickViewProps) {
super(props)
this.state = {
position: this.calculatePosition(
top: this.calculatePosition(
props.pullRequestItemTop,
maxQuickViewHeight
this.quickViewHeight
),
}
}
@ -52,24 +64,35 @@ export class PullRequestQuickView extends React.Component<
public componentDidUpdate = (prevProps: IPullRequestQuickViewProps) => {
if (
prevProps.pullRequest.pullRequestNumber ===
this.props.pullRequest.pullRequestNumber ||
this.quickViewRef === null
this.props.pullRequest.pullRequestNumber
) {
return
}
this.updateQuickViewPosition()
}
private updateQuickViewPosition = () => {
this.setState({
position: this.calculatePosition(
top: this.calculatePosition(
this.props.pullRequestItemTop,
this.quickViewRef.clientHeight
this.quickViewHeight
),
})
}
private viewOnGitHub = () => {
private onMarkdownParsed = () => {
this.updateQuickViewPosition()
}
private onViewOnGitHub = () => {
this.props.dispatcher.showPullRequestByPR(this.props.pullRequest)
}
private onMouseLeave = () => {
this.props.onMouseLeave()
}
/**
* Important to retrieve as it changes for maximization on macOS and quick
* view is relative to the top of branch container = foldout-container. But if
@ -86,11 +109,8 @@ export class PullRequestQuickView extends React.Component<
private calculatePosition(
prListItemTop: number,
quickViewHeight: number
): React.CSSProperties | undefined {
): number {
const topOfPRList = this.getTopPRList()
// This is currently staticly defined so not bothering to attain it from
// dom searching.
const heightPRListItem = 47
// We want to make sure that the quick view is always visible and highest
// being aligned to top of branch/pr dropdown (which is 0 since this is a
@ -101,14 +121,14 @@ export class PullRequestQuickView extends React.Component<
// Check if it has room to display aligned to top (likely top half of list)
if (window.innerHeight - prListItemTop > quickViewHeight) {
const alignedTop = prListItemTop - topOfPRList
return { top: clamp(alignedTop, minTop, maxTop) }
return clamp(alignedTop, minTop, maxTop)
}
// Can't align to top -> likely bottom half of list check if has room to display aligned to bottom.
if (prListItemTop - quickViewHeight > 0) {
const alignedTop = prListItemTop - topOfPRList
const alignedBottom = alignedTop - quickViewHeight + heightPRListItem
return { top: clamp(alignedBottom, minTop, maxTop) }
return clamp(alignedBottom, minTop, maxTop)
}
// If not enough room to display aligned top or bottom, attempt to center on
@ -118,11 +138,17 @@ export class PullRequestQuickView extends React.Component<
const middlePrListItem = prListItemTop + heightPRListItem / 2
const middleQuickView = quickViewHeight / 2
const alignedMiddle = middlePrListItem - middleQuickView
return { top: clamp(alignedMiddle, minTop, maxTop) }
return clamp(alignedMiddle, minTop, maxTop)
}
private onQuickViewRef = (quickViewRef: HTMLDivElement) => {
this.quickViewRef = quickViewRef
private getPointerPosition(top: number): React.CSSProperties {
const prListItemTopWRTQuickViewTopZero =
this.props.pullRequestItemTop - this.getTopPRList()
const prListItemPositionWRToQuickViewTop =
prListItemTopWRTQuickViewTopZero - top
const centerPointOnListItem =
prListItemPositionWRToQuickViewTop + heightPRListItem / 2
return { top: centerPointOnListItem }
}
private renderHeader = (): JSX.Element => {
@ -130,7 +156,7 @@ export class PullRequestQuickView extends React.Component<
<header className="header">
<Octicon symbol={OcticonSymbol.listUnordered} />
<div className="action-needed">Review requested</div>
<Button className="button-with-icon" onClick={this.viewOnGitHub}>
<Button className="button-with-icon" onClick={this.onViewOnGitHub}>
View on GitHub
<Octicon symbol={OcticonSymbol.linkExternal} />
</Button>
@ -180,29 +206,32 @@ export class PullRequestQuickView extends React.Component<
</div>
<SandboxedMarkdown
markdown={displayBody}
emoji={this.props.emoji}
baseHref={base.gitHubRepository.htmlURL}
onMarkdownParsed={this.onMarkdownParsed}
/>
</div>
)
}
private onMouseLeave = () => {
this.props.onMouseLeave()
}
public render() {
const { top } = this.state
return (
<div
className="pull-request-quick-view"
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.onMouseLeave}
style={this.state.position}
ref={this.onQuickViewRef}
style={{ top }}
ref={this.quickViewRef}
>
<div className="pull-request-quick-view-contents">
{this.renderHeader()}
{this.renderPR()}
</div>
<div
className="pull-request-pointer"
style={this.getPointerPosition(top)}
></div>
</div>
)
}

View file

@ -49,6 +49,9 @@ interface IRepositoriesListProps {
/** Called when the repository should be shown in Finder/Explorer/File Manager. */
readonly onShowRepository: (repository: Repositoryish) => void
/** Called when the repository should be opened on GitHub in the default web browser. */
readonly onViewOnGitHub: (repository: Repositoryish) => void
/** Called when the repository should be shown in the shell. */
readonly onOpenInShell: (repository: Repositoryish) => void
@ -137,6 +140,7 @@ export class RepositoriesList extends React.Component<
}
onRemoveRepository={this.props.onRemoveRepository}
onShowRepository={this.props.onShowRepository}
onViewOnGitHub={this.props.onViewOnGitHub}
onOpenInShell={this.props.onOpenInShell}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
onChangeRepositoryAlias={this.onChangeRepositoryAlias}

View file

@ -32,6 +32,9 @@ interface IRepositoryListItemProps {
/** Called when the repository should be shown in Finder/Explorer/File Manager. */
readonly onShowRepository: (repository: Repositoryish) => void
/** Called when the repository should be opened on GitHub in the default web browser. */
readonly onViewOnGitHub: (repository: Repositoryish) => void
/** Called when the repository should be shown in the shell. */
readonly onOpenInShell: (repository: Repositoryish) => void
@ -153,6 +156,8 @@ export class RepositoryListItem extends React.Component<
const repository = this.props.repository
const missing = repository instanceof Repository && repository.missing
const github =
repository instanceof Repository && repository.gitHubRepository != null
const openInExternalEditor = this.props.externalEditorLabel
? `Open in ${this.props.externalEditorLabel}`
: DefaultEditorLabel
@ -164,6 +169,11 @@ export class RepositoryListItem extends React.Component<
action: this.copyToClipboard,
},
{ type: 'separator' },
{
label: 'View on GitHub',
action: this.viewOnGitHub,
enabled: github,
},
{
label: `Open in ${this.props.shellLabel}`,
action: this.openInShell,
@ -224,6 +234,10 @@ export class RepositoryListItem extends React.Component<
this.props.onShowRepository(this.props.repository)
}
private viewOnGitHub = () => {
this.props.onViewOnGitHub(this.props.repository)
}
private openInShell = () => {
this.props.onOpenInShell(this.props.repository)
}

View file

@ -56,6 +56,9 @@ interface IBranchDropdownProps {
readonly shouldNudge: boolean
readonly showCIStatusPopover: boolean
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
}
interface IBranchDropdownState {
readonly badgeBottom: number
@ -94,6 +97,7 @@ export class BranchDropdown extends React.Component<
pullRequests={this.props.pullRequests}
currentPullRequest={this.props.currentPullRequest}
isLoadingPullRequests={this.props.isLoadingPullRequests}
emoji={this.props.emoji}
/>
)
}

View file

@ -2,16 +2,16 @@ import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Dispatcher } from '../dispatcher'
import { Ref } from '../lib/ref'
import { Repository } from '../../models/repository'
import { RepositoryWithGitHubRepository } from '../../models/repository'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { getDotComAPIEndpoint } from '../../lib/api'
const okButtonText = __DARWIN__ ? 'Continue in Browser' : 'Continue in browser'
interface IWorkflowPushRejectedDialogProps {
readonly rejectedPath: string
readonly repository: Repository
readonly repository: RepositoryWithGitHubRepository
readonly dispatcher: Dispatcher
readonly onDismissed: () => void
}
interface IWorkflowPushRejectedDialogState {
@ -62,9 +62,19 @@ export class WorkflowPushRejectedDialog extends React.Component<
private onSignIn = async () => {
this.setState({ loading: true })
await this.props.dispatcher.requestBrowserAuthenticationToDotcom()
const { repository, dispatcher } = this.props
const { endpoint } = repository.gitHubRepository
this.props.dispatcher.push(this.props.repository)
if (endpoint === getDotComAPIEndpoint()) {
await dispatcher.beginDotComSignIn()
} else {
await dispatcher.beginEnterpriseSignIn()
await dispatcher.setSignInEndpoint(endpoint)
}
await dispatcher.requestBrowserAuthentication()
dispatcher.push(repository)
this.props.onDismissed()
}
}

View file

@ -295,7 +295,9 @@ changes from the original, just a note that this will not match 1-1)
.markdown-body .emoji {
max-width: none;
vertical-align: text-top;
background-color: transparent
background-color: transparent;
height: 1em;
width: auto;
}
.markdown-body span.frame {

View file

@ -4,10 +4,9 @@
.pull-request-quick-view-contents {
background-color: var(--background-color);
margin: 0 var(--spacing-double);
margin: 0 var(--spacing);
min-width: 400px;
border-radius: var(--border-radius);
overflow: hidden;
.header {
display: flex;
@ -54,4 +53,30 @@
}
}
}
.pull-request-pointer {
position: absolute;
top: 23px;
left: -6px;
pointer-events: none;
margin-top: -7px;
&::before,
&::after {
display: inline-block;
position: absolute;
content: '';
}
&::before {
border: 8px solid transparent;
border-right-color: var(--box-border-color);
}
&::after {
border: 7px solid transparent;
border-right-color: var(--background-color);
left: 2px;
}
}
}

View file

@ -1,7 +1,7 @@
@import '../../mixins';
/** A React component holding the commit message entry */
#commit-message {
.commit-message-component {
border-top: 1px solid var(--box-border-color);
flex-direction: column;
flex-shrink: 0;

View file

@ -1,6 +1,6 @@
@import '../../mixins';
#commit-warning {
.commit-warning-component {
border-top: 1px solid var(--box-border-color);
flex-direction: column;
flex-shrink: 0;

View file

@ -2,7 +2,18 @@ dialog#commit-message-dialog {
.dialog-content {
padding: 0;
}
#commit-message {
.commit-message-component,
.commit-warning-component {
background-color: inherit;
}
.commit-warning-component {
.warning-icon-container {
.warning-icon,
.information-icon {
background-color: var(--background-color);
}
}
}
}

View file

@ -0,0 +1,116 @@
import {
endpointSatisfies,
VersionConstraint,
} from '../../src/lib/endpoint-capabilities'
import { SemVer, parse } from 'semver'
import { getDotComAPIEndpoint } from '../../src/lib/api'
import { forceUnwrap } from '../../src/lib/fatal-error'
describe('endpoint-capabilities', () => {
describe('endpointSatisfies', () => {
it('recognizes github.com', () => {
expect(testDotCom(true)).toBeTrue()
expect(testDotCom(false)).toBeFalse()
})
it('recognizes GHES', () => {
expect(testGHES(false)).toBeFalse()
expect(testGHES(true)).toBeTrue()
})
it('recognizes GHAE', () => {
expect(testGHAE(false)).toBeFalse()
expect(testGHAE(true)).toBeTrue()
})
// GHAE doesn't advertise the installed version so we'll assume its
// capabilities match that of a recent supported version of GHES. This is
// defined in the `assumedGHAEVersion` constant in endpoint-capabilities.ts
// and needs to be updated periodically.
it('assumes GHAE versions', () => {
expect(testGHAE('>= 3.2.1')).toBeFalse()
expect(testGHAE('>= 3.2.0')).toBeTrue()
})
// If we can't determine the actual version of a GitHub Enterprise Server
// instance we'll assume it's running the oldest still supported version
// of GHES. This is defined in the `assumedGHESVersion` constant in
// endpoint-capabilities.ts and needs to be updated periodically.
it('assumes GHES versions', () => {
expect(testGHES('>= 3.1.1')).toBeFalse()
expect(testGHES('>= 3.1.0')).toBeTrue()
})
it('parses semver ranges', () => {
expect(testGHES('>= 1', '1.0.0')).toBeTrue()
expect(testGHES('> 1.0.0', '1.0.0')).toBeFalse()
expect(testGHES('> 0.9.9', '1.0.0')).toBeTrue()
})
it('deals with common cases (smoketest)', () => {
expect(
testEndpoint('https://api.github.com', {
dotcom: true,
ae: '>= 3.0.0',
es: '>= 3.0.0',
})
).toBeTrue()
expect(
testEndpoint(
'https://ghe.io',
{
dotcom: false,
ae: '>= 4.0.0',
es: '>= 3.1.0',
},
'3.1.0'
)
).toBeTrue()
})
})
})
function testDotCom(
constraint: boolean,
endpointVersion: string | SemVer | null = null
) {
return testEndpoint(
getDotComAPIEndpoint(),
{ dotcom: constraint, ae: false, es: false },
endpointVersion
)
}
function testGHES(
constraint: boolean | string,
endpointVersion: string | SemVer | null = null
) {
return testEndpoint(
'https://ghe.io',
{ dotcom: false, ae: false, es: constraint },
endpointVersion
)
}
function testGHAE(
constraint: boolean | string,
endpointVersion: string | SemVer | null = null
) {
return testEndpoint(
'https://corp.ghe.com',
{ dotcom: false, ae: constraint, es: false },
endpointVersion
)
}
function testEndpoint(
endpoint: string,
constraint: VersionConstraint,
endpointVersion: string | SemVer | null = null
) {
const version = endpointVersion
? forceUnwrap(`Couldn't parse endpoint version`, parse(endpointVersion))
: null
return endpointSatisfies(constraint, () => version)(endpoint)
}