diff --git a/src/browser/types.ts b/src/browser/types.ts index e1b4f09d..e7c0c083 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -12,10 +12,12 @@ export interface PhabricatorMapping { */ export interface FeatureFlags { newTooltips: boolean + newInject: boolean } export const featureFlagDefaults: FeatureFlags = { newTooltips: true, + newInject: false, } // TODO(chris) Switch to Partial to eliminate bugs caused by diff --git a/src/extension/scripts/inject.tsx b/src/extension/scripts/inject.tsx index a3efbb5a..157c4bd4 100644 --- a/src/extension/scripts/inject.tsx +++ b/src/extension/scripts/inject.tsx @@ -17,6 +17,7 @@ import { import { featureFlags } from '../../shared/util/featureFlags' import { injectBitbucketServer } from '../../libs/bitbucket/inject' +import { injectCodeIntelligence } from '../../libs/code_intelligence' import { injectGitHubApplication } from '../../libs/github/inject' import { injectPhabricatorApplication } from '../../libs/phabricator/app' import { injectSourcegraphApp } from '../../libs/sourcegraph/inject' @@ -34,7 +35,7 @@ function injectApplication(): void { const href = window.location.href - const handleGetStorage = (items: StorageItems) => { + const handleGetStorage = async (items: StorageItems) => { if (items.disableExtension) { return } @@ -96,6 +97,14 @@ function injectApplication(): void { setSourcegraphUrl(sourcegraphServerUrl) injectBitbucketServer() } + + if (isGitHub || isPhabricator) { + if (await featureFlags.isEnabled('newInject')) { + const subscriptions = await injectCodeIntelligence() + window.addEventListener('unload', () => subscriptions.unsubscribe()) + } + } + setUseExtensions(items.useExtensions === undefined ? false : items.useExtensions) } diff --git a/src/libs/code_intelligence/inject.tsx b/src/libs/code_intelligence/code_intelligence.tsx similarity index 71% rename from src/libs/code_intelligence/inject.tsx rename to src/libs/code_intelligence/code_intelligence.tsx index b01c6a1e..88a0f503 100644 --- a/src/libs/code_intelligence/inject.tsx +++ b/src/libs/code_intelligence/code_intelligence.tsx @@ -15,13 +15,54 @@ import { HoverMerged } from '@sourcegraph/codeintellify/lib/types' import { toPrettyBlobURL } from '@sourcegraph/codeintellify/lib/url' import * as React from 'react' import { render } from 'react-dom' -import { Observable, of, Subject, Subscription } from 'rxjs' -import { filter, map, withLatestFrom } from 'rxjs/operators' +import { animationFrameScheduler, Observable, of, Subject, Subscription } from 'rxjs' +import { filter, map, mergeMap, observeOn, withLatestFrom } from 'rxjs/operators' import { createJumpURLFetcher } from '../../shared/backend/lsp' import { lspViaAPIXlang } from '../../shared/backend/lsp' import { ButtonProps, CodeViewToolbar } from '../../shared/components/CodeViewToolbar' import { eventLogger, sourcegraphUrl } from '../../shared/util/context' +import { githubCodeHost } from '../github/code_intelligence' +import { phabricatorCodeHost } from '../phabricator/code_intelligence' +import { findCodeViews } from './code_views' + +/** + * Defines a type of code view a given code host can have. It tells us how to + * look for the code view and how to do certain things when we find it. + */ +export interface CodeView { + /** A selector used by `document.querySelectorAll` to find the code view. */ + selector: string + /** The DOMFunctions for the code view. */ + dom: DOMFunctions + /** + * Finds or creates a DOM element where we should inject the + * `CodeViewToolbar`. This function is responsible for ensuring duplicate + * mounts aren't created. + */ + getToolbarMount?: (codeView: HTMLElement, part?: DiffPart) => HTMLElement + /** + * Resolves the file info for a given code view. It returns an observable + * because some code hosts need to resolve this asynchronously. The + * observable should only emit once. + */ + resolveFileInfo: (codeView: HTMLElement) => Observable + /** + * In some situations, we need to be able to adjust the position going into + * and coming out of codeintellify. For example, Phabricator converts tabs + * to spaces in it's DOM. + */ + adjustPosition?: PositionAdjuster + /** Props for styling the buttons in the `CodeViewToolbar`. */ + toolbarButtonProps?: ButtonProps +} + +export type CodeViewWithOutSelector = Pick> + +export interface CodeViewResolver { + selector: string + resolveCodeView: (elem: HTMLElement) => CodeViewWithOutSelector +} /** Information for adding code intelligence to code views on arbitrary code hosts. */ export interface CodeHost { @@ -29,10 +70,23 @@ export interface CodeHost { * The name of the code host. This will be added as a className to the overlay mount. */ name: string + /** * The list of types of code views to try to annotate. */ - codeViews: CodeView[] + codeViews?: CodeView[] + + /** + * Resolve `CodeView`s from the DOM. This is useful when each code view type + * doesn't have a distinct selector for + */ + codeViewResolver?: CodeViewResolver + + /** + * Checks to see if the current context the code is running in is within + * the given code host. + */ + check: () => Promise | boolean } export interface FileInfo { @@ -55,7 +109,6 @@ export interface FileInfo { * The revision the code view is at. If a `baseRev` is provided, this value is treated as the head rev. */ rev?: string - /** * The repo bath for the BASE side of a diff. This is useful for Phabricator * staging areas since they are separate repos. @@ -78,31 +131,6 @@ export interface FileInfo { baseHasFileContents?: boolean } -/** - * Defines a type of code view a given code host can have. It tells us how to - * look for the code view and how to do certain things when we find it. - */ -export interface CodeView { - /** A selector used by `document.querySelectorAll` to find the code view. */ - selector: string - /** The DOMFunctions for the code view. */ - dom: DOMFunctions - /** Finds or creates a DOM element where we should inject the `CodeViewToolbar`. */ - getToolbarMount?: (codeView: HTMLElement, part?: DiffPart) => HTMLElement - /** Resolves the file info for a given code view. It returns an observable - * because some code hosts need to resolve this asynchronously. The - * observable should only emit once. - */ - resolveFileInfo: (codeView: HTMLElement) => Observable - /** In some situations, we need to be able to adjust the position going into - * and coming out of codeintellify. For example, Phabricator converts tabs - * to spaces in it's DOM. - */ - adjustPosition?: PositionAdjuster - /** Props for styling the buttons in the `CodeViewToolbar`. */ - toolbarButtonProps?: ButtonProps -} - /** * Prepares the page for code intelligence. It creates the hoverifier, injects * and mounts the hover overlay and then returns the hoverifier. @@ -196,28 +224,26 @@ function initCodeIntelligence(codeHost: CodeHost): { hoverifier: Hoverifier } { * ResolvedCodeView attaches an actual code view DOM element that was found on * the page to the CodeView type being passed around by this file. */ -export interface ResolvedCodeView extends CodeView { +export interface ResolvedCodeView extends CodeViewWithOutSelector { /** The code view DOM element. */ codeView: HTMLElement } -function findCodeViews(codeViewInfos: CodeView[]): Observable { - return new Observable(observer => { - for (const info of codeViewInfos) { - const elements = document.querySelectorAll(info.selector) - for (const codeView of elements) { - observer.next({ ...info, codeView }) - } - } - }) -} +function handleCodeHost(codeHost: CodeHost): Subscription { + const { hoverifier } = initCodeIntelligence(codeHost) -export function injectCodeIntelligence(codeHostInfo: CodeHost): Subscription { - const { hoverifier } = initCodeIntelligence(codeHostInfo) + const subscriptions = new Subscription() - return findCodeViews(codeHostInfo.codeViews).subscribe( - ({ codeView, dom, resolveFileInfo, adjustPosition, getToolbarMount, toolbarButtonProps }) => - resolveFileInfo(codeView).subscribe(info => { + subscriptions.add( + of(document.body) + .pipe( + findCodeViews(codeHost), + mergeMap(({ codeView, resolveFileInfo, ...rest }) => + resolveFileInfo(codeView).pipe(map(info => ({ info, codeView, ...rest }))) + ), + observeOn(animationFrameScheduler) + ) + .subscribe(({ codeView, info, dom, adjustPosition, getToolbarMount, toolbarButtonProps }) => { const resolveContext: ContextResolver = ({ part }) => ({ repoPath: part === 'base' ? info.baseRepoPath || info.repoPath : info.repoPath, commitID: part === 'base' ? info.baseCommitID! : info.commitID, @@ -225,12 +251,16 @@ export function injectCodeIntelligence(codeHostInfo: CodeHost): Subscription { rev: part === 'base' ? info.baseRev || info.baseCommitID! : info.rev || info.commitID, }) - hoverifier.hoverify({ - dom, - positionEvents: of(codeView).pipe(findPositionsFromEvents(dom)), - resolveContext, - adjustPosition, - }) + subscriptions.add( + hoverifier.hoverify({ + dom, + positionEvents: of(codeView).pipe(findPositionsFromEvents(dom)), + resolveContext, + adjustPosition, + }) + ) + + codeView.classList.add('sg-mounted') if (!getToolbarMount) { return @@ -253,4 +283,32 @@ export function injectCodeIntelligence(codeHostInfo: CodeHost): Subscription { ) }) ) + + return subscriptions +} + +async function injectCodeIntelligenceToCodeHosts(codeHosts: CodeHost[]): Promise { + const subscriptions = new Subscription() + + for (const codeHost of codeHosts) { + const isCodeHost = await Promise.resolve(codeHost.check()) + if (isCodeHost) { + subscriptions.add(handleCodeHost(codeHost)) + } + } + + return subscriptions +} + +/** + * Injects all code hosts into the page. + * + * @returns A promise with a subscription containing all subscriptions for code + * intelligence. Unsubscribing will clean up subscriptions for hoverify and any + * incomplete setup requests. + */ +export async function injectCodeIntelligence(): Promise { + const codeHosts: CodeHost[] = [githubCodeHost, phabricatorCodeHost] + + return await injectCodeIntelligenceToCodeHosts(codeHosts) } diff --git a/src/libs/code_intelligence/code_views.ts b/src/libs/code_intelligence/code_views.ts new file mode 100644 index 00000000..db510401 --- /dev/null +++ b/src/libs/code_intelligence/code_views.ts @@ -0,0 +1,123 @@ +import { from, merge, Observable, of, Subject } from 'rxjs' +import { filter, map, mergeMap } from 'rxjs/operators' + +import { CodeHost, ResolvedCodeView } from './code_intelligence' + +/** + * Emits a ResolvedCodeView when it's DOM element is on or about to be on the page. + */ +const emitWhenIntersecting = (margin: number) => { + const codeViewStash = new Map() + + const intersectingElements = new Subject() + + const intersectionObserver = new IntersectionObserver( + entries => { + for (const entry of entries) { + // `entry` is an `IntersectionObserverEntry`, + // which has + // [isIntersecting](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/isIntersecting#Browser_compatibility) + // as a prop, but TS complains that it does not + // exist. + if ((entry as any).isIntersecting) { + intersectingElements.next(entry.target as HTMLElement) + } + } + }, + { + rootMargin: `${margin}px`, + threshold: 0, + } + ) + + return (codeViews: Observable) => + new Observable(observer => { + codeViews.subscribe(({ codeView, ...rest }) => { + intersectionObserver.observe(codeView) + codeViewStash.set(codeView, { codeView, ...rest }) + }) + + intersectingElements + .pipe( + map(element => codeViewStash.get(element)), + filter(codeView => !!codeView) + ) + .subscribe(observer) + }) +} + +/** + * findCodeViews finds all the code views on a page given a CodeHost. It emits code views + * that are lazily loaded as well. + */ +export const findCodeViews = (codeHost: CodeHost, watchChildrenModifications = true) => ( + containers: Observable +) => { + const codeViewsFromList: Observable = containers.pipe( + filter(() => !!codeHost.codeViews), + mergeMap(container => + from(codeHost.codeViews!).pipe( + map(({ selector, ...info }) => ({ + info, + matches: container.querySelectorAll(selector), + })) + ) + ), + mergeMap(({ info, matches }) => + of(...matches).pipe( + map(codeView => ({ + ...info, + codeView, + })) + ) + ) + ) + + const codeViewsFromResolver: Observable = containers.pipe( + filter(() => !!codeHost.codeViewResolver), + map(container => ({ + resolveCodeView: codeHost.codeViewResolver!.resolveCodeView, + matches: container.querySelectorAll(codeHost.codeViewResolver!.selector), + })), + mergeMap(({ resolveCodeView, matches }) => + of(...matches).pipe( + map(codeView => ({ + ...resolveCodeView(codeView), + codeView, + })) + ) + ) + ) + + const obs = [codeViewsFromList, codeViewsFromResolver] + + if (watchChildrenModifications) { + const possibleLazilyLoadedContainers = new Subject() + + const mutationObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (mutation.type === 'childList' && mutation.target instanceof HTMLElement) { + const { target } = mutation + + possibleLazilyLoadedContainers.next(target) + } + } + }) + + containers.subscribe(container => + mutationObserver.observe(container, { + childList: true, + subtree: true, + }) + ) + + const lazilyLoadedCodeViews = possibleLazilyLoadedContainers.pipe(findCodeViews(codeHost, false)) + + obs.push(lazilyLoadedCodeViews) + } + + return merge(...obs).pipe( + emitWhenIntersecting(250), + filter(({ codeView }) => !codeView.classList.contains('sg-mounted')) + ) +} diff --git a/src/libs/code_intelligence/index.ts b/src/libs/code_intelligence/index.ts new file mode 100644 index 00000000..52adc153 --- /dev/null +++ b/src/libs/code_intelligence/index.ts @@ -0,0 +1 @@ +export * from './code_intelligence' diff --git a/src/libs/github/code_intelligence.ts b/src/libs/github/code_intelligence.ts new file mode 100644 index 00000000..23e05a99 --- /dev/null +++ b/src/libs/github/code_intelligence.ts @@ -0,0 +1,105 @@ +import { AdjustmentDirection, PositionAdjuster } from '@sourcegraph/codeintellify' +import { trimStart } from 'lodash' +import { map } from 'rxjs/operators' +import { fetchBlobContentLines } from '../../shared/repo/backend' +import { CodeHost, CodeView, CodeViewResolver, CodeViewWithOutSelector } from '../code_intelligence' +import { diffDomFunctions, searchCodeSnippetDOMFunctions, singleFileDOMFunctions } from './dom_functions' +import { resolveDiffFileInfo, resolveFileInfo, resolveSnippetFileInfo } from './file_info' +import { createCodeViewToolbarMount, parseURL } from './util' + +const toolbarButtonProps = { + className: 'btn btn-sm tooltipped tooltipped-n', + style: { marginRight: '5px', textDecoration: 'none', color: 'inherit' }, +} + +const diffCodeView: CodeViewWithOutSelector = { + dom: diffDomFunctions, + getToolbarMount: createCodeViewToolbarMount, + resolveFileInfo: resolveDiffFileInfo, + toolbarButtonProps, +} + +const singleFileCodeView: CodeViewWithOutSelector = { + dom: singleFileDOMFunctions, + getToolbarMount: createCodeViewToolbarMount, + resolveFileInfo, + toolbarButtonProps, +} + +/** + * Some code snippets get leading white space trimmed. This adjusts based on + * this. See an example here https://github.com/sourcegraph/browser-extensions/issues/188. + */ +const adjustPositionForSnippet: PositionAdjuster = ({ direction, codeView, position }) => + fetchBlobContentLines(position).pipe( + map(lines => { + const codeElement = singleFileDOMFunctions.getCodeElementFromLineNumber( + codeView, + position.line, + position.part + ) + if (!codeElement) { + throw new Error('(adjustPosition) could not find code element for line provided') + } + + const actualLine = lines[position.line - 1] + const documentLine = codeElement.textContent || '' + + const actualLeadingWhiteSpace = actualLine.length - trimStart(actualLine).length + const documentLeadingWhiteSpace = documentLine.length - trimStart(documentLine).length + + const modifier = direction === AdjustmentDirection.ActualToCodeView ? -1 : 1 + const delta = Math.abs(actualLeadingWhiteSpace - documentLeadingWhiteSpace) * modifier + + return { + line: position.line, + character: position.character + delta, + } + }) + ) + +const searchResultCodeView: CodeView = { + selector: '.code-list-item', + dom: searchCodeSnippetDOMFunctions, + adjustPosition: adjustPositionForSnippet, + resolveFileInfo: resolveSnippetFileInfo, + toolbarButtonProps, +} + +const commentSnippetCodeView: CodeView = { + selector: '.js-comment-body', + dom: singleFileDOMFunctions, + resolveFileInfo: resolveSnippetFileInfo, + adjustPosition: adjustPositionForSnippet, + toolbarButtonProps, +} + +const resolveCodeView = (elem: HTMLElement): CodeViewWithOutSelector => { + const files = document.getElementsByClassName('file') + const { filePath } = parseURL() + const isSingleCodeFile = files.length === 1 && filePath && document.getElementsByClassName('diff-view').length === 0 + + return isSingleCodeFile ? singleFileCodeView : diffCodeView +} + +const codeViewResolver: CodeViewResolver = { + selector: '.file', + resolveCodeView, +} + +function checkIsGithub(): boolean { + const href = window.location.href + + 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 + + return isGithub || isGitHubEnterprise +} + +export const githubCodeHost: CodeHost = { + name: 'github', + codeViews: [searchResultCodeView, commentSnippetCodeView], + codeViewResolver, + check: checkIsGithub, +} diff --git a/src/libs/github/dom_functions.ts b/src/libs/github/dom_functions.ts index 5bb3ed98..7a0f4cfb 100644 --- a/src/libs/github/dom_functions.ts +++ b/src/libs/github/dom_functions.ts @@ -120,9 +120,9 @@ export const diffDomFunctions: DOMFunctions = { /** * Implementations of the DOM functions for GitHub blob code views */ -export const blobDOMFunctions: DOMFunctions = { +export const singleFileDOMFunctions: DOMFunctions = { getCodeElementFromTarget: getCodeCellFromTarget, - getCodeElementFromLineNumber: (codeView, line, part) => { + getCodeElementFromLineNumber: (codeView, line) => { const lineNumberCell = codeView.querySelector(`td[data-line-number="${line}"]`) if (!lineNumberCell) { return null @@ -154,7 +154,6 @@ export const searchCodeSnippetDOMFunctions: DOMFunctions = { const codeCell = lineNumberCell.nextElementSibling as HTMLTableCellElement // In blob views, the `` is the code element - console.log(codeCell) return codeCell }, getLineNumberFromCodeElement: (codeElement: HTMLElement): number => { diff --git a/src/libs/github/file_info.ts b/src/libs/github/file_info.ts new file mode 100644 index 00000000..ad83e3d2 --- /dev/null +++ b/src/libs/github/file_info.ts @@ -0,0 +1,110 @@ +import { isDefined, propertyIsDefined } from '@sourcegraph/codeintellify/lib/helpers' +import { Observable, of, zip } from 'rxjs' +import { filter, map, switchMap } from 'rxjs/operators' +import { GitHubBlobUrl } from '.' +import { resolveRev, retryWhenCloneInProgressError } from '../../shared/repo/backend' +import { FileInfo } from '../code_intelligence' +import { getDeltaFileName, getDiffResolvedRev, getGitHubState, parseURL } from './util' + +export const resolveDiffFileInfo = (codeView: HTMLElement): Observable => + of(codeView).pipe( + map(codeView => { + const { repoPath } = parseURL() + + return { codeView, repoPath } + }), + map(({ codeView, ...rest }) => { + const { headFilePath, baseFilePath } = getDeltaFileName(codeView) + if (!headFilePath) { + throw new Error('cannot determine file path') + } + + return { ...rest, codeView, headFilePath, baseFilePath } + }), + map(data => { + const diffResolvedRev = getDiffResolvedRev() + if (!diffResolvedRev) { + throw new Error('cannot determine delta info') + } + + return { + headRev: diffResolvedRev.headCommitID, + baseRev: diffResolvedRev.baseCommitID, + ...data, + } + }), + switchMap(({ repoPath, headRev, baseRev, ...rest }) => { + const resolvingHeadRev = resolveRev({ repoPath, rev: headRev }).pipe(retryWhenCloneInProgressError()) + const resolvingBaseRev = resolveRev({ repoPath, rev: baseRev }).pipe(retryWhenCloneInProgressError()) + + return zip(resolvingHeadRev, resolvingBaseRev).pipe( + map(([headCommitID, baseCommitID]) => ({ + repoPath, + headRev, + baseRev, + headCommitID, + baseCommitID, + ...rest, + })) + ) + }), + map(info => ({ + repoPath: info.repoPath, + filePath: info.headFilePath, + commitID: info.headCommitID, + rev: info.headRev, + + baseRepoPath: info.repoPath, + baseFilePath: info.baseFilePath || info.headFilePath, + baseCommitID: info.baseCommitID, + baseRev: info.baseRev, + + headHasFileContents: true, + baseHasFileContents: true, + })) + ) + +export const resolveFileInfo = (codeView: HTMLElement): Observable => + of(codeView).pipe( + map(() => { + const { repoPath, filePath, rev } = parseURL() + + return { repoPath, filePath, rev } + }), + filter(propertyIsDefined('filePath')), + switchMap(({ repoPath, rev, ...rest }) => + resolveRev({ repoPath, rev }).pipe( + retryWhenCloneInProgressError(), + map(commitID => ({ ...rest, repoPath, commitID, rev: rev || commitID })) + ) + ) + ) + +export const resolveSnippetFileInfo = (codeView: HTMLElement): Observable => + of(codeView).pipe( + map(codeView => { + const anchors = codeView.getElementsByTagName('a') + let githubState: GitHubBlobUrl | undefined + for (const anchor of anchors) { + const anchorState = getGitHubState(anchor.href) as GitHubBlobUrl + if (anchorState) { + githubState = anchorState + break + } + } + + return githubState + }), + filter(isDefined), + filter(propertyIsDefined('owner')), + filter(propertyIsDefined('repoName')), + filter(propertyIsDefined('rev')), + filter(propertyIsDefined('filePath')), + map(({ owner, repoName, ...rest }) => ({ repoPath: `${window.location.host}/${owner}/${repoName}`, ...rest })), + switchMap(({ repoPath, rev, ...rest }) => + resolveRev({ repoPath, rev }).pipe( + retryWhenCloneInProgressError(), + map(commitID => ({ ...rest, repoPath, commitID, rev: rev || commitID })) + ) + ) + ) diff --git a/src/libs/github/inject.tsx b/src/libs/github/inject.tsx index a85e424a..f4ae59f4 100644 --- a/src/libs/github/inject.tsx +++ b/src/libs/github/inject.tsx @@ -16,14 +16,14 @@ import { } from '@sourcegraph/extensions-client-common/lib/client/controller' import { Controller } from '@sourcegraph/extensions-client-common/lib/controller' import { isErrorLike } from '@sourcegraph/extensions-client-common/lib/errors' -import { ConfigurationCascade } from '@sourcegraph/extensions-client-common/lib/settings' -import { ConfigurationSubject } from '@sourcegraph/extensions-client-common/lib/settings' import { ConfigurationCascadeOrError, ConfiguredSubject, Settings, } from '@sourcegraph/extensions-client-common/lib/settings' -import { identity } from 'lodash' +import { ConfigurationSubject } from '@sourcegraph/extensions-client-common/lib/settings' +import { ConfigurationCascade } from '@sourcegraph/extensions-client-common/lib/settings' + import mermaid from 'mermaid' import * as React from 'react' import { render, unmountComponentAtNode } from 'react-dom' @@ -31,7 +31,7 @@ import { combineLatest, forkJoin, from, of, Subject } from 'rxjs' import { filter, map, take, withLatestFrom } from 'rxjs/operators' import { ContributableMenu } from 'sourcegraph/module/protocol' import { Disposable } from 'vscode-languageserver' -import { findElementWithOffset, getTargetLineAndOffset, GitHubBlobUrl } from '.' +import { GitHubBlobUrl } from '.' import storage from '../../browser/storage' import { createExtensionsContextController } from '../../shared/backend/extensions' import { applyDecoration, createMessageTransports } from '../../shared/backend/extensions' @@ -44,16 +44,15 @@ import { toTextDocumentIdentifier, } from '../../shared/backend/lsp' import { Alerts } from '../../shared/components/Alerts' -import { BlobAnnotator } from '../../shared/components/BlobAnnotator' import { ConfigureSourcegraphButton } from '../../shared/components/ConfigureSourcegraphButton' import { ContextualSourcegraphButton } from '../../shared/components/ContextualSourcegraphButton' import { CodeViewToolbar } from '../../shared/components/LegacyCodeViewToolbar' import { ServerAuthButton } from '../../shared/components/ServerAuthButton' import { SymbolsDropdownContainer } from '../../shared/components/SymbolsDropdownContainer' import { WithResolvedRev } from '../../shared/components/WithResolvedRev' -import { AbsoluteRepoFile, CodeCell, DiffResolvedRevSpec } from '../../shared/repo' +import { AbsoluteRepoFile, DiffResolvedRevSpec } from '../../shared/repo' import { resolveRev, retryWhenCloneInProgressError } from '../../shared/repo/backend' -import { getTableDataCell, hideTooltip } from '../../shared/repo/tooltips' +import { hideTooltip } from '../../shared/repo/tooltips' import { RepoRevSidebar } from '../../shared/tree/RepoRevSidebar' import { eventLogger, @@ -65,11 +64,10 @@ import { useExtensions, } from '../../shared/util/context' import { featureFlags } from '../../shared/util/featureFlags' -import { blobDOMFunctions, diffDomFunctions, searchCodeSnippetDOMFunctions } from './dom_functions' +import { diffDomFunctions, searchCodeSnippetDOMFunctions, singleFileDOMFunctions } from './dom_functions' import { initSearch } from './search' import { createBlobAnnotatorMount, - getCodeCells, getCodeCommentContainers, getDeltaFileName, getDiffRepoRev, @@ -77,12 +75,9 @@ import { getFileContainers, getGitHubState, getRepoCodeSearchContainers, - isDomSplitDiff, parseURL, } from './util' -const defaultFilterTarget = () => true - const buttonProps = { className: 'btn btn-sm tooltipped tooltipped-n', style: { marginRight: '5px', textDecoration: 'none', color: 'inherit' }, @@ -268,7 +263,7 @@ function injectCodeIntelligence(): void { injectBlobAnnotators(hoverifier, files, lspViaAPIXlang, extensionsContextController, extensionsController) - injectCodeSnippetAnnotator(hoverifier, getCodeCommentContainers(), '.border.rounded-1.my-2', blobDOMFunctions) + injectCodeSnippetAnnotator(hoverifier, getCodeCommentContainers(), '.border.rounded-1.my-2', singleFileDOMFunctions) injectCodeSnippetAnnotator( hoverifier, getRepoCodeSearchContainers(), @@ -279,15 +274,10 @@ function injectCodeIntelligence(): void { function inject(): void { featureFlags - .isEnabled('newTooltips') + .isEnabled('newInject') .then(isEnabled => { - if (isEnabled) { + if (!isEnabled) { injectCodeIntelligence() - } else { - injectBlobAnnotatorsOld() - - injectCodeSnippetAnnotatorOld(getCodeCommentContainers(), '.border.rounded-1.my-2', false) - injectCodeSnippetAnnotatorOld(getRepoCodeSearchContainers(), '.d-inline-block', true) } }) .catch(err => console.error('could not get feature flag', err)) @@ -397,87 +387,6 @@ function injectFileTree(): void { specChanges.next({ repoPath, commitID: gitHubState.rev || '' }) } -const findTokenCell = (td: HTMLElement, target: HTMLElement) => { - let curr = target - while ( - curr.parentElement && - (curr.parentElement === td || curr.parentElement.classList.contains('blob-code-inner')) - ) { - curr = curr.parentElement - } - return curr -} - -/** - * injectCodeSnippetAnnotator annotates the given containers and adds a view file button. - * @param containers The blob containers that holds the code snippet to be annotated. - * @param selector The selector of the element to append a "View File" button. - */ -function injectCodeSnippetAnnotatorOld( - containers: HTMLCollectionOf, - selector: string, - isRepoSearch: boolean -): void { - for (const file of Array.from(containers)) { - const filePathContainer = file.querySelector(selector) - if (!filePathContainer) { - continue - } - const anchors = file.getElementsByTagName('a') - let gitHubState: GitHubBlobUrl | undefined - for (const anchor of Array.from(anchors)) { - const anchorState = getGitHubState(anchor.href) as GitHubBlobUrl - if (anchorState) { - gitHubState = anchorState - break - } - } - - if (!gitHubState || !gitHubState.owner || !gitHubState.repoName || !gitHubState.rev || !gitHubState.filePath) { - continue - } - const mountEl = document.createElement('div') - mountEl.style.display = 'none' - mountEl.className = 'sourcegraph-app-annotator' - filePathContainer.appendChild(mountEl) - - const getTableElement = () => file.querySelector('table') - - const getCodeCellsCb = () => { - const opt = { isDelta: false } - const table = getTableElement() - const cells: CodeCell[] = [] - if (!table) { - return cells - } - return getCodeCells(table, opt) - } - - render( - , - mountEl - ) - } -} - /** * injectCodeSnippetAnnotator annotates the given containers and adds a view file button. * @param containers The blob containers that holds the code snippet to be annotated. @@ -513,7 +422,7 @@ function injectCodeSnippetAnnotator( const repoPath = `${window.location.host}/${owner}/${repoName}` const mount = document.createElement('div') - mount.style.display = 'none' + // mount.style.display = 'none' mount.className = 'sourcegraph-app-annotator' filePathContainer.appendChild(mount) @@ -755,8 +664,8 @@ function injectBlobAnnotators( .subscribe( commitID => { hoverifier.hoverify({ - dom: blobDOMFunctions, - positionEvents: of(file).pipe(findPositionsFromEvents(blobDOMFunctions)), + dom: singleFileDOMFunctions, + positionEvents: of(file).pipe(findPositionsFromEvents(singleFileDOMFunctions)), resolveContext: () => ({ repoPath, filePath: filePath!, @@ -880,256 +789,6 @@ function injectBlobAnnotators( }) } -function injectBlobAnnotatorsOld(): void { - const { repoPath, isDelta, isPullRequest, rev, isCommit, filePath, position } = parseURL() - if (!filePath && !isDelta) { - return - } - - function addBlobAnnotator(file: HTMLElement): void { - const getTableElement = () => file.querySelector('table') - const diffLoader = file.querySelector('.js-diff-load-container') - if (diffLoader) { - const observer = new MutationObserver(() => { - const element = diffLoader.querySelector('.diff-table') - if (element) { - addBlobAnnotator(file) - observer.disconnect() - } - }) - observer.observe(diffLoader, { childList: true }) - } - - if (!isDelta) { - const mount = createBlobAnnotatorMount(file) - if (!mount) { - return - } - - const getCodeCellsCb = () => { - const opt = { isDelta: false } - const table = getTableElement() - const cells: CodeCell[] = [] - if (!table) { - return cells - } - return getCodeCells(table, opt) - } - - render( - , - mount - ) - return - } - - const { headFilePath, baseFilePath } = getDeltaFileName(file) - if (!headFilePath) { - console.error('cannot determine file path') - return - } - - const isSplitDiff = isDomSplitDiff() - let baseCommitID: string | undefined - let headCommitID: string | undefined - let baseRepoPath: string | undefined - const deltaRevs = getDiffResolvedRev() - if (!deltaRevs) { - console.error('cannot determine deltaRevs') - return - } - - baseCommitID = deltaRevs.baseCommitID - headCommitID = deltaRevs.headCommitID - - const deltaInfo = getDiffRepoRev() - if (!deltaInfo) { - console.error('cannot determine deltaInfo') - return - } - - baseRepoPath = deltaInfo.baseRepoPath - - const getCodeCellsDiff = (isBase: boolean) => () => { - const opt = { isDelta: true, isSplitDiff, isBase } - const table = getTableElement() - const cells: CodeCell[] = [] - if (!table) { - return cells - } - return getCodeCells(table, opt) - } - const getCodeCellsBase = getCodeCellsDiff(true) - const getCodeCellsHead = getCodeCellsDiff(false) - - const filterTarget = (isBase: boolean, isSplitDiff: boolean) => (target: HTMLElement) => { - const td = getTableDataCell(target) - if (!td) { - return false - } - - if (isSplitDiff) { - if (td.classList.contains('empty-cell')) { - return false - } - // Check the relative position of the element to determine if it is - // on the left or right. - const previousEl = td.previousElementSibling - const isLeft = previousEl === td.parentElement!.firstElementChild - if (isBase) { - return isLeft - } else { - return !isLeft - } - } - - if (td.classList.contains('blob-code-deletion') && !isBase) { - return false - } - if (td.classList.contains('blob-code-deletion') && isBase) { - return true - } - if (td.classList.contains('blob-code-addition') && isBase) { - return false - } - if (td.classList.contains('blob-code-addition') && !isBase) { - return true - } - if (isBase) { - return false - } - return true - } - - const getNodeToConvert = (td: HTMLTableDataCellElement) => { - if (!td.classList.contains('blob-code-inner')) { - return td.querySelector('.blob-code-inner') as HTMLElement - } - return td - } - - const mountHead = createBlobAnnotatorMount(file) - if (!mountHead) { - return - } - - render( - , - mountHead - ) - - const mountBase = createBlobAnnotatorMount(file, true) - if (!mountBase) { - return - } - - render( - , - mountBase - ) - } - - // Get first loaded files and annotate them. - const files = getFileContainers() - for (const file of Array.from(files)) { - addBlobAnnotator(file as HTMLElement) - } - const mutationObserver = new MutationObserver(mutations => { - for (const mutation of mutations) { - const nodes = Array.prototype.slice.call(mutation.addedNodes) - for (const node of nodes) { - if (node && node.classList && node.classList.contains('file') && node.classList.contains('js-file')) { - const intersectionObserver = new IntersectionObserver( - entries => { - for (const file of entries) { - // File is an IntersectionObserverEntry, which has `isIntersecting` as a prop, but TS - // complains that it does not exist. - if ((file as any).isIntersecting && !file.target.classList.contains('annotated')) { - file.target.classList.add('annotated') - addBlobAnnotator(file.target as HTMLElement) - } - } - }, - { - rootMargin: '200px', - threshold: 0, - } - ) - intersectionObserver.observe(node) - } - } - } - }) - const filebucket = document.getElementById('files') - if (!filebucket) { - return - } - - mutationObserver.observe(filebucket, { - childList: true, - subtree: true, - attributes: false, - characterData: false, - }) -} - /** * Appends an Open on Sourcegraph button to the GitHub DOM. * The button is only rendered on a repo homepage after the "find file" button. diff --git a/src/libs/github/util.tsx b/src/libs/github/util.tsx index 338088f0..5661ab42 100644 --- a/src/libs/github/util.tsx +++ b/src/libs/github/util.tsx @@ -57,6 +57,46 @@ export function createBlobAnnotatorMount(fileContainer: HTMLElement, isBase?: bo return mountEl } +/** + * Creates the mount element for the CodeViewToolbar. + */ +export function createCodeViewToolbarMount(fileContainer: HTMLElement): HTMLElement { + const className = 'sourcegraph-app-annotator' + 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 + + const fileActions = fileContainer.querySelector('.file-actions') + if (!fileActions) { + throw new Error( + "File actions not found. Make sure you aren't trying to create " + + "a toolbar mount for a code snippet that shouldn't have one" + ) + } + + const buttonGroup = fileActions.querySelector('.BtnGroup') + if (buttonGroup && buttonGroup.parentNode && !fileContainer.querySelector('.show-file-notes')) { + // blob view + buttonGroup.parentNode.insertBefore(mountEl, buttonGroup) + } else { + // commit & pull request view + const note = fileContainer.querySelector('.show-file-notes') + if (!note || !note.parentNode) { + throw new Error('cannot find toolbar mount location') + } + note.parentNode.insertBefore(mountEl, note.nextSibling) + } + + return mountEl +} + export function isInlineCommentContainer(file: HTMLElement): boolean { return file.classList.contains('inline-review-comment') } diff --git a/src/libs/phabricator/app.tsx b/src/libs/phabricator/app.tsx index 380cc077..55e8bc6a 100644 --- a/src/libs/phabricator/app.tsx +++ b/src/libs/phabricator/app.tsx @@ -1,6 +1,4 @@ import { featureFlags } from '../../shared/util/featureFlags' -import { injectCodeIntelligence } from '../code_intelligence/inject' -import { phabCodeViews } from './code_views' import { injectPhabricatorBlobAnnotators } from './inject_old' import { expanderListen, javelinPierce, metaClickOverride, setupPageLoadListener } from './util' @@ -24,10 +22,9 @@ export function injectPhabricatorApplication(): void { function injectModules(): void { featureFlags - .isEnabled('newTooltips') + .isEnabled('newInject') .then(enabled => { if (enabled) { - injectCodeIntelligence({ codeViews: phabCodeViews, name: 'phabricator' }) return } diff --git a/src/libs/phabricator/code_views.ts b/src/libs/phabricator/code_intelligence.ts similarity index 87% rename from src/libs/phabricator/code_views.ts rename to src/libs/phabricator/code_intelligence.ts index 28616312..41fe2c15 100644 --- a/src/libs/phabricator/code_views.ts +++ b/src/libs/phabricator/code_intelligence.ts @@ -1,11 +1,12 @@ import { AdjustmentDirection, DiffPart, PositionAdjuster } from '@sourcegraph/codeintellify' import { map } from 'rxjs/operators' import { Position } from 'vscode-languageserver-types' +import { convertSpacesToTabs, spacesToTabsAdjustment } from '.' +import storage from '../../browser/storage' import { fetchBlobContentLines } from '../../shared/repo/backend' -import { CodeView } from '../code_intelligence/inject' +import { CodeHost, CodeView } from '../code_intelligence' import { diffDomFunctions, diffusionDOMFns } from './dom_functions' import { resolveDiffFileInfo, resolveDiffusionFileInfo } from './file_info' -import { convertSpacesToTabs, spacesToTabsAdjustment } from './index' function createMount( findMountLocation: (file: HTMLElement, part?: DiffPart) => HTMLElement @@ -29,8 +30,10 @@ function createMount( } } -// Gets the actual text content we care about and returns the number of characters we have stripped -// so that we can adjust accordingly. +/** + * Gets the actual text content we care about and returns the number of characters we have stripped + * so that we can adjust accordingly. + */ const getTextContent = (element: HTMLElement): { textContent: string; adjust: number } => { let textContent = element.textContent || '' let adjust = 0 @@ -126,3 +129,19 @@ export const phabCodeViews: CodeView[] = [ toolbarButtonProps, }, ] + +function checkIsPhabricator(): Promise { + if (document.querySelector('.phabricator-wordmark')) { + return Promise.resolve(true) + } + + return new Promise(resolve => + storage.getSync(items => resolve(!!items.enterpriseUrls.find(url => url === window.location.origin))) + ) +} + +export const phabricatorCodeHost: CodeHost = { + codeViews: phabCodeViews, + name: 'phabricator', + check: checkIsPhabricator, +} diff --git a/src/libs/phabricator/extension.tsx b/src/libs/phabricator/extension.tsx index 813d690d..c4871040 100644 --- a/src/libs/phabricator/extension.tsx +++ b/src/libs/phabricator/extension.tsx @@ -2,30 +2,23 @@ import '../../config/polyfill' import { setSourcegraphUrl } from '../../shared/util/context' import { featureFlags } from '../../shared/util/featureFlags' -import { injectCodeIntelligence } from '../code_intelligence/inject' +import { injectCodeIntelligence } from '../code_intelligence' import { getPhabricatorCSS, getSourcegraphURLFromConduit } from './backend' -import { phabCodeViews } from './code_views' import { injectPhabricatorBlobAnnotators } from './inject_old' import { expanderListen, metaClickOverride, setupPageLoadListener } from './util' // NOTE: injectModules is idempotent, so safe to call multiple times on the same page. -function injectModules(): void { +async function injectModules(): Promise { const extensionMarker = document.createElement('div') extensionMarker.id = 'sourcegraph-app-background' extensionMarker.style.display = 'none' document.body.appendChild(extensionMarker) - featureFlags - .isEnabled('newTooltips') - .then(enabled => { - if (enabled) { - injectCodeIntelligence({ name: 'phabricator', codeViews: phabCodeViews }) - return - } - - injectPhabricatorBlobAnnotators().catch(e => console.error(e)) - }) - .catch(err => console.error('could not get feature flag', err)) + if (await featureFlags.isEnabled('newInject')) { + await injectCodeIntelligence() + } else { + await injectPhabricatorBlobAnnotators() + } } export function init(): void { @@ -38,7 +31,7 @@ export function init(): void { // passed the bundle url. Legacy Phabricator extensions inject CSS via the loader.js script // so we do not need to do this here. if (!window.SOURCEGRAPH_BUNDLE_URL && !window.localStorage.getItem('SOURCEGRAPH_BUNDLE_URL')) { - injectModules() + injectModules().catch(err => console.error('Unable to inject modules', err)) metaClickOverride() expanderListen() return @@ -58,7 +51,7 @@ export function init(): void { setSourcegraphUrl(sourcegraphUrl) expanderListen() metaClickOverride() - injectModules() + injectModules().catch(err => console.error('Unable to inject modules', err)) }) .catch(e => { console.error(e) diff --git a/src/libs/phabricator/file_info.ts b/src/libs/phabricator/file_info.ts index 36b82e35..33eeda34 100644 --- a/src/libs/phabricator/file_info.ts +++ b/src/libs/phabricator/file_info.ts @@ -2,7 +2,7 @@ import { from, Observable, zip } from 'rxjs' import { catchError, filter, map, switchMap } from 'rxjs/operators' import { DifferentialState, DiffusionState, PhabricatorMode } from '.' import { fetchBlobContentLines } from '../../shared/repo/backend' -import { FileInfo } from '../code_intelligence/inject' +import { FileInfo } from '../code_intelligence' import { resolveDiffRev } from './backend' import { getFilepathFromFile, getPhabricatorState } from './util' diff --git a/src/libs/phabricator/util.tsx b/src/libs/phabricator/util.tsx index bcf4d542..cc30b572 100644 --- a/src/libs/phabricator/util.tsx +++ b/src/libs/phabricator/util.tsx @@ -292,7 +292,6 @@ export function getPhabricatorState( }) }) .catch(err => { - console.log('uhoh', err) reject(err) }) }) diff --git a/src/shared/components/CodeViewToolbar.tsx b/src/shared/components/CodeViewToolbar.tsx index 68576aed..5f702fcf 100644 --- a/src/shared/components/CodeViewToolbar.tsx +++ b/src/shared/components/CodeViewToolbar.tsx @@ -10,7 +10,7 @@ import { import * as React from 'react' import { Subscription } from 'rxjs' import { ContributableMenu } from 'sourcegraph/module/protocol' -import { FileInfo } from '../../libs/code_intelligence/inject' +import { FileInfo } from '../../libs/code_intelligence' import { SimpleProviderFns } from '../backend/lsp' import { fetchCurrentUser, fetchSite } from '../backend/server' import { CodeIntelStatusIndicator } from './CodeIntelStatusIndicator'