diff --git a/src/extension/manifest.spec.json b/src/extension/manifest.spec.json index a9bb1169..5aa330ad 100644 --- a/src/extension/manifest.spec.json +++ b/src/extension/manifest.spec.json @@ -43,6 +43,7 @@ { "matches": [ "https://github.com/*", + "https://gitlab.com/*", "https://sourcegraph.com/*", "https://localhost:3443/*", "http://localhost:32773/*" @@ -57,6 +58,7 @@ "activeTab", "contextMenus", "https://github.com/*", + "https://gitlab.com/*", "https://localhost:3443/*", "https://sourcegraph.com/*", "http://localhost:32773/*" @@ -66,7 +68,7 @@ "prod": { "content_scripts": [ { - "matches": ["https://github.com/*", "https://sourcegraph.com/*"], + "matches": ["https://github.com/*", "https://gitlab.com/*", "https://sourcegraph.com/*"], "run_at": "document_end", "js": ["js/inject.bundle.js"] } @@ -77,6 +79,7 @@ "storage", "contextMenus", "https://github.com/*", + "https://gitlab.com/*", "https://sourcegraph.com/*" ] } diff --git a/src/extension/scripts/inject.tsx b/src/extension/scripts/inject.tsx index 371cc4d4..6f3ab8f4 100644 --- a/src/extension/scripts/inject.tsx +++ b/src/extension/scripts/inject.tsx @@ -19,6 +19,7 @@ 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 { checkIsGitlab } from '../../libs/gitlab/code_intelligence' import { injectPhabricatorApplication } from '../../libs/phabricator/app' import { injectSourcegraphApp } from '../../libs/sourcegraph/inject' import { assertEnv } from '../envAssertion' @@ -56,6 +57,7 @@ function injectApplication(): void { const isBitbucket = document.querySelector('.bitbucket-header-logo') || document.querySelector('.aui-header-logo.aui-header-logo-bitbucket') + const isGitlab = checkIsGitlab() if (!isSourcegraphServer && !document.getElementById('ext-style-sheet')) { if (window.safari) { @@ -63,7 +65,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 || isGitlab) { const styleSheet = document.createElement('link') as HTMLLinkElement styleSheet.id = 'ext-style-sheet' styleSheet.rel = 'stylesheet' @@ -101,8 +103,8 @@ function injectApplication(): void { injectBitbucketServer() } - if (isGitHub || isPhabricator) { - if (await featureFlags.isEnabled('newInject')) { + if (isGitHub || isPhabricator || isGitlab) { + if (isGitlab || (await featureFlags.isEnabled('newInject'))) { const subscriptions = await injectCodeIntelligence() window.addEventListener('unload', () => subscriptions.unsubscribe()) } diff --git a/src/libs/code_intelligence/HoverOverlay.scss b/src/libs/code_intelligence/HoverOverlay.scss index e8f23c08..3cb0e400 100644 --- a/src/libs/code_intelligence/HoverOverlay.scss +++ b/src/libs/code_intelligence/HoverOverlay.scss @@ -16,3 +16,8 @@ } } } +.hover-overlay-mount__gitlab { + .hover-overlay { + z-index: 1000; + } +} diff --git a/src/libs/code_intelligence/code_intelligence.tsx b/src/libs/code_intelligence/code_intelligence.tsx index 88a0f503..08bc3fe6 100644 --- a/src/libs/code_intelligence/code_intelligence.tsx +++ b/src/libs/code_intelligence/code_intelligence.tsx @@ -23,8 +23,10 @@ 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 { gitlabCodeHost } from '../gitlab/code_intelligence' import { phabricatorCodeHost } from '../phabricator/code_intelligence' import { findCodeViews } from './code_views' +import { initSearch, SearchFeature } from './search' /** * Defines a type of code view a given code host can have. It tells us how to @@ -64,6 +66,11 @@ export interface CodeViewResolver { resolveCodeView: (elem: HTMLElement) => CodeViewWithOutSelector } +interface OverlayPosition { + top: number + left: number +} + /** Information for adding code intelligence to code views on arbitrary code hosts. */ export interface CodeHost { /** @@ -71,6 +78,12 @@ export interface CodeHost { */ name: string + /** + * Checks to see if the current context the code is running in is within + * the given code host. + */ + check: () => Promise | boolean + /** * The list of types of code views to try to annotate. */ @@ -83,10 +96,16 @@ export interface CodeHost { codeViewResolver?: CodeViewResolver /** - * Checks to see if the current context the code is running in is within - * the given code host. + * Adjust the position of the hover overlay. Useful for fixed headers or other + * elements that throw off the position of the tooltip within the relative + * element. */ - check: () => Promise | boolean + adjustOverlayPosition?: (position: OverlayPosition) => OverlayPosition + + /** + * Implementation of the search feature for a code host. + */ + search?: SearchFeature } export interface FileInfo { @@ -150,11 +169,19 @@ function initCodeIntelligence(codeHost: CodeHost): { hoverifier: Hoverifier } { const hoverOverlayElements = new Subject() const nextOverlayElement = (element: HTMLElement | null) => hoverOverlayElements.next(element) - const overlayMount = document.createElement('div') - overlayMount.style.height = '0px' - overlayMount.classList.add('hover-overlay-mount') - overlayMount.classList.add(`hover-overlay-mount__${codeHost.name}`) - document.body.appendChild(overlayMount) + const classNames = ['hover-overlay-mount', `hover-overlay-mount__${codeHost.name}`] + + const createMount = () => { + const overlayMount = document.createElement('div') + overlayMount.style.height = '0px' + for (const className of classNames) { + overlayMount.classList.add(className) + } + document.body.appendChild(overlayMount) + return overlayMount + } + + const overlayMount = document.querySelector(`.${classNames.join('.')}`) || createMount() const relativeElement = document.body @@ -201,9 +228,10 @@ function initCodeIntelligence(codeHost: CodeHost): { hoverifier: Hoverifier } { containerComponentUpdates.next() } public render(): JSX.Element | null { - return this.state.hoverOverlayProps ? ( + const hoverOverlayProps = this.getHoverOverlayProps() + return hoverOverlayProps ? ( eventLogger.logCodeIntelligenceEvent() + private getHoverOverlayProps(): HoverState['hoverOverlayProps'] { + if (!this.state.hoverOverlayProps) { + return undefined + } + + let { overlayPosition, ...rest } = this.state.hoverOverlayProps + if (overlayPosition && codeHost.adjustOverlayPosition) { + overlayPosition = codeHost.adjustOverlayPosition(overlayPosition) + } + + return { + ...rest, + overlayPosition, + } + } } render(, overlayMount) @@ -230,6 +273,10 @@ export interface ResolvedCodeView extends CodeViewWithOutSelector { } function handleCodeHost(codeHost: CodeHost): Subscription { + if (codeHost.search) { + initSearch(codeHost.search) + } + const { hoverifier } = initCodeIntelligence(codeHost) const subscriptions = new Subscription() @@ -247,7 +294,7 @@ function handleCodeHost(codeHost: CodeHost): Subscription { const resolveContext: ContextResolver = ({ part }) => ({ repoPath: part === 'base' ? info.baseRepoPath || info.repoPath : info.repoPath, commitID: part === 'base' ? info.baseCommitID! : info.commitID, - filePath: part === 'base' ? info.baseFilePath! : info.filePath, + filePath: part === 'base' ? info.baseFilePath || info.filePath : info.filePath, rev: part === 'base' ? info.baseRev || info.baseCommitID! : info.rev || info.commitID, }) @@ -308,7 +355,7 @@ async function injectCodeIntelligenceToCodeHosts(codeHosts: CodeHost[]): Promise * incomplete setup requests. */ export async function injectCodeIntelligence(): Promise { - const codeHosts: CodeHost[] = [githubCodeHost, phabricatorCodeHost] + const codeHosts: CodeHost[] = [githubCodeHost, gitlabCodeHost, phabricatorCodeHost] return await injectCodeIntelligenceToCodeHosts(codeHosts) } diff --git a/src/libs/code_intelligence/search.ts b/src/libs/code_intelligence/search.ts new file mode 100644 index 00000000..30e3c5de --- /dev/null +++ b/src/libs/code_intelligence/search.ts @@ -0,0 +1,79 @@ +import storage from '../../browser/storage' +import { resolveRev } from '../../shared/repo/backend' +import { getPlatformName, repoUrlCache, sourcegraphUrl } from '../../shared/util/context' + +export interface SearchPageInformation { + query: string + repoPath: string + rev?: string +} + +/** + * Interface containing information needed for the search feature. + */ +export interface SearchFeature { + /** + * Check that we're on the search page. + */ + checkIsSearchPage: () => boolean + /** + * Get information required for executing a search. + */ + getRepoInformation: () => SearchPageInformation +} + +function getSourcegraphURLProps({ + repoPath, + rev, + query, +}: SearchPageInformation): { url: string; repo: string; rev: string | undefined; query: string } | undefined { + if (repoPath) { + if (rev) { + return { + url: `search?q=${encodeURIComponent(query)}&sq=repo:%5E${encodeURIComponent( + repoPath.replace(/\./g, '\\.') + )}%24@${encodeURIComponent(rev)}&utm_source=${getPlatformName()}`, + repo: repoPath, + rev, + query: `${encodeURIComponent(query)} ${encodeURIComponent( + repoPath.replace(/\./g, '\\.') + )}%24@${encodeURIComponent(rev)}`, + } + } + + return { + url: `search?q=${encodeURIComponent(query)}&sq=repo:%5E${encodeURIComponent( + repoPath.replace(/\./g, '\\.') + )}%24&utm_source=${getPlatformName()}`, + repo: repoPath, + rev, + query: `repo:^${repoPath.replace(/\./g, '\\.')}$ ${query}`, + } + } +} + +export function initSearch({ getRepoInformation, checkIsSearchPage }: SearchFeature): void { + if (checkIsSearchPage()) { + storage.getSync(({ executeSearchEnabled }) => { + // GitHub search page pathname is //search + if (!executeSearchEnabled) { + return + } + + const { repoPath, rev, query } = getRepoInformation() + if (query) { + const linkProps = getSourcegraphURLProps({ repoPath, rev, query }) + + if (linkProps) { + // Ensure that we open the correct sourcegraph server url by checking which + // server instance can access the repository. + resolveRev({ repoPath: linkProps.repo }).subscribe(() => { + const baseUrl = repoUrlCache[linkProps.repo] || sourcegraphUrl + const url = `${baseUrl}/${linkProps.url}` + window.open(url, '_blank') + }) + } + } + }) + } +} diff --git a/src/libs/gitlab/api.ts b/src/libs/gitlab/api.ts new file mode 100644 index 00000000..f1717f8a --- /dev/null +++ b/src/libs/gitlab/api.ts @@ -0,0 +1,74 @@ +import { first } from 'lodash' +import { Observable } from 'rxjs' +import { ajax } from 'rxjs/ajax' +import { map } from 'rxjs/operators' + +import { memoizeObservable } from '../../shared/util/memoize' +import { GitLabDiffInfo } from './scrape' + +/** + * Significant revisions for a merge request. + */ +interface DiffRefs { + base_sha: string + head_sha: string + start_sha: string +} + +/** + * Response from the GitLab API for fetching a merge request. Note that there + * is more information returned but we are not using it. + */ +interface MergeRequestResponse { + diff_refs: DiffRefs +} + +/** + * Response from the GitLab API for fetching a specific version(diff) of a merge + * request. Note that there is more information returned but we are not using it. + */ +interface DiffVersionsResponse { + base_commit_sha: string +} + +type GetBaseCommitIDInput = Pick + +const buildURL = (owner: string, repoName: string, path: string) => + `${window.location.origin}/api/v4/projects/${owner}%2f${repoName}${path}` + +const get = (url: string): Observable => ajax.get(url).pipe(map(({ response }) => response as T)) + +/** + * Get the base commit ID for a merge request. + */ +export const getBaseCommitIDForMergeRequest: (info: GetBaseCommitIDInput) => Observable = memoizeObservable( + ({ owner, repoName, mergeRequestID, diffID }: GetBaseCommitIDInput) => { + const mrURL = buildURL(owner, repoName, `/merge_requests/${mergeRequestID}`) + + // If we have a `diffID`, retrieve the information for that individual diff. + if (diffID) { + return get(`${mrURL}/versions/${diffID}`).pipe( + map(({ base_commit_sha }) => base_commit_sha) + ) + } + + // Otherwise, just get the overall base `commitID` for the merge request. + return get(mrURL).pipe(map(({ diff_refs: { base_sha } }) => base_sha)) + }, + ({ mergeRequestID, diffID }) => mergeRequestID + (diffID ? `/${diffID}` : '') +) + +interface CommitResponse { + parent_ids: string[] +} + +/** + * Get the base commit ID for a commit. + */ +export const getBaseCommitIDForCommit: ( + { owner, repoName, commitID }: Pick & { commitID: string } +) => Observable = memoizeObservable(({ owner, repoName, commitID }) => + get(buildURL(owner, repoName, `/repository/commits/${commitID}`)).pipe( + map(({ parent_ids }) => first(parent_ids)!) // ! because it'll always have a parent if we are looking at the commit page. + ) +) diff --git a/src/libs/gitlab/code_intelligence.ts b/src/libs/gitlab/code_intelligence.ts new file mode 100644 index 00000000..35239f57 --- /dev/null +++ b/src/libs/gitlab/code_intelligence.ts @@ -0,0 +1,87 @@ +import { CodeHost, CodeViewResolver, CodeViewWithOutSelector } from '../code_intelligence' +import { diffDOMFunctions, singleFileDOMFunctions } from './dom_functions' +import { resolveCommitFileInfo, resolveDiffFileInfo, resolveFileInfo } from './file_info' +import { getPageInfo, GitLabPageKind } from './scrape' +import { search } from './search' + +const toolbarButtonProps = { + className: 'btn btn-default btn-sm', + style: { marginRight: '5px', textDecoration: 'none', color: 'inherit' }, +} + +export function checkIsGitlab(): boolean { + return !!document.head.querySelector('meta[content="GitLab"]') +} + +const adjustOverlayPosition: CodeHost['adjustOverlayPosition'] = ({ top, left }) => { + const header = document.querySelector('header') + + return { + top: header ? top + header.getBoundingClientRect().height : 0, + left, + } +} + +const createToolbarMount = (codeView: HTMLElement) => { + const fileActions = codeView.querySelector('.file-actions') + if (!fileActions) { + throw new Error('Unable to find mount location') + } + + const mount = document.createElement('div') + mount.classList.add('btn-group') + mount.classList.add('sg-toolbar-mount') + mount.classList.add('sg-toolbar-mount-gitlab') + + fileActions.insertAdjacentElement('afterbegin', mount) + + return mount +} + +const singleFileCodeView: CodeViewWithOutSelector = { + dom: singleFileDOMFunctions, + getToolbarMount: createToolbarMount, + resolveFileInfo, + toolbarButtonProps, +} + +const mergeRequestCodeView: CodeViewWithOutSelector = { + dom: diffDOMFunctions, + getToolbarMount: createToolbarMount, + resolveFileInfo: resolveDiffFileInfo, + toolbarButtonProps, +} + +const commitCodeView: CodeViewWithOutSelector = { + dom: diffDOMFunctions, + getToolbarMount: createToolbarMount, + resolveFileInfo: resolveCommitFileInfo, + toolbarButtonProps, +} + +const resolveCodeView = (codeView: HTMLElement): CodeViewWithOutSelector => { + const { pageKind } = getPageInfo() + + if (pageKind === GitLabPageKind.File) { + return singleFileCodeView + } + + if (pageKind === GitLabPageKind.MergeRequest) { + return mergeRequestCodeView + } + + return commitCodeView +} + +const codeViewResolver: CodeViewResolver = { + selector: '.file-holder', + resolveCodeView, +} + +export const gitlabCodeHost: CodeHost = { + name: 'gitlab', + check: checkIsGitlab, + codeViewResolver, + adjustOverlayPosition, + search, +} diff --git a/src/libs/gitlab/dom_functions.ts b/src/libs/gitlab/dom_functions.ts new file mode 100644 index 00000000..8b713b08 --- /dev/null +++ b/src/libs/gitlab/dom_functions.ts @@ -0,0 +1,60 @@ +import { DOMFunctions } from '@sourcegraph/codeintellify' + +export const singleFileDOMFunctions: DOMFunctions = { + getCodeElementFromTarget: target => target.closest('span.line') as HTMLElement | null, + getLineNumberFromCodeElement: codeElement => { + const line = codeElement.id.replace(/^LC/, '') + return parseInt(line, 10) + }, + getCodeElementFromLineNumber: (codeView, line) => codeView.querySelector(`#LC${line}`), +} + +export const diffDOMFunctions: DOMFunctions = { + getCodeElementFromTarget: singleFileDOMFunctions.getCodeElementFromTarget, + getLineNumberFromCodeElement: codeElement => { + let cell: HTMLElement | null = codeElement.closest('td') + while (cell && !cell.dataset.linenumber && cell.previousElementSibling) { + cell = cell.previousElementSibling as HTMLElement | null + } + + if (cell) { + return parseInt(cell.dataset.linenumber || '', 10) + } + + throw new Error('Unable to determine line number for diff code element') + }, + getCodeElementFromLineNumber: (codeView, line, part) => { + const lineNumberElement = codeView.querySelector( + `.${part === 'base' ? 'old_line' : 'new_line'} [data-linenumber="${line}"]` + ) + if (!lineNumberElement) { + return null + } + + const row = lineNumberElement.closest('tr') + if (!row) { + return null + } + + let selector = 'span.line' + + // Split diff + if (row.classList.contains('parallel')) { + selector = `.${part === 'base' ? 'left-side' : 'right-side'} ${selector}` + } + + return row.querySelector(selector) + }, + getDiffCodePart: codeElement => { + let selector = 'old' + + const row = codeElement.closest('td')! + + // Split diff + if (row.classList.contains('parallel')) { + selector = 'left-side' + } + + return row.classList.contains(selector) ? 'base' : 'head' + }, +} diff --git a/src/libs/gitlab/file_info.ts b/src/libs/gitlab/file_info.ts new file mode 100644 index 00000000..724d6a7b --- /dev/null +++ b/src/libs/gitlab/file_info.ts @@ -0,0 +1,116 @@ +import { propertyIsDefined } from '@sourcegraph/codeintellify/lib/helpers' +import { Observable, of, zip } from 'rxjs' +import { filter, map, switchMap } from 'rxjs/operators' + +import { resolveRev, retryWhenCloneInProgressError } from '../../shared/repo/backend' +import { FileInfo } from '../code_intelligence' +import { getBaseCommitIDForCommit, getBaseCommitIDForMergeRequest } from './api' +import { + getCommitPageInfo, + getDiffPageInfo, + getFilePageInfo, + getFilePathsFromCodeView, + getHeadCommitIDFromCodeView, +} from './scrape' + +const ensureRevisionsAreCloned = (files: Observable): Observable => + files.pipe( + switchMap(({ repoPath, rev, baseRev, ...rest }) => { + // Although we get the commit SHA's from elesewhere, we still need to + // use `resolveRev` otherwise we can't guarantee Sourcegraph has the + // revision cloned. + const resolvingHeadRev = resolveRev({ repoPath, rev }).pipe(retryWhenCloneInProgressError()) + const resolvingBaseRev = resolveRev({ repoPath, rev: baseRev }).pipe(retryWhenCloneInProgressError()) + + return zip(resolvingHeadRev, resolvingBaseRev).pipe(map(() => ({ repoPath, rev, baseRev, ...rest }))) + }) + ) + +/** + * Resolves file information for a page with a single file, not including diffs with only one file. + */ +export const resolveFileInfo = (codeView: HTMLElement): Observable => + of(undefined).pipe( + map(() => { + const { repoPath, filePath, rev } = getFilePageInfo() + + return { repoPath, filePath, rev } + }), + filter(propertyIsDefined('filePath')), + switchMap(({ repoPath, rev, ...rest }) => + resolveRev({ repoPath, rev }).pipe( + retryWhenCloneInProgressError(), + map(commitID => ({ ...rest, repoPath, commitID, rev: rev || commitID })) + ) + ) + ) + +/** + * Gets `FileInfo` for a diff file. + */ +export const resolveDiffFileInfo = (codeView: HTMLElement): Observable => + of(undefined).pipe( + map(getDiffPageInfo), + // Resolve base commit ID. + switchMap(({ owner, repoName, mergeRequestID, diffID, baseCommitID, ...rest }) => { + const gettingBaseCommitID = baseCommitID + ? // Commit was found in URL. + of(baseCommitID) + : // Commit needs to be fetched from the API. + getBaseCommitIDForMergeRequest({ owner, repoName, mergeRequestID, diffID }) + + return gettingBaseCommitID.pipe(map(baseCommitID => ({ baseCommitID, baseRev: baseCommitID, ...rest }))) + }), + map(info => { + // Head commit is found in the "View file @ ..." button in the code view. + const head = getHeadCommitIDFromCodeView(codeView) + + return { + ...info, + + rev: head, + commitID: head, + } + }), + map(info => ({ + ...info, + // Find both head and base file path if the name has changed. + ...getFilePathsFromCodeView(codeView), + })), + map(info => ({ + ...info, + + // https://github.com/sourcegraph/browser-extensions/issues/185 + headHasFileContents: true, + baseHasFileContents: true, + })), + ensureRevisionsAreCloned + ) + +/** + * Resolves file information for commit pages. + */ +export const resolveCommitFileInfo = (codeView: HTMLElement): Observable => + of(undefined).pipe( + map(getCommitPageInfo), + // Resolve base commit ID. + switchMap(({ owner, repoName, commitID, ...rest }) => + getBaseCommitIDForCommit({ owner, repoName, commitID }).pipe( + map(baseCommitID => ({ owner, repoName, commitID, baseCommitID, ...rest })) + ) + ), + map(info => ({ ...info, rev: info.commitID, baseRev: info.baseCommitID })), + map(info => ({ + ...info, + // Find both head and base file path if the name has changed. + ...getFilePathsFromCodeView(codeView), + })), + map(info => ({ + ...info, + + // https://github.com/sourcegraph/browser-extensions/issues/185 + headHasFileContents: true, + baseHasFileContents: true, + })), + ensureRevisionsAreCloned + ) diff --git a/src/libs/gitlab/scrape.ts b/src/libs/gitlab/scrape.ts new file mode 100644 index 00000000..23e253f3 --- /dev/null +++ b/src/libs/gitlab/scrape.ts @@ -0,0 +1,172 @@ +import { last } from 'lodash' + +import { FileInfo } from '../code_intelligence' + +export enum GitLabPageKind { + File, + Commit, + MergeRequest, +} + +/** + * General information that can be found on any GitLab page that we care about. (i.e. has code) + */ +export interface GitLabInfo { + pageKind: GitLabPageKind + + owner: string + repoName: string + + repoPath: string + + /** + * The parts of the URL following the repo name. + */ + urlParts: string[] +} + +/** + * Information about single file pages. + */ +export interface GitLabFileInfo extends Pick { + filePath: string + rev: string +} + +/** + * Gets information about the page. + */ +export function getPageInfo(): GitLabInfo { + const host = window.location.hostname + + const parts = window.location.pathname.slice(1).split('/') + + const owner = parts[0] + const repoName = parts[1] + + let pageKind: GitLabPageKind + if (window.location.pathname.includes(`${owner}/${repoName}/commit`)) { + pageKind = GitLabPageKind.Commit + } else if (window.location.pathname.includes(`${owner}/${repoName}/merge_requests`)) { + pageKind = GitLabPageKind.MergeRequest + } else { + pageKind = GitLabPageKind.File + } + + return { + owner, + repoName, + repoPath: [host, owner, repoName].join('/'), + urlParts: parts.slice(2), + pageKind, + } +} + +/** + * Gets information about a file view page. + */ +export function getFilePageInfo(): GitLabFileInfo { + const { repoPath } = getPageInfo() + + const parts = window.location.pathname.slice(1).split('/') + + const rev = parts[3] + const filePath = parts.slice(4).join('/') + + return { + repoPath, + filePath, + rev, + } +} + +const createErrorBuilder = (message: string) => (kind: string) => new Error(`${message} (${kind})`) + +/** + * Information specific to diff pages. + */ +export interface GitLabDiffInfo extends Pick, Pick { + mergeRequestID: string + + diffID?: string + baseCommitID?: string +} + +/** + * Scrapes the DOM for the repo path and revision information. + */ +export function getDiffPageInfo(): GitLabDiffInfo { + const { repoPath, owner, repoName, urlParts } = getPageInfo() + + const query = new URLSearchParams(window.location.search) + + return { + repoPath, + owner, + repoName, + mergeRequestID: urlParts[1], + diffID: query.get('diff_id') || undefined, + baseCommitID: query.get('start_sha') || undefined, + } +} + +const buildFileError = createErrorBuilder('Unable to file information') + +/** + * Finds the file paths from the code view. If the name has changed, it'll return the base and head file paths. + */ +export function getFilePathsFromCodeView(codeView: HTMLElement): Pick { + const filePathElements = codeView.querySelectorAll('.file-title-name') + if (filePathElements.length === 0) { + throw buildFileError('no-file-title-element') + } + + const getFilePathFromElem = (elem: HTMLElement) => { + const filePath = elem.dataset.originalTitle || elem.dataset.title + if (!filePath) { + throw buildFileError('no-file-title') + } + + return filePath + } + + const filePathDidChange = filePathElements.length > 1 + + return { + filePath: getFilePathFromElem(filePathElements.item(filePathDidChange ? 1 : 0)), + baseFilePath: filePathDidChange ? getFilePathFromElem(filePathElements.item(0)) : undefined, + } +} + +/** + * Gets the head commit ID from the "View file @ ..." link on the code view. + */ +export function getHeadCommitIDFromCodeView(codeView: HTMLElement): FileInfo['commitID'] { + const commitSHA = codeView.querySelector('.file-actions .commit-sha') + if (!commitSHA) { + throw buildFileError('no-commit-sha') + } + + const commitAnchor = commitSHA.closest('a')! as HTMLAnchorElement + const hrefParts = new URL(commitAnchor.href).pathname.slice(1).split('/') + + return hrefParts[3] +} + +interface GitLabCommitPageInfo extends Pick, Pick { + commitID: FileInfo['commitID'] +} + +/** + * Get the commit from the URL. + */ +export function getCommitPageInfo(): GitLabCommitPageInfo { + const { repoPath, owner, repoName } = getPageInfo() + + return { + repoPath, + owner, + repoName, + commitID: last(window.location.pathname.split('/'))!, + } +} diff --git a/src/libs/gitlab/search.ts b/src/libs/gitlab/search.ts new file mode 100644 index 00000000..24829d68 --- /dev/null +++ b/src/libs/gitlab/search.ts @@ -0,0 +1,29 @@ +import { SearchFeature } from '../code_intelligence/search' + +const getRepoInformation: SearchFeature['getRepoInformation'] = () => { + const project = document.querySelector('.js-search-project-dropdown .dropdown-toggle-text') + if (!project) { + throw new Error('Unable to find project dropdown (search)') + } + + const projectText = project.textContent || '' + const parts = projectText + .trim() + .split(/\s/) + .slice(1) + + const owner = parts[0] + const repoName = parts[2] + + return { + query: new URLSearchParams(window.location.search).get('search') || '', + repoPath: `${window.location.host}/${owner}/${repoName}`, + } +} + +export const search: SearchFeature = { + checkIsSearchPage: () => + window.location.pathname === '/search' && + new URLSearchParams(window.location.search).get('search_code') === 'true', + getRepoInformation, +} diff --git a/src/shared/components/codeIntelStatusIndicator.scss b/src/shared/components/codeIntelStatusIndicator.scss index 36f43561..7d2ce84d 100644 --- a/src/shared/components/codeIntelStatusIndicator.scss +++ b/src/shared/components/codeIntelStatusIndicator.scss @@ -66,6 +66,34 @@ &__icon { width: 1rem !important; height: 1rem !important; - vertical-align: middle; + vertical-align: middle !important; + } +} + +.sg-toolbar-mount { + &-gitlab { + .composite-container__header-action { + padding: 0 8px !important; + height: auto; + } + + & svg { + top: unset !important; + } + + .btn { + padding: 0 10px; + font-size: 13px; + line-height: 28px; + display: inline-block; + float: none; + } + + .btn svg { + height: 15px !important; + width: 15px !important; + position: relative; + top: 2px; + } } }