diff --git a/app/components/CodeIntelStatusIndicator.tsx b/app/components/CodeIntelStatusIndicator.tsx index cc140e10..15d98211 100644 --- a/app/components/CodeIntelStatusIndicator.tsx +++ b/app/components/CodeIntelStatusIndicator.tsx @@ -213,7 +213,7 @@ export class CodeIntelStatusIndicator extends React.Component< <>

No language server connected

Check{' '} - + langserver.org {' '} for {language} language servers @@ -248,7 +248,11 @@ export class CodeIntelStatusIndicator extends React.Component< <>

Connected to the - + {this.state.langServerOrError.displayName || language} language server

@@ -307,18 +311,28 @@ export class CodeIntelStatusIndicator extends React.Component< )} {this.props.userIsSiteAdmin && (

- Manage + + Manage +

)} {this.state.langServerOrError.issuesURL && (

- + Report issue

)}

caught)) ), makeRepoURI diff --git a/app/repo/index.tsx b/app/repo/index.tsx index 05a901a4..b52f2789 100644 --- a/app/repo/index.tsx +++ b/app/repo/index.tsx @@ -300,6 +300,25 @@ export function makeRepoURI(parsed: ParsedRepoURI): RepoURI { return uri } +export function normalizeRepoPath(origin: string): string { + let repoPath = origin + repoPath = repoPath.replace('\\', '') + if (origin.startsWith('git@')) { + repoPath = origin.substr('git@'.length) + repoPath = repoPath.replace(':', '/') + } else if (origin.startsWith('git://')) { + repoPath = origin.substr('git://'.length) + } else if (origin.startsWith('https://')) { + repoPath = origin.substr('https://'.length) + } else if (origin.includes('@')) { + // Assume the origin looks like `username@host:repo/path` + const split = origin.split('@') + repoPath = split[1] + repoPath = repoPath.replace(':', '/') + } + return repoPath.replace(/.git$/, '') +} + /** * A file at an exact commit of a known programming language */ diff --git a/app/review-board/backend/fetch.ts b/app/review-board/backend/fetch.ts new file mode 100644 index 00000000..ac7bbf4a --- /dev/null +++ b/app/review-board/backend/fetch.ts @@ -0,0 +1,19 @@ +import { from, Observable } from 'rxjs' +import { ReviewBoardRepository } from '../util' + +function getRepository(repoID: number): Promise { + return new Promise((resolve, reject) => { + fetch(`${window.location.origin}/api/repositories/${repoID}/`, { + method: 'GET', + credentials: 'include', + headers: new Headers({ Accept: 'application/json' }), + }) + .then(resp => resp.json()) + .then(resp => resolve(resp.repository)) + .catch(err => reject(err)) + }) +} + +export function getRepositoryFromReviewBoardAPI(repoID: number): Observable { + return from(getRepository(repoID)) +} diff --git a/app/review-board/dom/util.tsx b/app/review-board/dom/util.tsx new file mode 100644 index 00000000..844bf8ff --- /dev/null +++ b/app/review-board/dom/util.tsx @@ -0,0 +1,119 @@ +import { DiffPart, DOMFunctions } from '@sourcegraph/codeintellify' + +/** + * Gets the `` element for a target that contains the code + */ +const getCodeCellFromTarget = (target: HTMLElement): HTMLElement | null => { + if (target.nodeName === 'PRE') { + return null + } + const pre = target.closest('pre') as HTMLElement + if (!pre) { + return null + } + if (target.innerText.trim().length === 0) { + return null + } + const closest = (target.closest('.l') || target.closest('.r')) as HTMLElement + if (!closest.classList.contains('trimmed') && closest.classList.contains('annotated')) { + const firstChild = closest.firstElementChild + if ( + firstChild && + firstChild.nodeName === 'SPAN' && + firstChild.textContent && + firstChild.innerHTML.trim().length === 0 + ) { + const newElement = document.createElement('span') + newElement.innerHTML = ' ' + closest.replaceChild(newElement, firstChild) + } + closest.classList.add('trimmed') + } + return closest +} + +const getBlobCodeInner = (codeCell: HTMLElement) => { + if (codeCell.classList.contains('l') || codeCell.classList.contains('r')) { + return codeCell + } + return (codeCell.closest('.l') || codeCell.closest('.r')) as HTMLElement +} + +/** + * Gets the line number for a given code element on unified diff, split diff and blob views + */ +const getLineNumberFromCodeElement = (codeElement: HTMLElement): number => { + // In diff views, the code element is the `` inside the cell + // On blob views, the code element is the `` itself, so `closest()` will simply return it + // Walk all previous sibling cells until we find one with the line number + let cell = codeElement.closest('td') as HTMLElement + while (cell) { + if (cell.nodeName === 'TH') { + return parseInt(cell.innerText, 10) + } + cell = cell.previousElementSibling as HTMLTableRowElement + } + + cell = codeElement.closest('tr')! as HTMLTableRowElement + if (cell.getAttribute('line')) { + return parseInt(cell.getAttribute('line')!, 10) + } + throw new Error('Could not find a line number in any cell') +} + +/** + * getDeltaFileName returns the path of the file container. Reviewboard will always be a diff. + */ +export function getDeltaFileName(container: HTMLElement): { headFilePath: string; baseFilePath: string } { + const info = container.querySelector('.filename-row') as HTMLElement + if (!info) { + throw new Error(`Unable to getDeltaFileName for container: ${container}`) + } + return { headFilePath: info.innerText, baseFilePath: info.innerText } +} + +const getDiffCodePart = (codeElement: HTMLElement): DiffPart => { + const td = codeElement.closest('td')! + // If there are more cells on the right, this is the base, otherwise the head + return td.classList.contains('l') ? 'base' : 'head' +} + +/** + * Implementations of the DOM functions for diff code views + */ +export const diffDomFunctions: DOMFunctions = { + getCodeElementFromTarget: target => { + const codeCell = getCodeCellFromTarget(target) + return codeCell && getBlobCodeInner(codeCell) + }, + getCodeElementFromLineNumber: () => null, + getLineNumberFromCodeElement, + getDiffCodePart, +} + +/** + * createBlobAnnotatorMount creates a

element and adds it to the DOM + * where the BlobAnnotator component should be mounted. + */ +export function createBlobAnnotatorMount(fileContainer: HTMLElement, isBase?: boolean): HTMLElement | null { + const className = 'sourcegraph-app-annotator' + (isBase ? '-base' : '') + const existingMount = fileContainer.querySelector('.' + className) as HTMLElement + if (existingMount) { + return existingMount + } + + const mountEl = document.createElement('div') + mountEl.style.display = 'inline-flex' + mountEl.style.verticalAlign = 'middle' + mountEl.style.alignItems = 'center' + mountEl.className = className + mountEl.style.cssFloat = 'right' + + const fileActions = fileContainer.querySelector('.filename-row') as HTMLElement + if (!fileActions || !fileActions.firstElementChild) { + return null + } + ;(fileActions.firstElementChild as HTMLElement).style.overflow = 'visible' + fileActions.firstElementChild!.appendChild(mountEl) + return mountEl +} diff --git a/app/review-board/inject.tsx b/app/review-board/inject.tsx new file mode 100644 index 00000000..cd85f8b5 --- /dev/null +++ b/app/review-board/inject.tsx @@ -0,0 +1,197 @@ +import { + createHoverifier, + findPositionsFromEvents, + Hoverifier, + HoverOverlay, + HoverState, + LinkComponent, +} from '@sourcegraph/codeintellify' +import { propertyIsDefined } from '@sourcegraph/codeintellify/lib/helpers' +import { HoverMerged } from '@sourcegraph/codeintellify/lib/types' +import * as React from 'react' +import { render } from 'react-dom' +import { merge, Observable, of, Subject } from 'rxjs' +import { filter, map, withLatestFrom } from 'rxjs/operators' +import { createJumpURLFetcher, lspViaAPIXlang } from '../backend/lsp' +import { CodeViewToolbar } from '../components/CodeViewToolbar' +import { AbsoluteRepoFilePosition } from '../repo' +import { resolveRepo, resolveRev } from '../repo/backend' +import { normalizeRepoPath } from '../repo/index' +import { eventLogger, sourcegraphUrl } from '../util/context' +import { getRepositoryFromReviewBoardAPI } from './backend/fetch' +import { createBlobAnnotatorMount, diffDomFunctions, getDeltaFileName } from './dom/util' +import { + configureReviewBoardHandlers, + REVIEW_BOARD_LOADED_EVENT_ID, + ReviewBoardRepository, + ReviewBoardState, +} from './util' + +let reviewBoardState: ReviewBoardState | undefined + +const buttonProps = { + className: 'btn btn-sm tooltipped tooltipped-n', + style: { marginRight: '5px', textDecoration: 'none', color: 'inherit' }, +} + +export function injectReviewboardApplication(): void { + injectReviewBoard() +} + +function injectReviewBoard(): void { + if (!reviewBoardState) { + configureReviewBoardHandlers() + return + } + injectAnnotators() +} + +function resolveRepoPathFromName(rbRepository: ReviewBoardRepository): Observable { + return merge( + resolveRepo({ repoPath: rbRepository.name }), + resolveRepo({ repoPath: normalizeRepoPath(rbRepository.path) }) + ) +} + +function injectAnnotators(): void { + if (!reviewBoardState) { + return + } + const { repository, reviewRequest } = reviewBoardState + getRepositoryFromReviewBoardAPI(repository.id).subscribe(rbRepository => { + resolveRepoPathFromName(rbRepository).subscribe( + repoName => { + const baseCommitID = reviewRequest.branch + resolveRev({ repoPath: repoName, rev: baseCommitID }).subscribe(rev => { + const { hoverifier } = createCodeIntelligenceContainer(sourcegraphUrl) + function addBlobAnnotator(file: HTMLElement): void { + const { headFilePath, baseFilePath } = getDeltaFileName(file) + const baseCommitID = rev + let headCommitID = reviewRequest.extraData.local_branch + const commitField = document.getElementById('field_commit_id') as HTMLSpanElement + if (commitField && commitField.firstElementChild) { + headCommitID = (commitField.firstElementChild as HTMLSpanElement).title + } + hoverifier.hoverify({ + dom: diffDomFunctions, + positionEvents: of(file).pipe(findPositionsFromEvents(diffDomFunctions)), + resolveContext: ({ part }) => ({ + repoPath: repoName, + rev: part === 'base' ? baseCommitID : headCommitID, + commitID: part === 'base' ? baseCommitID : headCommitID, + // If a hover happened on the base, it must exist + filePath: part === 'base' ? baseFilePath! : headFilePath, + }), + }) + const mount = createBlobAnnotatorMount(file, true) + render( + , + mount + ) + } + const files = document.querySelectorAll('.diff-box') + for (const file of Array.from(files)) { + addBlobAnnotator(file as HTMLElement) + } + }) + }, + err => console.error(err) + ) + }) +} + +document.addEventListener(REVIEW_BOARD_LOADED_EVENT_ID, (e: CustomEvent) => { + reviewBoardState = e.detail + injectReviewBoard() +}) + +function createCodeIntelligenceContainer(baseUrl: string): { hoverifier: Hoverifier } { + /** Emits when the go to definition button was clicked */ + const goToDefinitionClicks = new Subject() + const nextGoToDefinitionClick = (event: MouseEvent) => goToDefinitionClicks.next(event) + + /** Emits when the close button was clicked */ + const closeButtonClicks = new Subject() + const nextCloseButtonClick = (event: MouseEvent) => closeButtonClicks.next(event) + + /** Emits whenever the ref callback for the hover element is called */ + const hoverOverlayElements = new Subject() + const nextOverlayElement = (element: HTMLElement | null) => hoverOverlayElements.next(element) + + const overlayMount = document.createElement('div') + overlayMount.style.height = '0px' + document.body.appendChild(overlayMount) + const relativeElement = document.body + + const fetchJumpURL = createJumpURLFetcher(lspViaAPIXlang.fetchDefinition, (def: AbsoluteRepoFilePosition) => { + const rev = def.commitID || def.rev + const url = baseUrl.endsWith('/') ? baseUrl.substring(baseUrl.length - 1) : baseUrl + return `${url}/${def.repoPath}@${rev || 'HEAD'}/-/blob/${def.filePath}#L${def.position.line}${ + def.position.character ? ':' + def.position.character : '' + }` + }) + + const containerComponentUpdates = new Subject() + const hoverifier = createHoverifier({ + closeButtonClicks, + goToDefinitionClicks, + hoverOverlayElements, + hoverOverlayRerenders: containerComponentUpdates.pipe( + withLatestFrom(hoverOverlayElements), + map(([, hoverOverlayElement]) => ({ hoverOverlayElement, relativeElement })), + filter(propertyIsDefined('hoverOverlayElement')) + ), + pushHistory: path => { + location.href = path + }, + fetchHover: ({ line, character, part, ...rest }) => + lspViaAPIXlang + .fetchHover({ ...rest, position: { line, character } }) + .pipe(map(hover => (hover ? (hover as HoverMerged) : hover))), + fetchJumpURL, + }) + + class HoverOverlayContainer extends React.Component<{}, HoverState> { + constructor(props: {}) { + super(props) + this.state = hoverifier.hoverState + hoverifier.hoverStateUpdates.subscribe(update => this.setState(update)) + } + public componentDidMount(): void { + containerComponentUpdates.next() + } + public componentDidUpdate(): void { + containerComponentUpdates.next() + } + public render(): JSX.Element | null { + return this.state.hoverOverlayProps ? ( + + ) : null + } + private log = () => eventLogger.logCodeIntelligenceEvent() + } + + render(, overlayMount) + + return { hoverifier } +} + +const LinkComponent: LinkComponent = ({ to, children, ...rest }) => ( + + {children} + +) diff --git a/app/review-board/util.ts b/app/review-board/util.ts new file mode 100644 index 00000000..15113e2f --- /dev/null +++ b/app/review-board/util.ts @@ -0,0 +1,114 @@ +const REVIEW_BOARD_STATE_ID = 'REVIEW_BOARD_STATE_ID' +export const REVIEW_BOARD_LOADED_EVENT_ID = 'REVIEW_BOARD_LOADED' + +interface ReviewBoardStateHandler { + reviewRequest?: { + attributes: { + repository: any + } & ReviewBoardReview + } +} + +export interface ReviewBoardState { + repository: ReviewBoardRepository + reviewRequest: ReviewBoardReview +} + +export interface ReviewBoardRepository { + id: number + cid: number + attributes: ReviewBoardRepositoryAttributes + mirror_path: string + name: string + path: string + tool: string + visible: boolean +} + +export interface ReviewBoardReview { + approved: boolean + branch: string + commitID: string + extraData: { + local_branch: string + } + id: number + lastUpdated: string + localSitePrefix: string + public: boolean + repository: ReviewBoardRepository + reviewURL: string + state: number +} + +interface ReviewBoardRepositoryAttributes { + filesOnly: boolean + id: number + loaded: boolean + localSitePrefix: string + name: string + requiresBaseDir: boolean + requiresChangeNumber: boolean + scmtoolName: string + supportsPostCommit: boolean +} + +export function configureReviewBoardHandlers(): void { + reviewBoardPierce(getReviewboardStateHandler, REVIEW_BOARD_STATE_ID) +} + +function getReviewboardStateHandler(): void { + const page = window.RB.PageManager.getPage() as ReviewBoardStateHandler + let reviewRequest = {} + if (page.reviewRequest) { + const { + approved, + branch, + commitID, + extraData, + id, + lastUpdated, + localSitePrefix, + repository, + reviewURL, + state, + } = page.reviewRequest.attributes + reviewRequest = { + approved, + branch, + commitID, + extraData, + id, + lastUpdated, + localSitePrefix, + repository, + reviewURL, + state, + } + } + + document.dispatchEvent( + new CustomEvent('REVIEW_BOARD_LOADED', { + detail: { + repository: page.reviewRequest!.attributes.repository, + reviewRequest, + } as ReviewBoardState, + }) + ) +} + +/** + * This injects code as a script tag into a web page body. + * Needed to reference the Review Board Internal RB code. + */ +function reviewBoardPierce(code: () => void, id: string): void { + let s = document.getElementById(id) as HTMLScriptElement + if (s) { + return + } + s = document.createElement('script') as HTMLScriptElement + s.id = id + s.setAttribute('type', 'text/javascript') + s.textContent = code.toString() + ';' + code.name + '();' + document.body.appendChild(s) +} diff --git a/chrome/extension/background.tsx b/chrome/extension/background.tsx index ccd5cdcb..a17b2b2e 100644 --- a/chrome/extension/background.tsx +++ b/chrome/extension/background.tsx @@ -46,7 +46,7 @@ storage.getSync(({ sourcegraphURL }) => { storage.setSync({ sourcegraphURL: DEFAULT_SOURCEGRAPH_URL }) setSourcegraphUrl(DEFAULT_SOURCEGRAPH_URL) } - + setSourcegraphUrl(sourcegraphURL) resolveClientConfiguration().subscribe( config => { // ClientConfiguration is the new storage option. diff --git a/chrome/extension/inject.tsx b/chrome/extension/inject.tsx index 2fb33abe..ec955296 100644 --- a/chrome/extension/inject.tsx +++ b/chrome/extension/inject.tsx @@ -5,6 +5,7 @@ import '../../app/util/polyfill' import { injectBitbucketServer } from '../../app/bitbucket/inject' import { injectGitHubApplication } from '../../app/github/inject' import { injectPhabricatorApplication } from '../../app/phabricator/app' +import { injectReviewboardApplication } from '../../app/review-board/inject' import { injectSourcegraphApp } from '../../app/sourcegraph/inject' import { setExecuteSearchEnabled, @@ -43,7 +44,7 @@ function injectApplication(): void { const isPhabricator = Boolean(document.querySelector('.phabricator-wordmark')) && Boolean(items.enterpriseUrls.find(url => url === window.location.origin)) - + const isReviewBoard = document.getElementById('rbinfo') const isGitHub = /^https?:\/\/(www.)?github.com/.test(href) const ogSiteName = document.head.querySelector(`meta[property='og:site_name']`) as HTMLMetaElement const isGitHubEnterprise = ogSiteName ? ogSiteName.content === 'GitHub Enterprise' : false @@ -57,7 +58,7 @@ function injectApplication(): void { type: 'insertCSS', payload: { file: 'css/style.bundle.css', origin: window.location.origin }, }) - } else if (isPhabricator || isGitHub || isGitHubEnterprise || isBitbucket) { + } else if (isPhabricator || isGitHub || isGitHubEnterprise || isBitbucket || isReviewBoard) { const styleSheet = document.createElement('link') as HTMLLinkElement styleSheet.id = 'ext-style-sheet' styleSheet.rel = 'stylesheet' @@ -93,6 +94,9 @@ function injectApplication(): void { ) { setSourcegraphUrl(sourcegraphServerUrl) injectBitbucketServer() + } else if (isReviewBoard) { + setSourcegraphUrl(sourcegraphServerUrl) + injectReviewboardApplication() } setUseCXP(items.useCXP === undefined ? false : items.useCXP) } diff --git a/types/globals/index.d.ts b/types/globals/index.d.ts index 99f53a61..ac12d170 100644 --- a/types/globals/index.d.ts +++ b/types/globals/index.d.ts @@ -15,6 +15,12 @@ interface Window { } // Bitbucket has a global require function on the DOM that we rely on to get the current Bitbucket state. require: any + // Reviewboard has a global RB function on that DOM that we rely on to get ReviewBoard state. + RB: { + PageManager: { + getPage: () => any + } + } } declare module '*.json' {