diff --git a/.vscode/launch.json b/.vscode/launch.json index 05a53d7e2ff..91b9c1bbecf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,6 +15,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceRoot}/client/vscode", "--disable-extension=kandalatj.sourcegraph-preview", + "--disable-extension=sourcegraph.sourcegraph", ], "stopOnEntry": false, "sourceMaps": true, @@ -28,6 +29,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceRoot}/client/vscode", "--disable-extension=kandalatj.sourcegraph-preview", + "--disable-extension=sourcegraph.sourcegraph", "--extensionDevelopmentKind=web", "--disable-web-security", ], diff --git a/client/search-ui/src/index.ts b/client/search-ui/src/index.ts index e8e45854ab3..61002b14cf4 100644 --- a/client/search-ui/src/index.ts +++ b/client/search-ui/src/index.ts @@ -1,6 +1,7 @@ export * from './components' export * from './input/toggles' export * from './input/SearchBox' +export * from './input/useQueryIntelligence' export * from './documentation/ModalVideo' export * from './results/StreamingSearchResultsList' export * from './results/progress/StreamingProgress' diff --git a/client/search-ui/src/input/MonacoQueryInput.tsx b/client/search-ui/src/input/MonacoQueryInput.tsx index 4c5a8a75993..fa9d1063e54 100644 --- a/client/search-ui/src/input/MonacoQueryInput.tsx +++ b/client/search-ui/src/input/MonacoQueryInput.tsx @@ -11,8 +11,6 @@ import { CaseSensitivityProps, SearchPatternTypeProps, SearchContextProps, - useQueryIntelligence, - useQueryDiagnostics, } from '@sourcegraph/search' import { MonacoEditor } from '@sourcegraph/shared/src/components/MonacoEditor' import { KeyboardShortcut } from '@sourcegraph/shared/src/keyboardShortcuts' @@ -23,6 +21,7 @@ import { fetchStreamSuggestions as defaultFetchStreamSuggestions } from '@source import { ThemeProps } from '@sourcegraph/shared/src/theme' import { IEditor } from './LazyMonacoQueryInput' +import { useQueryDiagnostics, useQueryIntelligence } from './useQueryIntelligence' import styles from './MonacoQueryInput.module.scss' diff --git a/client/search/src/useQueryIntelligence.ts b/client/search-ui/src/input/useQueryIntelligence.ts similarity index 100% rename from client/search/src/useQueryIntelligence.ts rename to client/search-ui/src/input/useQueryIntelligence.ts diff --git a/client/search-ui/src/results/StreamingSearchResultsList.tsx b/client/search-ui/src/results/StreamingSearchResultsList.tsx index 2d88cc96322..c48da52e5ac 100644 --- a/client/search-ui/src/results/StreamingSearchResultsList.tsx +++ b/client/search-ui/src/results/StreamingSearchResultsList.tsx @@ -1,12 +1,12 @@ import React, { useCallback } from 'react' import classNames from 'classnames' -import * as H from 'history' import AlphaSBoxIcon from 'mdi-react/AlphaSBoxIcon' import FileDocumentIcon from 'mdi-react/FileDocumentIcon' import FileIcon from 'mdi-react/FileIcon' import SourceCommitIcon from 'mdi-react/SourceCommitIcon' import SourceRepositoryIcon from 'mdi-react/SourceRepositoryIcon' +import { useLocation } from 'react-router' import { Observable } from 'rxjs' import { HoverMerged } from '@sourcegraph/client-api' @@ -48,7 +48,6 @@ export interface StreamingSearchResultsListProps PlatformContextProps<'requestGraphQL'> { isSourcegraphDotCom: boolean results?: AggregateStreamingSearchResults - location: H.Location allExpanded: boolean fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable authenticatedUser: AuthenticatedUser | null @@ -75,7 +74,6 @@ export interface StreamingSearchResultsListProps export const StreamingSearchResultsList: React.FunctionComponent = ({ results, - location, allExpanded, fetchHighlightedFileLineRanges, settingsCascade, @@ -96,6 +94,7 @@ export const StreamingSearchResultsList: React.FunctionComponent { const resultsNumber = results?.results.length || 0 const { itemsToShow, handleBottomHit } = useItemsToShow(executedQuery, resultsNumber) + const location = useLocation() const logSearchResultClicked = useCallback( (index: number, type: string) => { diff --git a/client/search-ui/src/results/sidebar/SearchSidebar.tsx b/client/search-ui/src/results/sidebar/SearchSidebar.tsx index 31c9c12ad3d..672c1c1d6c8 100644 --- a/client/search-ui/src/results/sidebar/SearchSidebar.tsx +++ b/client/search-ui/src/results/sidebar/SearchSidebar.tsx @@ -40,7 +40,7 @@ export interface SearchSidebarProps /** * Not yet implemented in the VS Code extension (blocked on Apollo Client integration). - * */ + */ getRevisions?: (revisionsProps: Omit) => (query: string) => JSX.Element /** diff --git a/client/search/src/index.ts b/client/search/src/index.ts index 8687eb2faf3..24308cb29a7 100644 --- a/client/search/src/index.ts +++ b/client/search/src/index.ts @@ -20,7 +20,6 @@ export * from './backend' export * from './searchQueryState' export * from './helpers' export * from './graphql-operations' -export * from './useQueryIntelligence' export * from './helpers/queryExample' export interface SearchPatternTypeProps { diff --git a/client/shared/dev/generateGraphQlOperations.js b/client/shared/dev/generateGraphQlOperations.js index f53236bc443..ef2da1b914f 100644 --- a/client/shared/dev/generateGraphQlOperations.js +++ b/client/shared/dev/generateGraphQlOperations.js @@ -10,6 +10,7 @@ const WEB_FOLDER = path.resolve(ROOT_FOLDER, './client/web') const BROWSER_FOLDER = path.resolve(ROOT_FOLDER, './client/browser') const SHARED_FOLDER = path.resolve(ROOT_FOLDER, './client/shared') const SEARCH_FOLDER = path.resolve(ROOT_FOLDER, './client/search') +const VSCODE_FOLDER = path.resolve(ROOT_FOLDER, './client/vscode') const SCHEMA_PATH = path.join(ROOT_FOLDER, './cmd/frontend/graphqlbackend/*.graphql') const SHARED_DOCUMENTS_GLOB = [ @@ -32,9 +33,17 @@ const BROWSER_DOCUMENTS_GLOB = [ const SEARCH_DOCUMENTS_GLOB = [`${SEARCH_FOLDER}/src/**/*.{ts,tsx}`] +const VSCODE_DOCUMENTS_GLOB = [`${VSCODE_FOLDER}/src/**/*.{ts,tsx}`] + // Define ALL_DOCUMENTS_GLOB as the union of the previous glob arrays. const ALL_DOCUMENTS_GLOB = [ - ...new Set([...SHARED_DOCUMENTS_GLOB, ...WEB_DOCUMENTS_GLOB, ...BROWSER_DOCUMENTS_GLOB, ...SEARCH_DOCUMENTS_GLOB]), + ...new Set([ + ...SHARED_DOCUMENTS_GLOB, + ...WEB_DOCUMENTS_GLOB, + ...BROWSER_DOCUMENTS_GLOB, + ...SEARCH_DOCUMENTS_GLOB, + ...VSCODE_DOCUMENTS_GLOB, + ]), ] const SHARED_PLUGINS = [ @@ -122,6 +131,17 @@ async function generateGraphQlOperations() { }, plugins: SHARED_PLUGINS, }, + + [path.join(VSCODE_FOLDER, './src/graphql-operations.ts')]: { + documents: VSCODE_DOCUMENTS_GLOB, + config: { + onlyOperationTypes: true, + noExport: false, + enumValues: '@sourcegraph/shared/src/graphql-operations', + interfaceNameForOperations: 'VSCodeGraphQlOperations', + }, + plugins: SHARED_PLUGINS, + }, }, }, true diff --git a/client/shared/src/api/client/connection.ts b/client/shared/src/api/client/connection.ts index b89a2a7286c..12a3d192869 100644 --- a/client/shared/src/api/client/connection.ts +++ b/client/shared/src/api/client/connection.ts @@ -87,6 +87,9 @@ export async function createExtensionHostClientConnection( } comlink.expose(clientAPI, endpoints.expose) + proxy.mainThreadAPIInitialized().catch(() => { + console.error('Error notifying extension host of main thread API init.') + }) // TODO(tj): return MainThreadAPI and add to Controller interface // to allow app to interact with APIs whose state lives in the main thread diff --git a/client/shared/src/api/extension/activation.ts b/client/shared/src/api/extension/activation.ts index e375ede5152..16b43b06020 100644 --- a/client/shared/src/api/extension/activation.ts +++ b/client/shared/src/api/extension/activation.ts @@ -1,6 +1,6 @@ import { Remote } from 'comlink' import { BehaviorSubject, combineLatest, from, Observable, Subscription } from 'rxjs' -import { catchError, concatMap, distinctUntilChanged, map, tap } from 'rxjs/operators' +import { catchError, concatMap, distinctUntilChanged, first, map, switchMap, tap } from 'rxjs/operators' import sourcegraph from 'sourcegraph' import { Contributions } from '@sourcegraph/client-api' @@ -16,13 +16,18 @@ import { parseContributionExpressions } from './api/contribution' import { ExtensionHostState } from './extensionHostState' export function observeActiveExtensions( - mainAPI: Remote + mainAPI: Remote, + mainThreadAPIInitializations: Observable ): { activeLanguages: ExtensionHostState['activeLanguages'] activeExtensions: ExtensionHostState['activeExtensions'] } { const activeLanguages = new BehaviorSubject>(new Set()) - const enabledExtensions = wrapRemoteObservable(mainAPI.getEnabledExtensions()) + // Wait until the main thread API has initialized since this runs during extension host init. + const enabledExtensions = mainThreadAPIInitializations.pipe( + first(initialized => initialized), + switchMap(() => wrapRemoteObservable(mainAPI.getEnabledExtensions())) + ) const activatedExtensionIDs = new Set() const activeExtensions: Observable<(ConfiguredExtension | ExecutableExtension)[]> = combineLatest([ @@ -59,6 +64,7 @@ export function activateExtensions( state: Pick, mainAPI: Remote>, createExtensionAPI: (extensionID: string) => typeof sourcegraph, + mainThreadAPIInitializations: Observable, /** * Function that activates an extension. * Returns a promise that resolves once the extension is activated. @@ -72,14 +78,19 @@ export function activateExtensions( ): Subscription { const getScriptURLs = memoizeObservable( () => - from(mainAPI.getScriptURLForExtension()).pipe( - map(getScriptURL => { - function getBundleURLs(urls: string[]): Promise<(string | ErrorLike)[]> { - return getScriptURL ? getScriptURL(urls) : Promise.resolve(urls) - } + mainThreadAPIInitializations.pipe( + first(initialized => initialized), + switchMap(() => + from(mainAPI.getScriptURLForExtension()).pipe( + map(getScriptURL => { + function getBundleURLs(urls: string[]): Promise<(string | ErrorLike)[]> { + return getScriptURL ? getScriptURL(urls) : Promise.resolve(urls) + } - return getBundleURLs - }) + return getBundleURLs + }) + ) + ) ), () => 'getScriptURL' ) diff --git a/client/shared/src/api/extension/api/api.ts b/client/shared/src/api/extension/api/api.ts index 9a7765bb10a..55a85b22ce6 100644 --- a/client/shared/src/api/extension/api/api.ts +++ b/client/shared/src/api/extension/api/api.ts @@ -7,4 +7,9 @@ export type ExtensionHostAPIFactory = (initData: InitData) => ExtensionHostAPI export interface ExtensionHostAPI extends ProxyMarked, FlatExtensionHostAPI { ping(): 'pong' + /** + * Main thread calls this to notify the extension host that `MainThreadAPI` has + * been created and exposed. + * */ + mainThreadAPIInitialized: () => void } diff --git a/client/shared/src/api/extension/extensionHost.ts b/client/shared/src/api/extension/extensionHost.ts index 3c14f443774..5fc11e39656 100644 --- a/client/shared/src/api/extension/extensionHost.ts +++ b/client/shared/src/api/extension/extensionHost.ts @@ -1,6 +1,6 @@ import * as comlink from 'comlink' import { isMatch } from 'lodash' -import { Subscription, Unsubscribable } from 'rxjs' +import { ReplaySubject, Subscription, Unsubscribable } from 'rxjs' import * as sourcegraph from 'sourcegraph' import { EndpointPair } from '../../platform/context' @@ -113,18 +113,28 @@ function createExtensionAndExtensionHostAPIs( registerComlinkTransferHandlers() + /** + * Used to wait until the main thread API has been initialized. Ensures + * that message of main thread API calls (e.g. getActiveExtensions) + * during extension host initialization are not dropped. + * + * Debt: ensure this works holds true for all clients. + * If not, add `waitForMainThread` parameter to make this opt-in. + */ + const mainThreadAPIInitializations = new ReplaySubject(1) + /** Proxy to main thread */ const proxy = comlink.wrap(endpoints.proxy) // Create extension host state - const extensionHostState = createExtensionHostState(initData, proxy) + const extensionHostState = createExtensionHostState(initData, proxy, mainThreadAPIInitializations) // Create extension host API const extensionHostAPINew = createExtensionHostAPI(extensionHostState) // Create extension API factory const createExtensionAPI = createExtensionAPIFactory(extensionHostState, proxy, initData) // Activate extensions. Create extension APIs on extension activation. - subscription.add(activateExtensions(extensionHostState, proxy, createExtensionAPI)) + subscription.add(activateExtensions(extensionHostState, proxy, createExtensionAPI, mainThreadAPIInitializations)) // Observe settings and update active loggers state subscription.add(setActiveLoggers(extensionHostState)) @@ -134,6 +144,9 @@ function createExtensionAndExtensionHostAPIs( [comlink.proxyMarker]: true, ping: () => 'pong', + mainThreadAPIInitialized: () => { + mainThreadAPIInitializations.next(true) + }, ...extensionHostAPINew, } diff --git a/client/shared/src/api/extension/extensionHostState.ts b/client/shared/src/api/extension/extensionHostState.ts index 972068e0826..1040880c62c 100644 --- a/client/shared/src/api/extension/extensionHostState.ts +++ b/client/shared/src/api/extension/extensionHostState.ts @@ -26,9 +26,10 @@ import { ReferenceCounter } from './utils/ReferenceCounter' export function createExtensionHostState( initData: Pick, - mainAPI: comlink.Remote + mainAPI: comlink.Remote, + mainThreadAPIInitializations: Observable ): ExtensionHostState { - const { activeLanguages, activeExtensions } = observeActiveExtensions(mainAPI) + const { activeLanguages, activeExtensions } = observeActiveExtensions(mainAPI, mainThreadAPIInitializations) return { haveInitialExtensionsLoaded: new BehaviorSubject(false), diff --git a/client/shared/src/api/extension/test/activation.test.ts b/client/shared/src/api/extension/test/activation.test.ts index aa6b0d062db..67ab0b7a949 100644 --- a/client/shared/src/api/extension/test/activation.test.ts +++ b/client/shared/src/api/extension/test/activation.test.ts @@ -1,4 +1,4 @@ -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, of } from 'rxjs' import { filter, first } from 'rxjs/operators' import sinon from 'sinon' import sourcegraph from 'sourcegraph' @@ -51,6 +51,7 @@ describe('Extension activation', () => { function createExtensionAPI() { return {} as typeof sourcegraph }, + of(true), noopPromise, noopPromise ) diff --git a/client/shared/src/api/extension/test/test-helpers.ts b/client/shared/src/api/extension/test/test-helpers.ts index 6b7936855af..df17f0b6f4b 100644 --- a/client/shared/src/api/extension/test/test-helpers.ts +++ b/client/shared/src/api/extension/test/test-helpers.ts @@ -1,4 +1,5 @@ import { Remote } from 'comlink' +import { BehaviorSubject } from 'rxjs' import { ClientAPI } from '../../client/api/api' import { FlatExtensionHostAPI } from '../../contract' @@ -14,7 +15,11 @@ export function initializeExtensionHostTest( mockMainThreadAPI: Remote = pretendRemote({}), extensionID: string = 'TEST' ): { extensionHostAPI: FlatExtensionHostAPI; extensionAPI: ReturnType> } { - const extensionHostState = createExtensionHostState(initData, mockMainThreadAPI) + // Since the mock main thread API is in the same thread and a connection is synchronously established, + // we can mock `mainThreadInitializations` as well. + const mainThreadInitializations = new BehaviorSubject(true) + + const extensionHostState = createExtensionHostState(initData, mockMainThreadAPI, mainThreadInitializations) const extensionHostAPI = createExtensionHostAPI(extensionHostState) const extensionAPIFactory = createExtensionAPIFactory(extensionHostState, mockMainThreadAPI, initData) diff --git a/client/shared/src/backend/file.ts b/client/shared/src/backend/file.ts new file mode 100644 index 00000000000..0e27e44cbbe --- /dev/null +++ b/client/shared/src/backend/file.ts @@ -0,0 +1,70 @@ +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' + +import { createAggregateError, memoizeObservable } from '@sourcegraph/common' +import { gql } from '@sourcegraph/http-client' + +import { FetchFileParameters } from '../components/CodeExcerpt' +import { HighlightedFileResult, HighlightedFileVariables } from '../graphql-operations' +import { PlatformContext } from '../platform/context' +import { makeRepoURI } from '../util/url' + +/** + * Fetches the specified highlighted file line ranges (`FetchFileParameters.ranges`) and returns + * them as a list of ranges, each describing a list of lines in the form of HTML table '...'. + */ +export const fetchHighlightedFileLineRanges = memoizeObservable( + ( + { + platformContext, + ...context + }: FetchFileParameters & { + platformContext: Pick + }, + force?: boolean + ): Observable => + platformContext + .requestGraphQL({ + request: gql` + query HighlightedFile( + $repoName: String! + $commitID: String! + $filePath: String! + $disableTimeout: Boolean! + $ranges: [HighlightLineRange!]! + ) { + repository(name: $repoName) { + commit(rev: $commitID) { + file(path: $filePath) { + isDirectory + richHTML + highlight(disableTimeout: $disableTimeout) { + aborted + lineRanges(ranges: $ranges) + } + } + } + } + } + `, + variables: { ...context, disableTimeout: !!context.disableTimeout }, + mightContainPrivateInfo: true, + }) + .pipe( + map(({ data, errors }) => { + if (!data?.repository?.commit?.file?.highlight) { + throw createAggregateError(errors) + } + const file = data.repository.commit.file + if (file.isDirectory) { + return [] + } + return file.highlight.lineRanges + }) + ), + context => + makeRepoURI(context) + + `?disableTimeout=${String(context.disableTimeout)}&ranges=${context.ranges + .map(range => `${range.startLine}:${range.endLine}`) + .join(',')}` +) diff --git a/client/shared/src/backend/repo.ts b/client/shared/src/backend/repo.ts index bb52dc036ea..233ae52e9d9 100644 --- a/client/shared/src/backend/repo.ts +++ b/client/shared/src/backend/repo.ts @@ -1,12 +1,13 @@ import { from, Observable } from 'rxjs' import { map } from 'rxjs/operators' -import { memoizeObservable } from '@sourcegraph/common' +import { createAggregateError, memoizeObservable } from '@sourcegraph/common' import { dataOrThrowErrors, gql } from '@sourcegraph/http-client' +import { TreeEntriesResult, TreeFields } from '../graphql-operations' import { PlatformContext } from '../platform/context' import * as GQL from '../schema' -import { RepoSpec } from '../util/url' +import { AbsoluteRepoFile, makeRepoURI, RepoSpec } from '../util/url' import { CloneInProgressError, RepoNotFoundError } from './errors' @@ -47,3 +48,57 @@ export const resolveRawRepoName = memoizeObservable( ), ({ repoName }) => repoName ) + +export const fetchTreeEntries = memoizeObservable( + ({ + requestGraphQL, + ...args + }: AbsoluteRepoFile & { first?: number } & Pick): Observable => + requestGraphQL({ + request: gql` + query TreeEntries( + $repoName: String! + $revision: String! + $commitID: String! + $filePath: String! + $first: Int + ) { + repository(name: $repoName) { + commit(rev: $commitID, inputRevspec: $revision) { + tree(path: $filePath) { + ...TreeFields + } + } + } + } + fragment TreeFields on GitTree { + isRoot + url + entries(first: $first, recursiveSingleChild: true) { + ...TreeEntryFields + } + } + fragment TreeEntryFields on TreeEntry { + name + path + isDirectory + url + submodule { + url + commit + } + isSingleChild + } + `, + variables: args, + mightContainPrivateInfo: true, + }).pipe( + map(({ data, errors }) => { + if (errors || !data?.repository?.commit?.tree) { + throw createAggregateError(errors) + } + return data.repository.commit.tree + }) + ), + ({ first, requestGraphQL, ...args }) => `${makeRepoURI(args)}:first-${String(first)}` +) diff --git a/client/shared/src/components/FileMatch.tsx b/client/shared/src/components/FileMatch.tsx index 5351e44f484..86380686cda 100644 --- a/client/shared/src/components/FileMatch.tsx +++ b/client/shared/src/components/FileMatch.tsx @@ -113,7 +113,7 @@ export const FileMatch: React.FunctionComponent = props => { // The number of lines of context to show before and after each match. const context = useMemo(() => { - if (props.location.pathname === '/search') { + if (props.location?.pathname === '/search') { // Check if search.contextLines is configured in settings. const contextLinesSetting = isSettingsValid(props.settingsCascade) && diff --git a/client/shared/src/components/FileMatchChildren.tsx b/client/shared/src/components/FileMatchChildren.tsx index cd15bda29c8..5acacd809ad 100644 --- a/client/shared/src/components/FileMatchChildren.tsx +++ b/client/shared/src/components/FileMatchChildren.tsx @@ -33,7 +33,7 @@ import { MatchGroup } from './ranking/PerFileResultRanking' import styles from './FileMatchChildren.module.scss' interface FileMatchProps extends SettingsCascadeProps, TelemetryProps { - location: H.Location + location?: H.Location result: ContentMatch | SymbolMatch | PathMatch grouped: MatchGroup[] /* Clicking on a match opens the link in a new tab */ @@ -41,7 +41,6 @@ interface FileMatchProps extends SettingsCascadeProps, TelemetryProps { /* Called when the first result has fully loaded. */ onFirstResultLoad?: () => void fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable - extensionsController?: Pick hoverifier?: Hoverifier } diff --git a/client/shared/src/extensions/controller.ts b/client/shared/src/extensions/controller.ts index 7fb1da063dd..53a45381691 100644 --- a/client/shared/src/extensions/controller.ts +++ b/client/shared/src/extensions/controller.ts @@ -53,7 +53,24 @@ export interface ExtensionsControllerProps +): Controller { const subscriptions = new Subscription() const initData: Omit = { diff --git a/client/shared/src/search/stream.ts b/client/shared/src/search/stream.ts index 423d21aa765..1a4127c0ad6 100644 --- a/client/shared/src/search/stream.ts +++ b/client/shared/src/search/stream.ts @@ -8,6 +8,11 @@ import { asError, ErrorLike, isErrorLike } from '@sourcegraph/common' import { SearchPatternType } from '../graphql-operations' import { SymbolKind } from '../schema' +// The latest supported version of our search syntax. Users should never be able to determine the search version. +// The version is set based on the release tag of the instance. Anything before 3.9.0 will not pass a version parameter, +// and will therefore default to V1. +export const LATEST_VERSION = 'V2' + /** All values that are valid for the `type:` filter. `null` represents default code search. */ export type SearchType = 'file' | 'repo' | 'path' | 'symbol' | 'diff' | 'commit' | null diff --git a/client/web/src/util/globbing.ts b/client/shared/src/util/globbing.ts similarity index 100% rename from client/web/src/util/globbing.ts rename to client/shared/src/util/globbing.ts diff --git a/client/shared/src/util/url.ts b/client/shared/src/util/url.ts index 6cc7e409649..e0397dcba49 100644 --- a/client/shared/src/util/url.ts +++ b/client/shared/src/util/url.ts @@ -583,3 +583,26 @@ export function buildGetStartedURL(source: string, returnTo?: string): string { return url.toString() } + +/** The results of parsing a repo-revision string like "my/repo@my/revision". */ +export interface ParsedRepoRevision { + repoName: string + + /** The URI-decoded revision (e.g., "my#branch" in "my/repo@my%23branch"). */ + revision?: string + + /** The raw revision (e.g., "my%23branch" in "my/repo@my%23branch"). */ + rawRevision?: string +} + +/** + * Parses a repo-revision string like "my/repo@my/revision" to the repo and revision components. + */ +export function parseRepoRevision(repoRevision: string): ParsedRepoRevision { + const [repository, revision] = repoRevision.split('@', 2) as [string, string | undefined] + return { + repoName: decodeURIComponent(repository), + revision: revision && decodeURIComponent(revision), + rawRevision: revision, + } +} diff --git a/client/vscode/.eslintignore b/client/vscode/.eslintignore index 0b9b47edb43..598540744ff 100644 --- a/client/vscode/.eslintignore +++ b/client/vscode/.eslintignore @@ -1,2 +1,3 @@ dist/ package.json +src/polyfills/eventSource.js diff --git a/client/vscode/.gitignore b/client/vscode/.gitignore index 579066eb02d..94edc132ee5 100644 --- a/client/vscode/.gitignore +++ b/client/vscode/.gitignore @@ -1,2 +1,3 @@ dist/ *.vsix +.vscode-test/ diff --git a/client/vscode/.vscodeignore b/client/vscode/.vscodeignore index 5b15ba1718b..c4d453ba37c 100644 --- a/client/vscode/.vscodeignore +++ b/client/vscode/.vscodeignore @@ -12,3 +12,8 @@ test/** tsconfig.json .eslintrc.js .eslintignore +.vscode +node_modules +out/ +src/ +webpack.config.js diff --git a/client/vscode/CHANGELOG.md b/client/vscode/CHANGELOG.md new file mode 100644 index 00000000000..cb49e4016ec --- /dev/null +++ b/client/vscode/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +The Sourcegraph extension uses major.EVEN_NUMBER.patch (eg. 2.0.1) for release versions and major.ODD_NUMBER.patch (eg. 2.1.1) for pre-release versions. + +## Next Release - 2.2.1 + +### Changes + +- Add Help and Feedback sidebar [issue/31021](https://github.com/sourcegraph/sourcegraph/issues/31021) +- Add CONTRIBUTING guide [issue/26536](https://github.com/sourcegraph/sourcegraph/issues/26536) +- Display error message when connected to unsupported instances [issue/31808](https://github.com/sourcegraph/sourcegraph/issues/31808) +- Log events with `IDEEXTENSION` as event source for instances on 3.38.0 and above [issue/32851](https://github.com/sourcegraph/sourcegraph/issues/32851) + +### Fixes + +- Improve developer scripts [issue/32741](https://github.com/sourcegraph/sourcegraph/issues/32741) +- Code Monitor button redirect issue for non signed-in users [issues/33631](https://github.com/sourcegraph/sourcegraph/issues/33631) +- Error regarding missing PatternType when creating save search [issues/31093](https://github.com/sourcegraph/sourcegraph/issues/31093) + +## 2.2.0 + +### Changes + +- Add pings for Sourcegraph ide extensions usage metrics [issue/29124](https://github.com/sourcegraph/sourcegraph/issues/29124) +- Add input fields to update Sourcegraph instance url [issue/31804](https://github.com/sourcegraph/sourcegraph/issues/31804) +- Clear search results on tab close [issue/30583](https://github.com/sourcegraph/sourcegraph/issues/30583) + +## 2.0.9 + +### Changes + +- Add Changelog for version tracking purpose [issue/28300](https://github.com/sourcegraph/sourcegraph/issues/28300) +- Add VS Code Web support for instances on 3.36.0+ [issue/28403](https://github.com/sourcegraph/sourcegraph/issues/28403) +- Update to use API endpoint for stream search [issue/30916](https://github.com/sourcegraph/sourcegraph/issues/30916) +- Add new configuration setting `sourcegraph.requestHeaders` for adding custom headers [issue/30916](https://github.com/sourcegraph/sourcegraph/issues/30916) + +### Fixes + +- Manage context display issue for instances under v3.36.0 [issue/31022](https://github.com/sourcegraph/sourcegraph/issues/31022) + +## 2.0.8 + +### Fixes + +- Files will open in the correct url scheme [issue/31095](https://github.com/sourcegraph/sourcegraph/issues/31095) +- The 'All Search Keywords' button is now linked to Sourcegraph docs site correctly [issue/31023](https://github.com/sourcegraph/sourcegraph/issues/31023) +- Update Sign Up links with the correct utm parameters + +## 2.0.7 + +### Changes + +- Remove Sign Up CTA in Sidebar for self-host instances + +### Fixes + +- Add backward compatibility for configuration settings from v1: `sourcegraph.defaultBranch` and `sourcegraph.remoteUrlReplacements` + +## 2.0.6 + +### Changes + +- Remove Sign Up CTAs in Search Result for self-host instances + +## 2.0.1 + +### Changes + +- Add Code Monitor diff --git a/client/vscode/CONTRIBUTING.md b/client/vscode/CONTRIBUTING.md new file mode 100644 index 00000000000..6369da9c294 --- /dev/null +++ b/client/vscode/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to Sourcegraph VS Code Extension + +Thank you for your interest in contributing to Sourcegraph! +The goal of this document is to provide a high-level overview of how you can contribute to the Sourcegraph VS Code Extension. +Please refer to our [main CONTRIBUTING](https://github.com/sourcegraph/sourcegraph/blob/main/CONTRIBUTING.md) docs for general information regarding contributing to any Sourcegraph repository. + +## Feedback + +Your feedback is important to us and is greatly appreciated. Please do not hesitate to submit your ideas or suggestions about how we can improve the extension to our [GitHub Feedback discussion board](https://github.com/sourcegraph/sourcegraph/discussions/categories/feedback). + +## Issues / Bugs + +New issues and feature requests can be filed through our [issue tracker](https://github.com/sourcegraph/sourcegraph/issues/new/choose) using the `vscode-extension` label. + +## Development + +### Build and run + +1. `git clone` the [Sourcegraph repository](https://github.com/sourcegraph/sourcegraph) +1. Install dependencies via `yarn` for the Sourcegraph repository +1. Run `yarn generate` at the root directory to generate the required schemas +1. Make your changes to the files within the `client/vscode` directory with VS Code +1. Run `yarn build-vsce` to build or `yarn watch-vsce` to build and watch the tasks in the `client/vscode` directory +1. Select `Launch VS Code Extension` (`Launch VS Code Web Extension` for VS Code Web) from the dropdown menu in the `Run and Debug` sidebar view to see your changes + +### Tests + +1. In the Sourcegraph repository: + 1. `yarn` + 2. `yarn generate` +2. In the `client/vscode` directory: + 1. `yarn build` + 2. `yarn package` + 3. `yarn test` + +### Debugging + +Please refer to the [How to Contribute](https://github.com/microsoft/vscode/wiki/How-to-Contribute#debugging) guide by VS Code for debugging tips. + +## Questions + +If you need guidance or have any questions regarding Sourcegraph or the extension in general, we invite you to connect with us on the [Sourcegraph Community Slack group](https://about.sourcegraph.com/community). + +## Resources + +- [Changelog](https://marketplace.visualstudio.com/items/sourcegraph.sourcegraph/changelog) +- [Code of Conduct](https://handbook.sourcegraph.com/company-info-and-process/community/code_of_conduct/) +- [Developing Sourcegraph guide](https://docs.sourcegraph.com/dev) +- [Developing the web clients](https://docs.sourcegraph.com/dev/background-information/web) +- [Issue Tracker](https://github.com/sourcegraph/sourcegraph/labels/vscode-extension) +- [Troubleshooting docs](https://docs.sourcegraph.com/admin/how-to/troubleshoot-sg-extension#vs-code-extension) + +## License + +Apache diff --git a/client/vscode/LICENSE b/client/vscode/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/client/vscode/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/client/vscode/README.md b/client/vscode/README.md new file mode 100644 index 00000000000..847354afa1d --- /dev/null +++ b/client/vscode/README.md @@ -0,0 +1,119 @@ +# Sourcegraph for Visual Studio Code + +[![vs marketplace](https://img.shields.io/vscode-marketplace/v/sourcegraph.sourcegraph.svg?label=vs%20marketplace)](https://marketplace.visualstudio.com/items?itemName=sourcegraph.sourcegraph) [![downloads](https://img.shields.io/vscode-marketplace/d/sourcegraph.sourcegraph.svg)](https://marketplace.visualstudio.com/items?itemName=sourcegraph.sourcegraph) + +![Search Gif](https://storage.googleapis.com/sourcegraph-assets/VS%20Marketplace/tableContainer2.gif) + +Sourcegraph’s code search allows you to find & fix things fast across all your code. + +Sourcegraph for VS Code allows you to search millions of open source repositories right from your VS Code IDE—for free. You can learn from helpful code examples, search best practices, and re-use code from millions of repositories across the open source universe. + +Plus, with a free Sourcegraph Cloud account, you can sync your own private and public repositories and search all of your code in a single view in VS Code. Sourcegraph’s Code Intelligence feature provides fast, cross-repository navigation with “Go to definition” and “Find references” features, allowing you to understand new code quickly and find answers in your code across codebases of any size. + +You can read more about Sourcegraph on our [website](https://about.sourcegraph.com/). + +## Installation + +### From the Visual Studio Marketplace: + +1. Install Sourcegraph from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=sourcegraph.sourcegraph). +2. Launch VS Code, and click on the Sourcegraph (Wildcard) icon in the VS Code Activity Bar to open the Sourcegraph extension. Alternatively, you can launch the extension by pressing Cmd+Shift+P or Ctrl+Shift+P and searching for “Sourcegraph: Open search tab.” + +### From within VS Code: + +1. Open the extensions tab on the left side of VS Code (Cmd+Shift+X or Ctrl+Shift+X). +2. Search for `Sourcegraph` -> `Install` and `Reload`. + +## Using the Sourcegraph extension + +To get started and open the Sourcegraph extension, simply click the Sourcegraph (Wildcard) icon in the VS Code Activity Bar. + +Sourcegraph functions like any search engine; simply type in your search query, and Sourcegraph will populate search results. + +Sourcegraph offers 3 different ways to search: + +1. [Literal search](https://learn.sourcegraph.com/how-to-search-code-with-sourcegraph-using-literal-patterns) +2. [Structural search](https://learn.sourcegraph.com/how-to-search-with-sourcegraph-using-structural-patterns) +3. [Regular expressions](https://learn.sourcegraph.com/how-to-search-with-sourcegraph-using-regular-expression-patterns) + +Sourcegraph also accepts filters to narrow down search results, such as `repo`, `file`, and `lang`. Check out our search [cheat sheet](https://learn.sourcegraph.com/how-to-search-code-with-sourcegraph-a-cheat-sheet). + +For example, you can search for "auth provider" in a Go repository with a search like this one: + +``` +repo:sourcegraph/sourcegraph lang:go auth provider +``` + +![Lang search gif](https://storage.googleapis.com/sourcegraph-assets/VS%20Marketplace/sourcegraph_search.gif) + +## Adding and searching your own code + +### Creating an account + +In addition to searching open source code, you can create a Sourcegraph Cloud account to search your own private and public repositories. You can create an account and sync your repositories with the following steps: + +1. Click the `Create an account` button in the sidebar of the Sourcegraph extension. You will be directed to sourcegraph.com in your browser. +2. Create an account using your email or connect directly to your code host. +3. Once you have created an account, navigate to Sourcegraph Cloud. Click on your profile icon in the navigation bar to go to `Your repositories`. +4. Click `Manage repositories`. From here, you can add your repositories to be synced to Sourcegraph. + +### Connecting Sourcegraph Cloud account + +Once you have repositories synced to Sourcegraph, you can generate an access token to connect your VS Code extension back to your Sourcegraph Cloud account. + +1. Back in Sourcegraph Cloud, in your account settings, navigate to `Access tokens`, then click `Generate new token`. +2. Once you have generated a token, navigate back to the Sourcegraph extension. In the sidebar, under `Create an account`, click `Have an account?`. +3. Copy and paste the generated token from step 4 into the input field in the sidebar. +4. Alternatively, you can copy and paste the generated token from step 4 in this format: `“sourcegraph.accessToken": "e4234234123112312”` into your VS Code Setting by going to `Code` > `Preference` > `Settings` > Search for "Sourcegraph" > `Edit in settings.json`. +5. The Editor will be reloaded automatically to use the newly added token. + +### Connecting to a private Sourcegraph instance + +1. In Sourcegraph, in your account settings, navigate to `Access tokens`, then click `Generate new token`. +2. Once you have generated a token, navigate to your VS Code Settings, then navigate to "Extension settings". +3. Navigate to `Code preferences`, then click `Settings`. +4. Search for `Sourcegraph`, and enter the newly generated access token as well as your Sourcegraph instance URL. +5. Add custom headers using the `sourcegraph.requestHeaders` setting (added in v2.0.9) if a specific header is required to make connection to your private instance. + +## Keyboard Shortcuts: + +| Description | Mac | Linux / Windows | +| -------------------------------------------- | -------------------------------------------- | --------------------------------------------- | +| Open Sourcegraph Search Tab/Search Selection | Cmd+Shift+8 | Ctrl+Shift+8 | +| Open File in Sourcegraph Cloud | Option+A | Alt+A | +| Search Selected Text in Sourcegraph Cloud | Option+S | Alt+S | + +## Extension Settings + +This extension contributes the following settings: + +| Setting | Description | Example | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | +| `sourcegraph.url` | Specify your on-premises Sourcegraph instance here, if applicable. The extension is connected to Sourcegraph Cloud by default. | "https://your-sourcegraph.com" | +| `sourcegraph.accessToken` | The access token to query the Sourcegraph API. Required to use this extension with private instances. | "6dfc880b320dff712d9f6cfcac5cbd13ebfad1d8" | +| `sourcegraph.remoteUrlReplacements` | Object, where each `key` is replaced by `value` in the remote url. | {"github": "gitlab", "master": "main"} | +| `sourcegraph.defaultBranch` | String to set the name of the default branch. Always open files in the default branch. | "master" | +| `sourcegraph.requestHeaders` | Takes object, where each value pair will be added to the request headers made to your instance. | {"Cache-Control": "no-cache", "Proxy-Authenticate": "Basic"} | + +## Questions & Feedback + +Please take a look at our [troubleshooting docs](https://docs.sourcegraph.com/admin/how-to/troubleshoot-sg-extension#vs-code-extension) for [known issues](https://docs.sourcegraph.com/admin/how-to/troubleshoot-sg-extension#unsupported-features-by-sourcegraph-version) and common issues in the VS Code extension. + +New issues and feature requests can be submitted at https://github.com/sourcegraph/sourcegraph-vscode/issues/new. + +## Uninstallation + +1. Open the extensions tab on the left side of VS Code (Cmd+Shift+X or Ctrl+Shift+X). +2. Search for `Sourcegraph` -> Gear icon -> `Uninstall` and `Reload`. + +## Changelog + +Click [here](https://marketplace.visualstudio.com/items/sourcegraph.sourcegraph/changelog) to check the full changelog. + +VS Code will auto-update extensions to the highest version available. Even if you have opted into a pre-release version, you will be updated to the released version when a higher version is released. + +The Sourcegraph extension uses major.EVEN_NUMBER.patch (eg. 2.0.1) for release versions and major.ODD_NUMBER.patch (eg. 2.1.1) for pre-release versions.``` + +## Development + +Please see the [CONTRIBUTING](./CONTRIBUTING.md) document if you are interested in contributing directly to our code base. diff --git a/client/vscode/gulpfile.js b/client/vscode/gulpfile.js new file mode 100644 index 00000000000..fa47dffaf6e --- /dev/null +++ b/client/vscode/gulpfile.js @@ -0,0 +1,91 @@ +const path = require('path') + +require('ts-node').register({ + transpileOnly: true, + // Use config with "module": "commonjs" because not all modules involved in tasks are esnext modules. + project: path.resolve(__dirname, './tsconfig.json'), +}) + +const log = require('fancy-log') +const gulp = require('gulp') +const createWebpackCompiler = require('webpack') + +const { + graphQlSchema, + graphQlOperations, + schema, + watchGraphQlSchema, + watchGraphQlOperations, + watchSchema, + cssModulesTypings, + watchCSSModulesTypings, +} = require('../shared/gulpfile') + +const createWebpackConfig = require('./webpack.config') + +const WEBPACK_STATS_OPTIONS = { + all: false, + timings: true, + errors: true, + warnings: true, + colors: true, +} + +/** + * @param {import('webpack').Stats} stats + */ +const logWebpackStats = stats => { + log(stats.toString(WEBPACK_STATS_OPTIONS)) +} + +async function webpack() { + const webpackConfig = await createWebpackConfig() + const compiler = createWebpackCompiler(webpackConfig) + /** @type {import('webpack').Stats} */ + const stats = await new Promise((resolve, reject) => { + compiler.run((error, stats) => (error ? reject(error) : resolve(stats))) + }) + logWebpackStats(stats) + if (stats.hasErrors()) { + throw Object.assign(new Error('Failed to compile'), { showStack: false }) + } +} + +async function watchWebpack() { + const webpackConfig = await createWebpackConfig() + const compiler = createWebpackCompiler(webpackConfig) + compiler.hooks.watchRun.tap('Notify', () => log('Webpack compiling...')) + await new Promise(() => { + compiler.watch({ aggregateTimeout: 300 }, (error, stats) => { + logWebpackStats(stats) + if (error || stats.hasErrors()) { + log.error('Webpack compilation error') + } else { + log('Webpack compilation done') + } + }) + }) +} + +// Ensure the typings that TypeScript depends on are build to avoid first-time-run errors +const generate = gulp.parallel(schema, graphQlSchema, graphQlOperations, cssModulesTypings) + +// Watches code generation only, rebuilds on file changes +const watchGenerators = gulp.parallel(watchSchema, watchGraphQlSchema, watchGraphQlOperations, watchCSSModulesTypings) + +/** + * Builds everything. + */ +const build = gulp.series(generate, webpack) + +/** + * Watches everything, rebuilds on file changes and writes the bundle to disk. + * Useful to running integration tests. + */ +const watch = gulp.series( + // Ensure the typings that TypeScript depends on are build to avoid first-time-run errors + generate, + gulp.parallel(watchGenerators, watchWebpack) +) + +module.exports = { build, watch, webpack, watchWebpack } diff --git a/client/vscode/images/logomark_dark.svg b/client/vscode/images/logomark_dark.svg new file mode 100644 index 00000000000..e017293e6ba --- /dev/null +++ b/client/vscode/images/logomark_dark.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/client/vscode/images/logomark_light.svg b/client/vscode/images/logomark_light.svg new file mode 100644 index 00000000000..ae0423535c9 --- /dev/null +++ b/client/vscode/images/logomark_light.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/client/vscode/images/sourcegraph-logo-dark.svg b/client/vscode/images/sourcegraph-logo-dark.svg new file mode 100644 index 00000000000..e44ad04a1ba --- /dev/null +++ b/client/vscode/images/sourcegraph-logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/vscode/images/sourcegraph-logo-light.svg b/client/vscode/images/sourcegraph-logo-light.svg new file mode 100644 index 00000000000..fa931a6bc9a --- /dev/null +++ b/client/vscode/images/sourcegraph-logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/vscode/package.json b/client/vscode/package.json index fe4fe0bda78..4ae87322eb1 100644 --- a/client/vscode/package.json +++ b/client/vscode/package.json @@ -1,27 +1,29 @@ { "private": true, - "name": "sourcegraph-preview", - "displayName": "Sourcegraph - preview", - "version": "0.0.2", + "name": "@sourcegraph/vscode", + "displayName": "Sourcegraph", + "version": "2.2.1", "description": "Sourcegraph for VS Code", - "publisher": "kandalatj", + "publisher": "sourcegraph", "sideEffects": false, "license": "Apache-2.0", "icon": "images/logo.png", "repository": { "type": "git", - "url": "https://github.com/sourcegraph/sourcegraph.git" + "url": "https://github.com/sourcegraph/sourcegraph.git", + "directory": "client/vscode" + }, + "bugs": { + "url": "https://github.com/sourcegraph/sourcegraph/issues" }, "engines": { - "vscode": "^1.61.0" + "vscode": "^1.63.2" }, "categories": [ "Other" ], "activationEvents": [ - "onCommand:sourcegraph.search", - "onView:sourcegraph.searchSidebar", - "onWebviewPanel:sourcegraphSearch" + "*" ], "main": "./dist/node/extension.js", "browser": "./dist/webworker/extension.js", @@ -30,7 +32,36 @@ { "command": "sourcegraph.search", "category": "Sourcegraph", - "title": "Open Sourcegraph Search Tab" + "title": "Search with Sourcegraph", + "icon": { + "light": "images/logo.svg", + "dark": "images/logo.svg" + } + }, + { + "command": "sourcegraph.openInBrowser", + "category": "Sourcegraph", + "title": "Open File in Sourcegraph Web", + "icon": { + "light": "images/logomark_dark.svg", + "dark": "images/logomark_light.svg" + } + }, + { + "command": "sourcegraph.copyFileLink", + "category": "Sourcegraph", + "title": "Copy Sourcegraph File Link" + }, + { + "command": "sourcegraph.selectionSearchWeb", + "category": "Sourcegraph", + "title": "Search Selection in Sourcegraph Web" + }, + { + "command": "sourcegraph.removeRepoTree", + "category": "Sourcegraph", + "title": "Remove Repository from Sourcegraph File System", + "icon": "$(trash)" } ], "viewsContainers": { @@ -53,8 +84,13 @@ { "id": "sourcegraph.files", "name": "Files", - "visibility": "visible", - "when": "sourcegraph.state == 'remote-browsing'" + "visibility": "visible" + }, + { + "type": "webview", + "id": "sourcegraph.helpSidebar", + "name": "Help and feedback", + "visibility": "collapsed" } ] }, @@ -81,6 +117,39 @@ ], "default": "", "description": "The access token to query the Sourcegraph API. Create a new access token at ${SOURCEGRAPH_URL}/users/settings/tokens" + }, + "sourcegraph.remoteUrlReplacements": { + "type": [ + "object" + ], + "default": {}, + "examples": [ + { + "github": "gitlab", + "master": "main" + } + ], + "description": "For each item in this object, replace key with value in the remote url." + }, + "sourcegraph.defaultBranch": { + "type": [ + "string" + ], + "default": "", + "description": "Always open local files on Sourcegraph Web at this default branch." + }, + "sourcegraph.requestHeaders": { + "type": [ + "object" + ], + "default": {}, + "examples": [ + { + "Cache-Control": "no-cache", + "Proxy-Authenticate": "Basic" + } + ], + "description": "Each value pair will be added to the request headers made to your instance." } } }, @@ -89,21 +158,64 @@ "command": "sourcegraph.search", "key": "ctrl+shift+8", "mac": "cmd+shift+8" + }, + { + "command": "sourcegraph.openInBrowser", + "key": "alt+a", + "mac": "option+a" + }, + { + "command": "sourcegraph.selectionSearchWeb", + "key": "alt+s", + "mac": "option+s" } ], "menus": { "editor/context": [ + { + "command": "sourcegraph.openInBrowser", + "group": "sourcegraph" + }, + { + "command": "sourcegraph.copyFileLink", + "group": "sourcegraph" + }, + { + "command": "sourcegraph.selectionSearchWeb", + "group": "sourcegraph", + "when": "editorHasSelection" + }, + { + "command": "sourcegraph.search", + "group": "sourcegraph" + } + ], + "view/title": [ + { + "command": "sourcegraph.removeRepoTree", + "when": "view == sourcegraph.files && sourcegraph.removeRepository", + "group": "navigation" + } + ], + "editor/title": [ + { + "command": "sourcegraph.openInBrowser", + "when": "resourceScheme == sourcegraph && editorReadonly", + "group": "navigation" + } ] } }, "scripts": { "eslint": "eslint --cache '**/*.[jt]s?(x)'", - "test": "echo \"No tests exist yet\" && exit 1", - "package": "echo \"package script not implemented yet\" && exit 1", - "build": "webpack --mode=development --config-name extension:node --config-name extension:webworker --config-name webviews", - "build:node": "webpack --mode=development --config-name extension:node --config-name webviews", - "build:web": "webpack --mode=development --config-name extension:webworker --config-name webviews", - "watch:node": "webpack --mode=development --watch --config-name extension:node --config-name webviews", - "watch:web": "webpack --mode=development --watch --config-name extension:node --config-name webviews" + "test": "ts-node ./tests/runTests.ts", + "package": "ts-node ./scripts/package.ts", + "task:gulp": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192\" gulp", + "build": "yarn task:gulp webpack", + "build:node": "TARGET_TYPE=node yarn task:gulp webpack", + "build:web": "TARGET_TYPE=webworker yarn task:gulp webpack", + "watch": "yarn task:gulp watchWebpack", + "watch:node": "NODE_ENV=development TARGET_TYPE=node yarn task:gulp watchWebpack", + "watch:web": "NODE_ENV=development TARGET_TYPE=webworker yarn task:gulp watchWebpack" } } diff --git a/client/vscode/scripts/package.ts b/client/vscode/scripts/package.ts new file mode 100644 index 00000000000..f554b1784eb --- /dev/null +++ b/client/vscode/scripts/package.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import childProcess from 'child_process' +import fs from 'fs' + +const originalPackageJson = fs.readFileSync('package.json').toString() + +try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const packageJson: any = JSON.parse(originalPackageJson) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + packageJson.name = 'sourcegraph' + fs.writeFileSync('package.json', JSON.stringify(packageJson)) + + childProcess.execSync('yarn vsce package --yarn --allow-star-activation -o dist', { stdio: 'inherit' }) +} finally { + fs.writeFileSync('package.json', originalPackageJson) +} diff --git a/client/vscode/src/backend/authenticatedUser.ts b/client/vscode/src/backend/authenticatedUser.ts index 427fbe9c652..1ed1ec29726 100644 --- a/client/vscode/src/backend/authenticatedUser.ts +++ b/client/vscode/src/backend/authenticatedUser.ts @@ -1,11 +1,46 @@ import { Observable, ReplaySubject } from 'rxjs' import * as vscode from 'vscode' -import { AuthenticatedUser, currentAuthStateQuery } from '@sourcegraph/shared/src/auth' +import { gql } from '@sourcegraph/http-client' +import { AuthenticatedUser } from '@sourcegraph/shared/src/auth' import { CurrentAuthStateResult, CurrentAuthStateVariables } from '@sourcegraph/shared/src/graphql-operations' import { requestGraphQLFromVSCode } from './requestGraphQl' +// Minimal auth state for the VS Code extension. +// Uses only old fields for backwards compatibility with old GraphQL API versions. +const currentAuthStateQuery = gql` + query CurrentAuthState { + currentUser { + __typename + id + databaseID + username + avatarURL + email + displayName + siteAdmin + tags + url + settingsURL + organizations { + nodes { + id + name + displayName + url + settingsURL + } + } + session { + canSignOut + } + viewerCanAdminister + tags + } + } +` + // Update authenticatedUser on accessToken changes export function observeAuthenticatedUser({ context, diff --git a/client/vscode/src/backend/blobContent.ts b/client/vscode/src/backend/blobContent.ts new file mode 100644 index 00000000000..6a45406fe30 --- /dev/null +++ b/client/vscode/src/backend/blobContent.ts @@ -0,0 +1,39 @@ +import { gql } from '@sourcegraph/http-client' + +import { BlobContentResult, BlobContentVariables } from '../graphql-operations' + +import { requestGraphQLFromVSCode } from './requestGraphQl' + +const blobContentQuery = gql` + query BlobContent($repository: String!, $revision: String!, $path: String!) { + repository(name: $repository) { + commit(rev: $revision) { + blob(path: $path) { + content + binary + byteSize + } + } + } + } +` + +export interface FileContents { + content: Uint8Array + isBinary: boolean + byteSize: number +} + +export async function getBlobContent(variables: BlobContentVariables): Promise { + const result = await requestGraphQLFromVSCode(blobContentQuery, variables) + + const blob = result.data?.repository?.commit?.blob + if (blob) { + return { + content: new TextEncoder().encode(blob.content), + isBinary: blob.binary, + byteSize: blob.byteSize, + } + } + return undefined +} diff --git a/client/vscode/src/backend/eventLogger.ts b/client/vscode/src/backend/eventLogger.ts new file mode 100644 index 00000000000..3c3ad74ef8b --- /dev/null +++ b/client/vscode/src/backend/eventLogger.ts @@ -0,0 +1,49 @@ +import { EMPTY, Subject } from 'rxjs' +import { bufferTime, catchError, concatMap } from 'rxjs/operators' + +import { gql } from '@sourcegraph/http-client/src/graphql/graphql' +import { LogEventsResult, LogEventsVariables, Event } from '@sourcegraph/web/src/graphql-operations' + +import { requestGraphQLFromVSCode } from './requestGraphQl' + +// Log events in batches. +const events = new Subject() + +events + .pipe( + bufferTime(1000), + concatMap(events => { + if (events.length > 0) { + return requestGraphQLFromVSCode(logEventsMutation, { + events, + }) + } + return EMPTY + }), + catchError(error => { + console.error('Error logging events:', error) + return [] + }) + ) + // eslint-disable-next-line rxjs/no-ignored-subscription + .subscribe() + +export const logEventsMutation = gql` + mutation LogEvents($events: [Event!]) { + logEvents(events: $events) { + alwaysNil + } + } +` + +/** + * Log a raw user action (used to allow site admins on a Sourcegraph instance + * to see a count of unique users on a daily, weekly, and monthly basis). + * + * When invoked on a non-Sourcegraph.com instance, this data is stored in the + * instance's database, and not sent to Sourcegraph.com. + */ + +export function logEvent(eventVariable: Event): void { + events.next(eventVariable) +} diff --git a/client/vscode/src/backend/files.ts b/client/vscode/src/backend/files.ts new file mode 100644 index 00000000000..8451775b0ff --- /dev/null +++ b/client/vscode/src/backend/files.ts @@ -0,0 +1,26 @@ +import { gql } from '@sourcegraph/http-client' + +import { FileNamesResult, FileNamesVariables } from '../graphql-operations' + +import { requestGraphQLFromVSCode } from './requestGraphQl' + +const fileNamesQuery = gql` + query FileNames($repository: String!, $revision: String!) { + repository(name: $repository) { + commit(rev: $revision) { + fileNames + } + } + } +` + +export async function getFiles(variables: FileNamesVariables): Promise { + const result = await requestGraphQLFromVSCode(fileNamesQuery, variables) + + if (result.data?.repository?.commit) { + return result.data.repository.commit.fileNames + } + + // Debt: surface error to users. + throw new Error(`Failed to fetch file names for ${variables.repository}@${variables.revision}`) +} diff --git a/client/vscode/src/backend/instanceVersion.ts b/client/vscode/src/backend/instanceVersion.ts new file mode 100644 index 00000000000..19c49f15950 --- /dev/null +++ b/client/vscode/src/backend/instanceVersion.ts @@ -0,0 +1,45 @@ +import { gql } from '@sourcegraph/http-client' +import { EventSource } from '@sourcegraph/shared/src/graphql-operations' + +import { INSTANCE_VERSION_NUMBER_KEY, LocalStorageService } from '../settings/LocalStorageService' + +import { requestGraphQLFromVSCode } from './requestGraphQl' + +/** + * Regular instance version format: ex 3.38.2 + * Insider version format: ex 134683_2022-03-02_5188fes0101 + * This function will return the EventSource Type based + * on the instance version + */ +export function initializeInstantVersionNumber(localStorageService: LocalStorageService): EventSource { + requestGraphQLFromVSCode(siteVersionQuery, {}) + .then(async siteVersionResult => { + if (siteVersionResult.data) { + // assume instance version longer than 8 is using insider version + const flattenVersion = + siteVersionResult.data.site.productVersion.length > 8 + ? '999999' + : siteVersionResult.data.site.productVersion.split('.').join('') + await localStorageService.setValue(INSTANCE_VERSION_NUMBER_KEY, flattenVersion) + } + }) + .catch(error => { + console.error('Failed to get instance version from host:', error) + }) + const versionNumber = localStorageService.getValue(INSTANCE_VERSION_NUMBER_KEY) + // instances below 3.38.0 does not support EventSource.IDEEXTENSION and should fallback to BACKEND source + return versionNumber >= '3380' ? EventSource.IDEEXTENSION : EventSource.BACKEND +} + +const siteVersionQuery = gql` + query { + site { + productVersion + } + } +` +interface SiteVersionResult { + site: { + productVersion: string + } +} diff --git a/client/vscode/src/backend/repositoryMetadata.ts b/client/vscode/src/backend/repositoryMetadata.ts new file mode 100644 index 00000000000..748bf61c041 --- /dev/null +++ b/client/vscode/src/backend/repositoryMetadata.ts @@ -0,0 +1,59 @@ +import { gql } from '@sourcegraph/http-client' + +import { RepositoryMetadataResult, RepositoryMetadataVariables } from '../graphql-operations' + +import { requestGraphQLFromVSCode } from './requestGraphQl' + +const repositoryMetadataQuery = gql` + query RepositoryMetadata($repositoryName: String!) { + repositoryRedirect(name: $repositoryName) { + ... on Repository { + id + mirrorInfo { + cloneInProgress + cloneProgress + cloned + } + commit(rev: "") { + oid + abbreviatedOID + tree(path: "") { + url + } + } + defaultBranch { + abbrevName + } + } + ... on Redirect { + url + } + } + } +` + +export interface RepositoryMetadata { + repositoryId: string + defaultOID?: string + defaultAbbreviatedOID?: string + defaultBranch?: string +} + +export async function getRepositoryMetadata( + variables: RepositoryMetadataVariables +): Promise { + const result = await requestGraphQLFromVSCode( + repositoryMetadataQuery, + variables + ) + if (result.data?.repositoryRedirect?.__typename === 'Repository') { + return { + repositoryId: result.data.repositoryRedirect.id, + defaultOID: result.data.repositoryRedirect.commit?.oid, + defaultAbbreviatedOID: result.data.repositoryRedirect.commit?.abbreviatedOID, + defaultBranch: result.data.repositoryRedirect.defaultBranch?.abbrevName, + } + } + // v1 Debt: surface error to user. + return undefined +} diff --git a/client/vscode/src/backend/requestGraphQl.ts b/client/vscode/src/backend/requestGraphQl.ts index 9515e7a6d2b..332f2989ffc 100644 --- a/client/vscode/src/backend/requestGraphQl.ts +++ b/client/vscode/src/backend/requestGraphQl.ts @@ -2,7 +2,7 @@ import { asError } from '@sourcegraph/common' import { checkOk, GraphQLResult, GRAPHQL_URI, isHTTPAuthError } from '@sourcegraph/http-client' import { accessTokenSetting, handleAccessTokenError } from '../settings/accessTokenSetting' -import { endpointSetting } from '../settings/endpointSetting' +import { endpointSetting, endpointRequestHeadersSetting } from '../settings/endpointSetting' let invalidated = false @@ -16,7 +16,8 @@ export function invalidateClient(): void { export const requestGraphQLFromVSCode = async ( request: string, variables: V, - overrideAccessToken?: string + overrideAccessToken?: string, + overrideSourcegraphURL?: string ): Promise> => { if (invalidated) { throw new Error( @@ -26,9 +27,11 @@ export const requestGraphQLFromVSCode = async ( const nameMatch = request.match(/^\s*(?:query|mutation)\s+(\w+)/) const apiURL = `${GRAPHQL_URI}${nameMatch ? '?' + nameMatch[1] : ''}` - - const headers: HeadersInit = [] - const sourcegraphURL = endpointSetting() + // load custom headers from user setting if any + const customHeaders = endpointRequestHeadersSetting() + // return empty array if no custom header is provided in configuration + const headers: HeadersInit = Object.entries(customHeaders) + const sourcegraphURL = overrideSourcegraphURL || endpointSetting() const accessToken = accessTokenSetting() // Add Access Token to request header diff --git a/client/vscode/src/backend/searchContexts.ts b/client/vscode/src/backend/searchContexts.ts new file mode 100644 index 00000000000..5243e319031 --- /dev/null +++ b/client/vscode/src/backend/searchContexts.ts @@ -0,0 +1,48 @@ +import { from, Observable, of } from 'rxjs' +import { catchError } from 'rxjs/operators' +import * as vscode from 'vscode' + +import { GraphQLResult } from '@sourcegraph/http-client' +import { getAvailableSearchContextSpecOrDefault } from '@sourcegraph/search' + +import { LocalStorageService, SELECTED_SEARCH_CONTEXT_SPEC_KEY } from '../settings/LocalStorageService' +import { VSCEStateMachine } from '../state' + +import { requestGraphQLFromVSCode } from './requestGraphQl' + +// Returns an Observable so webviews can easily block rendering on init. +export function initializeSearchContexts({ + localStorageService, + stateMachine, + context, +}: { + localStorageService: LocalStorageService + stateMachine: VSCEStateMachine + context: vscode.ExtensionContext +}): void { + const initialSearchContextSpec = localStorageService.getValue(SELECTED_SEARCH_CONTEXT_SPEC_KEY) + + const defaultSpec = 'global' + + const subscription = getAvailableSearchContextSpecOrDefault({ + spec: initialSearchContextSpec || defaultSpec, + defaultSpec, + platformContext: { + requestGraphQL: ({ request, variables }) => + from(requestGraphQLFromVSCode(request, variables)) as Observable>, + }, + }) + .pipe( + catchError(error => { + console.error('Error validating search context spec:', error) + return of(defaultSpec) + }) + ) + .subscribe(availableSearchContextSpecOrDefault => { + stateMachine.emit({ type: 'set_selected_search_context_spec', spec: availableSearchContextSpecOrDefault }) + }) + + context.subscriptions.push({ + dispose: () => subscription.unsubscribe(), + }) +} diff --git a/client/vscode/src/backend/sourcegraphSettings.ts b/client/vscode/src/backend/sourcegraphSettings.ts index f5f49a6416e..3b1222d14f1 100644 --- a/client/vscode/src/backend/sourcegraphSettings.ts +++ b/client/vscode/src/backend/sourcegraphSettings.ts @@ -2,8 +2,9 @@ import { Observable, of, ReplaySubject, Subject } from 'rxjs' import { catchError, map, switchMap, throttleTime } from 'rxjs/operators' import * as vscode from 'vscode' -import { createAggregateError } from '@sourcegraph/common' +import { createAggregateError, isErrorLike } from '@sourcegraph/common' import { viewerSettingsQuery } from '@sourcegraph/shared/src/backend/settings' +import { getEnabledExtensionsForSubject } from '@sourcegraph/shared/src/extensions/extensions' import { ViewerSettingsResult, ViewerSettingsVariables } from '@sourcegraph/shared/src/graphql-operations' import { ISettingsCascade } from '@sourcegraph/shared/src/schema' import { @@ -43,6 +44,7 @@ export function initializeSourcegraphSettings({ return gqlToCascade(data?.viewerSettings as ISettingsCascade) }), + map(settingsCascade => stripNonDefaultExtensions(settingsCascade)), catchError(() => of(EMPTY_SETTINGS_CASCADE)) ) .subscribe(settingsCascade => { @@ -60,3 +62,19 @@ export function initializeSourcegraphSettings({ }, } } + +/** + * Mutates settings cascade to remove all non-default Sourcegraph extensions. + * Remove when non-programming language extension features are implemented + * for the VS Code extension. + */ +function stripNonDefaultExtensions(settingsCascade: SettingsCascadeOrError): SettingsCascadeOrError { + if (!settingsCascade.final || isErrorLike(settingsCascade.final)) { + return settingsCascade + } + + const defaultExtensions = getEnabledExtensionsForSubject(settingsCascade, 'DefaultSettings') || {} + settingsCascade.final.extensions = defaultExtensions + + return settingsCascade +} diff --git a/client/vscode/src/backend/streamSearch.ts b/client/vscode/src/backend/streamSearch.ts new file mode 100644 index 00000000000..e621c55b546 --- /dev/null +++ b/client/vscode/src/backend/streamSearch.ts @@ -0,0 +1,63 @@ +import { of, Subscription } from 'rxjs' +import { throttleTime } from 'rxjs/operators' +import * as vscode from 'vscode' + +import { appendContextFilter } from '@sourcegraph/shared/src/search/query/transformer' +import { aggregateStreamingSearch } from '@sourcegraph/shared/src/search/stream' + +import { ExtensionCoreAPI } from '../contract' +import { VSCEStateMachine } from '../state' +import { focusSearchPanel } from '../webview/commands' + +export function createStreamSearch({ + context, + stateMachine, + sourcegraphURL, +}: { + context: vscode.ExtensionContext + stateMachine: VSCEStateMachine + sourcegraphURL: string +}): ExtensionCoreAPI['streamSearch'] { + // Ensure only one search is active at a time + let previousSearchSubscription: Subscription | null + + context.subscriptions.push({ + dispose: () => { + previousSearchSubscription?.unsubscribe() + }, + }) + + return function streamSearch(query, options) { + previousSearchSubscription?.unsubscribe() + + stateMachine.emit({ + type: 'submit_search_query', + submittedSearchQueryState: { + queryState: { query }, + searchCaseSensitivity: options.caseSensitive, + searchPatternType: options.patternType, + }, + }) + // Focus search panel if not already focused + // (in case e.g. user initiates search from search sidebar when panel is hidden). + focusSearchPanel() + + previousSearchSubscription = aggregateStreamingSearch( + of(appendContextFilter(query, stateMachine.state.context.selectedSearchContextSpec)), + { + ...options, + sourcegraphURL, + } + ) + .pipe(throttleTime(500, undefined, { leading: true, trailing: true })) + .subscribe(searchResults => { + if (searchResults.state === 'error') { + // Pass only primitive copied values because Error object is not cloneable + const { name, message, stack } = searchResults.error + searchResults.error = { name, message, stack } + } + + stateMachine.emit({ type: 'received_search_results', searchResults }) + }) + } +} diff --git a/client/vscode/src/code-intel/SourcegraphDefinitionProvider.ts b/client/vscode/src/code-intel/SourcegraphDefinitionProvider.ts new file mode 100644 index 00000000000..c3868f138c9 --- /dev/null +++ b/client/vscode/src/code-intel/SourcegraphDefinitionProvider.ts @@ -0,0 +1,72 @@ +import * as Comlink from 'comlink' +import { EMPTY, of } from 'rxjs' +import { first, switchMap } from 'rxjs/operators' +import * as vscode from 'vscode' + +import { finallyReleaseProxy, wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { makeRepoURI, parseRepoURI } from '@sourcegraph/shared/src/util/url' + +import { SearchSidebarAPI } from '../contract' +import { SourcegraphFileSystemProvider } from '../file-system/SourcegraphFileSystemProvider' + +export class SourcegraphDefinitionProvider implements vscode.DefinitionProvider { + constructor( + private readonly fs: SourcegraphFileSystemProvider, + private readonly sourcegraphExtensionHostAPI: Comlink.Remote + ) {} + public async provideDefinition( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): Promise { + const uri = this.fs.sourcegraphUri(document.uri) + const extensionHostUri = makeRepoURI({ + repoName: uri.repositoryName, + revision: uri.revision, + filePath: uri.path, + }) + + const definitions = wrapRemoteObservable( + this.sourcegraphExtensionHostAPI.getDefinition({ + textDocument: { + uri: extensionHostUri, + }, + position: { + line: position.line, + character: position.character, + }, + }) + ) + .pipe( + finallyReleaseProxy(), + switchMap(({ isLoading, result }) => { + if (isLoading) { + return EMPTY + } + + const locations = result.map(location => { + const uri = parseRepoURI(location.uri) + + return this.fs.toVscodeLocation({ + resource: { + path: uri.filePath ?? '', + repositoryName: uri.repoName, + revision: uri.commitID ?? uri.revision ?? '', + }, + range: location.range, + }) + }) + + return of(locations) + }), + first() + ) + .toPromise() + + token.onCancellationRequested(() => { + // Debt: manually create promise so we can cancel request. + }) + + return definitions + } +} diff --git a/client/vscode/src/code-intel/SourcegraphHoverProvider.ts b/client/vscode/src/code-intel/SourcegraphHoverProvider.ts new file mode 100644 index 00000000000..6e00c41e44e --- /dev/null +++ b/client/vscode/src/code-intel/SourcegraphHoverProvider.ts @@ -0,0 +1,76 @@ +import * as Comlink from 'comlink' +import { EMPTY, of } from 'rxjs' +import { first, switchMap } from 'rxjs/operators' +import * as vscode from 'vscode' + +import { finallyReleaseProxy, wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { makeRepoURI } from '@sourcegraph/shared/src/util/url' + +import { SearchSidebarAPI } from '../contract' +import { SourcegraphFileSystemProvider } from '../file-system/SourcegraphFileSystemProvider' + +export class SourcegraphHoverProvider implements vscode.HoverProvider { + constructor( + private readonly fs: SourcegraphFileSystemProvider, + private readonly sourcegraphExtensionHostAPI: Comlink.Remote + ) {} + public async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): Promise { + const uri = this.fs.sourcegraphUri(document.uri) + const extensionHostUri = makeRepoURI({ + repoName: uri.repositoryName, + revision: uri.revision, + filePath: uri.path, + }) + + const definitions = wrapRemoteObservable( + this.sourcegraphExtensionHostAPI.getHover({ + textDocument: { + uri: extensionHostUri, + }, + position: { + line: position.line, + character: position.character, + }, + }) + ) + .pipe( + finallyReleaseProxy(), + switchMap(({ isLoading, result }) => { + if (isLoading) { + return EMPTY + } + + const prefix = + result?.aggregatedBadges?.reduce((prefix, badge) => { + if (badge.linkURL) { + return prefix + `[${badge.text}](${badge.linkURL})\n` + } + return prefix + `${badge.text}\n` + }, `![*](${sourcegraphLogoDataURI}) `) || '' + + return of({ + contents: [ + ...(result?.contents ?? []).map( + content => new vscode.MarkdownString(prefix + content.value) + ), + ], + }) + }), + first() + ) + .toPromise() + + token.onCancellationRequested(() => { + // Debt: manually create promise so we can cancel request. + }) + + return definitions + } +} + +const sourcegraphLogoDataURI = + 'data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE0IiB2aWV3Qm94PSIwIDAgNTIgNTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMwLjggNTEuOGMtMi44LjUtNS41LTEuMy02LTQuMUwxNy4yIDYuMmMtLjUtMi44IDEuMy01LjUgNC4xLTZzNS41IDEuMyA2IDQuMWw3LjYgNDEuNWMuNSAyLjgtMS40IDUuNS00LjEgNnoiIGZpbGw9IiNGRjU1NDMiLz48cGF0aCBkPSJNMTAuOSA0NC43QzkuMSA0NSA3LjMgNDQuNCA2IDQzYy0xLjgtMi4yLTEuNi01LjQuNi03LjJMMzguNyA4LjVjMi4yLTEuOCA1LjQtMS42IDcuMi42IDEuOCAyLjIgMS42IDUuNC0uNiA3LjJsLTMyIDI3LjNjLS43LjYtMS42IDEtMi40IDEuMXoiIGZpbGw9IiNBMTEyRkYiLz48cGF0aCBkPSJNNDYuOCAzOC4xYy0uOS4yLTEuOC4xLTIuNi0uMkw0LjQgMjMuOGMtMi43LTEtNC4xLTMuOS0zLjEtNi42IDEtMi43IDMuOS00LjEgNi42LTMuMWwzOS43IDE0LjFjMi43IDEgNC4xIDMuOSAzLjEgNi42LS42IDEuOC0yLjIgMy0zLjkgMy4zeiIgZmlsbD0iIzAwQ0JFQyIvPjwvc3ZnPg==' diff --git a/client/vscode/src/code-intel/SourcegraphReferenceProvider.ts b/client/vscode/src/code-intel/SourcegraphReferenceProvider.ts new file mode 100644 index 00000000000..d7afcb5d1a4 --- /dev/null +++ b/client/vscode/src/code-intel/SourcegraphReferenceProvider.ts @@ -0,0 +1,78 @@ +import * as Comlink from 'comlink' +import { EMPTY, of } from 'rxjs' +import { debounceTime, first, switchMap } from 'rxjs/operators' +import * as vscode from 'vscode' + +import { finallyReleaseProxy, wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { makeRepoURI, parseRepoURI } from '@sourcegraph/shared/src/util/url' + +import { SearchSidebarAPI } from '../contract' +import { SourcegraphFileSystemProvider } from '../file-system/SourcegraphFileSystemProvider' + +export class SourcegraphReferenceProvider implements vscode.ReferenceProvider { + constructor( + private readonly fs: SourcegraphFileSystemProvider, + private readonly sourcegraphExtensionHostAPI: Comlink.Remote + ) {} + public async provideReferences( + document: vscode.TextDocument, + position: vscode.Position, + referenceContext: vscode.ReferenceContext, + token: vscode.CancellationToken + ): Promise { + const uri = this.fs.sourcegraphUri(document.uri) + const extensionHostUri = makeRepoURI({ + repoName: uri.repositoryName, + revision: uri.revision, + filePath: uri.path, + }) + + const definitions = wrapRemoteObservable( + this.sourcegraphExtensionHostAPI.getReferences( + { + textDocument: { + uri: extensionHostUri, + }, + position: { + line: position.line, + character: position.character, + }, + }, + referenceContext + ) + ) + .pipe( + finallyReleaseProxy(), + switchMap(({ isLoading, result }) => { + if (isLoading) { + return EMPTY + } + + const locations = result.map(location => { + // Create a sourcegraph URI from this git URI (so we need both fromGitURI and toGitURI.)` + const uri = parseRepoURI(location.uri) + + return this.fs.toVscodeLocation({ + resource: { + path: uri.filePath ?? '', + repositoryName: uri.repoName, + revision: uri.commitID ?? uri.revision ?? '', + }, + range: location.range, + }) + }) + + return of(locations) + }), + debounceTime(1000), + first() + ) + .toPromise() + + token.onCancellationRequested(() => { + // Debt: manually create promise so we can cancel request. + }) + + return definitions + } +} diff --git a/client/vscode/src/code-intel/initialize.ts b/client/vscode/src/code-intel/initialize.ts new file mode 100644 index 00000000000..59745941c8b --- /dev/null +++ b/client/vscode/src/code-intel/initialize.ts @@ -0,0 +1,77 @@ +import * as Comlink from 'comlink' +import vscode from 'vscode' + +import { makeRepoURI } from '@sourcegraph/shared/src/util/url' + +import { SearchSidebarAPI } from '../contract' +import { SourcegraphFileSystemProvider } from '../file-system/SourcegraphFileSystemProvider' + +import { toSourcegraphLanguage } from './languages' +import { SourcegraphDefinitionProvider } from './SourcegraphDefinitionProvider' +import { SourcegraphHoverProvider } from './SourcegraphHoverProvider' +import { SourcegraphReferenceProvider } from './SourcegraphReferenceProvider' + +export function initializeCodeIntel({ + context, + fs, + searchSidebarAPI, +}: { + context: vscode.ExtensionContext + fs: SourcegraphFileSystemProvider + searchSidebarAPI: Comlink.Remote +}): void { + // Register language-related features (they depend on Sourcegraph extensions). + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + { scheme: 'sourcegraph' }, + new SourcegraphDefinitionProvider(fs, searchSidebarAPI) + ) + ) + context.subscriptions.push( + vscode.languages.registerReferenceProvider( + { scheme: 'sourcegraph' }, + new SourcegraphReferenceProvider(fs, searchSidebarAPI) + ) + ) + context.subscriptions.push( + vscode.languages.registerHoverProvider( + { scheme: 'sourcegraph' }, + new SourcegraphHoverProvider(fs, searchSidebarAPI) + ) + ) + + // Debt: remove closed editors/documents + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(editor => { + // TODO store previously active editor -> SG viewer so we can remove on change + if (editor?.document.uri.scheme === 'sourcegraph') { + const text = editor.document.getText() + const sourcegraphUri = fs.sourcegraphUri(editor.document.uri) + const languageId = toSourcegraphLanguage(editor.document.languageId) + + const extensionHostUri = makeRepoURI({ + repoName: sourcegraphUri.repositoryName, + revision: sourcegraphUri.revision, + filePath: sourcegraphUri.path, + }) + + // We'll use the viewerId return value to remove viewer, get/set text decorations. + searchSidebarAPI + .addTextDocumentIfNotExists({ + text, + uri: extensionHostUri, + languageId, + }) + .then(() => + searchSidebarAPI.addViewerIfNotExists({ + type: 'CodeEditor', + resource: extensionHostUri, + selections: [], + isActive: true, + }) + ) + .catch(error => console.error(error)) + } + }) + ) +} diff --git a/client/vscode/src/code-intel/languages.ts b/client/vscode/src/code-intel/languages.ts new file mode 100644 index 00000000000..6651660ab03 --- /dev/null +++ b/client/vscode/src/code-intel/languages.ts @@ -0,0 +1,14 @@ +/** + * Converts VS Code language ID to Sourcegraph-compatible language ID + * if necessary (e.g. "typescriptreact" -> "typescript") + */ +export function toSourcegraphLanguage(vscodeLanguageID: string): string { + if (vscodeLanugageIDReplacements[vscodeLanguageID]) { + return vscodeLanugageIDReplacements[vscodeLanguageID]! + } + return vscodeLanguageID +} + +const vscodeLanugageIDReplacements: Record = { + typescriptreact: 'typescript', +} diff --git a/client/vscode/src/code-intel/location.ts b/client/vscode/src/code-intel/location.ts new file mode 100644 index 00000000000..b28a9192ab2 --- /dev/null +++ b/client/vscode/src/code-intel/location.ts @@ -0,0 +1,10 @@ +import { Range } from '@sourcegraph/extension-api-types' + +export interface LocationNode { + resource: { + path: string + repositoryName: string + revision: string + } + range?: Range +} diff --git a/client/vscode/src/contract.ts b/client/vscode/src/contract.ts index c3bd4a5c599..36017f4ed7f 100644 --- a/client/vscode/src/contract.ts +++ b/client/vscode/src/contract.ts @@ -1,35 +1,83 @@ import { GraphQLResult } from '@sourcegraph/http-client' import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract' import { ProxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common' +import { ViewerData, ViewerId } from '@sourcegraph/shared/src/api/viewerTypes' import { AuthenticatedUser } from '@sourcegraph/shared/src/auth' +import { EventSource } from '@sourcegraph/shared/src/graphql-operations' +import { SearchMatch, StreamSearchOptions } from '@sourcegraph/shared/src/search/stream' import { SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/settings' +import { Event } from '@sourcegraph/web/src/graphql-operations' -import { VSCEState, VSCEStateMachine } from './state' +import { VSCEQueryState, VSCEState, VSCEStateMachine } from './state' export interface ExtensionCoreAPI { /** For search panel webview to signal that it is ready for messages. */ panelInitialized: (panelId: string) => void - requestGraphQL: (request: string, variables: any, overrideAccessToken?: string) => Promise> + requestGraphQL: ( + request: string, + variables: any, + overrideAccessToken?: string, + overrideSourcegraphURL?: string + ) => Promise> observeSourcegraphSettings: () => ProxySubscribable getAuthenticatedUser: () => ProxySubscribable getInstanceURL: () => ProxySubscribable setAccessToken: (accessToken: string) => void + setEndpointUri: (uri: string) => void + /** + * Observe search box query state. + * Used to send current query from panel to sidebar. + * + * v1 Debt: Transient query state isn't stored in state machine for performance + * as it would lead to re-rendering the whole search panel on each keystroke. + * Implement selector system w/ key path for state machine. Alternatively, + * aggressively memoize top-level "View" components (i.e. don't just take whole state as prop). + */ + observePanelQueryState: () => ProxySubscribable observeState: () => ProxySubscribable emit: VSCEStateMachine['emit'] + /** Opens a remote file given a serialized SourcegraphUri */ + openSourcegraphFile: (uri: string) => void openLink: (uri: string) => void + copyLink: (uri: string) => void reloadWindow: () => void + focusSearchPanel: () => void + + /** + * Cancels previous search when called. + */ + streamSearch: (query: string, options: StreamSearchOptions) => void + fetchStreamSuggestions: (query: string, sourcegraphURL: string) => ProxySubscribable + setSelectedSearchContextSpec: (spec: string) => void + /** + * Used to send current query from panel to sidebar. + */ + setSidebarQueryState: (queryState: VSCEQueryState) => void + + getLocalStorageItem: (key: string) => string + setLocalStorageItem: (key: string, value: string) => Promise + + // For Telemetry Service + logEvents: (variables: Event) => void + + // Get EventSource Type to use based on instance version + getEventSource: EventSource } export interface SearchPanelAPI { - // TODO remove once other methods are implemented ping: () => ProxySubscribable<'pong'> + + focusSearchBox: () => void } -export interface SearchSidebarAPI extends Pick { - // TODO remove once other methods are implemented +export interface SearchSidebarAPI + extends Pick { ping: () => ProxySubscribable<'pong'> - // TODO: ExtensionHostAPI methods + + addViewerIfNotExists: (viewer: ViewerData) => Promise } + +export interface HelpSidebarAPI {} diff --git a/client/vscode/src/extension.ts b/client/vscode/src/extension.ts index 9df21bf2461..6f180cb5f7c 100644 --- a/client/vscode/src/extension.ts +++ b/client/vscode/src/extension.ts @@ -1,18 +1,33 @@ import 'cross-fetch/polyfill' + import { of, ReplaySubject } from 'rxjs' -import * as vscode from 'vscode' +import vscode, { env } from 'vscode' import { proxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common' +import { fetchStreamSuggestions } from '@sourcegraph/shared/src/search/suggestions' import { observeAuthenticatedUser } from './backend/authenticatedUser' +import { logEvent } from './backend/eventLogger' +import { initializeInstantVersionNumber } from './backend/instanceVersion' import { requestGraphQLFromVSCode } from './backend/requestGraphQl' +import { initializeSearchContexts } from './backend/searchContexts' import { initializeSourcegraphSettings } from './backend/sourcegraphSettings' +import { createStreamSearch } from './backend/streamSearch' import { ExtensionCoreAPI } from './contract' -import { updateAccessTokenSetting } from './settings/accessTokenSetting' -import { endpointSetting } from './settings/endpointSetting' +import { openSourcegraphUriCommand } from './file-system/commands' +import { initializeSourcegraphFileSystem } from './file-system/initialize' +import { SourcegraphUri } from './file-system/SourcegraphUri' +import { Event } from './graphql-operations' +import { initializeCodeSharingCommands } from './link-commands/initialize' +import polyfillEventSource from './polyfills/eventSource' +import { accessTokenSetting, updateAccessTokenSetting } from './settings/accessTokenSetting' +import { displayInstanceVersionWarnings } from './settings/displayWarnings' +import { endpointRequestHeadersSetting, endpointSetting, updateEndpointSetting } from './settings/endpointSetting' import { invalidateContextOnSettingsChange } from './settings/invalidation' -import { createVSCEStateMachine } from './state' -import { registerWebviews } from './webview/commands' +import { LocalStorageService, SELECTED_SEARCH_CONTEXT_SPEC_KEY } from './settings/LocalStorageService' +import { watchUninstall } from './settings/uninstall' +import { createVSCEStateMachine, VSCEQueryState } from './state' +import { focusSearchPanel, registerWebviews } from './webview/commands' // Sourcegraph VS Code extension architecture // ----- @@ -45,32 +60,51 @@ import { registerWebviews } from './webview/commands' // VS Code extension (that's why it exists, after all). export function activate(context: vscode.ExtensionContext): void { - const stateMachine = createVSCEStateMachine() - + const localStorageService = new LocalStorageService(context.globalState) + const stateMachine = createVSCEStateMachine({ localStorageService }) invalidateContextOnSettingsChange({ context, stateMachine }) + initializeSearchContexts({ localStorageService, stateMachine, context }) + const eventSourceType = initializeInstantVersionNumber(localStorageService) const sourcegraphSettings = initializeSourcegraphSettings({ context }) const authenticatedUser = observeAuthenticatedUser({ context }) const initialInstanceURL = endpointSetting() - // Add state to VS Code context to be used in context keys. - // Used e.g. by file tree view to only be visible in `remote-browsing` state. - const subscription = stateMachine.observeState().subscribe(state => { - vscode.commands.executeCommand('setContext', 'sourcegraph.state', state.status).then( - () => {}, - () => {} - ) - }) - context.subscriptions.push({ - dispose: () => subscription.unsubscribe(), - }) - + // Sets global `EventSource` for Node, which is required for streaming search. + // Used for VS Code web as well to be able to add Authorization header. + const initialAccessToken = accessTokenSetting() + // Add custom headers to `EventSource` Authorization header when provided + const customHeaders = endpointRequestHeadersSetting() + polyfillEventSource(initialAccessToken ? { Authorization: `token ${initialAccessToken}`, ...customHeaders } : {}) + // Update `EventSource` Authorization header on access token / headers change. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(config => { + if ( + config.affectsConfiguration('sourcegraph.accessToken') || + config.affectsConfiguration('sourcegraph.requestHeaders') + ) { + const newAccessToken = accessTokenSetting() + const newCustomHeaders = endpointRequestHeadersSetting() + polyfillEventSource( + newAccessToken ? { Authorization: `token ${newAccessToken}`, ...newCustomHeaders } : {} + ) + } + }) + ) // For search panel webview to signal that it is ready for messages. // Replay subject with large buffer size just in case panels are opened in quick succession. const initializedPanelIDs = new ReplaySubject(7) + // Used to observe search box query state from sidebar + const sidebarQueryStates = new ReplaySubject(1) + + const { fs } = initializeSourcegraphFileSystem({ context, initialInstanceURL }) + // Use api endpoint for stream search + const streamSearch = createStreamSearch({ context, stateMachine, sourcegraphURL: `${initialInstanceURL}/.api` }) + const extensionCoreAPI: ExtensionCoreAPI = { panelInitialized: panelId => initializedPanelIDs.next(panelId), observeState: () => proxySubscribable(stateMachine.observeState()), + observePanelQueryState: () => proxySubscribable(sidebarQueryStates.asObservable()), emit: event => stateMachine.emit(event), requestGraphQL: requestGraphQLFromVSCode, observeSourcegraphSettings: () => proxySubscribable(sourcegraphSettings.settings), @@ -78,14 +112,38 @@ export function activate(context: vscode.ExtensionContext): void { // `useObservable` hook. Add `usePromise`s hook to fix. getAuthenticatedUser: () => proxySubscribable(authenticatedUser), getInstanceURL: () => proxySubscribable(of(initialInstanceURL)), - openLink: async uri => { - await vscode.env.openExternal(vscode.Uri.parse(uri)) - }, + openSourcegraphFile: (uri: string) => openSourcegraphUriCommand(fs, SourcegraphUri.parse(uri)), + openLink: (uri: string) => vscode.env.openExternal(vscode.Uri.parse(uri)), + copyLink: (uri: string) => + env.clipboard.writeText(uri).then(() => vscode.window.showInformationMessage('Link Copied!')), setAccessToken: accessToken => updateAccessTokenSetting(accessToken), + setEndpointUri: uri => updateEndpointSetting(uri), reloadWindow: () => vscode.commands.executeCommand('workbench.action.reloadWindow'), + focusSearchPanel, + streamSearch, + fetchStreamSuggestions: (query, sourcegraphURL) => + proxySubscribable(fetchStreamSuggestions(query, sourcegraphURL)), + setSelectedSearchContextSpec: spec => { + stateMachine.emit({ type: 'set_selected_search_context_spec', spec }) + return localStorageService.setValue(SELECTED_SEARCH_CONTEXT_SPEC_KEY, spec) + }, + setSidebarQueryState: sidebarQueryState => sidebarQueryStates.next(sidebarQueryState), + getLocalStorageItem: key => localStorageService.getValue(key), + setLocalStorageItem: (key: string, value: string) => localStorageService.setValue(key, value), + logEvents: (variables: Event) => logEvent(variables), + getEventSource: eventSourceType, } - registerWebviews({ context, extensionCoreAPI, initializedPanelIDs, sourcegraphSettings }) - // TODO: registerCodeSharingCommands() - // TODO: registerCodeIntel() + // Also initializes code intel. + registerWebviews({ + context, + extensionCoreAPI, + initializedPanelIDs, + sourcegraphSettings, + fs, + instanceURL: initialInstanceURL, + }) + initializeCodeSharingCommands(context, eventSourceType, localStorageService) + displayInstanceVersionWarnings(localStorageService) + watchUninstall(eventSourceType, localStorageService) } diff --git a/client/vscode/src/file-system/FileTree.ts b/client/vscode/src/file-system/FileTree.ts new file mode 100644 index 00000000000..903eb598b96 --- /dev/null +++ b/client/vscode/src/file-system/FileTree.ts @@ -0,0 +1,114 @@ +import { SourcegraphUri } from './SourcegraphUri' + +/** + * Helper class to represent a flat list of relative file paths (type `string[]`) as a hierarchical file tree. + */ +export class FileTree { + constructor(public readonly uri: SourcegraphUri, public readonly files: string[]) { + files.sort() + } + + public toString(): string { + return `FileTree(${this.uri.uri}, files.length=${this.files.length})` + } + + public directChildren(directory: string): string[] { + return this.directChildrenInternal(directory, true) + } + + private directChildrenInternal(directory: string, allowRecursion: boolean): string[] { + const depth = this.depth(directory) + const directFiles = new Set() + const directDirectories = new Set() + const isRoot = directory === '' + if (!isRoot && !directory.endsWith('/')) { + directory = directory + '/' + } + let index = this.binarySearchDirectoryStart(directory) + while (index < this.files.length) { + const startIndex = index + const file = this.files[index] + if (file === '') { + index++ + continue + } + if (file.startsWith(directory)) { + const fileDepth = this.depth(file) + const isFile = isRoot ? fileDepth === 0 : fileDepth === depth + 1 + let path = isFile ? file : file.slice(0, file.indexOf('/', directory.length)) + let nestedChildren = allowRecursion && !isFile ? this.directChildrenInternal(path, false) : [] + while (allowRecursion && nestedChildren.length === 1) { + const child = SourcegraphUri.parse(nestedChildren[0]) + if (child.isDirectory()) { + path = child.path || '' + nestedChildren = this.directChildrenInternal(path, false) + } else { + break + } + } + const uri = SourcegraphUri.fromParts(this.uri.host, this.uri.repositoryName, { + revision: this.uri.revision, + path, + isDirectory: !isFile, + }).uri + if (isFile) { + directFiles.add(uri) + } else { + index = this.binarySearchDirectoryEnd(path + '/', index + 1) + directDirectories.add(uri) + } + } + if (index === startIndex) { + index++ + } + } + return [...directDirectories, ...directFiles] + } + + private binarySearchDirectoryStart(directory: string): number { + if (directory === '') { + return 0 + } + return this.binarySearch( + { low: 0, high: this.files.length }, + midpoint => this.files[midpoint].localeCompare(directory) > 0 + ) + } + + private binarySearchDirectoryEnd(directory: string, low: number): number { + while (low < this.files.length && this.files[low].localeCompare(directory) <= 0) { + low++ + } + return this.binarySearch( + { low, high: this.files.length }, + midpoint => !this.files[midpoint].startsWith(directory) + ) + } + + private binarySearch({ low, high }: SearchRange, isGreater: (midpoint: number) => boolean): number { + while (low < high) { + const midpoint = Math.floor(low + (high - low) / 2) + if (isGreater(midpoint)) { + high = midpoint + } else { + low = midpoint + 1 + } + } + return high + } + + private depth(path: string): number { + let result = 0 + for (const char of path) { + if (char === '/') { + result += 1 + } + } + return result + } +} + +interface SearchRange { + low: number + high: number +} diff --git a/client/vscode/src/file-system/FilesTreeDataProvider.ts b/client/vscode/src/file-system/FilesTreeDataProvider.ts new file mode 100644 index 00000000000..50049766ae8 --- /dev/null +++ b/client/vscode/src/file-system/FilesTreeDataProvider.ts @@ -0,0 +1,248 @@ +import * as vscode from 'vscode' + +import { log } from '../log' + +import { SourcegraphFileSystemProvider } from './SourcegraphFileSystemProvider' +import { SourcegraphUri } from './SourcegraphUri' + +export class FilesTreeDataProvider implements vscode.TreeDataProvider { + constructor(public readonly fs: SourcegraphFileSystemProvider) { + fs.onDidDownloadRepositoryFilenames(() => this.didChangeTreeData.fire(undefined)) + } + + private _isViewVisible = false + private isExpandedNode = new Set() + private treeView: vscode.TreeView | undefined + private activeUri: vscode.Uri | undefined + private selectedRepository: string | undefined + private didFocusToken = new vscode.CancellationTokenSource() + private treeItemCache = new Map() + private readonly didChangeTreeData = new vscode.EventEmitter() + public readonly onDidChangeTreeData: vscode.Event = this.didChangeTreeData.event + + public activeTextDocument(): SourcegraphUri | undefined { + return this.activeUri && this.activeUri.scheme === 'sourcegraph' + ? this.fs.sourcegraphUri(this.activeUri) + : undefined + } + public isViewVisible(): boolean { + return this._isViewVisible + } + public setTreeView(treeView: vscode.TreeView): void { + this.treeView = treeView + treeView.onDidChangeSelection(async event => { + // Check if a repository is selected for removing purpose + await this.isRepository(event.selection[0]) + }) + treeView.onDidChangeVisibility(async event => { + const didBecomeVisible = !this._isViewVisible && event.visible + this._isViewVisible = event.visible + if (didBecomeVisible) { + // NOTE: do not remove the line below even if you think it + // doesn't have an effect. Before you remove this line, make + // sure that the following steps don't cause the "Collapse All" + // button to become disabled: + // 1. Close "Files" view. + // 2. Execute "Reload window" command. + // 3. After VS Code loads, open the "Files" view. + this.didChangeTreeData.fire(undefined) + await this.didFocus(this.activeUri) + } + }) + treeView.onDidExpandElement(async event => { + await this.isRepository(event.element) + this.isExpandedNode.add(event.element) + }) + treeView.onDidCollapseElement(async event => { + await this.isRepository(event.element) + this.isExpandedNode.delete(event.element) + }) + } + + public async isRepository(selectedUri: string): Promise { + const isRepo = [...this.fs.allRepositoryUris()].includes(selectedUri) + this.selectedRepository = isRepo ? selectedUri : undefined + await vscode.commands.executeCommand('setContext', 'sourcegraph.removeRepository', isRepo) + } + + public async getParent(uriString?: string): Promise { + // log.appendLine(`getParent(${uriString})`) + try { + // Implementation note: this method is not implemented as + // `SourcegraphUri.parse(uri).parentUri()` because that would return + // URIs to directories that don't exist because they have no siblings + // and are therefore automatically merged with their parent. For example, + // imagine the following folder structure: + // .gitignore + // .github/workflows/ci.yml + // src/command.ts + // src/browse.ts + // The parent of `.github/workflows/ci.yml` is `.github/` because the `workflows/` + // directory has no sibling. + if (!uriString) { + return undefined + } + const uri = SourcegraphUri.parse(uriString) + if (!uri.path) { + return undefined + } + let ancestor: string | undefined = uri.repositoryUri() + let children = await this.getChildren(ancestor) + while (ancestor) { + const isParent = children?.includes(uriString) + if (isParent) { + break + } + ancestor = children?.find(childUri => { + const child = SourcegraphUri.parse(childUri) + return child.path && uri.path?.startsWith(child.path + '/') + }) + if (!ancestor) { + log.errorAndThrow(`getParent(${uriString || 'undefined'}) nothing startsWith`) + } + children = await this.getChildren(ancestor) + } + return ancestor + } catch (error) { + log.errorAndThrow(`getParent(${uriString || 'undefined'})`, error) + return undefined + } + } + + public async getChildren(uriString?: string): Promise { + try { + if (!uriString) { + const repos = [...this.fs.allRepositoryUris()] + return repos.map(repo => repo.replace('https://', 'sourcegraph://')) + } + const uri = SourcegraphUri.parse(uriString) + const tree = await this.fs.getFileTree(uri) + const directChildren = tree.directChildren(uri.path || '') + for (const child of directChildren) { + this.treeItemCache.set(child, this.newTreeItem(SourcegraphUri.parse(child), uri, directChildren.length)) + } + return directChildren + } catch (error) { + return log.errorAndThrow(`getChildren(${uriString || ''})`, error) + } + } + + public async focusActiveFile(): Promise { + await vscode.commands.executeCommand('sourcegraph.files.focus') + await this.didFocus(this.activeUri) + } + + public async didFocus(vscodeUri: vscode.Uri | undefined): Promise { + this.didFocusToken.cancel() + this.didFocusToken = new vscode.CancellationTokenSource() + this.activeUri = vscodeUri + await vscode.commands.executeCommand( + 'setContext', + 'sourcegraph.canFocusActiveDocument', + vscodeUri?.scheme === 'sourcegraph' + ) + if (vscodeUri && vscodeUri.scheme === 'sourcegraph' && this.treeView && this._isViewVisible) { + const uri = this.fs.sourcegraphUri(vscodeUri) + if (uri.uri === this.fs.emptyFileUri()) { + return + } + await this.fs.downloadFiles(uri) + await this.didFocusString(uri, true, this.didFocusToken.token) + } + } + + public isSourcegrapeRemoteFile(vscodeUri: vscode.Uri | undefined): boolean { + if (vscodeUri && vscodeUri.scheme === 'sourcegraph' && this.treeView && this._isViewVisible) { + return true + } + return false + } + + public async getTreeItem(uriString: string): Promise { + try { + const fromCache = this.treeItemCache.get(uriString) + if (fromCache) { + return fromCache + } + const uri = SourcegraphUri.parse(uriString) + const parentUri = await this.getParent(uri.uri) + return this.newTreeItem(uri, parentUri ? SourcegraphUri.parse(parentUri) : undefined, 0) + } catch (error) { + log.errorAndThrow(`getTreeItem(${uriString})`, error) + } + return {} + } + + private async didFocusString( + uri: SourcegraphUri, + isDestinationNode: boolean, + token: vscode.CancellationToken + ): Promise { + try { + if (this.treeView) { + const parent = await this.getParent(uri.uri) + if (parent) { + await this.didFocusString(SourcegraphUri.parse(parent), false, token) + } else { + await this.getChildren(undefined) + } + if (token.isCancellationRequested) { + return + } + await this.treeView.reveal(uri.uri, { + focus: true, + select: isDestinationNode, + expand: !isDestinationNode, + }) + } + } catch (error) { + log.error(`didFocusString(${uri.uri})`, error) + } + } + + // Remove selected repo from tree + public async removeTreeItem(): Promise { + if (this.selectedRepository) { + this.fs.removeRepository(this.selectedRepository) + } + this.selectedRepository = undefined + await vscode.commands.executeCommand('setContext', 'sourcegraph.removeRepository', false) + return this.didChangeTreeData.fire(undefined) + } + + private newTreeItem( + uri: SourcegraphUri, + parent: SourcegraphUri | undefined, + parentChildrenCount: number + ): vscode.TreeItem { + const command = uri.isFile() + ? { + command: 'sourcegraph.openFile', + title: 'Open file', + toolbar: 'test', + arguments: [uri.uri], + } + : undefined + // Check if this is a currently selected file + let selectedFile = false + if ( + vscode.window.activeTextEditor?.document && + uri.path === SourcegraphUri.parse(vscode.window.activeTextEditor?.document.uri.toString()).path + ) { + selectedFile = true + } + return { + id: uri.uri, + label: uri.treeItemLabel(parent), + tooltip: uri.uri.replace('sourcegraph://', 'https://'), + collapsibleState: uri.isFile() + ? vscode.TreeItemCollapsibleState.None + : parentChildrenCount === 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed, + command, + resourceUri: vscode.Uri.parse(uri.uri), + contextValue: !uri.isFile() ? 'directory' : selectedFile ? 'selected' : 'file', + } + } +} diff --git a/client/vscode/src/file-system/SourcegraphFileSystemProvider.ts b/client/vscode/src/file-system/SourcegraphFileSystemProvider.ts new file mode 100644 index 00000000000..c68f76627a4 --- /dev/null +++ b/client/vscode/src/file-system/SourcegraphFileSystemProvider.ts @@ -0,0 +1,317 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as vscode from 'vscode' + +import { getBlobContent } from '../backend/blobContent' +import { getFiles } from '../backend/files' +import { getRepositoryMetadata, RepositoryMetadata } from '../backend/repositoryMetadata' +import { LocationNode } from '../code-intel/location' +import { log } from '../log' +import { endpointHostnameSetting } from '../settings/endpointSetting' + +import { FileTree } from './FileTree' +import { SourcegraphUri } from './SourcegraphUri' + +export interface RepositoryFileNames { + repositoryUri: string + repositoryName: string + fileNames: string[] +} + +export interface Blob { + uri: string + repositoryName: string + revision: string + path: string + content: Uint8Array + isBinaryFile: boolean + byteSize: number + time: number + type: vscode.FileType +} + +export class SourcegraphFileSystemProvider implements vscode.FileSystemProvider { + constructor(private instanceURL: string) {} + + private fileNamesByRepository: Map> = new Map() + private metadata: Map = new Map() + private didDownloadFilenames = new vscode.EventEmitter() + + // ====================== + // FileSystemProvider API + // ====================== + + // We don't implement this because Sourcegraph files are read-only. + private didChangeFile = new vscode.EventEmitter() // Never used. + public readonly onDidChangeFile: vscode.Event = this.didChangeFile.event + public async stat(vscodeUri: vscode.Uri): Promise { + const uri = this.sourcegraphUri(vscodeUri) + const now = Date.now() + if (uri.uri === this.emptyFileUri()) { + return { mtime: now, ctime: now, size: 0, type: vscode.FileType.File } + } + const files = await this.downloadFiles(uri) + const isFile = uri.path && files.includes(uri.path) + const type = isFile ? vscode.FileType.File : vscode.FileType.Directory + // log.appendLine( + // `stat(${uri.uri}) path=${uri.path || '""'} files.length=${files.length} type=${vscode.FileType[type]}` + // ) + return { + // It seems to be OK to return hardcoded values for the timestamps + // and the byte size. If it turns out the byte size needs to be + // correct for some reason, then we can use + // `this.fetchBlob(uri).byteSize` to get the value for files. + mtime: now, + ctime: now, + size: 1337, + type, + } + } + + public emptyFileUri(): string { + return 'sourcegraph://sourcegraph.com/empty-file.txt' + } + + public async readFile(vscodeUri: vscode.Uri): Promise { + const uri = this.sourcegraphUri(vscodeUri) + if (uri.uri === this.emptyFileUri()) { + return new Uint8Array() + } + const blob = await this.fetchBlob(uri) + return blob.content + } + + public async readDirectory(vscodeUri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const uri = this.sourcegraphUri(vscodeUri) + if (uri.uri.endsWith('/-')) { + return [] + } + const tree = await this.getFileTree(uri) + const children = tree.directChildren(uri.path || '') + return children.map(childUri => { + const child = SourcegraphUri.parse(childUri) + const type = child.isDirectory() ? vscode.FileType.Directory : vscode.FileType.File + return [child.basename(), type] + }) + } + + public createDirectory(uri: vscode.Uri): void { + throw new Error('Method not supported in read-only file system.') + } + public writeFile( + _uri: vscode.Uri, + _content: Uint8Array, + _options: { create: boolean; overwrite: boolean } + ): void | Thenable { + throw new Error('Method not supported in read-only file system.') + } + public delete(_uri: vscode.Uri, _options: { recursive: boolean }): void { + throw new Error('Method not supported in read-only file system.') + } + public rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void { + throw new Error('Method not supported in read-only file system.') + } + public watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { + throw new Error('Method not supported in read-only file system.') + } + + // =============================== + // Helper methods for external use + // =============================== + + public onDidDownloadRepositoryFilenames: vscode.Event = this.didDownloadFilenames.event + + public allRepositoryUris(): string[] { + return [...this.fileNamesByRepository.keys()] + } + + public resetFileTree(): void { + return this.fileNamesByRepository.clear() + } + + // Remove Currently Selected Repository from Tree + public removeRepository(uriString: string): void { + this.fileNamesByRepository.delete(uriString) + } + + public async allFilesFromOpenRepositories(folder?: SourcegraphUri): Promise { + const promises: RepositoryFileNames[] = [] + const folderRepositoryUri = folder?.repositoryUri() + for (const [repositoryUri, downloadingFileNames] of this.fileNamesByRepository.entries()) { + if (folderRepositoryUri && repositoryUri !== folderRepositoryUri) { + continue + } + try { + const fileNames = await downloadingFileNames + const uri = SourcegraphUri.parse(repositoryUri) + promises.push({ + repositoryUri: uri.repositoryUri(), + repositoryName: `${uri.repositoryName}${uri.revisionPart()}`, + fileNames, + }) + } catch { + log.error(`failed to download files for repository '${repositoryUri}'`) + } + } + return promises + } + + public toVscodeLocation(node: LocationNode): vscode.Location { + const metadata = this.metadata.get(node.resource.repositoryName) + let revision = node.resource.revision + if (metadata?.defaultBranch && revision === metadata?.defaultOID) { + revision = metadata.defaultBranch + } + + let rangeOrPosition: vscode.Range | vscode.Position + if (node.range) { + rangeOrPosition = new vscode.Range( + new vscode.Position(node.range.start.line, node.range.start.character), + new vscode.Position(node.range.end.line, node.range.end.character) + ) + } else { + rangeOrPosition = new vscode.Position(0, 0) + } + + return new vscode.Location( + vscode.Uri.parse( + SourcegraphUri.fromParts(endpointHostnameSetting(), node.resource.repositoryName, { + revision, + path: node.resource.path, + }).uri + ), + rangeOrPosition + ) + } + + /** + * @returns the URI of a file in the given repository. The file is the + * toplevel readme file if it exists, otherwise it's the file with the + * shortest name in the repository. + */ + public async defaultFileUri(repositoryName: string): Promise { + const defaultBranch = (await this.repositoryMetadata(repositoryName))?.defaultBranch + if (!defaultBranch) { + log.errorAndThrow(`repository '${repositoryName}' has no default branch`) + } + const uri = SourcegraphUri.fromParts(endpointHostnameSetting(), repositoryName, { revision: defaultBranch }) + const files = await this.downloadFiles(uri) + const readmes = files.filter(name => name.match(/readme/i)) + const candidates = readmes.length > 0 ? readmes : files + let readme: string | undefined + for (const candidate of candidates) { + if (candidate === '' || candidate === 'lsif-java.json') { + // Skip auto-generated file for JVM packages + continue + } + if (!readme) { + readme = candidate + } else if (candidate.length < readme.length) { + readme = candidate + } + } + const defaultFile = readme || files[0] + return SourcegraphUri.fromParts(endpointHostnameSetting(), repositoryName, { + revision: defaultBranch, + path: defaultFile, + }) + } + + public async fetchBlob(uri: SourcegraphUri): Promise { + await this.repositoryMetadata(uri.repositoryName) + if (!uri.revision) { + log.errorAndThrow(`missing revision for URI '${uri.uri}'`) + } + const path = uri.path || '' + const content = await getBlobContent({ + repository: uri.repositoryName, + revision: uri.revision, + path, + }) + + if (content) { + const toCacheResult: Blob = { + uri: uri.uri, + repositoryName: uri.repositoryName, + revision: uri.revision, + content: content.content, + isBinaryFile: content.isBinary, + byteSize: content.byteSize, + path, + time: new Date().getMilliseconds(), + type: vscode.FileType.File, + } + + // Start downloading the repository files in the background. + this.downloadFiles(uri).then( + () => {}, + () => {} + ) + + return toCacheResult + } + return log.errorAndThrow(`fetchBlob(${uri.uri}) not found`) + } + + public async repositoryMetadata(repositoryName: string): Promise { + let metadata = this.metadata.get(repositoryName) + if (metadata) { + return metadata + } + metadata = await getRepositoryMetadata({ repositoryName }) + if (metadata) { + this.metadata.set(repositoryName, metadata) + } + return metadata + } + + public downloadFiles(uri: SourcegraphUri): Promise { + const key = uri.repositoryUri() + const fileNamesByRepository = this.fileNamesByRepository + let downloadingFiles = this.fileNamesByRepository.get(key) + if (!downloadingFiles) { + downloadingFiles = getFiles({ repository: uri.repositoryName, revision: uri.revision }) + vscode.window + .withProgress( + { + location: vscode.ProgressLocation.Window, + title: `Loading ${uri.repositoryName}`, + }, + async progress => { + try { + await downloadingFiles + this.didDownloadFilenames.fire(key) + } catch (error) { + log.error(`downloadFiles(${key})`, error) + fileNamesByRepository.delete(key) + } + progress.report({ increment: 100 }) + } + ) + .then( + () => {}, + () => {} + ) + + this.fileNamesByRepository.set(key, downloadingFiles) + } + return downloadingFiles + } + + public sourcegraphUri(uri: vscode.Uri): SourcegraphUri { + const sourcegraphUri = SourcegraphUri.parse(uri.toString(true)) + if (sourcegraphUri.host !== new URL(this.instanceURL).host) { + const message = 'Sourcegraph instance URL has changed. Close files opened through the previous instance.' + vscode.window.showWarningMessage(message).then( + () => {}, + () => {} + ) + throw new Error(message) + } + return sourcegraphUri + } + + public async getFileTree(uri: SourcegraphUri): Promise { + const files = await this.downloadFiles(uri) + return new FileTree(uri, files) + } +} diff --git a/client/vscode/src/file-system/SourcegraphUri.ts b/client/vscode/src/file-system/SourcegraphUri.ts new file mode 100644 index 00000000000..3160c150ec0 --- /dev/null +++ b/client/vscode/src/file-system/SourcegraphUri.ts @@ -0,0 +1,225 @@ +import { Position } from '@sourcegraph/extension-api-types' +import { parseQueryAndHash, parseRepoRevision } from '@sourcegraph/shared/src/util/url' + +export interface SourcegraphUriOptionals { + revision?: string + path?: string + position?: Position + isDirectory?: boolean + isCommit?: boolean + compareRange?: CompareRange +} + +export interface CompareRange { + base: string + head: string +} + +/** + * SourcegraphUri encodes a URI like `sourcegraph://HOST/REPOSITORY@REVISION/-/blob/PATH?L1337`. + * + * This class is used in both webviews and extensions, so try to avoid state management in this class or module. + */ +export class SourcegraphUri { + private constructor( + public readonly uri: string, + public readonly host: string, + public readonly repositoryName: string, + public readonly revision: string, + public readonly path: string | undefined, + public readonly position: Position | undefined, + public readonly compareRange: CompareRange | undefined + ) {} + + public withRevision(newRevision: string | undefined): SourcegraphUri { + const newRevisionPath = newRevision ? `@${newRevision}` : '' + return SourcegraphUri.parse( + `sourcegraph://${this.host}/${this.repositoryName}${newRevisionPath}/-/blob/${ + this.path || '' + }${this.positionSuffix()}` + ) + } + + public with(optionals: SourcegraphUriOptionals): SourcegraphUri { + return SourcegraphUri.fromParts(this.host, this.repositoryName, { + path: this.path, + revision: this.revision, + compareRange: this.compareRange, + position: this.position, + ...optionals, + }) + } + + public withPath(newPath: string): SourcegraphUri { + return SourcegraphUri.parse(`${this.repositoryUri()}/-/blob/${newPath}${this.positionSuffix()}`) + } + + public basename(): string { + const parts = (this.path || '').split('/') + return parts[parts.length - 1] + } + + public dirname(): string { + const parts = (this.path || '').split('/') + return parts.slice(0, -1).join('/') + } + + public parentUri(): string | undefined { + if (typeof this.path === 'string') { + const slash = this.uri.lastIndexOf('/') + if (slash < 0 || !this.path.includes('/')) { + return `sourcegraph://${this.host}/${this.repositoryName}${this.revisionPart()}` + } + const parent = this.uri.slice(0, slash).replace('/-/blob/', '/-/tree/') + return parent + } + return undefined + } + + public withIsDirectory(isDirectory: boolean): SourcegraphUri { + return SourcegraphUri.fromParts(this.host, this.repositoryName, { + isDirectory, + path: this.path, + revision: this.revision, + position: this.position, + }) + } + + public isCommit(): boolean { + return this.uri.includes('/-/commit/') + } + + public isCompare(): boolean { + return this.uri.includes('/-/compare/') && this.compareRange !== undefined + } + + public isDirectory(): boolean { + return this.uri.includes('/-/tree/') + } + + public isFile(): boolean { + return this.uri.includes('/-/blob/') + } + + public static fromParts(host: string, repositoryName: string, optional?: SourcegraphUriOptionals): SourcegraphUri { + const revisionPart = optional?.revision ? `@${optional.revision}` : '' + const directoryPart = optional?.isDirectory + ? 'tree' + : optional?.isCommit + ? 'commit' + : optional?.compareRange + ? 'compare' + : 'blob' + const pathPart = optional?.compareRange + ? `/-/compare/${optional.compareRange.base}...${optional.compareRange.head}` + : optional?.isCommit && optional.revision + ? `/-/commit/${optional.revision}` + : optional?.path + ? `/-/${directoryPart}/${optional?.path}` + : '' + const uri = `sourcegraph://${host}/${repositoryName}${revisionPart}${pathPart}` + return new SourcegraphUri( + uri, + host, + repositoryName, + optional?.revision || '', + optional?.path, + optional?.position, + optional?.compareRange + ) + } + public repositoryUri(): string { + return `sourcegraph://${this.host}/${this.repositoryName}${this.revisionPart()}` + } + public treeItemLabel(parent?: SourcegraphUri): string { + if (this.path) { + if (parent?.path) { + return this.path.slice(parent.path.length + 1) + } + return this.path + } + return `${this.repositoryName}` + } + public revisionPart(): string { + return this.revision ? `@${this.revision}` : '' + } + public positionSuffix(): string { + return typeof this.position === 'undefined' ? '' : `?L${this.position.line}:${this.position.character}` + } + + // Debt: refactor and use shared functions. Below is based on parseBrowserRepoURL + // https://sourcegraph.com/github.com/sourcegraph/sourcegraph@56dfaaa3e3172f9afd4a29a4780a7f1a34198238/-/blob/client/shared/src/util/url.ts?L287 + // In the browser, pass in window.URL. When we use the shared implementation, pass in the URL module from Node. + public static parse(uri: string, URLModule = URL): SourcegraphUri { + uri = uri.replace('https://', 'sourcegraph://') + const url = new URLModule(uri.replace('sourcegraph://', 'https://')) + let pathname = url.pathname.slice(1) // trim leading '/' + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1) // trim trailing '/' + } + + const indexOfSeparator = pathname.indexOf('/-/') + + // examples: + // - 'github.com/gorilla/mux' + // - 'github.com/gorilla/mux@revision' + // - 'foo/bar' (from 'sourcegraph.mycompany.com/foo/bar') + // - 'foo/bar@revision' (from 'sourcegraph.mycompany.com/foo/bar@revision') + // - 'foobar' (from 'sourcegraph.mycompany.com/foobar') + // - 'foobar@revision' (from 'sourcegraph.mycompany.com/foobar@revision') + let repoRevision: string + if (indexOfSeparator === -1) { + repoRevision = pathname // the whole string + } else { + repoRevision = pathname.slice(0, indexOfSeparator) // the whole string leading up to the separator (allows revision to be multiple path parts) + } + let { repoName, revision } = parseRepoRevision(repoRevision) + + let path: string | undefined + let compareRange: CompareRange | undefined + const treeSeparator = pathname.indexOf('/-/tree/') + const blobSeparator = pathname.indexOf('/-/blob/') + const commitSeparator = pathname.indexOf('/-/commit/') + const comparisonSeparator = pathname.indexOf('/-/compare/') + if (treeSeparator !== -1) { + path = decodeURIComponent(pathname.slice(treeSeparator + '/-/tree/'.length)) + } + if (blobSeparator !== -1) { + path = decodeURIComponent(pathname.slice(blobSeparator + '/-/blob/'.length)) + } + if (commitSeparator !== -1) { + path = decodeURIComponent(pathname.slice(commitSeparator + '/-/commit/'.length)) + } + if (comparisonSeparator !== -1) { + const range = pathname.slice(comparisonSeparator + '/-/compare/'.length) + const parts = range.split('...') + if (parts.length === 2) { + const [base, head] = parts + compareRange = { base, head } + } + } + let position: Position | undefined + + const parsedHash = parseQueryAndHash(url.search, url.hash) + if (parsedHash.line) { + position = { + line: parsedHash.line, + character: parsedHash.character || 0, + } + } + const isDirectory = uri.includes('/-/tree/') + const isCommit = uri.includes('/-/commit/') + if (isCommit) { + revision = url.pathname.replace(new RegExp('.*/-/commit/([^/]+).*'), (_unused, oid: string) => oid) + path = path?.slice(`${revision}/`.length) + } + return SourcegraphUri.fromParts(url.host, repoName, { + revision, + path, + position, + isDirectory, + isCommit, + compareRange, + }) + } +} diff --git a/client/vscode/src/file-system/commands.ts b/client/vscode/src/file-system/commands.ts new file mode 100644 index 00000000000..b7e82951500 --- /dev/null +++ b/client/vscode/src/file-system/commands.ts @@ -0,0 +1,62 @@ +import * as vscode from 'vscode' + +import { SourcegraphFileSystemProvider } from './SourcegraphFileSystemProvider' +import { SourcegraphUri } from './SourcegraphUri' + +export async function openSourcegraphUriCommand(fs: SourcegraphFileSystemProvider, uri: SourcegraphUri): Promise { + if (uri.compareRange) { + // noop. v2 Debt: implement. Open in browser for v1 + return + } + if (!uri.revision) { + const metadata = await fs.repositoryMetadata(uri.repositoryName) + uri = uri.withRevision(metadata?.defaultBranch || 'HEAD') + } + const textDocument = await vscode.workspace.openTextDocument(vscode.Uri.parse(uri.uri)) + const selection = getSelection(uri, textDocument) + await vscode.window.showTextDocument(textDocument, { + selection, + viewColumn: vscode.ViewColumn.Active, + preview: false, + }) +} + +function getSelection(uri: SourcegraphUri, textDocument: vscode.TextDocument): vscode.Range | undefined { + if (typeof uri?.position?.line !== 'undefined' && typeof uri?.position?.character !== 'undefined') { + return offsetRange(uri.position.line, uri.position.character) + } + if (typeof uri?.position?.line !== 'undefined') { + return offsetRange(uri.position.line, 0) + } + + // There's no explicitly provided line number. Instead of focusing on the + // first line (which usually contains lots of imports), we use a heuristic + // to guess the location where the "main symbol" is defined (a + // function/class/struct/interface with the same name as the filename). + if (uri.path && isFilenameThatMayDefineSymbols(uri.path)) { + const fileNames = uri.path.split('/') + const fileName = fileNames[fileNames.length - 1] + const symbolName = fileName.split('.')[0] + const text = textDocument.getText() + const symbolMatches = new RegExp(` ${symbolName}\\b`).exec(text) + if (symbolMatches) { + const position = textDocument.positionAt(symbolMatches.index + 1) + return new vscode.Range(position, position) + } + } + + return undefined +} + +function offsetRange(line: number, character: number): vscode.Range { + const position = new vscode.Position(line, character) + return new vscode.Range(position, position) +} + +/** + * @returns true if this file may contain code from a programming language that + * defines symbol. + */ +function isFilenameThatMayDefineSymbols(path: string): boolean { + return !(path.endsWith('.md') || path.endsWith('.markdown') || path.endsWith('.txt') || path.endsWith('.log')) +} diff --git a/client/vscode/src/file-system/initialize.ts b/client/vscode/src/file-system/initialize.ts new file mode 100644 index 00000000000..0dfad06138b --- /dev/null +++ b/client/vscode/src/file-system/initialize.ts @@ -0,0 +1,49 @@ +import vscode from 'vscode' + +import { log } from '../log' + +import { openSourcegraphUriCommand } from './commands' +import { FilesTreeDataProvider } from './FilesTreeDataProvider' +import { SourcegraphFileSystemProvider } from './SourcegraphFileSystemProvider' +import { SourcegraphUri } from './SourcegraphUri' + +export function initializeSourcegraphFileSystem({ + context, + initialInstanceURL, +}: { + context: vscode.ExtensionContext + initialInstanceURL: string +}): { fs: SourcegraphFileSystemProvider } { + const fs = new SourcegraphFileSystemProvider(initialInstanceURL) + context.subscriptions.push(vscode.workspace.registerFileSystemProvider('sourcegraph', fs, { isReadonly: true })) + + const files = new FilesTreeDataProvider(fs) + + const filesTreeView = vscode.window.createTreeView('sourcegraph.files', { + treeDataProvider: files, + showCollapseAll: true, + }) + files.setTreeView(filesTreeView) + context.subscriptions.push(filesTreeView) + + // Open remote Sourcegraph file from remote file tree + context.subscriptions.push( + vscode.commands.registerCommand('sourcegraph.openFile', async uri => { + if (typeof uri === 'string') { + await openSourcegraphUriCommand(fs, SourcegraphUri.parse(uri)) + } else { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + log.error(`extension.openRemoteFile(${uri}) argument is not a string`) + } + }) + ) + + // Remove Selected Repository from File Tree + context.subscriptions.push( + vscode.commands.registerCommand('sourcegraph.removeRepoTree', async () => { + await files.removeTreeItem() + }) + ) + + return { fs } +} diff --git a/client/vscode/src/link-commands/browserActionsNode.ts b/client/vscode/src/link-commands/browserActionsNode.ts new file mode 100644 index 00000000000..8812c028807 --- /dev/null +++ b/client/vscode/src/link-commands/browserActionsNode.ts @@ -0,0 +1,49 @@ +import vscode, { env } from 'vscode' + +import { getSourcegraphFileUrl, repoInfo } from './git-helpers' +import { generateSourcegraphBlobLink, vsceUtms } from './initialize' +/** + * Open active file in the browser on the configured Sourcegraph instance. + */ + +export async function browserActions(action: string, logRedirectEvent: (uri: string) => void): Promise { + const editor = vscode.window.activeTextEditor + if (!editor) { + throw new Error('No active editor') + } + const uri = editor.document.uri + let sourcegraphUrl = String() + // check if the current file is a remote file or not + if (uri.scheme === 'sourcegraph') { + sourcegraphUrl = generateSourcegraphBlobLink( + uri, + editor.selection.start.line, + editor.selection.start.character, + editor.selection.end.line, + editor.selection.end.character + ) + } else { + const repositoryInfo = await repoInfo(editor.document.uri.fsPath) + if (!repositoryInfo) { + return + } + const { remoteURL, branch, fileRelative } = repositoryInfo + const instanceUrl = vscode.workspace.getConfiguration('sourcegraph').get('url') + if (typeof instanceUrl === 'string') { + // construct sourcegraph url for current file + sourcegraphUrl = getSourcegraphFileUrl(instanceUrl, remoteURL, branch, fileRelative, editor) + vsceUtms + } + } + // Log redirect events + logRedirectEvent(sourcegraphUrl) + + // Open in browser or Copy file link + if (action === 'open' && sourcegraphUrl) { + await vscode.env.openExternal(vscode.Uri.parse(sourcegraphUrl)) + } else if (action === 'copy' && sourcegraphUrl) { + const decodedUri = decodeURIComponent(sourcegraphUrl) + await env.clipboard.writeText(decodedUri).then(() => vscode.window.showInformationMessage('Copied!')) + } else { + throw new Error(`Failed to ${action} file link: invalid URL`) + } +} diff --git a/client/vscode/src/link-commands/browserActionsWeb.ts b/client/vscode/src/link-commands/browserActionsWeb.ts new file mode 100644 index 00000000000..f8566eb415c --- /dev/null +++ b/client/vscode/src/link-commands/browserActionsWeb.ts @@ -0,0 +1,47 @@ +import vscode, { env } from 'vscode' + +import { generateSourcegraphBlobLink, vsceUtms } from './initialize' + +/** + * browser Actions for Web does not run node modules to get git info + * Open active file in the browser on the configured Sourcegraph instance. + */ +export async function browserActions(action: string, logRedirectEvent: (uri: string) => void): Promise { + const editor = vscode.window.activeTextEditor + if (!editor) { + throw new Error('No active editor') + } + const uri = editor.document.uri + const instanceUrl = vscode.workspace.getConfiguration('sourcegraph').get('url') + let sourcegraphUrl = String() + // check if the current file is a remote file or not + if (uri.scheme === 'sourcegraph') { + sourcegraphUrl = generateSourcegraphBlobLink( + uri, + editor.selection.start.line, + editor.selection.start.character, + editor.selection.end.line, + editor.selection.end.character + ) + } else if (uri.authority === 'github' && typeof instanceUrl === 'string') { + // For remote github files + const repoInfo = uri.fsPath.split('/') + const repositoryName = `${repoInfo[1]}/${repoInfo[2]}` + const filePath = repoInfo.length === 4 ? repoInfo[3] : repoInfo.slice(3).join('/') + sourcegraphUrl = `${instanceUrl}github.com/${repositoryName}/-/blob/${filePath || ''}${vsceUtms}` + } else { + await vscode.window.showInformationMessage('Non-Remote files are not supported on VS Code Web currently') + } + // Log redirect events + logRedirectEvent(sourcegraphUrl) + + // Open in browser or Copy file link + if (action === 'open' && sourcegraphUrl) { + await vscode.env.openExternal(vscode.Uri.parse(sourcegraphUrl)) + } else if (action === 'copy' && sourcegraphUrl) { + const decodedUri = decodeURIComponent(sourcegraphUrl) + await env.clipboard.writeText(decodedUri).then(() => vscode.window.showInformationMessage('Copied!')) + } else { + throw new Error(`Failed to ${action} file link: invalid URL`) + } +} diff --git a/client/vscode/src/link-commands/git-helpers.ts b/client/vscode/src/link-commands/git-helpers.ts new file mode 100644 index 00000000000..f2f1eff231b --- /dev/null +++ b/client/vscode/src/link-commands/git-helpers.ts @@ -0,0 +1,221 @@ +import * as path from 'path' + +import execa from 'execa' +import vscode, { TextEditor } from 'vscode' + +import { version } from '../../package.json' +import { log } from '../log' + +interface RepositoryInfo extends Branch, RemoteName { + /** Git repository remote URL */ + remoteURL: string + + /** File path relative to the repository root */ + fileRelative: string +} + +export type GitHelpers = typeof gitHelpers + +export interface RemoteName { + /** + * Remote name of the upstream repository, + * or the first found remote name if no upstream is found + */ + remoteName: string +} + +export interface Branch { + /** + * Remote branch name, or 'HEAD' if it isn't found because + * e.g. detached HEAD state, upstream branch points to a local branch + */ + branch: string +} + +/** + * Returns the Git repository remote URL, the current branch, and the file path + * relative to the repository root. Returns undefined if no remote is found + */ +export async function repoInfo(filePath: string): Promise { + try { + // Determine repository root directory. + const fileDirectory = path.dirname(filePath) + const repoRoot = await gitHelpers.rootDirectory(fileDirectory) + + // Determine file path relative to repository root. + let fileRelative = filePath.slice(repoRoot.length + 1) + + let { branch, remoteName } = await gitRemoteNameAndBranch(repoRoot, gitHelpers, log) + + branch = getDefaultBranch() || branch + + const remoteURL = await gitRemoteUrlWithReplacements(repoRoot, remoteName, gitHelpers, log) + + if (process.platform === 'win32') { + fileRelative = fileRelative.replace(/\\/g, '/') + } + return { remoteURL, branch, fileRelative, remoteName } + } catch { + return undefined + } +} + +export async function gitRemoteNameAndBranch( + repoDirectory: string, + git: Pick, + log?: { + appendLine: (value: string) => void + } +): Promise { + let remoteName: string | undefined + + // Used to determine which part of upstreamAndBranch is the remote name, or as fallback if no upstream is set + const remotes = await git.remotes(repoDirectory) + const branch = await git.branch(repoDirectory) + + try { + const upstreamAndBranch = await git.upstreamAndBranch(repoDirectory) + // Subtract $BRANCH_NAME from $UPSTREAM_REMOTE/$BRANCH_NAME. + // We can't just split on the delineating `/`, since refnames can include `/`: + // https://sourcegraph.com/github.com/git/git@454cb6bd52a4de614a3633e4f547af03d5c3b640/-/blob/refs.c#L52-67 + + // Example: + // stdout: remote/two/tj/feature + // remoteName: remote/two, branch: tj/feature + + const branchPosition = upstreamAndBranch.lastIndexOf(branch) + const maybeRemote = upstreamAndBranch.slice(0, branchPosition - 1) + if (branchPosition !== -1 && maybeRemote) { + remoteName = maybeRemote + } + } catch { + // noop. upstream may not be set + } + + // If we cannot find the remote name deterministically, we use the first + // Git remote found. + if (!remoteName) { + if (remotes.length > 1) { + log?.appendLine(`no upstream found, using first git remote: ${remotes[0]}`) + } + remoteName = remotes[0] + } + + // Throw if a remote still isn't found + if (!remoteName) { + throw new Error('no configured git remotes') + } + + return { remoteName, branch } +} + +export const gitHelpers = { + /** + * Returns the repository root directory for any directory within the + * repository. + */ + async rootDirectory(repoDirectory: string): Promise { + const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], { cwd: repoDirectory }) + return stdout + }, + + /** + * Returns the names of all git remotes, e.g. ["origin", "foobar"] + */ + async remotes(repoDirectory: string): Promise { + const { stdout } = await execa('git', ['remote'], { cwd: repoDirectory }) + return stdout.split('\n') + }, + + /** + * Returns the remote URL for the given remote name. + * e.g. `origin` -> `git@github.com:foo/bar` + */ + async remoteUrl(remoteName: string, repoDirectory: string): Promise { + const { stdout } = await execa('git', ['remote', 'get-url', remoteName], { cwd: repoDirectory }) + return stdout + }, + + /** + * Returns either the current branch name of the repository OR in all + * other cases (e.g. detached HEAD state), it returns "HEAD". + */ + async branch(repoDirectory: string): Promise { + const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDirectory }) + return stdout + }, + + /** + * Returns a string in the format $UPSTREAM_REMOTE/$BRANCH_NAME, e.g. "origin/branch-name", throws if not found + */ + async upstreamAndBranch(repoDirectory: string): Promise { + const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD@{upstream}'], { cwd: repoDirectory }) + return stdout + }, +} + +/** + * Returns the remote URL for the given remote name with remote URL replacements. + * e.g. `origin` -> `git@github.com:foo/bar` + */ +export async function gitRemoteUrlWithReplacements( + repoDirectory: string, + remoteName: string, + gitHelpers: Pick, + log?: { appendLine: (value: string) => void } +): Promise { + let stdout = await gitHelpers.remoteUrl(remoteName, repoDirectory) + const replacementsList = getRemoteUrlReplacements() + + const stdoutBefore = stdout + + for (const replacement in replacementsList) { + if (typeof replacement === 'string') { + stdout = stdout.replace(replacement, replacementsList[replacement]) + } + } + + log?.appendLine(`${stdoutBefore} became ${stdout}`) + return stdout +} + +/** + * Uses editor endpoint to construct sourcegraph file URL + */ +export function getSourcegraphFileUrl( + SourcegraphUrl: string, + remoteURL: string, + branch: string, + fileRelative: string, + editor: TextEditor +): string { + return ( + `${SourcegraphUrl}/-/editor` + + `?remote_url=${encodeURIComponent(remoteURL)}` + + `&branch=${encodeURIComponent(branch)}` + + `&file=${encodeURIComponent(fileRelative)}` + + `&editor=${encodeURIComponent('VSCode')}` + + `&version=${encodeURIComponent(version)}` + + `&start_row=${encodeURIComponent(String(editor.selection.start.line))}` + + `&start_col=${encodeURIComponent(String(editor.selection.start.character))}` + + `&end_row=${encodeURIComponent(String(editor.selection.end.line))}` + + `&end_col=${encodeURIComponent(String(editor.selection.end.character))}` + ) +} + +function getRemoteUrlReplacements(): Record { + // has default value + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const replacements = vscode.workspace + .getConfiguration('sourcegraph') + .get>('remoteUrlReplacements')! + return replacements +} + +export function getDefaultBranch(): string { + // has default value + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const branch = vscode.workspace.getConfiguration('sourcegraph').get('defaultBranch')! + + return branch +} diff --git a/client/vscode/src/link-commands/initialize.ts b/client/vscode/src/link-commands/initialize.ts new file mode 100644 index 00000000000..2d6d8cca691 --- /dev/null +++ b/client/vscode/src/link-commands/initialize.ts @@ -0,0 +1,77 @@ +import vscode from 'vscode' + +import { EventSource } from '@sourcegraph/shared/src/graphql-operations' + +import { version } from '../../package.json' +import { logEvent } from '../backend/eventLogger' +import { SourcegraphUri } from '../file-system/SourcegraphUri' +import { LocalStorageService, ANONYMOUS_USER_ID_KEY } from '../settings/LocalStorageService' + +import { browserActions } from './browserActionsNode' + +export function initializeCodeSharingCommands( + context: vscode.ExtensionContext, + eventSourceType: EventSource, + localStorageService: LocalStorageService +): void { + // Open local file or remote Sourcegraph file in browser + context.subscriptions.push( + vscode.commands.registerCommand('sourcegraph.openInBrowser', async () => { + await browserActions('open', logRedirectEvent) + }) + ) + // Copy Sourcegraph link to file + context.subscriptions.push( + vscode.commands.registerCommand('sourcegraph.copyFileLink', async () => { + await browserActions('copy', logRedirectEvent) + }) + ) + // Search Selected Text in Sourcegraph Search Tab + context.subscriptions.push( + vscode.commands.registerCommand('sourcegraph.selectionSearchWeb', async () => { + const instanceUrl = + vscode.workspace.getConfiguration('sourcegraph').get('url') || 'https://sourcegraph.com' + const editor = vscode.window.activeTextEditor + const selectedQuery = editor?.document.getText(editor.selection) + if (!editor || !selectedQuery) { + throw new Error('No selection detected') + } + const uri = `${instanceUrl}/search?q=context:global+${encodeURIComponent( + selectedQuery + )}&patternType=literal${vsceUtms}` + await vscode.env.openExternal(vscode.Uri.parse(uri)) + }) + ) + // Log Redirect Event + function logRedirectEvent(sourcegraphUrl: string): void { + const userEventVariables = { + event: 'IDERedirected', + userCookieID: localStorageService.getValue(ANONYMOUS_USER_ID_KEY), + referrer: 'VSCE', + url: sourcegraphUrl, + source: eventSourceType, + argument: JSON.stringify({ editor: 'vscode', version }), + } + logEvent(userEventVariables) + } +} + +export const vsceUtms = + '&utm_campaign=vscode-extension&utm_medium=direct_traffic&utm_source=vscode-extension&utm_content=vsce-commands' + +export function generateSourcegraphBlobLink( + uri: vscode.Uri, + startLine: number, + startChar: number, + endLine: number, + endChar: number +): string { + const instanceUrl = vscode.workspace.getConfiguration('sourcegraph').get('url') || 'https://sourcegraph.com' + // Using SourcegraphUri.parse to properly decode repo revision + const decodedUri = SourcegraphUri.parse(uri.toString()).uri + return `${decodedUri.replace(uri.scheme, instanceUrl.startsWith('https') ? 'https' : 'http')}?L${encodeURIComponent( + String(startLine) + )}:${encodeURIComponent(String(startChar))}-${encodeURIComponent(String(endLine))}:${encodeURIComponent( + String(endChar) + )}${vsceUtms}` +} diff --git a/client/vscode/src/log.ts b/client/vscode/src/log.ts new file mode 100644 index 00000000000..8e62c135193 --- /dev/null +++ b/client/vscode/src/log.ts @@ -0,0 +1,34 @@ +import vscode from 'vscode' + +const outputChannel = vscode.window.createOutputChannel('Sourcegraph') + +export const log = { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + error: (what: string, error?: any): void => { + outputChannel.appendLine(`ERROR ${errorMessage(what, error)}`) + }, + errorAndThrow: (what: string, error?: any): never => { + log.error(what, error) + throw new Error(errorMessage(what, error)) + }, + debug: (what: any): void => { + for (const key of Object.keys(what)) { + const value = JSON.stringify(what[key]) + outputChannel.appendLine(`${key}=${value}`) + } + }, + appendLine: (message: string): void => { + outputChannel.appendLine(message) + }, +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function errorMessage(what: string, error?: any): string { + const errorMessage = + error instanceof Error + ? ` ${error.message} ${error.stack || ''}` + : error !== undefined + ? ` ${JSON.stringify(error)}` + : '' + return what + errorMessage +} diff --git a/client/vscode/src/polyfills/eventSource.d.ts b/client/vscode/src/polyfills/eventSource.d.ts new file mode 100644 index 00000000000..98ddf134639 --- /dev/null +++ b/client/vscode/src/polyfills/eventSource.d.ts @@ -0,0 +1,2 @@ +function polyfillEventSource(headers: { [name: string]: string }): void +export default polyfillEventSource diff --git a/client/vscode/src/polyfills/eventSource.js b/client/vscode/src/polyfills/eventSource.js new file mode 100644 index 00000000000..64029e496a7 --- /dev/null +++ b/client/vscode/src/polyfills/eventSource.js @@ -0,0 +1,485 @@ +// From https://github.com/EventSource/eventsource. +// Sets global `EventSource` for Node, which is required for streaming search. +// Used for VS Code web as well to be able to add Authorization header. + +const original = require('original') + +const parse = require('url').parse +const events = require('events') +const http = require('http') +const https = require('https') +const util = require('util') + +let fixedHeaders = {} + +module.exports = function polyfillEventSource(headers) { + fixedHeaders = { ...headers } + + global.EventSource = EventSource + global.MessageEvent = MessageEvent + global.Event = Event +} + +const httpsOptions = new Set([ + 'pfx', + 'key', + 'passphrase', + 'cert', + 'ca', + 'ciphers', + 'rejectUnauthorized', + 'secureProtocol', + 'servername', + 'checkServerIdentity', +]) + +const bom = [239, 187, 191] +const colon = 58 +const space = 32 +const lineFeed = 10 +const carriageReturn = 13 + +function hasBom(buf) { + return bom.every((charCode, index) => buf[index] === charCode) +} + +/** + * Creates a new EventSource object + * + * @param {string} url the URL to which to connect + * @param {Object} [eventSourceInitDict] extra init params. See README for details. + * @api public + **/ +function EventSource(url, eventSourceInitDict) { + let readyState = EventSource.CONNECTING + Object.defineProperty(this, 'readyState', { + get() { + return readyState + }, + }) + + Object.defineProperty(this, 'url', { + get() { + return url + }, + }) + + const self = this + self.reconnectInterval = 1000 + self.connectionInProgress = false + + function onConnectionClosed(message) { + if (readyState === EventSource.CLOSED) { + return + } + readyState = EventSource.CONNECTING + _emit('error', new Event('error', { message })) + + // The url may have been changed by a temporary + // redirect. If that's the case, revert it now. + if (reconnectUrl) { + url = reconnectUrl + reconnectUrl = null + } + setTimeout(() => { + if (readyState !== EventSource.CONNECTING || self.connectionInProgress) { + return + } + self.connectionInProgress = true + connect() + }, self.reconnectInterval) + } + + let request + let lastEventId = '' + if (eventSourceInitDict && eventSourceInitDict.headers && eventSourceInitDict.headers['Last-Event-ID']) { + lastEventId = eventSourceInitDict.headers['Last-Event-ID'] + delete eventSourceInitDict.headers['Last-Event-ID'] + } + + let discardTrailingNewline = false + let data = '' + let eventName = '' + + var reconnectUrl = null + + function connect() { + const options = parse(url) + let isSecure = options.protocol === 'https:' + options.headers = { + Accept: 'text/event-stream', + ...fixedHeaders, + } + if (lastEventId) { + options.headers['Last-Event-ID'] = lastEventId + } + if (eventSourceInitDict && eventSourceInitDict.headers) { + for (const index in eventSourceInitDict.headers) { + const header = eventSourceInitDict.headers[index] + if (header) { + options.headers[index] = header + } + } + } + + // Legacy: this should be specified as `eventSourceInitDict.https.rejectUnauthorized`, + // but for now exists as a backwards-compatibility layer + options.rejectUnauthorized = !(eventSourceInitDict && !eventSourceInitDict.rejectUnauthorized) + + if (eventSourceInitDict && eventSourceInitDict.createConnection !== undefined) { + options.createConnection = eventSourceInitDict.createConnection + } + + // If specify http proxy, make the request to sent to the proxy server, + // and include the original url in path and Host headers + const useProxy = eventSourceInitDict && eventSourceInitDict.proxy + if (useProxy) { + const proxy = parse(eventSourceInitDict.proxy) + isSecure = proxy.protocol === 'https:' + + options.protocol = isSecure ? 'https:' : 'http:' + options.path = url + options.headers.Host = options.host + options.hostname = proxy.hostname + options.host = proxy.host + options.port = proxy.port + } + + // If https options are specified, merge them into the request options + if (eventSourceInitDict && eventSourceInitDict.https) { + for (const optName in eventSourceInitDict.https) { + if (!httpsOptions.has(optName)) { + continue + } + + const option = eventSourceInitDict.https[optName] + if (option !== undefined) { + options[optName] = option + } + } + } + + // Pass this on to the XHR + if (eventSourceInitDict && eventSourceInitDict.withCredentials !== undefined) { + options.withCredentials = eventSourceInitDict.withCredentials + } + + request = (isSecure ? https : http).request(options, res => { + self.connectionInProgress = false + // Handle HTTP errors + if (res.statusCode === 500 || res.statusCode === 502 || res.statusCode === 503 || res.statusCode === 504) { + _emit('error', new Event('error', { status: res.statusCode, message: res.statusMessage })) + onConnectionClosed() + return + } + + // Handle HTTP redirects + if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) { + if (!res.headers.location) { + // Server sent redirect response without Location header. + _emit('error', new Event('error', { status: res.statusCode, message: res.statusMessage })) + return + } + if (res.statusCode === 307) { + reconnectUrl = url + } + url = res.headers.location + process.nextTick(connect) + return + } + + if (res.statusCode !== 200) { + _emit('error', new Event('error', { status: res.statusCode, message: res.statusMessage })) + return self.close() + } + + readyState = EventSource.OPEN + res.on('close', () => { + res.removeAllListeners('close') + res.removeAllListeners('end') + onConnectionClosed() + }) + + res.on('end', () => { + res.removeAllListeners('close') + res.removeAllListeners('end') + onConnectionClosed() + }) + _emit('open', new Event('open')) + + // text/event-stream parser adapted from webkit's + // Source/WebCore/page/EventSource.cpp + let isFirst = true + let buf + let startingPosition = 0 + let startingFieldLength = -1 + res.on('data', chunk => { + buf = buf ? Buffer.concat([buf, chunk]) : chunk + if (isFirst && hasBom(buf)) { + buf = buf.slice(bom.length) + } + + isFirst = false + let position = 0 + const length = buf.length + + while (position < length) { + if (discardTrailingNewline) { + if (buf[position] === lineFeed) { + ++position + } + discardTrailingNewline = false + } + + let lineLength = -1 + let fieldLength = startingFieldLength + var c + + for (let index = startingPosition; lineLength < 0 && index < length; ++index) { + c = buf[index] + if (c === colon) { + if (fieldLength < 0) { + fieldLength = index - position + } + } else if (c === carriageReturn) { + discardTrailingNewline = true + lineLength = index - position + } else if (c === lineFeed) { + lineLength = index - position + } + } + + if (lineLength < 0) { + startingPosition = length - position + startingFieldLength = fieldLength + break + } else { + startingPosition = 0 + startingFieldLength = -1 + } + + parseEventStreamLine(buf, position, fieldLength, lineLength) + + position += lineLength + 1 + } + + if (position === length) { + buf = void 0 + } else if (position > 0) { + buf = buf.slice(position) + } + }) + }) + + request.on('error', error => { + self.connectionInProgress = false + onConnectionClosed(error.message) + }) + + if (request.setNoDelay) { + request.setNoDelay(true) + } + request.end() + } + + connect() + + function _emit() { + if (self.listeners(arguments[0]).length > 0) { + self.emit.apply(self, arguments) + } + } + + this._close = function () { + if (readyState === EventSource.CLOSED) { + return + } + readyState = EventSource.CLOSED + if (request.abort) { + request.abort() + } + if (request.xhr && request.xhr.abort) { + request.xhr.abort() + } + } + + function parseEventStreamLine(buf, position, fieldLength, lineLength) { + if (lineLength === 0) { + if (data.length > 0) { + const type = eventName || 'message' + _emit( + type, + new MessageEvent(type, { + data: data.slice(0, -1), // remove trailing newline + lastEventId, + origin: original(url), + }) + ) + data = '' + } + eventName = void 0 + } else if (fieldLength > 0) { + const noValue = fieldLength < 0 + let step = 0 + const field = buf.slice(position, position + (noValue ? lineLength : fieldLength)).toString() + + if (noValue) { + step = lineLength + } else if (buf[position + fieldLength + 1] !== space) { + step = fieldLength + 1 + } else { + step = fieldLength + 2 + } + position += step + + const valueLength = lineLength - step + const value = buf.slice(position, position + valueLength).toString() + + if (field === 'data') { + data += value + '\n' + } else if (field === 'event') { + eventName = value + } else if (field === 'id') { + lastEventId = value + } else if (field === 'retry') { + const retry = parseInt(value, 10) + if (!Number.isNaN(retry)) { + self.reconnectInterval = retry + } + } + } + } +} + +// module.exports = EventSource + +util.inherits(EventSource, events.EventEmitter) +EventSource.prototype.constructor = EventSource // make stacktraces readable +;['open', 'error', 'message'].forEach(method => { + Object.defineProperty(EventSource.prototype, 'on' + method, { + /** + * Returns the current listener + * + * @returns {Mixed} the set function or undefined + * @api private + */ + get: function get() { + const listener = this.listeners(method)[0] + return listener ? (listener._listener ? listener._listener : listener) : undefined + }, + + /** + * Start listening for events + * + * @param {Function} listener the listener + * @returns {Mixed} the set function or undefined + * @api private + */ + set: function set(listener) { + this.removeAllListeners(method) + this.addEventListener(method, listener) + }, + }) +}) + +/** + * Ready states + */ +Object.defineProperty(EventSource, 'CONNECTING', { enumerable: true, value: 0 }) +Object.defineProperty(EventSource, 'OPEN', { enumerable: true, value: 1 }) +Object.defineProperty(EventSource, 'CLOSED', { enumerable: true, value: 2 }) + +EventSource.prototype.CONNECTING = 0 +EventSource.prototype.OPEN = 1 +EventSource.prototype.CLOSED = 2 + +/** + * Closes the connection, if one is made, and sets the readyState attribute to 2 (closed) + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/close + * @api public + */ +EventSource.prototype.close = function () { + this._close() +} + +/** + * Emulates the W3C Browser based WebSocket interface using addEventListener. + * + * @param {string} type A string representing the event type to listen out for + * @param {Function} listener callback + * @see https://developer.mozilla.org/en/DOM/element.addEventListener + * @see http://dev.w3.org/html5/websockets/#the-websocket-interface + * @api public + */ +EventSource.prototype.addEventListener = function addEventListener(type, listener) { + if (typeof listener === 'function') { + // store a reference so we can return the original function again + listener._listener = listener + this.on(type, listener) + } +} + +/** + * Emulates the W3C Browser based WebSocket interface using dispatchEvent. + * + * @param {Event} event An event to be dispatched + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent + * @api public + */ +EventSource.prototype.dispatchEvent = function dispatchEvent(event) { + if (!event.type) { + throw new Error('UNSPECIFIED_EVENT_TYPE_ERR') + } + // if event is instance of an CustomEvent (or has 'details' property), + // send the detail object as the payload for the event + this.emit(event.type, event.detail) +} + +/** + * Emulates the W3C Browser based WebSocket interface using removeEventListener. + * + * @param {string} type A string representing the event type to remove + * @param {Function} listener callback + * @see https://developer.mozilla.org/en/DOM/element.removeEventListener + * @see http://dev.w3.org/html5/websockets/#the-websocket-interface + * @api public + */ +EventSource.prototype.removeEventListener = function removeEventListener(type, listener) { + if (typeof listener === 'function') { + listener._listener = undefined + this.removeListener(type, listener) + } +} + +/** + * W3C Event + * + * @see http://www.w3.org/TR/DOM-Level-3-Events/#interface-Event + * @api private + */ +function Event(type, optionalProperties) { + Object.defineProperty(this, 'type', { writable: false, value: type, enumerable: true }) + if (optionalProperties) { + for (const f in optionalProperties) { + if (optionalProperties.hasOwnProperty(f)) { + Object.defineProperty(this, f, { writable: false, value: optionalProperties[f], enumerable: true }) + } + } + } +} + +/** + * W3C MessageEvent + * + * @see http://www.w3.org/TR/webmessaging/#event-definitions + * @api private + */ +function MessageEvent(type, eventInitDict) { + Object.defineProperty(this, 'type', { writable: false, value: type, enumerable: true }) + for (const f in eventInitDict) { + if (eventInitDict.hasOwnProperty(f)) { + Object.defineProperty(this, f, { writable: false, value: eventInitDict[f], enumerable: true }) + } + } +} diff --git a/client/vscode/src/settings/LocalStorageService.ts b/client/vscode/src/settings/LocalStorageService.ts new file mode 100644 index 00000000000..f929b582d6d --- /dev/null +++ b/client/vscode/src/settings/LocalStorageService.ts @@ -0,0 +1,25 @@ +// VS Code Docs https://code.visualstudio.com/api/references/vscode-api#Memento +// A memento represents a storage utility. It can store and retrieve values. +import { Memento } from 'vscode' + +export class LocalStorageService { + constructor(private storage: Memento) {} + + public getValue(key: string): string { + return this.storage.get(key, '') + } + + public async setValue(key: string, value: string): Promise { + try { + await this.storage.update(key, value) + return true + } catch { + return false + } + } +} + +export const SELECTED_SEARCH_CONTEXT_SPEC_KEY = 'selected-search-context-spec' +export const INSTANCE_VERSION_NUMBER_KEY = 'sourcegraphVersionNumber' +export const ANONYMOUS_USER_ID_KEY = 'sourcegraphAnonymousUid' +export const DISMISS_SEARCH_CTA_KEY = 'sourcegraphSearchCtaDismissed' diff --git a/client/vscode/src/settings/displayWarnings.ts b/client/vscode/src/settings/displayWarnings.ts new file mode 100644 index 00000000000..06289f2a3e1 --- /dev/null +++ b/client/vscode/src/settings/displayWarnings.ts @@ -0,0 +1,20 @@ +import vscode from 'vscode' + +import { INSTANCE_VERSION_NUMBER_KEY, LocalStorageService } from './LocalStorageService' + +export async function displayWarning(warning: string): Promise { + await vscode.window.showErrorMessage(warning) +} + +export function displayInstanceVersionWarnings(localStorageService: LocalStorageService): void { + const versionNumber = localStorageService.getValue(INSTANCE_VERSION_NUMBER_KEY) + if (!versionNumber) { + displayWarning('Cannot determine instance version number').catch(() => {}) + } + if (versionNumber < '3320') { + displayWarning( + 'Your Sourcegraph instance version is not fully compatible with the Sourcegraph extension. Please ask your site admin to upgrade to version 3.32.0 or above. Read more about version support in our [troubleshooting docs](https://docs.sourcegraph.com/admin/how-to/troubleshoot-sg-extension#unsupported-features-by-sourcegraph-version).' + ).catch(() => {}) + } + return +} diff --git a/client/vscode/src/settings/endpointSetting.ts b/client/vscode/src/settings/endpointSetting.ts index f53d99f0a0e..be0efeb37e1 100644 --- a/client/vscode/src/settings/endpointSetting.ts +++ b/client/vscode/src/settings/endpointSetting.ts @@ -1,13 +1,12 @@ +import * as vscode from 'vscode' + import { readConfiguration } from './readConfiguration' export function endpointSetting(): string { // has default value // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const url = readConfiguration().get('url')! - if (url.endsWith('/')) { - return url.slice(0, -1) - } - return url + return removeEndingSlash(url) } export function endpointHostnameSetting(): string { @@ -25,3 +24,24 @@ export function endpointAccessTokenSetting(): boolean { } return false } + +export function endpointRequestHeadersSetting(): object { + return readConfiguration().get('requestHeaders') || {} +} + +export async function updateEndpointSetting(newEndpoint: string): Promise { + const newEndpointURL = removeEndingSlash(newEndpoint) + try { + await readConfiguration().update('url', newEndpointURL, vscode.ConfigurationTarget.Global) + return true + } catch { + return false + } +} + +function removeEndingSlash(uri: string): string { + if (uri.endsWith('/')) { + return uri.slice(0, -1) + } + return uri +} diff --git a/client/vscode/src/settings/uninstall.ts b/client/vscode/src/settings/uninstall.ts new file mode 100644 index 00000000000..07a0ef8ed91 --- /dev/null +++ b/client/vscode/src/settings/uninstall.ts @@ -0,0 +1,69 @@ +import vscode from 'vscode' + +import { EventSource } from '@sourcegraph/shared/src/graphql-operations' + +import { version } from '../../package.json' +import { logEvent } from '../backend/eventLogger' + +import { ANONYMOUS_USER_ID_KEY, LocalStorageService } from './LocalStorageService' + +// This function allows us to watch for uninstall event while still having access to the VS Code API +export function watchUninstall(eventSourceType: EventSource, localStorageService: LocalStorageService): void { + const extensionName = 'sourcegraph.sourcegraph' + try { + const extensionPath = vscode.extensions.getExtension(extensionName)?.extensionPath + const pathComponents = extensionPath?.split('/').slice(0, -1) + const extensionsDirectoryPath = pathComponents?.join('/') + // All upgrades, downgrades, and uninstalls will be logged in the .obsolete file + pathComponents?.push('.obsolete') + const uninstalledPath = pathComponents?.join('/') + if (extensionsDirectoryPath && uninstalledPath) { + // Watch the .obsolete file - it does not exist when VS Code is started + // Check if uninstall has happened when the file was created or when changes are made + const watchPattern = new vscode.RelativePattern(extensionsDirectoryPath, '.obsolete') + const watchFileListener = vscode.workspace.createFileSystemWatcher(watchPattern) + watchFileListener.onDidCreate(() => checkUninstall(uninstalledPath, extensionsDirectoryPath)) + watchFileListener.onDidChange(() => checkUninstall(uninstalledPath, extensionsDirectoryPath)) + } + } catch (error) { + console.error('failed to invoke uninstall:', error) + } + + /** + * Assume the extension has been uninstalled if the count of all the versions listed in + * the .obsolete file is equal to the count of all the version-divided directories. + * For example, if there are 5 versions of the extension were installed while there + * are 4 versions of the extension listed in the .obsolete file pending to be deleted, + * it means 1 version of the extension is still installed, therefore no uninstallation + * has happened + **/ + function checkUninstall(uninstalledPath: string, extensionsDirectoryPath: string): void { + Promise.all([ + // .obsolete file includes all extensions versions that need to be remove at restart + vscode.workspace.fs.readFile(vscode.Uri.file(uninstalledPath)), + // Each versions of the extension has its own directory + vscode.workspace.fs.readDirectory(vscode.Uri.parse(extensionsDirectoryPath)), + ]) + .then(([obsoleteExtensionsRaw, extensionsDirectory]) => { + const obsoleteExtensionsCount = Object.keys(JSON.parse(obsoleteExtensionsRaw.toString())).filter(id => + id.includes(extensionName) + ).length + const downloadedExtensionsCount = extensionsDirectory + .map(([name]) => name) + .filter(id => id.includes(extensionName)).length + // Compare count of extension name in .obsolete file vs count of directories with the same extension name + if (downloadedExtensionsCount === obsoleteExtensionsCount) { + logEvent({ + event: 'IDEUninstalled', + userCookieID: localStorageService.getValue(ANONYMOUS_USER_ID_KEY), + referrer: 'VSCE', + url: '', + source: eventSourceType, + argument: JSON.stringify({ editor: 'vscode', version }), + publicArgument: JSON.stringify({ editor: 'vscode', version }), + }) + } + }) + .catch(error => console.error(error)) + } +} diff --git a/client/vscode/src/state.ts b/client/vscode/src/state.ts index 81153870101..aac0c5aca68 100644 --- a/client/vscode/src/state.ts +++ b/client/vscode/src/state.ts @@ -1,7 +1,11 @@ import { cloneDeep } from 'lodash' import { BehaviorSubject, Observable } from 'rxjs' +import { SearchQueryState } from '@sourcegraph/search' import { AuthenticatedUser } from '@sourcegraph/shared/src/auth' +import { AggregateStreamingSearchResults } from '@sourcegraph/shared/src/search/stream' + +import { LocalStorageService, SELECTED_SEARCH_CONTEXT_SPEC_KEY } from './settings/LocalStorageService' // State management in the Sourcegraph VS Code extension // ----- @@ -52,21 +56,23 @@ export type VSCEState = SearchHomeState | SearchResultsState | RemoteBrowsingSta export interface SearchHomeState { status: 'search-home' - context: CommonContext & {} + context: CommonContext } export interface SearchResultsState { status: 'search-results' - context: CommonContext & {} + context: CommonContext & { + submittedSearchQueryState: Pick + } } export interface RemoteBrowsingState { status: 'remote-browsing' - context: CommonContext & {} + context: CommonContext } export interface IdleState { status: 'idle' - context: CommonContext & {} + context: CommonContext } export interface ContextInvalidatedState { @@ -74,21 +80,66 @@ export interface ContextInvalidatedState { context: CommonContext } +/** + * Subset of SearchQueryState that's necessary and clone-able (`postMessage`) for the VS Code extension. + */ +export type VSCEQueryState = Pick | null + interface CommonContext { authenticatedUser: AuthenticatedUser | null - // Whether a search has already been submitted. - dirty: boolean + + submittedSearchQueryState: VSCEQueryState + + searchSidebarQueryState: { + proposedQueryState: VSCEQueryState + /** + * The current query state as known to the sidebar. + * Used to "anchor" query state updates to the correct state + * in case the panel's search query state has changed since + * the sidebar event. + * + * Debt: we don't use this yet. + */ + currentQueryState: VSCEQueryState + } + + searchResults: AggregateStreamingSearchResults | null + + selectedSearchContextSpec: string | undefined } -const INITIAL_STATE: VSCEState = { status: 'search-home', context: { authenticatedUser: null, dirty: false } } +function createInitialState({ localStorageService }: { localStorageService: LocalStorageService }): VSCEState { + return { + status: 'search-home', + context: { + authenticatedUser: null, + submittedSearchQueryState: null, + searchResults: null, + selectedSearchContextSpec: localStorageService.getValue(SELECTED_SEARCH_CONTEXT_SPEC_KEY) || undefined, + searchSidebarQueryState: { + proposedQueryState: null, + currentQueryState: null, + }, + }, + } +} // Temporary placeholder events. We will replace these with the actual events as we implement the webviews. export type VSCEEvent = SearchEvent | TabsEvent | SettingsEvent -type SearchEvent = { type: 'set_query_state' } | { type: 'submit_search_query' } +type SearchEvent = + | { type: 'set_query_state' } + | { + type: 'submit_search_query' + submittedSearchQueryState: NonNullable + } + | { type: 'received_search_results'; searchResults: AggregateStreamingSearchResults } + | { type: 'set_selected_search_context_spec'; spec: string } // TODO see how this handles instance change + | { type: 'sidebar_query_update'; proposedQueryState: VSCEQueryState; currentQueryState: VSCEQueryState } type TabsEvent = + | { type: 'search_panel_disposed' } | { type: 'search_panel_unfocused' } | { type: 'search_panel_focused' } | { type: 'remote_file_focused' } @@ -98,8 +149,12 @@ interface SettingsEvent { type: 'sourcegraph_url_change' } -export function createVSCEStateMachine(): VSCEStateMachine { - const states = new BehaviorSubject(INITIAL_STATE) +export function createVSCEStateMachine({ + localStorageService, +}: { + localStorageService: LocalStorageService +}): VSCEStateMachine { + const states = new BehaviorSubject(createInitialState({ localStorageService })) function reducer(state: VSCEState, event: VSCEEvent): VSCEState { // End state. @@ -112,7 +167,52 @@ export function createVSCEStateMachine(): VSCEStateMachine { return { status: 'context-invalidated', context: { - ...INITIAL_STATE.context, + ...createInitialState({ localStorageService }).context, + }, + } + } + if (event.type === 'set_selected_search_context_spec') { + return { + ...state, + context: { + ...state.context, + selectedSearchContextSpec: event.spec, + }, + } as VSCEState + // Type assertion is safe since existing context should be assignable to the existing state. + // debt: refactor switch statement to elegantly handle this event safely. + } + if (event.type === 'sidebar_query_update') { + return { + ...state, + context: { + ...state.context, + searchSidebarQueryState: { + proposedQueryState: event.proposedQueryState, + currentQueryState: event.currentQueryState, + }, + }, + } as VSCEState + // Type assertion is safe since existing context should be assignable to the existing state. + // debt: refactor switch statement to elegantly handle this event safely. + } + if (event.type === 'submit_search_query') { + return { + status: 'search-results', + context: { + ...state.context, + submittedSearchQueryState: event.submittedSearchQueryState, + searchResults: null, // Null out previous results. + }, + } + } + if (event.type === 'received_search_results' && state.context.submittedSearchQueryState) { + return { + status: 'search-results', + context: { + ...state.context, + submittedSearchQueryState: state.context.submittedSearchQueryState, + searchResults: event.searchResults, }, } } @@ -121,12 +221,14 @@ export function createVSCEStateMachine(): VSCEStateMachine { case 'search-home': case 'search-results': switch (event.type) { - case 'submit_search_query': + case 'search_panel_disposed': return { - status: 'search-results', + ...state, + status: 'search-home', context: { ...state.context, - dirty: true, + submittedSearchQueryState: null, + searchResults: null, }, } @@ -146,12 +248,22 @@ export function createVSCEStateMachine(): VSCEStateMachine { case 'remote-browsing': switch (event.type) { - case 'search_panel_focused': - return { - ...state, - status: state.context.dirty ? 'search-results' : 'search-home', + case 'search_panel_focused': { + if (state.context.submittedSearchQueryState) { + return { + status: 'search-results', + context: { + ...state.context, + submittedSearchQueryState: state.context.submittedSearchQueryState, + }, + } } + return { + ...state, + status: 'search-home', + } + } case 'remote_file_unfocused': return { ...state, @@ -163,11 +275,22 @@ export function createVSCEStateMachine(): VSCEStateMachine { case 'idle': switch (event.type) { - case 'search_panel_focused': + case 'search_panel_focused': { + if (state.context.submittedSearchQueryState) { + return { + status: 'search-results', + context: { + ...state.context, + submittedSearchQueryState: state.context.submittedSearchQueryState, + }, + } + } + return { ...state, - status: state.context.dirty ? 'search-results' : 'search-home', + status: 'search-home', } + } case 'remote_file_focused': return { diff --git a/client/vscode/src/webview/comlink/extensionEndpoint.ts b/client/vscode/src/webview/comlink/extensionEndpoint.ts index 6d33cef1f03..b1f65e9a282 100644 --- a/client/vscode/src/webview/comlink/extensionEndpoint.ts +++ b/client/vscode/src/webview/comlink/extensionEndpoint.ts @@ -3,7 +3,14 @@ import vscode from 'vscode' import { EndpointPair } from '@sourcegraph/shared/src/platform/context' -import { generateUUID, isNestedConnection, isProxyMarked, NestedConnectionData, RelationshipType } from '.' +import { + generateUUID, + isNestedConnection, + isProxyMarked, + isUnsubscribable, + NestedConnectionData, + RelationshipType, +} from '.' // Used to scope message to panel (and `connectionId` further scopes to function call). let nextPanelId = 1 @@ -44,6 +51,10 @@ export function createEndpointsForWebview( nextPanelId++ let disposed = false + // Keep track of proxied unsubscribables to clean up when a webview is closed. In that case, + // the webview will likely be unable to send an unsubscribe message. + const proxiedUnsubscribables = new Set<{ unsubscribe: () => unknown }>() + /** * Handles values sent to webviews that are marked to be proxied. */ @@ -53,6 +64,11 @@ export function createEndpointsForWebview( // send it "over the wire" delete value.proxyMarkedValue + if (isUnsubscribable(proxyMarkedValue)) { + proxiedUnsubscribables.add(proxyMarkedValue) + // Debt: ideally remove unsubscribable from set when we receive a unsubscribe message. + } + const endpoint = createEndpoint(value.nestedConnectionId) Comlink.expose(proxyMarkedValue, endpoint) } @@ -114,6 +130,10 @@ export function createEndpointsForWebview( panel.onDidDispose(() => { disposed = true endpointFactories.delete(panelId) + + for (const unsubscribable of proxiedUnsubscribables) { + unsubscribable.unsubscribe() + } }) const webviewEndpoint = createEndpoint('webview') diff --git a/client/vscode/src/webview/comlink/index.ts b/client/vscode/src/webview/comlink/index.ts index 1d33d980a9c..dc8e48c2b6b 100644 --- a/client/vscode/src/webview/comlink/index.ts +++ b/client/vscode/src/webview/comlink/index.ts @@ -50,3 +50,7 @@ export function isNestedConnection(value: unknown): value is NestedConnectionDat export function isProxyMarked(value: unknown): value is Comlink.ProxyMarked { return isObject(value) && (value as Comlink.ProxyMarked)[Comlink.proxyMarker] } + +export function isUnsubscribable(value: object): value is { unsubscribe: () => unknown } { + return hasProperty('unsubscribe')(value) && typeof value.unsubscribe === 'function' +} diff --git a/client/vscode/src/webview/commands.ts b/client/vscode/src/webview/commands.ts index bf5a3df84c6..ed8d30d8c6a 100644 --- a/client/vscode/src/webview/commands.ts +++ b/client/vscode/src/webview/commands.ts @@ -1,56 +1,80 @@ import { Observable } from 'rxjs' import * as vscode from 'vscode' -import { initializeSourcegraphSettings } from '../backend/sourcegraphSettings' -import { ExtensionCoreAPI } from '../contract' +import { LATEST_VERSION } from '@sourcegraph/shared/src/search/stream' -import { initializeSearchPanelWebview, initializeSearchSidebarWebview } from './initialize' +import { initializeSourcegraphSettings } from '../backend/sourcegraphSettings' +import { initializeCodeIntel } from '../code-intel/initialize' +import { ExtensionCoreAPI } from '../contract' +import { SourcegraphFileSystemProvider } from '../file-system/SourcegraphFileSystemProvider' +import { SearchPatternType } from '../graphql-operations' + +import { + initializeHelpSidebarWebview, + initializeSearchPanelWebview, + initializeSearchSidebarWebview, +} from './initialize' + +// Track current active webview panel to make sure only one panel exists at a time +let currentSearchPanel: vscode.WebviewPanel | 'initializing' | undefined +let searchSidebarWebviewView: vscode.WebviewView | 'initializing' | undefined export function registerWebviews({ context, extensionCoreAPI, initializedPanelIDs, sourcegraphSettings, + fs, + instanceURL, }: { context: vscode.ExtensionContext extensionCoreAPI: ExtensionCoreAPI initializedPanelIDs: Observable sourcegraphSettings: ReturnType + fs: SourcegraphFileSystemProvider + instanceURL: string }): void { - // Track current active webview panel to make sure only one panel exists at a time - let currentActiveWebviewPanel: vscode.WebviewPanel | undefined - let searchSidebarWebviewView: vscode.WebviewView | undefined - // TODO if remote files are open from previous session, we need - // to focus search sidebar to activate code intel (load extension host), - // and to do that we need to make sourcegraph:// file opening an activation event. + // to focus search sidebar to activate code intel (load extension host) // Open Sourcegraph search tab on `sourcegraph.search` command. context.subscriptions.push( vscode.commands.registerCommand('sourcegraph.search', async () => { + // If text selected, submit search for it. Capture selection first. + const activeEditor = vscode.window.activeTextEditor + const selection = activeEditor?.selection + const selectedQuery = activeEditor?.document.getText(selection) + // Focus search sidebar in case this command was the activation event, // as opposed to visibiilty of sidebar. if (!searchSidebarWebviewView) { focusSearchSidebar() } - if (currentActiveWebviewPanel) { - currentActiveWebviewPanel.reveal() - } else { + if (currentSearchPanel && currentSearchPanel !== 'initializing') { + currentSearchPanel.reveal() + } else if (!currentSearchPanel) { sourcegraphSettings.refreshSettings() - const { webviewPanel } = await initializeSearchPanelWebview({ + currentSearchPanel = 'initializing' + + const { webviewPanel, searchPanelAPI } = await initializeSearchPanelWebview({ extensionUri: context.extensionUri, extensionCoreAPI, initializedPanelIDs, }) - currentActiveWebviewPanel = webviewPanel + currentSearchPanel = webviewPanel webviewPanel.onDidChangeViewState(() => { if (webviewPanel.active) { extensionCoreAPI.emit({ type: 'search_panel_focused' }) focusSearchSidebar() + searchPanelAPI.focusSearchBox().catch(() => {}) + } + + if (webviewPanel.visible) { + searchPanelAPI.focusSearchBox().catch(() => {}) } if (!webviewPanel.visible) { @@ -60,12 +84,24 @@ export function registerWebviews({ }) webviewPanel.onDidDispose(() => { - currentActiveWebviewPanel = undefined + currentSearchPanel = undefined // Ideally focus last used sidebar tab on search panel close. In lieu of that (for v1), // just focus the file explorer if the search sidebar is currently focused. - if (searchSidebarWebviewView?.visible) { + if (searchSidebarWebviewView !== 'initializing' && searchSidebarWebviewView?.visible) { focusFileExplorer() } + // Clear search result + extensionCoreAPI.emit({ type: 'search_panel_disposed' }) + }) + } + + if (selectedQuery) { + extensionCoreAPI.streamSearch(selectedQuery, { + patternType: SearchPatternType.literal, + caseSensitive: false, + version: LATEST_VERSION, + trace: undefined, + sourcegraphURL: instanceURL, }) } }) @@ -77,7 +113,7 @@ export function registerWebviews({ { // This typically will be called only once since `retainContextWhenHidden` is set to `true`. resolveWebviewView: (webviewView, _context, _token) => { - initializeSearchSidebarWebview({ + const { searchSidebarAPI } = initializeSearchSidebarWebview({ extensionUri: context.extensionUri, extensionCoreAPI, webviewView, @@ -86,6 +122,8 @@ export function registerWebviews({ // Initialize search panel. openSearchPanelCommand() + initializeCodeIntel({ context, fs, searchSidebarAPI }) + // Bring search panel back if it was previously closed on sidebar visibility change webviewView.onDidChangeVisibility(() => { if (webviewView.visible) { @@ -97,6 +135,39 @@ export function registerWebviews({ { webviewOptions: { retainContextWhenHidden: true } } ) ) + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + 'sourcegraph.helpSidebar', + { + // This typically will be called only once since `retainContextWhenHidden` is set to `true`. + resolveWebviewView: (webviewView, _context, _token) => { + initializeHelpSidebarWebview({ + extensionUri: context.extensionUri, + extensionCoreAPI, + webviewView, + }) + }, + }, + { webviewOptions: { retainContextWhenHidden: true } } + ) + ) + + // Clone Remote Git Repos Locally using VS Code Git API + // https://github.com/microsoft/vscode/issues/48428 + context.subscriptions.push( + vscode.commands.registerCommand('sourcegraph.gitClone', async () => { + const editor = vscode.window.activeTextEditor + if (!editor) { + throw new Error('No active editor') + } + const uri = editor.document.uri.path + const gitUrl = `https:/${uri.split('@')[0]}.git` + const vsCodeCloneUrl = `vscode://vscode.git/clone?url=${gitUrl}` + await vscode.env.openExternal(vscode.Uri.parse(vsCodeCloneUrl)) + // vscode://vscode.git/clone?url=${gitUrl} + }) + ) } function openSearchPanelCommand(): void { @@ -117,6 +188,12 @@ function focusSearchSidebar(): void { ) } +export function focusSearchPanel(): void { + if (currentSearchPanel && currentSearchPanel !== 'initializing') { + currentSearchPanel.reveal() + } +} + function focusFileExplorer(): void { vscode.commands.executeCommand('workbench.view.explorer').then( () => {}, diff --git a/client/vscode/src/webview/forkedBranded.scss b/client/vscode/src/webview/forkedBranded.scss new file mode 100644 index 00000000000..b9ed9e5e4f0 --- /dev/null +++ b/client/vscode/src/webview/forkedBranded.scss @@ -0,0 +1,182 @@ +@import '../../../branded/src/global-styles/colors.scss'; +@import '../../../branded/src/global-styles/border-radius.scss'; + +// Bootstrap configuration before Bootstrap is imported +$border-radius: var(--border-radius); +$border-radius-sm: var(--border-radius); +$border-radius-lg: var(--border-radius); +$popover-border-radius: var(--popover-border-radius); + +$font-size-base: 0.875rem; +$line-height-base: (20/14); + +$box-shadow: var(--box-shadow); + +$grid-gutter-width: 1.5rem; + +// No max width except for xl. +$container-max-widths: ( + xl: 1140px, +); + +$border-color: var(--border-color); + +// Links + +$link-color: var(--link-color); +$link-hover-color: var(--link-hover-color); + +// Forms + +$form-check-input-margin-y: var(--form-check-input-margin-y); +$form-feedback-font-size: 0.75rem; +$input-btn-focus-width: 2px; +// The default focus ring for buttons is very hard to see, raise opacity. +// We only show the focus ring when using the keyboard, when the focus ring +// should be clearly visible. +$btn-focus-box-shadow: var(--focus-box-shadow); +$btn-link-disabled-color: var(--btn-link-disabled-color); +$btn-padding-y-sm: var(--btn-padding-y-sm); + +// Forms don't manipulate the colors at compile time, +// which is why we can use CSS variables for theming here +// That's nice because the forms theming CSS would otherwise +// be way more complex than it is for other components +$input-bg: var(--input-bg); +$input-disabled-bg: var(--input-disabled-bg); +$input-border-color: var(--input-border-color); +$input-color: var(--input-color); +$input-placeholder-color: var(--input-placeholder-color); +$input-group-addon-color: var(--input-group-addon-color); +$input-group-addon-bg: var(--input-group-addon-bg); +$input-group-addon-border-color: var(--input-group-addon-border-color); +$input-focus-border-color: var(--input-focus-border-color); +$input-focus-box-shadow: var(--input-focus-box-shadow); + +// Custom Selects +$custom-select-bg-size: 16px 16px; +$custom-select-disabled-bg: var(--input-disabled-bg); +$custom-select-focus-box-shadow: var(--input-focus-box-shadow); +// Icon: mdi-react/ChevronDownIcon +$custom-select-indicator: url("data:image/svg+xml,"); +// Hide feedback icon for custom-select +$custom-select-feedback-icon-size: 0; + +// Dropdown +$dropdown-bg: var(--dropdown-bg); +$dropdown-border-color: var(--dropdown-border-color); +$dropdown-divider-bg: var(--border-color); +$dropdown-link-color: var(--body-color); +$dropdown-link-hover-color: var(--body-color); +$dropdown-link-hover-bg: var(--dropdown-link-hover-bg); +$dropdown-link-active-color: #ffffff; +$dropdown-link-active-bg: var(--primary); +$dropdown-link-disabled-color: var(--text-muted); +$dropdown-header-color: var(--dropdown-header-color); +$dropdown-item-padding-y: 0.25rem; +$dropdown-item-padding-x: 0.5rem; +$dropdown-padding-y: $dropdown-item-padding-y; + +// Tables + +$table-cell-padding: 0.625rem; +$table-border-color: var(--border-color); + +$hr-border-color: var(--border-color); +$hr-margin-y: 0.25rem; + +// Disable transitions +$input-transition: none; + +// Spacer +$spacer: 1rem; + +:root { + --spacer: #{$spacer}; +} + +// Apply static variables before Bootstrap imports. +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/mixins'; +@import 'bootstrap/scss/reboot'; +@import 'bootstrap/scss/utilities'; +@import 'bootstrap/scss/grid'; +@import 'bootstrap/scss/transitions'; + +// Modified in `./buttons.scss` +@import 'bootstrap/scss/buttons'; + +// Modified in `./forms.scss` +@import 'bootstrap/scss/forms'; +@import 'bootstrap/scss/custom-forms'; +@import 'bootstrap/scss/input-group'; + +// Global styles provided by @reach packages. Should be imported once in the global scope. +@import '@reach/tabs/styles'; + +@import 'wildcard/src/global-styles/breakpoints'; +@import 'shared/src/global-styles/icons'; +@import '../../../branded/src/global-styles/background'; +@import '../../../branded/src/global-styles/dropdown'; +@import '../../../branded/src/global-styles/meter'; +@import '../../../branded/src/global-styles/popover'; +@import '../../../branded/src/global-styles/nav'; +@import '../../../branded/src/global-styles/list-group'; +@import '../../../branded/src/global-styles/typography'; +@import '../../../branded/src/global-styles/tables'; +@import '../../../branded/src/global-styles/code'; +@import '../../../branded/src/global-styles/buttons'; +@import '../../../branded/src/global-styles/button-group'; +@import '../../../branded/src/global-styles/forms'; +@import '../../../branded/src/global-styles/tabs'; +@import '../../../branded/src/global-styles/progress'; + +* { + box-sizing: border-box; +} + +// Our simple popovers only need these styles. We don't want the caret or special font sizes from +// Bootstrap's popover CSS. +.popover-inner { + background-color: var(--color-bg-1); + border: solid 1px var(--border-color); + box-shadow: var(--dropdown-shadow); + border-radius: var(--popover-border-radius); + // Ensure content is clipped by border + overflow: hidden; +} + +// Show a focus ring when performing keyboard navigation. Uses the polyfill at +// https://github.com/WICG/focus-visible because few browsers support :focus-visible. +:focus:not(:focus-visible) { + outline: none; +} +:focus-visible { + outline: 0; + box-shadow: var(--focus-box-shadow); +} + +.cursor-pointer, +input[type='radio'], +input[type='checkbox'] { + &:not(:disabled) { + cursor: pointer; + } +} + +// Replace the old '../../../branded/src/global-styles/card' file +$card-spacer-y: 0.5rem; +$card-spacer-x: 0.5rem; +$card-bg: var(--card-bg); +$card-border-color: var(--card-border-color); +$card-cap-bg: var(--color-bg-2); + +.card { + --card-bg: var(--color-bg-1); + --card-border-color: var(--border-color); + --card-spacer-y: #{$card-spacer-y}; + --card-spacer-x: #{$card-spacer-x}; +} + +@import 'bootstrap/scss/card'; diff --git a/client/vscode/src/webview/index.scss b/client/vscode/src/webview/index.scss index 9ee973bd16f..92039f2f089 100644 --- a/client/vscode/src/webview/index.scss +++ b/client/vscode/src/webview/index.scss @@ -1,10 +1,75 @@ -@import '../../../branded/src/global-styles/index.scss'; +@import './forkedBranded.scss'; +@import './theming/highlight.scss'; +@import './theming/monaco.scss'; +@import '~@vscode/codicons/dist/codicon.css'; :root { - --border-color: rgba(0, 0, 0, 0.125); - --mark-bg: var(--mark-bg-light); // v2/debt: redefine our CSS variables using VS Code's CSS variables // instead of hackily overriding the necessary classes' properties. + .theme-light, + .theme-dark { + --body-color: var(--vscode-foreground); + --code-bg: var(--vscode-editor-background); + --color-bg-1: var(--vscode-editor-background); + --color-bg-2: var(--vscode-editorWidget-background); + + --border-color: var(--vscode-editor-lineHighlightBorder); + --border-color-2: var(--vscode-editor-lineHighlightBorder); + + // VS Code themes cannot change border radius, so we can safely hardcode it. + --border-radius: 0; + --popover-border-radius: 0; + + --dropdown-bg: var(--vscode-dropdown-background); + --dropdown-border-color: var(--vscode-dropdown-border); + --dropdown-header-color: var(--vscode-panelTitle-activeForeground); + + .dropdown-menu { + --body-color: var(--vscode-dropdown-foreground); + --primary: var(--vscode-textLink-foreground); // hover background + --color-bg-3: var(--color-bg-3); // active background + + & input { + background-color: var(--vscode-input-background) !important; + } + } + + --input-bg: var(--vscode-editorWidget-background); + --input-border-color: var(--vscode-input-border); + --border-active-color: var(--vscode-focusBorder); + --link-color: var(--vscode-textLink-foreground); + --search-filter-keyword-color: var(--vscode-textLink-foreground); + --body-bg: var(--vscode-editor-background); + --text-muted: var(--vscode-descriptionForeground); + --primary: var(--vscode-button-background); + + // Debt: alert overrides + --info-3: var(--vscode-inputValidation-infoBorder); + --danger: var(--vscode-inputValidation-errorBackground); + } + + .theme-dark { + .sourcegraph-tooltip { + --tooltip-bg: var(--vscode-input-background); + --tooltip-color: var(--vscode-editorWidget-foreground); + } + } + + .theme-light { + // Ensure tooltip always has a dark background with light text. + .sourcegraph-tooltip { + --tooltip-bg: var(--vscode-editorWidget-foreground); + --tooltip-color: var(--vscode-input-background); + } + } + + --max-homepage-container-width: 65rem; + + // Media breakpoints + --media-sm: 576px; + --media-md: 768px; + --media-lg: 992px; + --media-xl: 1200px; } // stylelint-disable-next-line selector-max-id @@ -14,49 +79,25 @@ body, height: 100%; } -.selection-highlight, -.selection-highlight-sticky { - background-color: var(--mark-bg); -} - -// Used for -.flex-shrink-past-contents { - flex-shrink: 1; - min-width: 0; -} - -small { - color: var(--vscode-foreground) !important; -} - -// Adapt to VS Code appearance. body { - background-color: transparent; - // color: var(--vscode-textPreformat-foreground); font-family: var(--vscode-font-family); font-weight: var(--vscode-font-weight); - font-size: var(--vscode-font-size); + font-size: var(--vscode-editor-font-size); + --body-color: var(--vscode-dropdown-foreground); + + &.search-sidebar { + background-color: transparent !important; + } } code { font-family: var(--vscode-editor-font-family) !important; font-weight: var(--vscode-editor-font-weight) !important; font-size: var(--vscode-editor-font-size) !important; - color: var(--vscode-editor-foreground) !important; -} - -input { - background-color: var(--vscode-input-background) !important; -} - -a, -.btn-link, -.btm-link-sm { - color: var(--vscode-textLink-foreground) !important; + color: var(--vscode-editor-foreground); } .btn-primary { - background-color: var(--vscode-button-background) !important; color: var(--vscode-button-foreground) !important; &:hover { @@ -68,41 +109,21 @@ a, } } -.textarea { - background-color: var(--vscode-input-background) !important; -} - -.cta-card { - background-color: var(--vscode-textCodeBlock-background) !important; -} - -.mtk13 { - color: var(--vscode-textLink-foreground) !important; -} - -.search-filter-keyword { - color: var(--vscode-textLink-foreground) !important; - - &:hover { - color: var(--vscode-button-background) !important; - background-color: var(--vscode-button-foreground) !important; - } -} - -.text { - color: var(--vscode-foreground); +.btn-text-link { + color: var(--vscode-textLink-foreground); + padding: 0; + font-weight: var(--vscode-editor-font-weight) !important; font-size: var(--vscode-editor-font-size) !important; } -.vsce-text { - color: var(--vscode-foreground); - font-size: var(--vscode-editor-font-size) !important; -} +.input, +.form-control { + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + padding: 0.5rem; -.btn-outline-secondary { - color: var(--vscode-foreground) !important; - - &:hover { - background: transparent !important; + &:focus { + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); } } diff --git a/client/vscode/src/webview/initialize.ts b/client/vscode/src/webview/initialize.ts index 490411fde77..3adcf182b87 100644 --- a/client/vscode/src/webview/initialize.ts +++ b/client/vscode/src/webview/initialize.ts @@ -3,7 +3,8 @@ import { Observable } from 'rxjs' import { filter, first } from 'rxjs/operators' import * as vscode from 'vscode' -import { ExtensionCoreAPI, SearchPanelAPI, SearchSidebarAPI } from '../contract' +import { ExtensionCoreAPI, HelpSidebarAPI, SearchPanelAPI, SearchSidebarAPI } from '../contract' +import { endpointSetting } from '../settings/endpointSetting' import { createEndpointsForWebview } from './comlink/extensionEndpoint' @@ -25,6 +26,7 @@ export async function initializeSearchPanelWebview({ const panel = vscode.window.createWebviewPanel('sourcegraphSearch', 'Sourcegraph', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true, + enableFindWidget: true, localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'dist', 'webview')], }) @@ -33,6 +35,7 @@ export async function initializeSearchPanelWebview({ const scriptSource = panel.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'searchPanel.js')) const cssModuleSource = panel.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'searchPanel.css')) const styleSource = panel.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'style.css')) + const codiconFontSource = panel.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'codicon.ttf')) const { proxy, expose, panelId } = createEndpointsForWebview(panel) @@ -57,19 +60,30 @@ export async function initializeSearchPanelWebview({ // Apply Content-Security-Policy // panel.webview.cspSource comes from the webview object + // debt: load codicon ourselves. panel.webview.html = ` - + @font-face { + font-family: 'codicon'; + src: url(${codiconFontSource.toString()}) + } + + + } vscode-resource: 'unsafe-inline' http: https: data:; connect-src 'self' http: https:; frame-src https:; font-src ${ + panel.webview.cspSource + };"> Sourcegraph Search - +
@@ -95,6 +109,7 @@ export function initializeSearchSidebarWebview({ } { webviewView.webview.options = { enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'dist', 'webview')], } const webviewPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview') @@ -102,6 +117,7 @@ export function initializeSearchSidebarWebview({ const scriptSource = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'searchSidebar.js')) const cssModuleSource = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'searchSidebar.css')) const styleSource = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'style.css')) + const codiconFontSource = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'codicon.ttf')) const { proxy, expose, panelId } = createEndpointsForWebview(webviewView) @@ -111,26 +127,31 @@ export function initializeSearchSidebarWebview({ // Expose the Sourcegraph VS Code Extension API to the Webview. Comlink.expose(extensionCoreAPI, expose) - // Specific scripts to run using nonce - const nonce = getNonce() - // Apply Content-Security-Policy - // panel.webview.cspSource comes from the webview object + // debt: load codicon ourselves. webviewView.webview.html = ` - + - + } http: https: data:; connect-src 'self' http: https:; font-src vscode-resource: blob: https:;"> Sourcegraph Search - +
- + ` @@ -139,6 +160,57 @@ export function initializeSearchSidebarWebview({ } } +export function initializeHelpSidebarWebview({ + extensionUri, + extensionCoreAPI, + webviewView, +}: SourcegraphWebviewConfig & { + webviewView: vscode.WebviewView +}): { + helpSidebarAPI: Comlink.Remote +} { + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'dist', 'webview')], + } + + const webviewPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview') + + const scriptSource = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'helpSidebar.js')) + const cssModuleSource = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'helpSidebar.css')) + const styleSource = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'style.css')) + + const { proxy, expose, panelId } = createEndpointsForWebview(webviewView) + + // Get a proxy for the Sourcegraph Webview API to communicate with the Webview. + const helpSidebarAPI = Comlink.wrap(proxy) + + // Expose the Sourcegraph VS Code Extension API to the Webview. + Comlink.expose(extensionCoreAPI, expose) + + // Apply Content-Security-Policy + webviewView.webview.html = ` + + + + + + Help and Feedback + + + +
+ + + ` + + return { + helpSidebarAPI, + } +} + export function getNonce(): string { let text = '' const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' diff --git a/client/vscode/src/webview/platform/EventLogger.ts b/client/vscode/src/webview/platform/EventLogger.ts new file mode 100644 index 00000000000..6eeeef3f42e --- /dev/null +++ b/client/vscode/src/webview/platform/EventLogger.ts @@ -0,0 +1,155 @@ +import * as Comlink from 'comlink' +import * as uuid from 'uuid' + +import { EventSource, Event as EventType } from '@sourcegraph/shared/src/graphql-operations' + +import { version } from '../../../package.json' +import { ExtensionCoreAPI } from '../../contract' +import { ANONYMOUS_USER_ID_KEY } from '../../settings/LocalStorageService' + +import { VsceTelemetryService } from './telemetryService' + +// Event Logger for VS Code Extension +export class EventLogger implements VsceTelemetryService { + private anonymousUserID = '' + private evenSourceType = EventSource.BACKEND || EventSource.IDEEXTENSION + private eventID = 0 + private listeners: Set<(eventName: string) => void> = new Set() + private vsceAPI: Comlink.Remote + private newInstall = false + private editorInfo = { editor: 'vscode', version } + + constructor(extensionAPI: Comlink.Remote) { + this.vsceAPI = extensionAPI + this.initializeLogParameters() + .then(() => {}) + .catch(() => {}) + } + + /** + * Log a pageview. + * Page titles should be specific and human-readable in pascal case, e.g. "SearchResults" or "Blob" or "NewOrg" + */ + public logViewEvent(pageTitle: string, eventProperties?: any, publicArgument?: any, url?: string): void { + if (pageTitle) { + this.tracker( + `View${pageTitle}`, + { ...eventProperties, ...this.editorInfo }, + { ...publicArgument, ...this.editorInfo }, + url + ) + } + } + + public logPageView(): void { + // Debt: migrate VS Code `logViewEvent` calls to `logPageView` + } + + /** + * Log a user action or event. + * Event labels should be specific and follow a ${noun}${verb} structure in pascal case, e.g. "ButtonClicked" or "SignInInitiated" + * + * @param eventLabel: the event name. + * @param eventProperties: event properties. These get logged to our database, but do not get + * sent to our analytics systems. This may contain private info such as repository names or search queries. + * @param publicArgument: event properties that include only public information. Do NOT + * include any private information, such as full URLs that may contain private repo names or + * search queries. The contents of this parameter are sent to our analytics systems. + */ + public log(eventLabel: string, eventProperties?: any, publicArgument?: any, uri?: string): void { + if (!eventLabel) { + return + } + switch (eventLabel) { + case 'DynamicFilterClicked': + eventLabel = 'VSCESidebarDynamicFiltersClick' + break + case 'SearchSnippetClicked': + eventLabel = 'VSCESidebarRepositoriesClick' + break + case 'SearchReferenceOpened': + eventLabel = 'VSCESidebarSearchReferenceClick' + break + } + for (const listener of this.listeners) { + listener(eventLabel) + } + this.tracker( + eventLabel, + { ...eventProperties, ...this.editorInfo }, + { ...publicArgument, ...this.editorInfo }, + uri + ) + } + + /** + * Gets the anonymous user ID and cohort ID of the user from VSCE storage utility. + * If user doesn't have an anonymous user ID yet, a new one is generated + * And a new ide install event will be logged + */ + private async initializeLogParameters(): Promise { + let anonymousUserID = await this.vsceAPI.getLocalStorageItem(ANONYMOUS_USER_ID_KEY) + const source = await this.vsceAPI.getEventSource + if (!anonymousUserID) { + anonymousUserID = uuid.v4() + this.newInstall = true + await this.vsceAPI.setLocalStorageItem(ANONYMOUS_USER_ID_KEY, anonymousUserID) + } + this.anonymousUserID = anonymousUserID + this.evenSourceType = source + if (this.newInstall) { + this.log('IDEInstalled') + this.newInstall = false + } + } + + /** + * Get the anonymous identifier for this user (used to allow site admins + * on a Sourcegraph instance to see a count of unique users on a daily, + * weekly, and monthly basis). + */ + public getAnonymousUserID(): string { + return this.anonymousUserID + } + + /** + * Regular instance version format: 3.38.2 + * Insider version format: 134683_2022-03-02_5188fes0101 + */ + public getEventSourceType(): EventSource { + return this.evenSourceType + } + + /** + * Event ID is used to deduplicate events in Amplitude. + * This is used in the case that multiple events with the same userID and timestamp + * are sent. https://developers.amplitude.com/docs/http-api-v2#optional-keys + */ + public getEventID(): number { + this.eventID++ + return this.eventID + } + + public addEventLogListener(callback: (eventName: string) => void): () => void { + this.listeners.add(callback) + return () => this.listeners.delete(callback) + } + + public tracker(eventName: string, eventProperties?: unknown, publicArgument?: unknown, uri?: string): void { + const userEventVariables: EventType = { + event: eventName, + userCookieID: this.getAnonymousUserID(), + referrer: 'VSCE', + url: uri || '', + source: this.getEventSourceType(), + argument: eventProperties ? JSON.stringify(eventProperties) : null, + publicArgument: JSON.stringify(publicArgument), + deviceID: this.getAnonymousUserID(), + eventID: this.getEventID(), + } + this.vsceAPI + .logEvents(userEventVariables) + .then(() => {}) + .catch(error => console.log(error)) + } +} diff --git a/client/vscode/src/webview/platform/context.ts b/client/vscode/src/webview/platform/context.ts index d8cecb6cfd8..bf6d44d031e 100644 --- a/client/vscode/src/webview/platform/context.ts +++ b/client/vscode/src/webview/platform/context.ts @@ -1,15 +1,20 @@ +import { createContext, useContext } from 'react' + import * as Comlink from 'comlink' import { print } from 'graphql' import { BehaviorSubject, from, Observable } from 'rxjs' import { GraphQLResult } from '@sourcegraph/http-client' import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { AuthenticatedUser } from '@sourcegraph/shared/src/auth' import { PlatformContext } from '@sourcegraph/shared/src/platform/context' -import { TelemetryService } from '@sourcegraph/shared/src/telemetry/telemetryService' +import { SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/settings' +import { TooltipController } from '@sourcegraph/wildcard' import { ExtensionCoreAPI } from '../../contract' -import { vscodeTelemetryService } from './telemetryService' +import { EventLogger } from './EventLogger' +import { VsceTelemetryService } from './telemetryService' export interface VSCodePlatformContext extends Pick< @@ -24,21 +29,25 @@ export interface VSCodePlatformContext | 'getStaticExtensions' | 'telemetryService' | 'clientApplication' + | 'forceUpdateTooltip' > { // Ensure telemetryService is non-nullable. - telemetryService: TelemetryService + telemetryService: VsceTelemetryService requestGraphQL: (options: { request: string variables: V mightContainPrivateInfo: boolean overrideAccessToken?: string + overrideSourcegraphURL?: string }) => Observable> } export function createPlatformContext(extensionCoreAPI: Comlink.Remote): VSCodePlatformContext { const context: VSCodePlatformContext = { - requestGraphQL({ request, variables, overrideAccessToken }) { - return from(extensionCoreAPI.requestGraphQL(request, variables, overrideAccessToken)) + requestGraphQL({ request, variables, overrideAccessToken, overrideSourcegraphURL }) { + return from( + extensionCoreAPI.requestGraphQL(request, variables, overrideAccessToken, overrideSourcegraphURL) + ) }, // TODO add true Apollo Client support for v2 getGraphQLClient: () => @@ -49,12 +58,13 @@ export function createPlatformContext(extensionCoreAPI: Comlink.Remote Promise.resolve(), - telemetryService: vscodeTelemetryService, + telemetryService: new EventLogger(extensionCoreAPI), sideloadedExtensionURL: new BehaviorSubject(null), clientApplication: 'other', // TODO add 'vscode-extension' to `clientApplication`, getScriptURLForExtension: () => undefined, - // TODO showMessage + forceUpdateTooltip: () => TooltipController.forceUpdate(), // TODO showInputBox + // TODO showMessage } return context @@ -64,5 +74,20 @@ export interface WebviewPageProps { extensionCoreAPI: Comlink.Remote platformContext: VSCodePlatformContext theme: 'theme-dark' | 'theme-light' + authenticatedUser: AuthenticatedUser | null + settingsCascade: SettingsCascadeOrError instanceURL: string } + +// Webview page context. Used to pass to aliased components. +export const WebviewPageContext = createContext(undefined) + +export function useWebviewPageContext(): WebviewPageProps { + const context = useContext(WebviewPageContext) + + if (context === undefined) { + throw new Error('useWebviewPageContext must be used within a WebviewPageContextProvider') + } + + return context +} diff --git a/client/vscode/src/webview/platform/polyfills/index.ts b/client/vscode/src/webview/platform/polyfills/index.ts new file mode 100644 index 00000000000..69c60d36bb4 --- /dev/null +++ b/client/vscode/src/webview/platform/polyfills/index.ts @@ -0,0 +1,2 @@ +import '@sourcegraph/shared/src/polyfills/configure-core-js' +import './polyfill' diff --git a/client/vscode/src/webview/platform/polyfills/polyfill.ts b/client/vscode/src/webview/platform/polyfills/polyfill.ts new file mode 100644 index 00000000000..10db08d3330 --- /dev/null +++ b/client/vscode/src/webview/platform/polyfills/polyfill.ts @@ -0,0 +1,3 @@ +// Polyfill URL because Chrome and Firefox are not spec-compliant +// Hostnames of URIs with custom schemes (e.g. git) are not parsed out +import 'core-js/web/url' diff --git a/client/vscode/src/webview/platform/telemetryService.ts b/client/vscode/src/webview/platform/telemetryService.ts index 52974915f78..9537d0ad92d 100644 --- a/client/vscode/src/webview/platform/telemetryService.ts +++ b/client/vscode/src/webview/platform/telemetryService.ts @@ -1,10 +1,44 @@ +import { noop } from 'lodash' + import { TelemetryService } from '@sourcegraph/shared/src/telemetry/telemetryService' -export const vscodeTelemetryService: TelemetryService = { - // TODO: generate and store anon user id. - // store w Memento - - log: () => {}, - logViewEvent: () => {}, - logPageView: () => {}, +/** + * Props interface that can be extended by React components depending on the TelemetryService. + */ +export interface VsceTelemetryProps { + /** + * A telemetry service implementation to log events. + */ + telemetryService: VsceTelemetryService +} + +/** + * The telemetry service logs events. + */ +export interface VsceTelemetryService extends TelemetryService { + /** + * Log an event (by sending it to the server). + * Provide uri manually for some events (e.g ViewRepository, ViewBlob) as webview does not provide link location + */ + log(eventName: string, eventProperties?: any, publicArgument?: any, uri?: string): void + /** + * Log a pageview event (by sending it to the server). + */ + logViewEvent(eventName: string, eventProperties?: any, publicArgument?: any, uri?: string): void + /** + * Listen for event logs + * + * @returns a cleanup/removeEventListener function + */ + addEventLogListener?(callback: (eventName: string) => void): () => void +} + +/** + * A noop telemetry service. + * * Provide uri manually for some events + */ +export const NOOP_TELEMETRY_SERVICE: VsceTelemetryService = { + log: noop, + logViewEvent: noop, + logPageView: noop, } diff --git a/client/vscode/src/webview/search-panel/MatchHandlersContext.ts b/client/vscode/src/webview/search-panel/MatchHandlersContext.ts new file mode 100644 index 00000000000..c53d7c29a24 --- /dev/null +++ b/client/vscode/src/webview/search-panel/MatchHandlersContext.ts @@ -0,0 +1,123 @@ +import { createContext, useContext, useMemo } from 'react' + +import * as Comlink from 'comlink' +import { noop } from 'lodash' + +import { AuthenticatedUser } from '@sourcegraph/shared/src/auth' +import { RepositoryMatch } from '@sourcegraph/shared/src/search/stream' + +import { ExtensionCoreAPI } from '../../contract' +import { SourcegraphUri, SourcegraphUriOptionals } from '../../file-system/SourcegraphUri' +import { VSCodePlatformContext } from '../platform/context' + +type MinimalRepositoryMatch = Pick + +export interface MatchHandlersContext { + openRepo: (repository: MinimalRepositoryMatch) => void + openFile: (repositoryName: string, optional?: SourcegraphUriOptionals) => void + openSymbol: (symbolUrl: string) => void + openCommit: (commitUrl: string) => void + instanceURL: string +} +export const MatchHandlersContext = createContext({ + // Initialize in `SearchResultsView` (via `useMatchHandlers`) + openRepo: noop, + openFile: noop, + openSymbol: noop, + openCommit: noop, + instanceURL: '', +}) + +export function useMatchHandlers({ + platformContext, + extensionCoreAPI, + onRepoSelected, + authenticatedUser, + instanceURL, +}: { + platformContext: VSCodePlatformContext + extensionCoreAPI: Comlink.Remote + onRepoSelected: (repositoryMatch: MinimalRepositoryMatch) => void + authenticatedUser: AuthenticatedUser | null + instanceURL: string +}): Omit { + const host = useMemo(() => new URL(instanceURL).host, [instanceURL]) + + const matchHandlers: Omit = useMemo( + () => ({ + openRepo: repositoryMatch => { + // noop, implementation in SearchResultsView component since the repo page depends on its state. + // nvm, pass "onRepoSelected" prop + onRepoSelected(repositoryMatch) + + extensionCoreAPI + .openSourcegraphFile(`sourcegraph://${host}/${repositoryMatch.repository}`) + .catch(error => { + console.error('Error opening Sourcegraph repository', error) + }) + // Log View Event to sync search history + // URL must be provided to render Recent Searches on Web + platformContext.telemetryService.logViewEvent( + 'Repository', + null, + authenticatedUser !== null, + `https://${host}/${repositoryMatch.repository}` + ) + }, + openFile: (repositoryName, optionals) => { + // Create sourcegraph URI + const sourcegraphUri = SourcegraphUri.fromParts(host, repositoryName, optionals) + + const uri = sourcegraphUri.uri + sourcegraphUri.positionSuffix() + + // Log View Event to sync search history + platformContext.telemetryService.logViewEvent( + 'Blob', + null, + authenticatedUser !== null, + sourcegraphUri.uri.replace('sourcegraph://', 'https://') + ) + + extensionCoreAPI + .openSourcegraphFile(uri) + .catch(error => console.error('Error opening Sourcegraph file', error)) + }, + openSymbol: (symbolUrl: string) => { + const { path, position, revision, repositoryName, host: codeHost } = SourcegraphUri.parse( + `https:/${symbolUrl}`, + window.URL + ) + const sourcegraphUri = SourcegraphUri.fromParts(host, `${codeHost}/${repositoryName}`, { + revision, + path, + position: position + ? { + line: position.line - 1, // Convert to 1-based + character: position.character - 1, + } + : undefined, + }) + const uri = sourcegraphUri.uri + sourcegraphUri.positionSuffix() + + extensionCoreAPI.openSourcegraphFile(uri).catch(error => { + console.error('Error opening Sourcegraph file', error) + }) + }, + openCommit: commitUrl => { + const commitURL = new URL(commitUrl, instanceURL) + extensionCoreAPI.openLink(commitURL.href).catch(error => { + console.error('Error opening commit in browser', error) + }) + + // Roadmap: open diff in VS Code instead of Sourcegraph Web. + }, + }), + [extensionCoreAPI, platformContext, authenticatedUser, onRepoSelected, host, instanceURL] + ) + + return matchHandlers +} + +export function useOpenSearchResultsContext(): MatchHandlersContext { + return useContext(MatchHandlersContext) +} diff --git a/client/vscode/src/webview/search-panel/RepoView.module.scss b/client/vscode/src/webview/search-panel/RepoView.module.scss new file mode 100644 index 00000000000..14adda22377 --- /dev/null +++ b/client/vscode/src/webview/search-panel/RepoView.module.scss @@ -0,0 +1,51 @@ +.tree-entries-section { + // To avoid having empty columns (and thus the items appearing not flush with the left margin), + // the component only applies this class when there are >= 6 items. This number is chosen + // because it is greater than the maximum number of columns that will be shown and ensures that + // at least 1 column has more than 1 item. + // See also MIN_ENTRIES_FOR_COLUMN_LAYOUT. + &--columns { + column-gap: 1.5rem; + column-width: 13rem; + column-rule: 1px solid var(--border-color); + border-right: solid 1px var(--border-color); + + @media (--sm-breakpoint-up) { + column-count: 1; + } + @media (--md-breakpoint-up) { + column-count: 3; + } + @media (--md-breakpoint-down) { + column-count: 4; + } + } +} + +.tree-entry { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + margin-left: -0.25rem; + margin-right: -0.25rem; + padding: 0.125rem 0.25rem; + + break-inside: avoid-column; + + --focus-box-shadow: none; + + &:hover { + background-color: var(--color-bg-1); + } + + &--no-columns { + max-width: 18rem; + } +} + +.section { + width: 100%; + max-width: var(--media-xl); +} diff --git a/client/vscode/src/webview/search-panel/RepoView.tsx b/client/vscode/src/webview/search-panel/RepoView.tsx new file mode 100644 index 00000000000..0a17d2df14d --- /dev/null +++ b/client/vscode/src/webview/search-panel/RepoView.tsx @@ -0,0 +1,152 @@ +import React, { useMemo, useState } from 'react' + +import { VSCodeProgressRing } from '@vscode/webview-ui-toolkit/react' +import classNames from 'classnames' +import ArrowLeftIcon from 'mdi-react/ArrowLeftIcon' +import FileDocumentOutlineIcon from 'mdi-react/FileDocumentOutlineIcon' +import FolderOutlineIcon from 'mdi-react/FolderOutlineIcon' +import SourceRepositoryIcon from 'mdi-react/SourceRepositoryIcon' +import { catchError } from 'rxjs/operators' + +import { QueryState } from '@sourcegraph/search' +import { fetchTreeEntries } from '@sourcegraph/shared/src/backend/repo' +import { displayRepoName } from '@sourcegraph/shared/src/components/RepoFileLink' +import { RepositoryMatch } from '@sourcegraph/shared/src/search/stream' +import { PageHeader, useObservable } from '@sourcegraph/wildcard' + +import { WebviewPageProps } from '../platform/context' + +import styles from './RepoView.module.scss' + +interface RepoViewProps extends Pick { + onBackToSearchResults: () => void + // Debt: just use repository name and make GraphQL Repository query to get metadata. + // This will enable more info (like description) when navigating here from file matches. + repositoryMatch: Pick + setQueryState: (query: QueryState) => void +} + +export const RepoView: React.FunctionComponent = ({ + extensionCoreAPI, + platformContext, + repositoryMatch, + onBackToSearchResults, + instanceURL, + setQueryState, +}) => { + const [directoryStack, setDirectoryStack] = useState([]) + + // File tree results are memoized, so going back isn't expensive. + const treeEntries = useObservable( + useMemo( + () => + fetchTreeEntries({ + repoName: repositoryMatch.repository, + commitID: '', + revision: repositoryMatch.branches?.[0] ?? 'HEAD', + filePath: directoryStack.length > 0 ? directoryStack[directoryStack.length - 1] : '', + requestGraphQL: platformContext.requestGraphQL, + }).pipe( + catchError(error => { + console.error(error, { repositoryMatch }) + // TODO: remove and add error boundary in searchresultsview + return [] + }) + ), + [platformContext, repositoryMatch, directoryStack] + ) + ) + + const onPreviousDirectory = (): void => { + const newDirectoryStack = directoryStack.slice(0, -1) + setQueryState({ + query: `repo:^${repositoryMatch.repository}$ ${ + newDirectoryStack.length > 0 ? `file:^${newDirectoryStack[newDirectoryStack.length - 1]}` : '' + }`, + }) + setDirectoryStack(newDirectoryStack) + } + + const onSelect = (isDirectory: boolean, path: string, url: string): void => { + const host = new URL(instanceURL).host + if (isDirectory) { + setQueryState({ query: `repo:^${repositoryMatch.repository}$ file:^${path}` }) + setDirectoryStack([...directoryStack, path]) + } else { + extensionCoreAPI.openSourcegraphFile(`sourcegraph://${host}${url}`).catch(error => { + console.error('Error opening Sourcegraph file', error) + }) + } + } + + return ( +
+ + {directoryStack.length > 0 && ( + + )} + + {repositoryMatch.description &&

{repositoryMatch.description}

} +
+

Files and directories

+ {treeEntries === undefined ? ( + + ) : ( +
+ {treeEntries.entries.map(entry => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/client/vscode/src/webview/search-panel/SearchHomeView.tsx b/client/vscode/src/webview/search-panel/SearchHomeView.tsx new file mode 100644 index 00000000000..ae2ad6c51d6 --- /dev/null +++ b/client/vscode/src/webview/search-panel/SearchHomeView.tsx @@ -0,0 +1,209 @@ +import React, { useCallback, useMemo, useState } from 'react' + +import classNames from 'classnames' +import { Observable } from 'rxjs' +import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect' + +import { + SearchPatternType, + getUserSearchContextNamespaces, + fetchAutoDefinedSearchContexts, + QueryState, +} from '@sourcegraph/search' +import { SearchBox } from '@sourcegraph/search-ui' +import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { collectMetrics } from '@sourcegraph/shared/src/search/query/metrics' +import { appendContextFilter, sanitizeQueryForTelemetry } from '@sourcegraph/shared/src/search/query/transformer' +import { LATEST_VERSION, SearchMatch } from '@sourcegraph/shared/src/search/stream' +import { globbingEnabledFromSettings } from '@sourcegraph/shared/src/util/globbing' + +import { SearchHomeState } from '../../state' +import { WebviewPageProps } from '../platform/context' + +import { fetchSearchContexts } from './alias/fetchSearchContext' +import { BrandHeader } from './components/BrandHeader' +import { HomeFooter } from './components/HomeFooter' + +import styles from './index.module.scss' +export interface SearchHomeViewProps extends WebviewPageProps { + context: SearchHomeState['context'] +} + +export const SearchHomeView: React.FunctionComponent = ({ + extensionCoreAPI, + authenticatedUser, + platformContext, + settingsCascade, + theme, + context, + instanceURL, +}) => { + // Toggling case sensitivity or pattern type does NOT trigger a new search on home view. + const [caseSensitive, setCaseSensitivity] = useState(false) + const [patternType, setPatternType] = useState(SearchPatternType.literal) + + const [userQueryState, setUserQueryState] = useState({ + query: '', + }) + + const isSourcegraphDotCom = useMemo(() => { + const hostname = new URL(instanceURL).hostname + return hostname === 'sourcegraph.com' || hostname === 'www.sourcegraph.com' + }, [instanceURL]) + + const onSubmit = useCallback(() => { + extensionCoreAPI + .streamSearch(userQueryState.query, { + caseSensitive, + patternType, + version: LATEST_VERSION, + trace: undefined, + }) + .catch(error => { + // TODO surface error to users? Errors will typically be caught and + // surfaced throught streaming search reuls. + console.error(error) + }) + + extensionCoreAPI + .setSidebarQueryState({ + queryState: { query: userQueryState.query }, + searchCaseSensitivity: caseSensitive, + searchPatternType: patternType, + }) + .catch(error => { + // TODO surface error to users + console.error('Error updating sidebar query state from panel', error) + }) + + // Log Search History + const hostname = new URL(instanceURL).hostname + let queryString = `${userQueryState.query}${caseSensitive ? ' case:yes' : ''}` + if (context.selectedSearchContextSpec) { + queryString = appendContextFilter(queryString, context.selectedSearchContextSpec) + } + const metrics = queryString ? collectMetrics(queryString) : undefined + platformContext.telemetryService.log( + 'SearchResultsQueried', + { + code_search: { + query_data: { + query: metrics, + combined: queryString, + empty: !queryString, + }, + }, + }, + { + code_search: { + query_data: { + // 🚨 PRIVACY: never provide any private query data in the + // { code_search: query_data: query } property, + // which is also potentially exported in pings data. + query: metrics, + + // 🚨 PRIVACY: Only collect the full query string for unauthenticated users + // on Sourcegraph.com, and only after sanitizing to remove certain filters. + combined: + !authenticatedUser && isSourcegraphDotCom + ? sanitizeQueryForTelemetry(queryString) + : undefined, + empty: !queryString, + }, + }, + }, + `https://${hostname}/search?q=${encodeURIComponent(queryString)}&patternType=${patternType}` + ) + }, [ + extensionCoreAPI, + userQueryState.query, + caseSensitive, + patternType, + instanceURL, + context.selectedSearchContextSpec, + platformContext.telemetryService, + authenticatedUser, + isSourcegraphDotCom, + ]) + + // Update local query state on sidebar query state updates. + useDeepCompareEffectNoCheck(() => { + if (context.searchSidebarQueryState.proposedQueryState?.queryState) { + setUserQueryState(context.searchSidebarQueryState.proposedQueryState?.queryState) + } + }, [context.searchSidebarQueryState.proposedQueryState?.queryState]) + + const globbing = useMemo(() => globbingEnabledFromSettings(settingsCascade), [settingsCascade]) + + const setSelectedSearchContextSpec = useCallback( + (spec: string) => { + extensionCoreAPI.setSelectedSearchContextSpec(spec).catch(error => { + console.error('Error persisting search context spec.', error) + }) + }, + [extensionCoreAPI] + ) + + const fetchStreamSuggestions = useCallback( + (query): Observable => + wrapRemoteObservable(extensionCoreAPI.fetchStreamSuggestions(query, instanceURL)), + [extensionCoreAPI, instanceURL] + ) + + return ( +
+ + +
+ {/* eslint-disable-next-line react/forbid-elements */} +
{ + event.preventDefault() + onSubmit() + }} + > + + + + +
+
+ ) +} diff --git a/client/vscode/src/webview/search-panel/SearchResultsView.tsx b/client/vscode/src/webview/search-panel/SearchResultsView.tsx new file mode 100644 index 00000000000..a7294078b5a --- /dev/null +++ b/client/vscode/src/webview/search-panel/SearchResultsView.tsx @@ -0,0 +1,432 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import classNames from 'classnames' +import { Observable } from 'rxjs' +import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect' + +import { + SearchPatternType, + fetchAutoDefinedSearchContexts, + getUserSearchContextNamespaces, + QueryState, +} from '@sourcegraph/search' +import { IEditor, SearchBox, StreamingProgress, StreamingSearchResultsList } from '@sourcegraph/search-ui' +import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { fetchHighlightedFileLineRanges } from '@sourcegraph/shared/src/backend/file' +import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' +import { CtaAlert } from '@sourcegraph/shared/src/components/CtaAlert' +import { appendContextFilter, updateFilters } from '@sourcegraph/shared/src/search/query/transformer' +import { LATEST_VERSION, RepositoryMatch, SearchMatch } from '@sourcegraph/shared/src/search/stream' +import { globbingEnabledFromSettings } from '@sourcegraph/shared/src/util/globbing' +import { buildSearchURLQuery } from '@sourcegraph/shared/src/util/url' + +import { DISMISS_SEARCH_CTA_KEY } from '../../settings/LocalStorageService' +import { SearchResultsState } from '../../state' +import { WebviewPageProps } from '../platform/context' + +import { fetchSearchContexts } from './alias/fetchSearchContext' +import { setFocusSearchBox } from './api' +import { SearchBetaIcon } from './components/icons' +import { SavedSearchCreateForm } from './components/SavedSearchForm' +import { SearchResultsInfoBar } from './components/SearchResultsInfoBar' +import { MatchHandlersContext, useMatchHandlers } from './MatchHandlersContext' +import { RepoView } from './RepoView' + +import styles from './index.module.scss' + +export interface SearchResultsViewProps extends WebviewPageProps { + context: SearchResultsState['context'] +} + +export const SearchResultsView: React.FunctionComponent = ({ + extensionCoreAPI, + authenticatedUser, + platformContext, + settingsCascade, + theme, + context, + instanceURL, +}) => { + const [userQueryState, setUserQueryState] = useState(context.submittedSearchQueryState.queryState) + const [repoToShow, setRepoToShow] = useState | null>(null) + + // Check VS Code local storage to see if user has clicked dismiss button before + const [dismissSearchCta, setDismissSearchCta] = useState(false) + // Return empty string if not in vs code local storage or 'search' if it exists + const showCtaAlert = useMemo(() => extensionCoreAPI.getLocalStorageItem(DISMISS_SEARCH_CTA_KEY), [extensionCoreAPI]) + const onDismissCtaAlert = useCallback(async () => { + setDismissSearchCta(true) + await extensionCoreAPI.setLocalStorageItem(DISMISS_SEARCH_CTA_KEY, 'true') + }, [extensionCoreAPI]) + + useEffect(() => { + showCtaAlert + .then(result => { + setDismissSearchCta(result.length > 0) + }) + .catch(() => setDismissSearchCta(false)) + }, [showCtaAlert]) + + // Editor focus. + const editorReference = useRef() + const setEditor = useCallback((editor: IEditor) => { + editorReference.current = editor + setTimeout(() => editor.focus(), 0) + }, []) + + // TODO explain + useEffect(() => { + setFocusSearchBox(() => editorReference.current?.focus()) + + return () => { + setFocusSearchBox(null) + } + }, []) + + const onChange = useCallback( + (newState: QueryState) => { + setUserQueryState(newState) + + extensionCoreAPI + .setSidebarQueryState({ + queryState: newState, + searchCaseSensitivity: context.submittedSearchQueryState?.searchCaseSensitivity, + searchPatternType: context.submittedSearchQueryState?.searchPatternType, + }) + .catch(error => { + // TODO surface error to users + console.error('Error updating sidebar query state from panel', error) + }) + }, + [ + extensionCoreAPI, + context.submittedSearchQueryState.searchCaseSensitivity, + context.submittedSearchQueryState.searchPatternType, + ] + ) + + const [allExpanded, setAllExpanded] = useState(false) + const onExpandAllResultsToggle = useCallback(() => { + setAllExpanded(oldValue => !oldValue) + platformContext.telemetryService.log(allExpanded ? 'allResultsExpanded' : 'allResultsCollapsed') + }, [allExpanded, platformContext]) + + const [showSavedSearchForm, setShowSavedSearchForm] = useState(false) + + // Update local query state on sidebar query state updates. + useDeepCompareEffectNoCheck(() => { + if (context.searchSidebarQueryState.proposedQueryState?.queryState) { + setUserQueryState(context.searchSidebarQueryState.proposedQueryState?.queryState) + } + }, [context.searchSidebarQueryState.proposedQueryState?.queryState]) + + // Update local search query state on sidebar search submission. + useDeepCompareEffectNoCheck(() => { + setUserQueryState(context.submittedSearchQueryState.queryState) + // It's a whole new object on each state update, so we need + // to compare (alternatively, construct full query TODO) + + // Clear repo view + setRepoToShow(null) + }, [context.submittedSearchQueryState.queryState]) + + // Track sidebar + keyboard shortcut search submissions + useEffect(() => { + platformContext.telemetryService.log('IDESearchSubmitted') + }, [platformContext, context.submittedSearchQueryState.queryState.query]) + + const onSubmit = useCallback( + (options?: { caseSensitive?: boolean; patternType?: SearchPatternType; newQuery?: string }) => { + const previousSearchQueryState = context.submittedSearchQueryState + + const query = options?.newQuery ?? userQueryState.query + const caseSensitive = options?.caseSensitive ?? previousSearchQueryState.searchCaseSensitivity + const patternType = options?.patternType ?? previousSearchQueryState.searchPatternType + + extensionCoreAPI + .streamSearch(query, { + caseSensitive, + patternType, + version: LATEST_VERSION, + trace: undefined, + }) + .then(() => { + editorReference.current?.focus() + }) + .catch(error => { + // TODO surface error to users? Errors will typically be caught and + // surfaced throught streaming search reuls. + console.error(error) + }) + + extensionCoreAPI + .setSidebarQueryState({ + queryState: { query }, + searchCaseSensitivity: caseSensitive, + searchPatternType: patternType, + }) + .catch(error => { + // TODO surface error to users + console.error('Error updating sidebar query state from panel', error) + }) + + // Clear repo view + setRepoToShow(null) + }, + [userQueryState.query, context.submittedSearchQueryState, extensionCoreAPI] + ) + + // Submit new search on change + const setCaseSensitivity = useCallback( + (caseSensitive: boolean) => { + onSubmit({ caseSensitive }) + }, + [onSubmit] + ) + + // Submit new search on change + const setPatternType = useCallback( + (patternType: SearchPatternType) => { + console.log({ patternType }) + onSubmit({ patternType }) + }, + [onSubmit] + ) + + const fetchHighlightedFileLineRangesWithContext = useCallback( + (parameters: FetchFileParameters) => fetchHighlightedFileLineRanges({ ...parameters, platformContext }), + [platformContext] + ) + + const fetchStreamSuggestions = useCallback( + (query): Observable => + wrapRemoteObservable(extensionCoreAPI.fetchStreamSuggestions(query, instanceURL)), + [extensionCoreAPI, instanceURL] + ) + + const globbing = useMemo(() => globbingEnabledFromSettings(settingsCascade), [settingsCascade]) + + const setSelectedSearchContextSpec = useCallback( + (spec: string) => { + extensionCoreAPI + .setSelectedSearchContextSpec(spec) + .catch(error => { + console.error('Error persisting search context spec.', error) + }) + .finally(() => { + // Execute search with new context state + onSubmit() + }) + }, + [extensionCoreAPI, onSubmit] + ) + + const onSearchAgain = useCallback( + (additionalFilters: string[]) => { + platformContext.telemetryService.log('SearchSkippedResultsAgainClicked') + onSubmit({ + newQuery: applyAdditionalFilters(context.submittedSearchQueryState.queryState.query, additionalFilters), + }) + }, + [context.submittedSearchQueryState.queryState, platformContext, onSubmit] + ) + + const onShareResultsClick = useCallback((): void => { + const queryState = context.submittedSearchQueryState + + const path = `/search?${buildSearchURLQuery( + queryState.queryState.query, + queryState.searchPatternType, + queryState.searchCaseSensitivity, + context.selectedSearchContextSpec + )}&utm_campaign=vscode-extension&utm_medium=direct_traffic&utm_source=vscode-extension&utm_content=save-search` + extensionCoreAPI.copyLink(new URL(path, instanceURL).href).catch(error => { + console.error('Error copying search link to clipboard:', error) + }) + platformContext.telemetryService.log('VSCEShareLinkClick') + }, [context, instanceURL, extensionCoreAPI, platformContext]) + + const fullQuery = useMemo( + () => + appendContextFilter( + context.submittedSearchQueryState.queryState.query ?? '', + context.selectedSearchContextSpec + ), + [context] + ) + + const isSourcegraphDotCom = useMemo(() => { + const hostname = new URL(instanceURL).hostname + return hostname === 'sourcegraph.com' || hostname === 'www.sourcegraph.com' + }, [instanceURL]) + + const onSignUpClick = useCallback( + (event?: React.FormEvent): void => { + event?.preventDefault() + platformContext.telemetryService.log( + 'VSCECreateAccountBannerClick', + { campaign: 'Sign up link' }, + { campaign: 'Sign up link' } + ) + }, + [platformContext.telemetryService] + ) + + const matchHandlers = useMatchHandlers({ + platformContext, + extensionCoreAPI, + authenticatedUser, + onRepoSelected: setRepoToShow, + instanceURL, + }) + + const clearRepositoryToShow = (): void => setRepoToShow(null) + + return ( +
+ {/* eslint-disable-next-line react/forbid-elements */} +
{ + event.preventDefault() + onSubmit() + }} + > + + + + {!repoToShow ? ( +
+ {isSourcegraphDotCom && !authenticatedUser && !dismissSearchCta && ( + } + className={classNames('percy-display-none', styles.ctaContainer)} + onClose={onDismissCtaAlert} + /> + )} + + } + allExpanded={allExpanded} + onExpandAllResultsToggle={onExpandAllResultsToggle} + instanceURL={instanceURL} + fullQuery={fullQuery} + /> + {authenticatedUser && showSavedSearchForm && ( + setShowSavedSearchForm(false)} + platformContext={platformContext} + instanceURL={instanceURL} + /> + )} + + + +
+ ) : ( +
+ +
+ )} +
+ ) +} + +const applyAdditionalFilters = (query: string, additionalFilters: string[]): string => { + let newQuery = query + for (const filter of additionalFilters) { + const fieldValue = filter.split(':', 2) + newQuery = updateFilters(newQuery, fieldValue[0], fieldValue[1]) + } + return newQuery +} diff --git a/client/vscode/src/webview/search-panel/alias/FileMatchChildren.tsx b/client/vscode/src/webview/search-panel/alias/FileMatchChildren.tsx new file mode 100644 index 00000000000..7ff98e690cc --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/FileMatchChildren.tsx @@ -0,0 +1,355 @@ +import React, { MouseEvent, KeyboardEvent, useCallback, useMemo } from 'react' + +import classNames from 'classnames' +import * as H from 'history' +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' + +import { HoverMerged } from '@sourcegraph/client-api' +import { Hoverifier } from '@sourcegraph/codeintellify' +import { + appendLineRangeQueryParameter, + appendSubtreeQueryParameter, + isErrorLike, + toPositionOrRangeQueryParameter, +} from '@sourcegraph/common' +import { ActionItemAction } from '@sourcegraph/shared/src/actions/ActionItem' +import { CodeExcerpt, FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' +import styles from '@sourcegraph/shared/src/components/FileMatchChildren.module.scss' +import { LastSyncedIcon } from '@sourcegraph/shared/src/components/LastSyncedIcon' +import { MatchGroup } from '@sourcegraph/shared/src/components/ranking/PerFileResultRanking' +import { Controller as ExtensionsController } from '@sourcegraph/shared/src/extensions/controller' +import { HoverContext } from '@sourcegraph/shared/src/hover/HoverOverlay.types' +import { IHighlightLineRange } from '@sourcegraph/shared/src/schema' +import { ContentMatch, SymbolMatch, PathMatch, getFileMatchUrl } from '@sourcegraph/shared/src/search/stream' +import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' +import { SymbolIcon } from '@sourcegraph/shared/src/symbols/SymbolIcon' +import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' +import { useCodeIntelViewerUpdates } from '@sourcegraph/shared/src/util/useCodeIntelViewerUpdates' + +import { useOpenSearchResultsContext } from '../MatchHandlersContext' + +interface FileMatchProps extends SettingsCascadeProps, TelemetryProps { + location?: H.Location + result: ContentMatch | SymbolMatch | PathMatch + grouped: MatchGroup[] + /* Clicking on a match opens the link in a new tab */ + openInNewTab?: boolean + /* Called when the first result has fully loaded. */ + onFirstResultLoad?: () => void + fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable + extensionsController?: Pick + hoverifier?: Hoverifier +} + +/** + * This helper function determines whether a mouse/click event was triggered as + * a result of selecting text in search results. + * There are at least to ways to do this: + * + * - Tracking `mouseup`, `mousemove` and `mousedown` events. The occurrence of + * a `mousemove` event would indicate a text selection. However, users + * might slightly move the mouse while clicking, and solutions that would + * take this into account seem fragile. + * - (implemented here) Inspect the Selection object returned by + * `window.getSelection()`. + * + * CAVEAT: Chromium and Firefox (and maybe other browsers) behave + * differently when a search result is clicked *after* text selection was + * made: + * + * - Firefox will clear the selection before executing the click event + * handler, i.e. the search result will be opened. + * - Chrome will only clear the selection if the click happens *outside* + * of the selected text (in which case the search result will be + * opened). If the click happens inside the selected text the selection + * will be cleared only *after* executing the click event handler. + */ +function isTextSelectionEvent(event: MouseEvent): boolean { + const selection = window.getSelection() + + // Text selections are always ranges. Should the type not be set, verify + // that the selection is not empty. + if (selection && (selection.type === 'Range' || selection.toString() !== '')) { + // Firefox specific: Because our code excerpts are implemented as tables, + // CTRL+click would select the table cell. Since users don't know that we + // use tables, the most likely wanted to open the search results in a new + // tab instead though. + if ((event.ctrlKey || event.metaKey) && selection.anchorNode?.nodeName === 'TR') { + // Ugly side effect: We don't want the table cell to be highlighted. + // The focus style that Firefox uses doesn't seem to be affected by + // CSS so instead we clear the selection. + selection.empty() + return false + } + + return true + } + + return false +} + +/** + * A helper function to replicate browser behavior when clicking on links. + * A very common interaction is to open links in a new in the _background_ via + * CTRL/CMD + click or middle click. + * Unfortunately `window.open` doesn't give us much control over how the new + * window/tab should be opened, and the behavior is inconcistent between + * browsers. + * In order to replicate the standard behvior as much as possible this function + * dynamically creates an `` element and triggers a click event on it. + */ +function openLinkInNewTab( + url: string, + event: Pick, + button: 'primary' | 'middle' +): void { + const link = document.createElement('a') + link.href = url + link.style.display = 'none' + link.target = '_blank' + link.rel = 'noopener noreferrer' + const clickEvent = new window.MouseEvent('click', { + bubbles: false, + altKey: event.altKey, + shiftKey: event.shiftKey, + // Regarding middle click: Setting "button: 1:" doesn't seem to suffice: + // Firefox doesn't react to the event at all, Chromium opens the tab in + // the foreground. So in order to simulate a middle click, we set + // ctrlKey and metaKey to `true` instead. + ctrlKey: button === 'middle' ? true : event.ctrlKey, + metaKey: button === 'middle' ? true : event.metaKey, + view: window, + }) + + // It looks the link has to be part of the document, otherwise Firefox won't + // trigger the default behavior (it works without appending in Chromium). + document.body.append(link) + link.dispatchEvent(clickEvent) + link.remove() +} + +/** + * Since we are not using a real link anymore, we have to simulate opening + * the file in a new tab when the search result is clicked on with the + * middle mouse button. + * This handler is bound to the `mouseup` event because the `auxclick` + * (https://w3c.github.io/uievents/#event-type-auxclick) event is not + * support by all browsers yet (https://caniuse.com/?search=auxclick) + */ +function navigateToFileOnMiddleMouseButtonClick(event: MouseEvent): void { + const href = event.currentTarget.getAttribute('data-href') + if (href && event.button === 1) { + openLinkInNewTab(href, event, 'middle') + } +} + +export const FileMatchChildren: React.FunctionComponent = props => { + // If optimizeHighlighting is enabled, compile a list of the highlighted file ranges we want to + // fetch (instead of the entire file.) + const optimizeHighlighting = + props.settingsCascade.final && + !isErrorLike(props.settingsCascade.final) && + props.settingsCascade.final.experimentalFeatures && + props.settingsCascade.final.experimentalFeatures.enableFastResultLoading + + const { + result, + grouped, + fetchHighlightedFileLineRanges, + telemetryService, + onFirstResultLoad, + extensionsController, + } = props + + const { openFile, openSymbol } = useOpenSearchResultsContext() + + const fetchHighlightedFileRangeLines = React.useCallback( + (isFirst, startLine, endLine) => { + const startTime = Date.now() + return fetchHighlightedFileLineRanges( + { + repoName: result.repository, + commitID: result.commit || '', + filePath: result.path, + disableTimeout: false, + ranges: optimizeHighlighting + ? grouped.map( + (group): IHighlightLineRange => ({ + startLine: group.startLine, + endLine: group.endLine, + }) + ) + : [{ startLine: 0, endLine: 2147483647 }], // entire file, + }, + false + ).pipe( + map(lines => { + if (isFirst && onFirstResultLoad) { + onFirstResultLoad() + } + telemetryService.log( + 'search.latencies.frontend.code-load', + { durationMs: Date.now() - startTime }, + { durationMs: Date.now() - startTime } + ) + return optimizeHighlighting + ? lines[grouped.findIndex(group => group.startLine === startLine && group.endLine === endLine)] + : lines[0].slice(startLine, endLine) + }) + ) + }, + [result, fetchHighlightedFileLineRanges, grouped, optimizeHighlighting, telemetryService, onFirstResultLoad] + ) + + const createCodeExcerptLink = (group: MatchGroup): string => { + const positionOrRangeQueryParameter = toPositionOrRangeQueryParameter({ position: group.position }) + return appendLineRangeQueryParameter( + appendSubtreeQueryParameter(getFileMatchUrl(result)), + positionOrRangeQueryParameter + ) + } + + const codeIntelViewerUpdatesProps = useMemo( + () => + grouped && result.type === 'content' && extensionsController + ? { + extensionsController, + repositoryName: result.repository, + filePath: result.path, + revision: result.commit, + } + : undefined, + [extensionsController, result, grouped] + ) + const viewerUpdates = useCodeIntelViewerUpdates(codeIntelViewerUpdatesProps) + + /** + * This handler implements the logic to simulate the click/keyboard + * activation behavior of links, while also allowing the selection of text + * inside the element. + * Because a click event is dispatched in both cases (clicking the search + * result to open it as well as selecting text within it), we have to be + * able to distinguish between those two actions. + * If we detect a text selection action, we don't have to do anything. + * + * CAVEATS: + * - In Firefox, Shift+click will open the URL in a new tab instead of + * a window (unlike Chromium which seems to show the same behavior as with + * native links). + * - Firefox will insert \t\n in between table rows, causing the copied + * text to be different from what is in the file/search result. + */ + const navigateToFile = useCallback( + ( + event: KeyboardEvent | MouseEvent, + { line, character }: { line: number; character: number } + ): void => { + // Testing for text selection is only necessary for mouse/click + // events. Middle-click (event.button === 1) is already handled in the `onMouseUp` callback. + if ( + (event.type === 'click' && + !isTextSelectionEvent(event as MouseEvent) && + (event as MouseEvent).button !== 1) || + (event as KeyboardEvent).key === 'Enter' + ) { + const href = event.currentTarget.getAttribute('data-href') + if (!event.defaultPrevented && href) { + event.preventDefault() + + openFile(result.repository, { + path: result.path, + revision: result.commit, + position: { + line: line - 1, + character: character - 1, + }, + }) + } + } + }, + [openFile, result] + ) + + return ( +
+ {result.repoLastFetched && } + {/* Path */} + {result.type === 'path' && ( +
+ Path match +
+ )} + + {/* Symbols */} + {((result.type === 'symbol' && result.symbols) || []).map(symbol => ( + + ))} + + {/* Line matches */} + {grouped && ( +
+ {grouped.map((group, index) => ( +
+
+ navigateToFile(event, { + line: group.position.line, + character: group.position.character, + }) + } + onMouseUp={navigateToFileOnMiddleMouseButtonClick} + onKeyDown={event => + navigateToFile(event, { + line: group.position.line, + character: group.position.character, + }) + } + data-testid="file-match-children-item" + tabIndex={0} + role="link" + > + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/client/vscode/src/webview/search-panel/alias/Link.tsx b/client/vscode/src/webview/search-panel/alias/Link.tsx new file mode 100644 index 00000000000..1b1f49cfef1 --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/Link.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import classNames from 'classnames' +import * as H from 'history' +import isAbsoluteUrl from 'is-absolute-url' + +import styles from '@sourcegraph/wildcard/src/components/Link/AnchorLink/AnchorLink.module.scss' +import { useWildcardTheme } from '@sourcegraph/wildcard/src/hooks/useWildcardTheme' + +// This is based off the @sourcegraph/wildcard/Link component +// to handle links in VSCE that works differently than in our web app +// because in VSCE history does not work as it is in browser + +export interface LinkProps + extends Pick< + React.AnchorHTMLAttributes, + Exclude, 'href'> + > { + to: string | H.LocationDescriptor + ref?: React.Ref +} + +/** + * The component used to render a link. All shared code must use this component for links—not
, , etc. + * + * Different platforms (web app vs. browser extension) require the use of different link components: + * + * The web app uses , which uses react-router-dom's for relative URLs (for page + * navigation using the HTML history API) and for absolute URLs. The react-router-dom component only + * works inside a react-router context, so it wouldn't work in the browser extension. + * + * The browser extension uses for everything (because code hosts don't generally use react-router). A + * react-router-dom wouldn't work in the browser extension, because there is no . + * + * This variable must be set at initialization time by calling {@link setLinkComponent}. + * + * The `to` property holds the destination URL (do not use `href`). If is used, the `to` property value is + * given as the `href` property value on the element. + * + * @see setLinkComponent + */ +export let Link: React.FunctionComponent = ({ to, children, ...props }) => ( + + {children} + +) + +if (process.env.NODE_ENV !== 'production') { + // Fail with helpful message if setLinkComponent has not been called when the component is used. + Link = () => { + throw new Error('No Link component set. You must call setLinkComponent to set the Link component to use.') + } +} + +/** + * Sets (globally) the component to use for links. This must be set at initialization time. + * + * @see Link + * @see AnchorLink + */ +export function setLinkComponent(component: typeof Link): void { + Link = component +} + +export type AnchorLinkProps = LinkProps & { + as?: LinkComponent +} + +export type LinkComponent = React.FunctionComponent + +export const AnchorLink: React.FunctionComponent = React.forwardRef( + ({ to, as: Component, children, className, ...rest }: AnchorLinkProps, reference) => { + const { isBranded } = useWildcardTheme() + + const commonProps = { + ref: reference, + className: classNames(isBranded && styles.anchorLink, className), + } + + if (!Component) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) + } +) + +/** + * Uses react-router-dom's for relative URLs, for absolute URLs. This is useful because passing an + * absolute URL to will create an (almost certainly invalid) URL where the absolute URL is resolved to the + * current URL, such as https://example.com/a/b/https://example.com/c/d. + */ +export const RouterLink: React.FunctionComponent = React.forwardRef( + ({ to, children, ...rest }: AnchorLinkProps, reference) => ( + + {children} + + ) +) + +/** + * Check if link is valid + * Set invalid links to '#' because VS Code Web opens invalid links in new tabs + * Invalid links includes links that start with 'sourcegraph://' + */ +function checkLink(uri: string): string { + // Private instance user are required to provide access token + // This is for users who has not provide an access token and is using dotcom by default + if ( + uri.startsWith('/sign-up') || + uri.startsWith('/contexts') || + uri.startsWith('/code_search/reference/queries') || + uri.startsWith('/help') + ) { + return `https://sourcegraph.com${uri}?editor=vscode&utm_medium=VSCODE&utm_source=sidebar&utm_campaign=vsce-sign-up&utm_content=sign-up` + } + if (uri.startsWith('https://')) { + return uri + } + return '#' +} diff --git a/client/vscode/src/webview/search-panel/alias/ModalVideo.module.scss b/client/vscode/src/webview/search-panel/alias/ModalVideo.module.scss new file mode 100644 index 00000000000..ba1f743466a --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/ModalVideo.module.scss @@ -0,0 +1,73 @@ +@import 'wildcard/src/global-styles/breakpoints'; + +.wrapper { + &:hover button { + text-decoration: underline; + } + + figure { + margin: 0; + } +} + +.thumbnail-button { + width: 100%; + position: relative; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background-color: transparent; + padding: 0; +} + +.thumbnail-image { + width: 100%; + // Note: This is to reduce cumulative layout shift + // It should be as close as possible to the source image aspect ratio + aspect-ratio: 176/115; +} + +.play-icon-wrapper { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.modal { + width: 70vw; + @media (--lg-breakpoint-down) { + width: 90vw; + } +} + +.modal-content { + display: flex; + align-items: stretch; + flex-direction: column; + height: 100%; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.iframe-video-wrapper { + position: relative; + // stylelint-disable-next-line declaration-property-unit-allowed-list + padding-top: 56.25%; +} + +.iframe-video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/client/vscode/src/webview/search-panel/alias/ModalVideo.tsx b/client/vscode/src/webview/search-panel/alias/ModalVideo.tsx new file mode 100644 index 00000000000..6ee6e7622f3 --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/ModalVideo.tsx @@ -0,0 +1,113 @@ +import React from 'react' + +import classNames from 'classnames' + +import { useWebviewPageContext } from '../../platform/context' + +import styles from './ModalVideo.module.scss' + +// We can't play video in VS Code Desktop: https://stackoverflow.com/a/57512681 +// Open video in YouTube instead. + +interface ModalVideoProps { + id: string + title: string + src: string + thumbnail?: { src: string; alt: string } + onToggle?: (isOpen: boolean) => void + showCaption?: boolean + className?: string + assetsRoot?: string +} + +export const ModalVideo: React.FunctionComponent = ({ + title, + src, + thumbnail, + onToggle, + showCaption = false, + className, + assetsRoot = '', +}) => { + const { extensionCoreAPI } = useWebviewPageContext() + + const onClick = (): void => { + onToggle?.(false) + extensionCoreAPI.openLink(src).catch(error => { + console.error(`Error opening video at ${src}`, error) + }) + } + + let thumbnailElement = thumbnail ? ( + + ) : null + + if (showCaption) { + thumbnailElement = ( +
+ {thumbnailElement} +
+ +
+
+ ) + } + + return
{thumbnailElement}
+} + +const PlayIcon = React.memo(() => ( + + + + + + + + + + + + + + + + + + + + + +)) diff --git a/client/vscode/src/webview/search-panel/alias/README.md b/client/vscode/src/webview/search-panel/alias/README.md new file mode 100644 index 00000000000..1c30b92b256 --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/README.md @@ -0,0 +1,19 @@ +# Aliased files + +Lots of shared client code used in the initial implementation of the VS Code +To expedite the implementation and review processes, we decided to "fork" +code that would require significant refactoring to work in the VS Code +extension context. + +Our plan is remove the need for these aliased/forked files (method TBD). +Resolving this divergence will reduce the risk of regressions introduced +by changes in the way that base code interacts with forked code. + +- Search result handling + - We can't use relative links like in the web app, for most search result types (and eventually all), + we need click handlers that call VS Code extension APIs. + - Forked components: `FileMatchChildren`, `SearchResult`, `RepoFileLink` + - What's changed: + - Create a React context to wrap around `StreamingSearchResultsList` (shared) to pass + VS Code extension APIs to forked search result components. + - Change links to buttons, call VS Code file handlers from context on click. diff --git a/client/vscode/src/webview/search-panel/alias/RepoFileLink.tsx b/client/vscode/src/webview/search-panel/alias/RepoFileLink.tsx new file mode 100644 index 00000000000..5341cea2757 --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/RepoFileLink.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' + +import { parseRepoRevision } from '@sourcegraph/shared/src/util/url' +import { useIsTruncated } from '@sourcegraph/wildcard' + +import { useOpenSearchResultsContext } from '../MatchHandlersContext' + +/** + * Returns the friendly display form of the repository name (e.g., removing "github.com/"). + */ +export function displayRepoName(repoName: string): string { + let parts = repoName.split('/') + if (parts.length >= 3 && parts[0].includes('.')) { + parts = parts.slice(1) // remove hostname from repo name (reduce visual noise) + } + return parts.join('/') +} + +/** + * Splits the repository name into the dir and base components. + */ +export function splitPath(path: string): [string, string] { + const components = path.split('/') + return [components.slice(0, -1).join('/'), components[components.length - 1]] +} + +interface Props { + repoName: string + repoURL: string + filePath: string + fileURL: string + repoDisplayName?: string + className?: string +} + +/** + * A link to a repository or a file within a repository, formatted as "repo" or "repo > file". Unless you + * absolutely need breadcrumb-like behavior, use this instead of FilePathBreadcrumb. + */ +export const RepoFileLink: React.FunctionComponent = ({ + repoDisplayName, + repoName, + repoURL, + filePath, + className, +}) => { + /** + * Use the custom hook useIsTruncated to check if overflow: ellipsis is activated for the element + * We want to do it on mouse enter as browser window size might change after the element has been + * loaded initially + */ + const [titleReference, truncated, checkTruncation] = useIsTruncated() + + const [fileBase, fileName] = splitPath(filePath) + + const { openRepo, openFile } = useOpenSearchResultsContext() + + const getRepoAndRevision = (): { repoName: string; revision: string | undefined } => { + // Example: `/github.com/sourcegraph/sourcegraph@main` + const indexOfSeparator = repoURL.indexOf('/-/') + let repoRevision: string + if (indexOfSeparator === -1) { + repoRevision = repoURL // the whole string + } else { + repoRevision = repoURL.slice(0, indexOfSeparator) // the whole string leading up to the separator (allows revision to be multiple path parts) + } + let { repoName, revision } = parseRepoRevision(repoRevision) + // Remove leading slash + if (repoName.startsWith('/')) { + repoName = repoName.slice(1) + } + return { repoName, revision } + } + + const onRepoClick = (): void => { + const { repoName, revision } = getRepoAndRevision() + + openRepo({ + repository: repoName, + branches: revision ? [revision] : undefined, + }) + } + + const onFileClick = (): void => { + const { repoName, revision } = getRepoAndRevision() + openFile(repoName, { path: filePath, revision }) + } + + return ( +
+ {' '} + ›{' '} + +
+ ) +} diff --git a/client/vscode/src/webview/search-panel/alias/SearchResult.tsx b/client/vscode/src/webview/search-panel/alias/SearchResult.tsx new file mode 100644 index 00000000000..5c5050b24a4 --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/SearchResult.tsx @@ -0,0 +1,208 @@ +import React from 'react' + +import classNames from 'classnames' +import ArchiveIcon from 'mdi-react/ArchiveIcon' +import LockIcon from 'mdi-react/LockIcon' +import SourceForkIcon from 'mdi-react/SourceForkIcon' + +import { CommitSearchResultMatch } from '@sourcegraph/search-ui/src/components/CommitSearchResultMatch' +import styles from '@sourcegraph/search-ui/src/components/SearchResult.module.scss' +import { LastSyncedIcon } from '@sourcegraph/shared/src/components/LastSyncedIcon' +import { displayRepoName } from '@sourcegraph/shared/src/components/RepoFileLink' +import { RepoIcon } from '@sourcegraph/shared/src/components/RepoIcon' +import { ResultContainer } from '@sourcegraph/shared/src/components/ResultContainer' +import { SearchResultStar } from '@sourcegraph/shared/src/components/SearchResultStar' +import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context' +import { + CommitMatch, + getCommitMatchUrl, + getRepoMatchLabel, + RepositoryMatch, +} from '@sourcegraph/shared/src/search/stream' +import { formatRepositoryStarCount } from '@sourcegraph/shared/src/util/stars' +import { Timestamp } from '@sourcegraph/web/src/components/time/Timestamp' +import { Icon } from '@sourcegraph/wildcard' + +import { useOpenSearchResultsContext } from '../MatchHandlersContext' + +interface Props extends PlatformContextProps<'requestGraphQL'> { + result: CommitMatch | RepositoryMatch + repoName: string + icon: React.ComponentType<{ className?: string }> + onSelect: () => void + openInNewTab?: boolean + containerClassName?: string +} + +export const SearchResult: React.FunctionComponent = ({ + result, + icon, + repoName, + platformContext, + onSelect, + openInNewTab, + containerClassName, +}) => { + const { openRepo, openCommit, instanceURL } = useOpenSearchResultsContext() + + const renderTitle = (): JSX.Element => { + const formattedRepositoryStarCount = formatRepositoryStarCount(result.repoStars) + return ( +
+ + + {result.type === 'commit' && ( + <> + + {' › '} + + {': '} + + + )} + {result.type === 'repo' && ( + + )} + + + {result.type === 'commit' && ( + + )} + {result.type === 'commit' && formattedRepositoryStarCount &&
} + {formattedRepositoryStarCount && ( + <> + + {formattedRepositoryStarCount} + + )} +
+ ) + } + + const renderBody = (): JSX.Element => { + if (result.type === 'repo') { + return ( +
+
+ {result.repoLastFetched && } +
+
+ Repository match +
+ {result.fork && ( + <> +
+
+ +
+
+ Fork +
+ + )} + {result.archived && ( + <> +
+
+ +
+
+ Archived +
+ + )} + {result.private && ( + <> +
+
+ +
+
+ Private +
+ + )} +
+ {result.description && ( + <> +
+
+ + {result.description} + +
+ + )} +
+
+ ) + } + + return ( + + ) + } + + return ( + + ) +} diff --git a/client/vscode/src/webview/search-panel/alias/fetchSearchContext.ts b/client/vscode/src/webview/search-panel/alias/fetchSearchContext.ts new file mode 100644 index 00000000000..c357814fecd --- /dev/null +++ b/client/vscode/src/webview/search-panel/alias/fetchSearchContext.ts @@ -0,0 +1,184 @@ +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' + +import { gql, dataOrThrowErrors } from '@sourcegraph/http-client' +import { PlatformContext } from '@sourcegraph/shared/src/platform/context' +import * as GQL from '@sourcegraph/shared/src/schema' +export type Exact = { [K in keyof T]: T[K] } +export type Maybe = T | null + +/** All built-in and custom scalars, mapped to their actual values */ +export interface Scalars { + ID: string + String: string + Boolean: boolean + Int: number + Float: number + /** A quadruple that represents all possible states of the published value: true, false, 'draft', or null. */ + PublishedValue: boolean | 'draft' + /** A valid JSON value. */ + JSONValue: unknown + /** A string that contains valid JSON, with additional support for //-style comments and trailing commas. */ + JSONCString: string + /** A Git object ID (SHA-1 hash, 40 hexadecimal characters). */ + GitObjectID: string + /** An arbitrarily large integer encoded as a decimal string. */ + BigInt: string + /** + * An RFC 3339-encoded UTC date string, such as 1973-11-29T21:33:09Z. This value can be parsed into a + * JavaScript Date using Date.parse. To produce this value from a JavaScript Date instance, use + * Date#toISOString. + */ + DateTime: string +} + +/** + * This is a copy of the fetchSearchContexts function from @sourcegraph/search created for VSCE use + * We have removed `query` from SearchContext to support instances below v3.36.0 + * as query does not exist in Search Context type in older instances + * More context in https://github.com/sourcegraph/sourcegraph/issues/31022 + **/ + +const searchContextFragment = gql` + fragment SearchContextFields on SearchContext { + __typename + id + name + namespace { + __typename + id + namespaceName + } + spec + description + public + autoDefined + updatedAt + viewerCanManage + repositories { + __typename + repository { + name + } + revisions + } + } +` + +export function fetchSearchContexts({ + first, + namespaces, + query, + after, + orderBy, + descending, + platformContext, +}: { + first: number + query?: string + namespaces?: Maybe[] + after?: string + orderBy?: GQL.SearchContextsOrderBy + descending?: boolean + platformContext: Pick +}): Observable { + return platformContext + .requestGraphQL({ + request: gql` + query ListSearchContexts( + $first: Int! + $after: String + $query: String + $namespaces: [ID] + $orderBy: SearchContextsOrderBy + $descending: Boolean + ) { + searchContexts( + first: $first + after: $after + query: $query + namespaces: $namespaces + orderBy: $orderBy + descending: $descending + ) { + nodes { + ...SearchContextFields + } + pageInfo { + hasNextPage + endCursor + } + totalCount + } + } + ${searchContextFragment} + `, + variables: { + first, + after: after ?? null, + query: query ?? null, + namespaces: namespaces ?? [], + orderBy: orderBy ?? GQL.SearchContextsOrderBy.SEARCH_CONTEXT_SPEC, + descending: descending ?? false, + }, + mightContainPrivateInfo: true, + }) + .pipe( + map(dataOrThrowErrors), + map(data => data.searchContexts) + ) +} + +export interface SearchContextFields { + __typename: 'SearchContext' + id: string + name: string + spec: string + description: string + public: boolean + autoDefined: boolean + updatedAt: string + viewerCanManage: boolean + query: string + namespace: Maybe< + | { __typename: 'User'; id: string; namespaceName: string } + | { __typename: 'Org'; id: string; namespaceName: string } + > + repositories: { + __typename: 'SearchContextRepositoryRevisions' + revisions: string[] + repository: { __typename?: 'Repository'; name: string } + }[] +} + +export type AutoDefinedSearchContextsVariables = Exact<{ [key: string]: never }> + +export interface AutoDefinedSearchContextsResult { + __typename?: 'Query' + autoDefinedSearchContexts: ({ __typename?: 'SearchContext' } & SearchContextFields)[] +} + +export type ListSearchContextsVariables = Exact<{ + first: Scalars['Int'] + after: Maybe + query: Maybe + namespaces: Maybe[]> + orderBy: Maybe + descending: Maybe +}> + +export interface ListSearchContextsResult { + __typename?: 'Query' + searchContexts: { + __typename?: 'SearchContextConnection' + totalCount: number + nodes: ({ __typename?: 'SearchContext' } & SearchContextFields)[] + pageInfo: { __typename?: 'PageInfo'; hasNextPage: boolean; endCursor: Maybe } + } +} + +/** SearchContextsOrderBy enumerates the ways a search contexts list can be ordered. */ +export enum SearchContextsOrderBy { + SEARCH_CONTEXT_SPEC = 'SEARCH_CONTEXT_SPEC', + SEARCH_CONTEXT_UPDATED_AT = 'SEARCH_CONTEXT_UPDATED_AT', +} diff --git a/client/vscode/src/webview/search-panel/api.ts b/client/vscode/src/webview/search-panel/api.ts new file mode 100644 index 00000000000..a545ffa20cc --- /dev/null +++ b/client/vscode/src/webview/search-panel/api.ts @@ -0,0 +1,24 @@ +import { of } from 'rxjs' + +import { proxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common' + +import { SearchPanelAPI } from '../../contract' + +export const searchPanelAPI: SearchPanelAPI = { + ping: () => { + console.log('ping called') + return proxySubscribable(of('pong')) + }, + focusSearchBox: () => { + // Call dynamic `focusSearchBox`. + focusSearchBox() + }, +} +let focusSearchBox = (): void => { + // Initially a noop. Waiting for monaco init +} + +// TODO move to api.ts file +export const setFocusSearchBox = (replacementFocusSearchBox: (() => void) | null): void => { + focusSearchBox = replacementFocusSearchBox || (() => {}) +} diff --git a/client/vscode/src/webview/search-panel/components/BrandHeader.tsx b/client/vscode/src/webview/search-panel/components/BrandHeader.tsx new file mode 100644 index 00000000000..c977f90a3d3 --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/BrandHeader.tsx @@ -0,0 +1,22 @@ +import React from 'react' + +import classNames from 'classnames' + +import { WebviewPageProps } from '../../platform/context' + +import styles from '../index.module.scss' + +export const BrandHeader: React.FunctionComponent> = ({ theme }) => ( + <> + Sourcegraph logo +
+ Search your code and 2M+ open source repositories +
+ +) diff --git a/client/vscode/src/webview/search-panel/components/ButtonDropdownCta.module.scss b/client/vscode/src/webview/search-panel/components/ButtonDropdownCta.module.scss new file mode 100644 index 00000000000..b0bc5c44154 --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/ButtonDropdownCta.module.scss @@ -0,0 +1,28 @@ +.container { + width: 31rem; + padding: 0.75rem; +} + +.toggle { + border-radius: var(--border-radius) !important; + padding: 0.375rem 0.5rem; +} + +.title { + margin-bottom: 0.25rem; + font-size: 1rem; +} + +.copy-text { + font-size: 0.875rem; +} + +.icon { + // stylelint-disable-next-line declaration-property-unit-whitelist + padding: 15px; + color: var(--merged-3); + + svg { + margin-right: 0 !important; + } +} diff --git a/client/vscode/src/webview/search-panel/components/ButtonDropdownCta.tsx b/client/vscode/src/webview/search-panel/components/ButtonDropdownCta.tsx new file mode 100644 index 00000000000..da3b2badc60 --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/ButtonDropdownCta.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useEffect, useState } from 'react' + +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' +import classNames from 'classnames' +import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap' + +import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' + +import { WebviewPageProps } from '../../platform/context' + +import styles from './ButtonDropdownCta.module.scss' + +// Debt: this is a fork of the web . + +export interface ButtonDropdownCtaProps extends TelemetryProps, Pick { + button: JSX.Element + icon: JSX.Element + title: string + copyText: string + source: string + viewEventName: string + returnTo: string + onToggle?: () => void + className?: string + instanceURL?: string +} + +export const ButtonDropdownCta: React.FunctionComponent = ({ + button, + icon, + title, + copyText, + telemetryService, + source, + viewEventName, + returnTo, + onToggle, + className, + extensionCoreAPI, +}) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + + const toggleDropdownOpen = useCallback(() => { + setIsDropdownOpen(isOpen => !isOpen) + onToggle?.() + }, [onToggle]) + + // Whenever dropdown opens, log view event + useEffect(() => { + if (isDropdownOpen) { + telemetryService.log(viewEventName) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDropdownOpen]) + + // Use cloud url instead of instance url + // This will only display for non private uers + // Because private users are required use with token = signed up + const signUpURL = `https://sourcegraph.com/sign-up?src=${source}&returnTo=${encodeURIComponent( + returnTo + )}&utm_medium=VSCODE&utm_source=sidebar&utm_campaign=vsce-sign-up&utm_content=sign-up` + + const onClick = (): void => { + telemetryService.log(`VSCE${source}SignUpModalClick`) + extensionCoreAPI.openLink(signUpURL).catch(() => { + console.error('Error opening sign up link') + }) + } + + return ( + + + {button} + + +
+
+
{icon}
+
+
+
+ {title} +
+
{copyText}
+
+
+ + Sign up for Sourcegraph + +
+
+ ) +} diff --git a/client/vscode/src/webview/search-panel/components/HomeFooter.module.scss b/client/vscode/src/webview/search-panel/components/HomeFooter.module.scss new file mode 100644 index 00000000000..1f07641b96c --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/HomeFooter.module.scss @@ -0,0 +1,146 @@ +@import 'wildcard/src/global-styles/breakpoints'; + +.footer-container { + width: 100%; + margin-top: 3rem; + + @media (--xl-breakpoint-up) { + max-width: var(--max-homepage-container-width); + } +} + +.help-content { + display: flex; + justify-content: center; + margin-bottom: 3rem; + + @media (--xs-breakpoint-down) { + align-items: stretch; + flex-direction: column; + } +} + +.search-examples { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 1rem; + + @media (--md-breakpoint-down) { + grid-template-columns: 1fr 1fr 1fr; + } + + @media (--sm-breakpoint-down) { + grid-template-columns: 1fr 1fr; + } + + @media (--xs-breakpoint-down) { + grid-template-columns: 1fr; + } +} + +.search-example-card { + display: flex; + flex-direction: row; + align-items: stretch; + font-size: var(--vscode-editor-font-size); + text-decoration: none !important; + margin-bottom: 0.5rem; + background-color: var(--vscode-editorWidget-background); + + &:hover { + .search-example-icon { + color: var(--vscode-button-secondaryHoverBackground) !important; + background-color: var(--vscode-button-foreground) !important; + } + } +} + +.search-example-icon { + color: var(--primary); + padding: 0.5rem 0.75rem; + display: flex; + align-items: center; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + color: var(--vscode-button-foreground) !important; + + &:hover { + color: var(--vscode-button-secondaryHoverBackground) !important; + background-color: var(--vscode-button-foreground) !important; + } +} + +.search-example-query-wrapper { + padding: 0.5rem 0; + align-self: center; +} + +.search-example-query { + padding: 0 0.75rem; + border-left: 1px solid var(--border-color); + min-height: 1.5rem; + text-align: left; +} + +.search-examples-wrapper { + flex: 1; + margin-right: 1rem; + + @media (--xs-breakpoint-down) { + margin-right: 0; + } +} + +.search-examples-title-wrapper { + @media (--sm-breakpoint-down) { + flex-direction: column; + } +} + +.search-examples-title { + font-size: var(--vscode-font-size) !important; + + @media (--sm-breakpoint-down) { + margin-bottom: 0.5rem; + } +} + +.search-example-label { + font-size: var(--vscode-font-size) !important; + font-weight: 400; +} + +.search-examples-subtitle { + font-weight: 500; + color: var(--vscode-descriptionForeground) !important; + font-size: var(--vscode-font-size) !important; + + @media (--sm-breakpoint-down) { + margin-bottom: 0.5rem; + } +} + +.title { + font-size: var(--vscode-font-size); + font-weight: 700; + text-transform: uppercase; +} + +.thumbnailWrapper { + padding: 0; + + @media (--xs-breakpoint-down) { + margin-top: 1.5rem; + text-align: center; + } +} + +.thumbnail { + &:hover { + opacity: 0.8; + } + + img { + width: 9rem !important; + } +} diff --git a/client/vscode/src/webview/search-panel/components/HomeFooter.tsx b/client/vscode/src/webview/search-panel/components/HomeFooter.tsx new file mode 100644 index 00000000000..cff812fb069 --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/HomeFooter.tsx @@ -0,0 +1,114 @@ +import React, { useCallback } from 'react' + +import classNames from 'classnames' + +import { QueryState } from '@sourcegraph/search' +import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui' +import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' +import { ThemeProps } from '@sourcegraph/shared/src/theme' + +import { ModalVideo } from '../alias/ModalVideo' + +import { SearchExample, exampleQueries } from './SearchExamples' + +import styles from './HomeFooter.module.scss' + +export interface HomeFooterProps extends TelemetryProps, ThemeProps { + setQuery: (newState: QueryState) => void +} +interface SearchExamplesProps extends TelemetryProps { + title: string + subtitle: string + examples: SearchExample[] + icon: JSX.Element + setQuery: (newState: QueryState) => void +} + +const SearchExamples: React.FunctionComponent = ({ + title, + subtitle, + examples, + icon, + telemetryService, + setQuery, +}) => { + const searchExampleClicked = useCallback( + (trackEventName: string, fullQuery: string) => (): void => { + setQuery({ query: fullQuery }) + telemetryService.log(trackEventName) + }, + [setQuery, telemetryService] + ) + return ( +
+
+
{title}
+
{subtitle}
+
+
+ {examples.map(example => ( +
+ +

{example.label}

+
+ ))} +
+
+ ) +} + +export const HomeFooter: React.FunctionComponent = props => ( + <> +
+
+ } + {...props} + /> +
+
Watch and learn
+
+ {/* TODO: UPLOAD PREVIEW IMAGE TO SG TO USE SG AS ACCESSROOT */} + props.telemetryService.log('VSCEHomeWatch&Lean')} + // assetsRoot="https://sourcegraph.com/.assets/" + assetsRoot="https://i.ibb.co/" + /> +
+
+
+
+ +) + +const MagnifyingGlassSearchIcon = React.memo(() => ( + + + +)) diff --git a/client/vscode/src/webview/search-panel/components/SavedSearchForm.module.scss b/client/vscode/src/webview/search-panel/components/SavedSearchForm.module.scss new file mode 100644 index 00000000000..ed1fa7e130c --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/SavedSearchForm.module.scss @@ -0,0 +1,20 @@ +.container { + background-color: var(--vscode-editorWidget-background) !important; + padding: 0.5rem; +} + +.checkbox { + margin-right: 0.25rem; +} + +.label { + font-weight: bold; +} + +// Hide icon in alert +.code-monitoring-alert { + &::before, + &::after { + display: none; + } +} diff --git a/client/vscode/src/webview/search-panel/components/SavedSearchForm.tsx b/client/vscode/src/webview/search-panel/components/SavedSearchForm.tsx new file mode 100644 index 00000000000..fc8c67e2838 --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/SavedSearchForm.tsx @@ -0,0 +1,273 @@ +import React, { useMemo, useState } from 'react' + +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' +import classNames from 'classnames' +import CloseIcon from 'mdi-react/CloseIcon' +import { map } from 'rxjs/operators' +import { Omit } from 'utility-types' + +import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts' +import { Form } from '@sourcegraph/branded/src/components/Form' +import { dataOrThrowErrors, gql } from '@sourcegraph/http-client' +import { AuthenticatedUser } from '@sourcegraph/shared/src/auth' +import { Container, Icon, PageHeader } from '@sourcegraph/wildcard' + +import { CreateSavedSearchResult, CreateSavedSearchVariables, SavedSearchFields } from '../../../graphql-operations' +import { WebviewPageProps } from '../../platform/context' + +import styles from './SavedSearchForm.module.scss' + +// Debt: this is a fork of the web . + +export interface SavedSearchFormProps extends Pick { + authenticatedUser: AuthenticatedUser | null + defaultValues?: Partial + title?: string + submitLabel: string + onSubmit: (fields: Omit) => void + loading: boolean + error?: any + fullQuery: string + onComplete: () => void +} + +export interface SavedSearchCreateFormProps + extends Omit, + Pick { + authenticatedUser: AuthenticatedUser +} + +const savedSearchFragment = gql` + fragment SavedSearchFields on SavedSearch { + id + description + notify + notifySlack + query + namespace { + __typename + id + namespaceName + } + slackWebhookURL + } +` + +const createSavedSearchQuery = gql` + mutation CreateSavedSearch( + $description: String! + $query: String! + $notifyOwner: Boolean! + $notifySlack: Boolean! + $userID: ID + $orgID: ID + ) { + createSavedSearch( + description: $description + query: $query + notifyOwner: $notifyOwner + notifySlack: $notifySlack + userID: $userID + orgID: $orgID + ) { + ...SavedSearchFields + } + } + ${savedSearchFragment} +` + +export const SavedSearchCreateForm: React.FunctionComponent = props => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState() + const onSubmit: SavedSearchFormProps['onSubmit'] = fields => { + if (!loading) { + setLoading(true) + props.platformContext.telemetryService.log('VSCESaveSearchSubmited') + props.platformContext + .requestGraphQL({ + request: createSavedSearchQuery, + variables: { + ...fields, + notifyOwner: fields.notify, + orgID: null, + userID: props.authenticatedUser.id, + }, + mightContainPrivateInfo: true, + }) + .pipe(map(dataOrThrowErrors)) + .toPromise() + .then(() => { + // Don't need to set loading to false, this form will be closed. + props.onComplete() + }) + .catch(error => { + setLoading(false) + setError(error) + }) + } + } + + const defaultValues: Partial = { + id: '', + description: '', + query: props.fullQuery, + notify: false, + notifySlack: false, + slackWebhookURL: null, + } + + return ( + + ) +} + +const SavedSearchForm: React.FunctionComponent = props => { + const [values, setValues] = useState>(() => ({ + description: props.defaultValues?.description || '', + query: props.defaultValues?.query || '', + notify: props.defaultValues?.notify || false, + notifySlack: props.defaultValues?.notifySlack || false, + slackWebhookURL: props.defaultValues?.slackWebhookURL || '', + })) + + /** + * Returns an input change handler that updates the SavedQueryFields in the component's state + * + * @param key The key of saved query fields that a change of this input should update + */ + const createInputChangeHandler = ( + key: keyof SavedSearchFields + ): React.FormEventHandler => event => { + const { value, checked, type } = event.currentTarget + setValues(values => ({ + ...values, + [key]: type === 'checkbox' ? checked : value, + })) + } + + const handleSubmit = (event: React.FormEvent): void => { + event.preventDefault() + props.onSubmit(values) + } + + /** + * Tells if the query is unsupported for sending notifications. + */ + const isUnsupportedNotifyQuery = useMemo((): boolean => { + const notifying = values.notify || values.notifySlack + return notifying && !values.query.includes('type:diff') && !values.query.includes('type:commit') + }, [values]) + + const { query, description, notify, notifySlack, slackWebhookURL } = values + + return ( +
+ +
+ + +
+ + +
+
+ + +
+ + {props.defaultValues?.notify && ( +
+ {/* Label is for visual benefit, input has more specific label attached */} + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ +
+
+ )} + + {notifySlack && slackWebhookURL && ( +
+ + + + Slack webhooks are deprecated and will be removed in a future Sourcegraph version. + +
+ )} + {isUnsupportedNotifyQuery && ( +
+ Warning: non-commit searches do not currently support notifications. + Consider adding type:diff or type:commit to your query. +
+ )} + {notify && !isUnsupportedNotifyQuery && ( +
+ Warning: Sending emails is not currently configured on this Sourcegraph + server. {props.authenticatedUser && 'Contact your server admin to enable sending emails.'} +
+ )} + {props.error && !props.loading && } +
+ + {props.submitLabel} + +
+
+
+
+ ) +} diff --git a/client/vscode/src/webview/search-panel/components/SearchExamples.tsx b/client/vscode/src/webview/search-panel/components/SearchExamples.tsx new file mode 100644 index 00000000000..f8c3ac31126 --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/SearchExamples.tsx @@ -0,0 +1,28 @@ +export interface SearchExample { + label: string + trackEventName: string + queryPreview: string + fullQuery: string +} + +export const exampleQueries: SearchExample[] = [ + { + label: 'Search all of your repos, without escaping or regex', + trackEventName: 'VSCEHomeSearchExamplesClick', + queryPreview: 'repo:sourcegraph/.* Sprintf("%d -file:tests', + fullQuery: 'repo:sourcegraph/.* Sprintf("%d -file:tests case:yes', + }, + { + label: 'Search and review commits faster than git log and grep', + trackEventName: 'VSCEHomeSearchExamplesClick', + queryPreview: 'type:diff before:"last week" TODO', + fullQuery: + 'repo:^github.com/sourcegraph/sourcegraph$ type:diff after:"last week" select:commit.diff.added TODO', + }, + { + label: 'Quickly filter by language and other key attributes', + trackEventName: 'VSCEHomeSearchExamplesClick', + queryPreview: 'repo:sourcegraph lang:go or lang:Typescript', + fullQuery: 'repo:sourcegraph/* -f:tests (lang:TypeScript or lang:go) Config() case:yes', + }, +] diff --git a/client/vscode/src/webview/search-panel/components/SearchResultsInfoBar.module.scss b/client/vscode/src/webview/search-panel/components/SearchResultsInfoBar.module.scss new file mode 100644 index 00000000000..1d35ff2681e --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/SearchResultsInfoBar.module.scss @@ -0,0 +1,51 @@ +.search-results-info-bar { + display: flex; + flex-direction: column; + justify-content: space-between; + align-self: stretch; + + :global(.alert) { + margin-bottom: 0.5rem; + } + + :global(.mdi-icon) { + margin-right: 0.125rem; + } +} + +.nav-item:last-child { + margin-right: 0 !important; +} + +.row { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: flex-start; + gap: 0.5rem; + height: 100%; + flex: 1 0 auto; + text-align: left; +} + +.notice { + display: flex; + align-items: center; + margin-right: 1rem; + padding: 0.375rem 0; +} + +.divider { + height: 1rem; + border-right: 1px solid var(--border-color); + margin: 0 0.5rem 0 0; + + &:first-child { + display: none; + } +} + +.expander { + display: block; + flex-grow: 1; +} diff --git a/client/vscode/src/webview/search-panel/components/SearchResultsInfoBar.tsx b/client/vscode/src/webview/search-panel/components/SearchResultsInfoBar.tsx new file mode 100644 index 00000000000..9fe5b2f1664 --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/SearchResultsInfoBar.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useMemo } from 'react' + +import classNames from 'classnames' +import BookmarkOutlineIcon from 'mdi-react/BookmarkOutlineIcon' +import FormatQuoteOpenIcon from 'mdi-react/FormatQuoteOpenIcon' +import LinkIcon from 'mdi-react/LinkIcon' + +import { SearchPatternType } from '@sourcegraph/shared/src/schema' +import { FilterKind, findFilter } from '@sourcegraph/shared/src/search/query/query' +import { Icon } from '@sourcegraph/wildcard' + +import { WebviewPageProps } from '../../platform/context' + +import { ButtonDropdownCta, ButtonDropdownCtaProps } from './ButtonDropdownCta' +import { BookmarkRadialGradientIcon, CodeMonitoringLogo } from './icons' + +import styles from './SearchResultsInfoBar.module.scss' + +// Debt: this is a fork of the web . +export interface SearchResultsInfoBarProps + extends Pick { + stats: JSX.Element + + onShareResultsClick: () => void + setShowSavedSearchForm: (status: boolean) => void + showSavedSearchForm: boolean + fullQuery: string + patternType: SearchPatternType + + // Expand all feature + allExpanded: boolean + onExpandAllResultsToggle: () => void +} + +interface ExperimentalActionButtonProps extends ButtonDropdownCtaProps { + showExperimentalVersion: boolean + nonExperimentalLinkTo?: string + isNonExperimentalLinkDisabled?: boolean + onNonExperimentalLinkClick?: () => void + className?: string +} + +const ExperimentalActionButton: React.FunctionComponent = props => { + if (props.showExperimentalVersion) { + return + } + return ( + + ) +} + +/** + * A notice for when the user is searching literally and has quotes in their + * query, in which case it is possible that they think their query `"foobar"` + * will be searching literally for `foobar` (without quotes). This notice + * informs them that this may be the case to avoid confusion. + */ +const QuotesInterpretedLiterallyNotice: React.FunctionComponent = props => + props.patternType === SearchPatternType.literal && props.fullQuery && props.fullQuery.includes('"') ? ( + + + + Searching literally (including quotes) + + + ) : null + +export const SearchResultsInfoBar: React.FunctionComponent = props => { + const { + extensionCoreAPI, + platformContext, + authenticatedUser, + showSavedSearchForm, + setShowSavedSearchForm, + onShareResultsClick, + stats, + instanceURL, + fullQuery, + patternType, + } = props + + const showActionButtonExperimentalVersion = !authenticatedUser + + const onSaveSearchButtonClick = useCallback( + (event?: React.FormEvent): void => { + event?.preventDefault() + setShowSavedSearchForm(!showSavedSearchForm) + platformContext.telemetryService.log('VSCESaveSearchClick') + }, + [platformContext.telemetryService, setShowSavedSearchForm, showSavedSearchForm] + ) + + const onCreateCodeMonitorButtonClick = useCallback( + (event?: React.FormEvent): void => { + event?.preventDefault() + platformContext.telemetryService.log('VSCECreateCodeMonitorClick') + + const searchParameters = new URLSearchParams() + searchParameters.set('q', fullQuery) + searchParameters.set('trigger-query', `${fullQuery} patternType:${patternType}`) + const createMonitorURL = new URL(`/code-monitoring/new?${searchParameters.toString()}`, instanceURL) + extensionCoreAPI.openLink(createMonitorURL.href).catch(() => { + console.error('Error opening create code monitor link') + }) + }, + [platformContext.telemetryService, extensionCoreAPI, fullQuery, instanceURL, patternType] + ) + + const canCreateMonitorFromQuery = useMemo(() => { + if (!fullQuery) { + return false + } + const globalTypeFilterInQuery = findFilter(fullQuery, 'type', FilterKind.Global) + const globalTypeFilterValue = globalTypeFilterInQuery?.value ? globalTypeFilterInQuery.value.value : undefined + return globalTypeFilterValue === 'diff' || globalTypeFilterValue === 'commit' + }, [fullQuery]) + + const createCodeMonitorButton = useMemo(() => { + const searchParameters = new URLSearchParams() + searchParameters.set('q', fullQuery) + searchParameters.set('trigger-query', `${fullQuery} patternType:${patternType}`) + return ( +
  • + + + Monitor + + } + icon={} + title="Monitor code for changes" + copyText="Create a monitor and get notified when your code changes. Free for registered users." + source="CodeMonitor" + viewEventName="VSCECodeMonitorCTAShown" + returnTo={`/code-monitoring/new?${searchParameters.toString()}`} + telemetryService={platformContext.telemetryService} + isNonExperimentalLinkDisabled={!canCreateMonitorFromQuery} + instanceURL={instanceURL} + /> +
  • + ) + }, [ + fullQuery, + patternType, + extensionCoreAPI, + showActionButtonExperimentalVersion, + onCreateCodeMonitorButtonClick, + canCreateMonitorFromQuery, + platformContext.telemetryService, + instanceURL, + ]) + + const saveSearchButton = useMemo( + () => ( +
  • + + + Save search + + } + icon={} + title="Saved searches" + copyText="Save your searches and quickly run them again. Free for registered users." + source="SavedSearches" + viewEventName="VSCESaveSearchCTAShown" + returnTo="" + telemetryService={platformContext.telemetryService} + isNonExperimentalLinkDisabled={showActionButtonExperimentalVersion} + instanceURL={instanceURL} + onToggle={() => setShowSavedSearchForm(!showSavedSearchForm)} + /> +
  • + ), + [ + extensionCoreAPI, + showActionButtonExperimentalVersion, + onSaveSearchButtonClick, + platformContext.telemetryService, + instanceURL, + setShowSavedSearchForm, + showSavedSearchForm, + ] + ) + + const ShareLinkButton = useMemo( + () => ( +
  • + +
  • + ), + [onShareResultsClick] + ) + + return ( +
    +
    + {stats} + +
    +
      + {createCodeMonitorButton} + {saveSearchButton} + {ShareLinkButton} +
    +
    +
    + ) +} diff --git a/client/vscode/src/webview/search-panel/components/icons.tsx b/client/vscode/src/webview/search-panel/components/icons.tsx new file mode 100644 index 00000000000..da23f8d7b9f --- /dev/null +++ b/client/vscode/src/webview/search-panel/components/icons.tsx @@ -0,0 +1,99 @@ +import * as React from 'react' + +export const BookmarkRadialGradientIcon = React.memo(() => ( + + + + + + + + + + +)) + +export const SearchBetaIcon = React.memo(() => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + +)) + +export const CodeMonitoringLogo: React.FunctionComponent> = ( + props: React.SVGProps +) => ( + + + +) diff --git a/client/vscode/src/webview/search-panel/index.module.scss b/client/vscode/src/webview/search-panel/index.module.scss new file mode 100644 index 00000000000..44b2e1941b2 --- /dev/null +++ b/client/vscode/src/webview/search-panel/index.module.scss @@ -0,0 +1,91 @@ +@import 'wildcard/src/global-styles/breakpoints'; + +:global(body) { + padding: 0 1rem; +} + +.search-box { + background-color: var(--vscode-editorWidget-background); + + &:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + // Override for search context separator + --border-color-2: var(--vscode-textLink-foreground); + // For button hover background + --color-bg-2: var(--vscode-input-background); + + .btn:hover { + background-color: var(--vscode-input-background); + } +} + +.search-box-container { + @media (--xs-breakpoint-down) { + background-color: var(--vscode-editorWidget-background); + } +} + +.logo { + flex: 0 0 auto; + display: flex; + align-items: center; + width: 14.5rem; + margin-top: 7rem; + max-width: 90%; + + /* Reserve the image's height to avoid jitter before it loads. */ + min-height: 3.375rem; +} + +.logo-text { + margin-top: 0.5rem; + align-items: center; + font-style: italic; + font-weight: normal; + align-items: center; + color: var(--vscode-descriptionForeground) !important; + font-size: var(--vscode-font-size) !important; +} + +.feedback-button { + font-size: var(--vscode-font-size) !important; +} + +.home-search-box-container { + flex: 1 1 auto; + flex-grow: 0; + margin-top: 2rem; + margin-bottom: 4.5rem; + width: 100%; + max-width: var(--max-homepage-container-width); + + position: relative; +} + +// For fixed search box +.results-view { + &-layout { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + isolation: isolate; + } + + &-scroll-container { + padding: 0 1rem 0 0.5rem; + min-height: 0; + overflow: auto; + width: 100%; + height: 100%; + } +} + +.cta-container { + background-color: var(--vscode-editorWidget-background) !important; + font-size: var(--vscode-font-size) !important; +} diff --git a/client/vscode/src/webview/search-panel/index.tsx b/client/vscode/src/webview/search-panel/index.tsx index 54fc6dcf330..0984ddc183b 100644 --- a/client/vscode/src/webview/search-panel/index.tsx +++ b/client/vscode/src/webview/search-panel/index.tsx @@ -1,52 +1,131 @@ +import '../platform/polyfills' + import React, { useMemo } from 'react' +import { ShortcutProvider } from '@slimsag/react-shortcuts' +import { VSCodeProgressRing } from '@vscode/webview-ui-toolkit/react' import * as Comlink from 'comlink' import { render } from 'react-dom' -import { of } from 'rxjs' +import { MemoryRouter } from 'react-router' import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' -import { proxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common' -import { AnchorLink, setLinkComponent, useObservable } from '@sourcegraph/wildcard' +import { + AnchorLink, + setLinkComponent, + useObservable, + WildcardThemeContext, + // This is the root Tooltip usage + // eslint-disable-next-line no-restricted-imports + Tooltip, +} from '@sourcegraph/wildcard' -import { ExtensionCoreAPI, SearchPanelAPI } from '../../contract' +import { ExtensionCoreAPI } from '../../contract' import { createEndpointsForWebToNode } from '../comlink/webviewEndpoint' -import { adaptToEditorTheme } from '../theme' +import { createPlatformContext, WebviewPageContext, WebviewPageProps } from '../platform/context' +import { adaptMonacoThemeToEditorTheme } from '../theming/monacoTheme' +import { adaptSourcegraphThemeToEditorTheme } from '../theming/sourcegraphTheme' + +import { searchPanelAPI } from './api' +import { SearchHomeView } from './SearchHomeView' +import { SearchResultsView } from './SearchResultsView' + +import './index.module.scss' const vsCodeApi = window.acquireVsCodeApi() -const searchPanelAPI: SearchPanelAPI = { - ping: () => { - console.log('ping called') - return proxySubscribable(of('pong')) - }, -} - const { proxy, expose } = createEndpointsForWebToNode(vsCodeApi) Comlink.expose(searchPanelAPI, expose) export const extensionCoreAPI: Comlink.Remote = Comlink.wrap(proxy) -const themes = adaptToEditorTheme() +const themes = adaptSourcegraphThemeToEditorTheme() +adaptMonacoThemeToEditorTheme() extensionCoreAPI.panelInitialized(document.documentElement.dataset.panelId!).catch(() => { // noop (TODO?) }) -// TODO create platform context. +const platformContext = createPlatformContext(extensionCoreAPI) setLinkComponent(AnchorLink) const Main: React.FC = () => { - console.log('rendering webview', { themes }) - const state = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeState()), [])) + const authenticatedUser = useObservable( + useMemo(() => wrapRemoteObservable(extensionCoreAPI.getAuthenticatedUser()), []) + ) + + const instanceURL = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.getInstanceURL()), [])) + + const theme = useObservable(themes) + + const settingsCascade = useObservable( + useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeSourcegraphSettings()), []) + ) + // Do not block rendering on settings unless we observe UI jitter + + // TODO: If init is taking too long, show a message. + // Also check if anything has errored out. + + // If any of the remote values have yet to load. + const initialized = + state !== undefined && + authenticatedUser !== undefined && + instanceURL !== undefined && + theme !== undefined && + settingsCascade !== undefined + + if (!initialized) { + return + } + + const webviewPageProps: WebviewPageProps = { + extensionCoreAPI, + platformContext, + theme, + authenticatedUser, + settingsCascade, + instanceURL, + } + + if (state?.status === 'context-invalidated') { + // TODO context-invalidated state + return null + } + + // Render SearchHomeView until the user submits a search. + if (state.context.submittedSearchQueryState === null) { + return ( + + + + ) + } + return ( -
    -

    state: {state?.status}

    -
    + + + ) } -render(
    , document.querySelector('#root')) +render( + + + {/* Required for shared components that depend on `location`. */} + +
    + + + + , + document.querySelector('#root') +) diff --git a/client/vscode/src/webview/search-sidebar/AuthSidebarView.module.scss b/client/vscode/src/webview/search-sidebar/AuthSidebarView.module.scss deleted file mode 100644 index 6d72e095029..00000000000 --- a/client/vscode/src/webview/search-sidebar/AuthSidebarView.module.scss +++ /dev/null @@ -1,3 +0,0 @@ -.form-container { - margin-top: 2.5rem; -} diff --git a/client/vscode/src/webview/search-sidebar/AuthSidebarView.tsx b/client/vscode/src/webview/search-sidebar/AuthSidebarView.tsx deleted file mode 100644 index 1a1515d7809..00000000000 --- a/client/vscode/src/webview/search-sidebar/AuthSidebarView.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React, { useMemo, useState } from 'react' - -import { Form } from '@sourcegraph/branded/src/components/Form' -import { LoaderInput } from '@sourcegraph/branded/src/components/LoaderInput' -import { currentAuthStateQuery } from '@sourcegraph/shared/src/auth' -import { CurrentAuthStateResult, CurrentAuthStateVariables } from '@sourcegraph/shared/src/graphql-operations' -import { Alert, Button, ButtonProps } from '@sourcegraph/wildcard' - -import { WebviewPageProps } from '../platform/context' - -import styles from './AuthSidebarView.module.scss' - -/** - * Rendered by sidebar in search-home state when user doesn't have a valid access token. - */ -export const AuthSidebarView: React.FunctionComponent = ({ - instanceURL, - extensionCoreAPI, - platformContext, -}) => { - const [state, setState] = useState<'initial' | 'validating' | 'success' | 'failure'>('initial') - - const [hasAccount, setHasAccount] = useState(false) - - const signUpURL = useMemo(() => new URL('sign-up?editor=vscode', instanceURL).href, [instanceURL]) - const instanceHostname = useMemo(() => new URL(instanceURL).hostname, [instanceURL]) - - const ctaButtonProps: Partial = { - variant: 'primary', - className: 'font-weight-normal w-100 my-1 border-0', - } - const buttonLinkProps: Partial = { - variant: 'link', - size: 'sm', - display: 'block', - className: 'pl-0', - } - - const validateAccessToken: React.FormEventHandler = (event): void => { - event.preventDefault() - if (state !== 'validating') { - const newAccessToken = (event.currentTarget.elements.namedItem('token') as HTMLInputElement).value - - setState('validating') - const currentAuthStateResult = platformContext - .requestGraphQL({ - request: currentAuthStateQuery, - variables: {}, - mightContainPrivateInfo: true, - overrideAccessToken: newAccessToken, - }) - .toPromise() - - currentAuthStateResult - .then(({ data }) => { - if (data?.currentUser) { - setState('success') - return extensionCoreAPI.setAccessToken(newAccessToken) - } - setState('failure') - return - }) - // v2/debt: Disambiguate network vs auth errors like we do in the browser extension. - .catch(() => setState('failure')) - } - // If successful, update setting. This form will no longer be rendered - } - - const onSignUpClick = (): void => { - setHasAccount(true) - extensionCoreAPI - .openLink(signUpURL) - .then(() => {}) - .catch(() => {}) - - platformContext.telemetryService.log('VSCESidebarCreateAccount') - } - - const onLinkClick = (type: 'Sourcegraph' | 'Extension'): void => - platformContext.telemetryService.log(`VSCESidebarLearn${type}Click`) - - if (state === 'success') { - // This form should no longer be rendered as the extension context - // will be invalidated. We should show a notification that the accessToken - // has successfully been updated. - return null - } - - const renderCommon = (content: JSX.Element): JSX.Element => ( - - ) - - if (!hasAccount) { - return renderCommon( - <> -

    - Create an account to enhance search across your private repositories: search multiple repos & commit - history, monitor, save searches and more. -

    - - - - ) - } - - return renderCommon( - <> -

    Sign in by entering an access token created through your user settings on {instanceHostname}.

    -

    - See our user docs for a - video guide on how to create an access token. -

    - {state === 'failure' && ( - - Unable to verify your access token for {instanceHostname}. Please try again with a new access token. - - )} - - - - - - - ) -} diff --git a/client/vscode/src/webview/search-sidebar/api.ts b/client/vscode/src/webview/search-sidebar/api.ts deleted file mode 100644 index 2ca40279d0e..00000000000 --- a/client/vscode/src/webview/search-sidebar/api.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { of } from 'rxjs' - -import { proxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common' - -import { SearchSidebarAPI } from '../../contract' - -export function createSearchSidebarAPI(): SearchSidebarAPI { - return { - ping: () => { - console.log('ping called') - return proxySubscribable(of('pong')) - }, - addTextDocumentIfNotExists: () => { - console.log('addTextDocumentIfNotExists called') - }, - } -} diff --git a/client/vscode/src/webview/search-sidebar/extension-host/index.ts b/client/vscode/src/webview/search-sidebar/extension-host/index.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/client/vscode/src/webview/search-sidebar/index.tsx b/client/vscode/src/webview/search-sidebar/index.tsx deleted file mode 100644 index f98301c5586..00000000000 --- a/client/vscode/src/webview/search-sidebar/index.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useMemo } from 'react' - -import * as Comlink from 'comlink' -import { render } from 'react-dom' - -import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' -import { - AnchorLink, - LoadingSpinner, - setLinkComponent, - useObservable, - WildcardThemeContext, -} from '@sourcegraph/wildcard' - -import { ExtensionCoreAPI } from '../../contract' -import { createEndpointsForWebToNode } from '../comlink/webviewEndpoint' -import { createPlatformContext, WebviewPageProps } from '../platform/context' -import { adaptToEditorTheme } from '../theme' - -import { createSearchSidebarAPI } from './api' -import { AuthSidebarView } from './AuthSidebarView' -import { ContextInvalidatedSidebarView } from './ContextInvalidatedSidebarView' - -// TODO: load extension host - -const vsCodeApi = window.acquireVsCodeApi() - -const { proxy, expose } = createEndpointsForWebToNode(vsCodeApi) - -const searchSidebarAPI = createSearchSidebarAPI() -Comlink.expose(searchSidebarAPI, expose) -export const extensionCoreAPI: Comlink.Remote = Comlink.wrap(proxy) - -const platformContext = createPlatformContext(extensionCoreAPI) - -setLinkComponent(AnchorLink) - -const themes = adaptToEditorTheme() - -const Main: React.FC = () => { - const state = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeState()), [])) - - const authenticatedUser = useObservable( - useMemo(() => wrapRemoteObservable(extensionCoreAPI.getAuthenticatedUser()), []) - ) - - const instanceURL = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.getInstanceURL()), [])) - - const theme = useObservable(themes) - - // If any of the remote values have yet to load. - const initialized = - state !== undefined && authenticatedUser !== undefined && instanceURL !== undefined && theme !== undefined - - if (!initialized) { - return - } - - const webviewPageProps: WebviewPageProps = { - extensionCoreAPI, - platformContext, - theme, - instanceURL, - } - - if (state.status === 'context-invalidated') { - return - } - - // TODO: should we hide the access token form permanently if an unauthenticated user - // has performed a search before? Or just for this session? - if (state.status === 'search-home' && !authenticatedUser) { - return ( - - - - ) - } - - if (state.status === 'remote-browsing') { - // TODO files sidebar - } - - if (state.status === 'idle') { - // Search sidebar? - } - - return ( -
    -

    state: {state?.status}

    -
    - ) -} - -render(
    , document.querySelector('#root')) diff --git a/client/vscode/src/webview/sidebars/auth/AuthSidebarView.module.scss b/client/vscode/src/webview/sidebars/auth/AuthSidebarView.module.scss new file mode 100644 index 00000000000..c1268894df7 --- /dev/null +++ b/client/vscode/src/webview/sidebars/auth/AuthSidebarView.module.scss @@ -0,0 +1,68 @@ +.form-container { + margin-top: 2.5rem; +} + +.cta-container { + letter-spacing: 0.5px; + font-size: var(--vscode-editor-font-size) !important; + color: var(--vscode-foreground); + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.cta-paragraph { + margin-bottom: 1rem; + font-weight: 400; +} + +.cta-input { + font-size: var(--vscode-editor-font-size) !important; + + &:active { + border-color: var(--vscode-inputOption-activeBorder); + } + + &:focus { + border-color: var(--vscode-inputOption-activeBorder); + } +} + +.cta-title { + letter-spacing: 0.5px; + text-transform: uppercase; + font-weight: 600; + font-size: var(--vscode-editor-font-size) !important; + margin-top: 0; + margin-bottom: 0.25rem; + display: flex; + align-items: center; + text-align: left; + width: calc(100% + 0.5rem); // Take full width + account for negative margin + border: none; + padding-left: 0; +} + +.cta { + display: flex; + flex-direction: column; + align-items: left; + justify-content: center; + margin-bottom: 2.5rem; +} + +.cta-button { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: 0; + width: 100%; + font-size: var(--vscode-editor-font-size) !important; + + &:hover { + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-hoverBackground); + } +} + +.cta-button-wrapper-with-context-below { + margin-bottom: 0.5rem; +} diff --git a/client/vscode/src/webview/sidebars/auth/AuthSidebarView.tsx b/client/vscode/src/webview/sidebars/auth/AuthSidebarView.tsx new file mode 100644 index 00000000000..39b5b2888b4 --- /dev/null +++ b/client/vscode/src/webview/sidebars/auth/AuthSidebarView.tsx @@ -0,0 +1,254 @@ +import React, { useMemo, useState } from 'react' + +import classNames from 'classnames' + +import { Form } from '@sourcegraph/branded/src/components/Form' +import { LoaderInput } from '@sourcegraph/branded/src/components/LoaderInput' +import { currentAuthStateQuery } from '@sourcegraph/shared/src/auth' +import { CurrentAuthStateResult, CurrentAuthStateVariables } from '@sourcegraph/shared/src/graphql-operations' +import { Alert } from '@sourcegraph/wildcard' + +import { WebviewPageProps } from '../../platform/context' + +import styles from './AuthSidebarView.module.scss' + +const SIDEBAR_UTM_PARAMS = 'utm_medium=VSCODE&utm_source=sidebar&utm_campaign=vsce-sign-up&utm_content=sign-up' + +interface AuthSidebarViewProps + extends Pick {} + +interface AuthSidebarCtaProps extends Pick {} + +/** + * Rendered by sidebar in search-home state when user doesn't have a valid access token. + */ +export const AuthSidebarView: React.FunctionComponent = ({ + instanceURL, + extensionCoreAPI, + platformContext, + authenticatedUser, +}) => { + const [state, setState] = useState<'initial' | 'validating' | 'success' | 'failure'>('initial') + const [hasAccount, setHasAccount] = useState(!authenticatedUser) + const [usePrivateInstance, setUsePrivateInstance] = useState(false) + const signUpURL = `https://sourcegraph.com/sign-up?editor=vscode&${SIDEBAR_UTM_PARAMS}` + const instanceHostname = useMemo(() => new URL(instanceURL).hostname, [instanceURL]) + const [hostname, setHostname] = useState(instanceHostname) + + const validateAccessToken: React.FormEventHandler = (event): void => { + event.preventDefault() + if (state !== 'validating') { + const newAccessToken = (event.currentTarget.elements.namedItem('token') as HTMLInputElement).value + let authStateVariables = { + request: currentAuthStateQuery, + variables: {}, + mightContainPrivateInfo: true, + overrideAccessToken: newAccessToken, + } + let newInstanceUrl: string + if (usePrivateInstance) { + newInstanceUrl = (event.currentTarget.elements.namedItem('instance-url') as HTMLInputElement).value + setHostname(newInstanceUrl) + authStateVariables = { ...authStateVariables, ...{ overrideSourcegraphURL: newInstanceUrl } } + } + setState('validating') + const currentAuthStateResult = platformContext + .requestGraphQL(authStateVariables) + .toPromise() + + currentAuthStateResult + .then(async ({ data }) => { + if (data?.currentUser) { + setState('success') + if (newInstanceUrl) { + await extensionCoreAPI.setEndpointUri(newInstanceUrl) + } + return extensionCoreAPI.setAccessToken(newAccessToken) + } + setState('failure') + return + }) + // v2/debt: Disambiguate network vs auth errors like we do in the browser extension. + .catch(() => setState('failure')) + } + // If successful, update setting. This form will no longer be rendered + } + + const onSignUpClick = async (): Promise => { + setHasAccount(true) + platformContext.telemetryService.log('VSCESidebarCreateAccount') + await extensionCoreAPI.openLink(signUpURL) + } + + if (state === 'success') { + // This form should no longer be rendered as the extension context + // will be invalidated. We should show a notification that the accessToken + // has successfully been updated. + return null + } + + const renderCommon = (content: JSX.Element): JSX.Element => ( + <> +
    +
    + + {content} +
    +
    + + ) + + if (!hasAccount) { + return renderCommon( + <> +

    + Create an account to search across your private repositories and access advanced features: search + multiple repositories & commit history, monitor code changes, save searches, and more. +

    +

    + +

    + + + ) + } + + return renderCommon( + <> +

    + Sign in by entering an access token created through your user settings on {hostname}. +

    +

    + See our{' '} + platformContext.telemetryService.log('VSCESidebarCreateToken')} + > + user docs + {' '} + for a video guide on how to create an access token. +

    +

    + + + + +

    + {usePrivateInstance && ( +

    + + + + +

    + )} + + {state === 'failure' && ( + + Unable to verify your access token for {hostname}. Please try again with a new access token. + + )} + {!usePrivateInstance ? ( + + ) : ( + + )} +
    + +
    + + ) +} + +export const AuthSidebarCta: React.FunctionComponent = ({ platformContext }) => { + const onLinkClick = (type: 'Sourcegraph' | 'Extension'): void => + platformContext.telemetryService.log(`VSCESidebarLearn${type}Click`) + + return ( +
    + +

    + The Sourcegraph extension allows you to search millions of open source repositories without cloning them + to your local machine. +

    +

    + Developers use Sourcegraph every day to onboard to new code bases, find code to reuse, resolve + incidents, fix security vulnerabilities, and more. +

    + +
    + ) +} diff --git a/client/vscode/src/webview/sidebars/help/HelpSidebarView.module.scss b/client/vscode/src/webview/sidebars/help/HelpSidebarView.module.scss new file mode 100644 index 00000000000..def994f9fd5 --- /dev/null +++ b/client/vscode/src/webview/sidebars/help/HelpSidebarView.module.scss @@ -0,0 +1,41 @@ +.sidebar-container { + padding: 0.125rem 0 0 0 !important; + color: var(--vscode-foreground); + font-size: var(--vscode-editor-font-size) !important; + display: flex; + flex-direction: column; + + a { + color: var(--vscode-textLink-foreground); + } +} + +.item-container { + display: flex; + flex-direction: row; + gap: 3px; + text-overflow: ellipsis; + overflow: hidden; + flex-wrap: nowrap; + margin: 0; + align-items: center; + + .codicon { + flex-basis: 100%; + height: auto; + justify-content: center; + } + + button, + span { + color: var(--vscode-foreground); + padding: 0; + font-weight: var(--vscode-font-weight) !important; + font-size: var(--vscode-editor-font-size) !important; + } + + img { + width: calc(var(--vscode-font-size) * 1.05); + height: auto; + } +} diff --git a/client/vscode/src/webview/sidebars/help/HelpSidebarView.tsx b/client/vscode/src/webview/sidebars/help/HelpSidebarView.tsx new file mode 100644 index 00000000000..ff28db09d38 --- /dev/null +++ b/client/vscode/src/webview/sidebars/help/HelpSidebarView.tsx @@ -0,0 +1,116 @@ +import React, { useMemo, useState } from 'react' + +import classNames from 'classnames' + +import { WebviewPageProps } from '../../platform/context' +import { AuthSidebarView } from '../auth/AuthSidebarView' + +import styles from './HelpSidebarView.module.scss' + +interface HelpSidebarViewProps + extends Pick {} + +/** + * Rendered by sidebar in search-home state when user doesn't have a valid access token. + */ +export const HelpSidebarView: React.FunctionComponent = ({ + platformContext, + extensionCoreAPI, + authenticatedUser, + instanceURL, +}) => { + const [hasAccount, setHasAccount] = useState(false) + + const hostname = useMemo(() => new URL(instanceURL).hostname, [instanceURL]) + + const onHelpItemClick = async (url: string, item: string): Promise => { + platformContext.telemetryService.log(`VSCEHelpSidebar${item}Click`) + await extensionCoreAPI.openLink(url) + } + + return ( +
    + + + + + + + {hasAccount && ( +
    + {!authenticatedUser ? ( + + ) : ( +

    + Connected to {hostname} as {authenticatedUser.displayName} +

    + )} +
    + )} +
    + ) +} diff --git a/client/vscode/src/webview/sidebars/help/index.tsx b/client/vscode/src/webview/sidebars/help/index.tsx new file mode 100644 index 00000000000..85b91646d03 --- /dev/null +++ b/client/vscode/src/webview/sidebars/help/index.tsx @@ -0,0 +1,54 @@ +import '../../platform/polyfills' + +import React, { useMemo } from 'react' + +import { VSCodeProgressRing } from '@vscode/webview-ui-toolkit/react' +import * as Comlink from 'comlink' +import { render } from 'react-dom' + +import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { AnchorLink, setLinkComponent, useObservable } from '@sourcegraph/wildcard' + +import { ExtensionCoreAPI, HelpSidebarAPI } from '../../../contract' +import { createEndpointsForWebToNode } from '../../comlink/webviewEndpoint' +import { createPlatformContext } from '../../platform/context' + +import { HelpSidebarView } from './HelpSidebarView' + +const vsCodeApi = window.acquireVsCodeApi() + +const { proxy, expose } = createEndpointsForWebToNode(vsCodeApi) + +export const extensionCoreAPI: Comlink.Remote = Comlink.wrap(proxy) + +const helpSidebarAPI: HelpSidebarAPI = {} + +const platformContext = createPlatformContext(extensionCoreAPI) + +Comlink.expose(helpSidebarAPI, expose) + +setLinkComponent(AnchorLink) + +const Main: React.FC = () => { + const authenticatedUser = useObservable( + useMemo(() => wrapRemoteObservable(extensionCoreAPI.getAuthenticatedUser()), []) + ) + + const state = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeState()), [])) + + const instanceURL = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.getInstanceURL()), [])) + if (authenticatedUser === undefined || instanceURL === undefined || state === undefined) { + return + } + + return ( + + ) +} + +render(
    , document.querySelector('#root')) diff --git a/client/vscode/src/webview/sidebars/history/HistorySidebarView.tsx b/client/vscode/src/webview/sidebars/history/HistorySidebarView.tsx new file mode 100644 index 00000000000..c3d790cf0a6 --- /dev/null +++ b/client/vscode/src/webview/sidebars/history/HistorySidebarView.tsx @@ -0,0 +1,30 @@ +import React from 'react' + +import classNames from 'classnames' + +import { AuthenticatedUser } from '@sourcegraph/shared/src/auth' + +import { WebviewPageProps } from '../../platform/context' + +import { RecentFilesSection } from './components/RecentFilesSection' +import { RecentRepositoriesSection } from './components/RecentRepositoriesSection' +import { RecentSearchesSection } from './components/RecentSearchesSection' +import { SavedSearchesSection } from './components/SavedSearchesSection' + +import styles from '../search/SearchSidebarView.module.scss' + +export interface HistorySidebarProps extends WebviewPageProps { + authenticatedUser: AuthenticatedUser +} + +/** + * Search history sidebar for "home" page for authenticated users. + */ +export const HistoryHomeSidebar: React.FunctionComponent = props => ( +
    + + + + +
    +) diff --git a/client/vscode/src/webview/sidebars/history/components/RecentFilesSection.tsx b/client/vscode/src/webview/sidebars/history/components/RecentFilesSection.tsx new file mode 100644 index 00000000000..4314019dd83 --- /dev/null +++ b/client/vscode/src/webview/sidebars/history/components/RecentFilesSection.tsx @@ -0,0 +1,138 @@ +import React, { useMemo, useState } from 'react' + +import classNames from 'classnames' +import ChevronDownIcon from 'mdi-react/ChevronDownIcon' +import ChevronLeftIcon from 'mdi-react/ChevronLeftIcon' + +import { EventLogResult, fetchRecentFileViews } from '@sourcegraph/search' +import { Link, useObservable } from '@sourcegraph/wildcard' + +import { HistorySidebarProps } from '../HistorySidebarView' + +import styles from '../../search/SearchSidebarView.module.scss' + +interface RecentFile { + repoName: string + filePath: string + timestamp: string + url: string +} + +export const RecentFilesSection: React.FunctionComponent = ({ + platformContext, + authenticatedUser, + extensionCoreAPI, +}) => { + const itemsToLoad = 15 + const [collapsed, setCollapsed] = useState(false) + + // Debt: lift this shared query up to HistorySidebarView. + const recentFilesResult = useObservable( + useMemo(() => fetchRecentFileViews(authenticatedUser.id, itemsToLoad, platformContext), [ + authenticatedUser.id, + itemsToLoad, + platformContext, + ]) + ) + + if (!recentFilesResult) { + return null + } + + const processedFiles = processRecentFiles(recentFilesResult) + + if (!processedFiles) { + return null + } + + const onRecentFileClick = (uri: string): void => { + platformContext.telemetryService.log('VSCERecentFilesClick') + extensionCoreAPI.openSourcegraphFile(uri).catch(error => { + // TODO surface to user + console.error('Error submitting search from Sourcegraph sidebar', error) + }) + } + + return ( +
    + + + {!collapsed && ( +
    + {processedFiles + ?.filter((search, index) => index < itemsToLoad) + .map((recentFile, index) => ( +
    + + onRecentFileClick(recentFile.url)} + > + {recentFile.repoName.split('@')[0]} › {recentFile.filePath} + + +
    + ))} +
    + )} +
    + ) +} + +function processRecentFiles(eventLogResult?: EventLogResult): RecentFile[] | null { + if (!eventLogResult) { + return null + } + + const recentFiles: RecentFile[] = [] + + for (const node of eventLogResult.nodes) { + if (node.argument && node.url) { + const parsedArguments = JSON.parse(node.argument) + let repoName = parsedArguments?.repoName as string + let filePath = parsedArguments?.filePath as string + + if (!repoName || !filePath) { + ;({ repoName, filePath } = extractFileInfoFromUrl(node.url)) + } + + if ( + filePath && + repoName && + !recentFiles.some(file => file.repoName === repoName && file.filePath === filePath) // Don't show the same file twice + ) { + recentFiles.push({ + url: node.url.replace('https://', 'sourcegraph://'), // So that clicking on link would open the file directly + repoName, + filePath, + timestamp: node.timestamp, + }) + } + } + } + + return recentFiles +} + +function extractFileInfoFromUrl(url: string): { repoName: string; filePath: string } { + const parsedUrl = new URL(url) + + // Remove first character as it's a '/' + const [repoName, filePath] = parsedUrl.pathname.slice(1).split('/-/blob/') + if (!repoName || !filePath) { + return { repoName: '', filePath: '' } + } + return { repoName, filePath } +} diff --git a/client/vscode/src/webview/sidebars/history/components/RecentRepositoriesSection.tsx b/client/vscode/src/webview/sidebars/history/components/RecentRepositoriesSection.tsx new file mode 100644 index 00000000000..d5e34ba595f --- /dev/null +++ b/client/vscode/src/webview/sidebars/history/components/RecentRepositoriesSection.tsx @@ -0,0 +1,127 @@ +import React, { useMemo, useState } from 'react' + +import classNames from 'classnames' +import ChevronDownIcon from 'mdi-react/ChevronDownIcon' +import ChevronLeftIcon from 'mdi-react/ChevronLeftIcon' + +import { EventLogResult, fetchRecentSearches } from '@sourcegraph/search' +import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui' +import { scanSearchQuery } from '@sourcegraph/shared/src/search/query/scanner' +import { isRepoFilter } from '@sourcegraph/shared/src/search/query/validate' +import { LATEST_VERSION } from '@sourcegraph/shared/src/search/stream' +import { useObservable } from '@sourcegraph/wildcard' + +import { SearchPatternType } from '../../../../graphql-operations' +import { HistorySidebarProps } from '../HistorySidebarView' + +import styles from '../../search/SearchSidebarView.module.scss' + +export const RecentRepositoriesSection: React.FunctionComponent = ({ + platformContext, + authenticatedUser, + extensionCoreAPI, +}) => { + const itemsToLoad = 15 + const [collapsed, setCollapsed] = useState(false) + + // Debt: lift this shared query up to HistorySidebarView. + const recentRepositoriesResult = useObservable( + useMemo(() => fetchRecentSearches(authenticatedUser.id, itemsToLoad, platformContext), [ + authenticatedUser.id, + itemsToLoad, + platformContext, + ]) + ) + + if (!recentRepositoriesResult) { + return null + } + + const processedRepositories = processRepositories(recentRepositoriesResult) + + if (!processedRepositories) { + return null + } + + const onRecentRepositoryClick = (query: string): void => { + platformContext.telemetryService.log('VSCERecentRepositoryClick') + extensionCoreAPI + .streamSearch(query, { + // Debt: using defaults here. The saved search should override these, though. + caseSensitive: false, + patternType: SearchPatternType.literal, + version: LATEST_VERSION, + trace: undefined, + }) + .catch(error => { + // TODO surface to user + console.error('Error submitting search from Sourcegraph sidebar', error) + }) + } + + return ( +
    + + + {!collapsed && ( +
    + {processedRepositories + .filter((search, index) => index < itemsToLoad) + .map((repository, index) => ( +
    + + + +
    + ))} +
    + )} +
    + ) +} + +export function parseSearchURLQuery(query: string): string | undefined { + const searchParameters = new URLSearchParams(query) + return searchParameters.get('q') || undefined +} + +function processRepositories(eventLogResult: EventLogResult): string[] | null { + if (!eventLogResult) { + return null + } + const recentlySearchedRepos: string[] = [] + for (const node of eventLogResult.nodes) { + if (node.url) { + const url = new URL(node.url) + const queryFromURL = parseSearchURLQuery(url.search) + const scannedQuery = scanSearchQuery(queryFromURL || '') + if (scannedQuery.type === 'success') { + for (const token of scannedQuery.term) { + if (isRepoFilter(token)) { + if (token.value && !recentlySearchedRepos.includes(token.value.value)) { + recentlySearchedRepos.push(token.value.value) + } + } + } + } + } + } + return recentlySearchedRepos +} diff --git a/client/vscode/src/webview/sidebars/history/components/RecentSearchesSection.tsx b/client/vscode/src/webview/sidebars/history/components/RecentSearchesSection.tsx new file mode 100644 index 00000000000..2d68792ef14 --- /dev/null +++ b/client/vscode/src/webview/sidebars/history/components/RecentSearchesSection.tsx @@ -0,0 +1,132 @@ +import React, { useMemo, useState } from 'react' + +import classNames from 'classnames' +import ChevronDownIcon from 'mdi-react/ChevronDownIcon' +import ChevronLeftIcon from 'mdi-react/ChevronLeftIcon' + +import { EventLogResult, fetchRecentSearches } from '@sourcegraph/search' +import { SyntaxHighlightedSearchQuery } from '@sourcegraph/search-ui' +import { LATEST_VERSION } from '@sourcegraph/shared/src/search/stream' +import { useObservable } from '@sourcegraph/wildcard' + +import { SearchPatternType } from '../../../../graphql-operations' +import { HistorySidebarProps } from '../HistorySidebarView' + +import styles from '../../search/SearchSidebarView.module.scss' + +export const RecentSearchesSection: React.FunctionComponent = ({ + platformContext, + extensionCoreAPI, + authenticatedUser, +}) => { + const itemsToLoad = 15 + const [collapsed, setCollapsed] = useState(false) + + const recentSearchesResult = useObservable( + useMemo(() => fetchRecentSearches(authenticatedUser.id, itemsToLoad, platformContext), [ + authenticatedUser.id, + itemsToLoad, + platformContext, + ]) + ) + + const recentSearches: RecentSearch[] | null = useMemo( + () => processRecentSearches(recentSearchesResult ?? undefined), + [recentSearchesResult] + ) + + if (!recentSearches) { + return null + } + + const onSavedSearchClick = (query: string): void => { + platformContext.telemetryService.log('VSCERecentSearchClick') + extensionCoreAPI + .streamSearch(query, { + // Debt: using defaults here. The saved search should override these, though. + caseSensitive: false, + patternType: SearchPatternType.literal, + version: LATEST_VERSION, + trace: undefined, + }) + .catch(error => { + // TODO surface to user + console.error('Error submitting search from Sourcegraph sidebar', error) + }) + } + + return ( +
    + + + {!collapsed && ( +
    + {recentSearches + .filter((search, index) => index < itemsToLoad) + .map(search => ( +
    + + + +
    + ))} +
    + )} +
    + ) +} + +interface RecentSearch { + count: number + searchText: string + timestamp: string + url: string +} + +function processRecentSearches(eventLogResult?: EventLogResult): RecentSearch[] | null { + if (!eventLogResult) { + return null + } + + const recentSearches: RecentSearch[] = [] + + for (const node of eventLogResult.nodes) { + if (node.argument && node.url) { + const parsedArguments = JSON.parse(node.argument) + const searchText: string | undefined = parsedArguments?.code_search?.query_data?.combined + + if (searchText) { + if (recentSearches.length > 0 && recentSearches[recentSearches.length - 1].searchText === searchText) { + recentSearches[recentSearches.length - 1].count += 1 + } else { + const parsedUrl = new URL(node.url) + recentSearches.push({ + count: 1, + url: parsedUrl.pathname + parsedUrl.search, // Strip domain from URL so clicking on it doesn't reload page + searchText, + timestamp: node.timestamp, + }) + } + } + } + } + + return recentSearches +} diff --git a/client/vscode/src/webview/sidebars/history/components/SavedSearchesSection.tsx b/client/vscode/src/webview/sidebars/history/components/SavedSearchesSection.tsx new file mode 100644 index 00000000000..5cb9d92c77a --- /dev/null +++ b/client/vscode/src/webview/sidebars/history/components/SavedSearchesSection.tsx @@ -0,0 +1,122 @@ +import React, { useMemo, useState } from 'react' + +import classNames from 'classnames' +import ChevronDownIcon from 'mdi-react/ChevronDownIcon' +import ChevronLeftIcon from 'mdi-react/ChevronLeftIcon' +import { catchError } from 'rxjs/operators' + +import { gql } from '@sourcegraph/http-client' +import { LATEST_VERSION } from '@sourcegraph/shared/src/search/stream' +import { useObservable } from '@sourcegraph/wildcard' + +import { SavedSearchesResult, SavedSearchesVariables, SearchPatternType } from '../../../../graphql-operations' +import { HistorySidebarProps } from '../HistorySidebarView' + +import styles from '../../search/SearchSidebarView.module.scss' + +const savedSearchQuery = gql` + query SavedSearches { + savedSearches { + ...SavedSearchFields + } + } + fragment SavedSearchFields on SavedSearch { + id + description + notify + notifySlack + query + namespace { + __typename + id + namespaceName + } + slackWebhookURL + } +` + +export const SavedSearchesSection: React.FunctionComponent = ({ + platformContext, + extensionCoreAPI, +}) => { + const itemsToLoad = 15 + const [collapsed, setCollapsed] = useState(false) + + const savedSearchesResult = useObservable( + useMemo( + () => + platformContext + .requestGraphQL({ + request: savedSearchQuery, + variables: {}, + mightContainPrivateInfo: true, + }) + .pipe( + catchError(error => { + console.error('Error fetching saved searches', error) + return [null] + }) + ), + [platformContext] + ) + ) + + const savedSearches = savedSearchesResult?.data?.savedSearches + + if (!savedSearches || savedSearches.length === 0) { + return null + } + + const onSavedSearchClick = (query: string): void => { + platformContext.telemetryService.log('VSCESidebarSavedSearchClick') + extensionCoreAPI + .streamSearch(query, { + // Debt: using defaults here. The saved search should override these, though. + caseSensitive: false, + patternType: SearchPatternType.literal, + version: LATEST_VERSION, + trace: undefined, + }) + .catch(error => { + // TODO surface to user + console.error('Error submitting search from Sourcegraph sidebar', error) + }) + } + + return ( +
    + + + {!collapsed && savedSearches && ( +
    + {savedSearches + .filter((search, index) => index < itemsToLoad) + .map(search => ( +
    + + + +
    + ))} +
    + )} +
    + ) +} diff --git a/client/vscode/src/webview/search-sidebar/ContextInvalidatedSidebarView.tsx b/client/vscode/src/webview/sidebars/search/ContextInvalidatedSidebarView.tsx similarity index 92% rename from client/vscode/src/webview/search-sidebar/ContextInvalidatedSidebarView.tsx rename to client/vscode/src/webview/sidebars/search/ContextInvalidatedSidebarView.tsx index 43f8584518a..433eaa0166d 100644 --- a/client/vscode/src/webview/search-sidebar/ContextInvalidatedSidebarView.tsx +++ b/client/vscode/src/webview/sidebars/search/ContextInvalidatedSidebarView.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Button } from '@sourcegraph/wildcard' -import { WebviewPageProps } from '../platform/context' +import { WebviewPageProps } from '../../platform/context' export interface ContextInvalidatedSidebarViewProps extends WebviewPageProps {} diff --git a/client/vscode/src/webview/sidebars/search/SearchSidebarView.module.scss b/client/vscode/src/webview/sidebars/search/SearchSidebarView.module.scss new file mode 100644 index 00000000000..d258ce35374 --- /dev/null +++ b/client/vscode/src/webview/sidebars/search/SearchSidebarView.module.scss @@ -0,0 +1,169 @@ +.sidebar-container { + padding: 0.125rem 0 0 0 !important; + + & [data-reach-tabs] { + // Override our default background color. + background: none; + } + + --brand-secondary: var(--vscode-button-hoverBackground); + --link-hover-color: var(--vscode-textLink-foreground); + + .btn-outline-secondary:hover { + background: transparent !important; + } + + a, + ul, + li, + span, + button { + font-size: var(--vscode-editor-font-size) !important; + } + + &__sticky-box { + @media (--md-breakpoint-down) { + // Sidebar shouldn't be sticky in smaller screens + position: static !important; + + display: flex; + flex-direction: row; + flex-wrap: wrap; + column-gap: 1.5rem; + } + + @media (--xs-breakpoint-down) { + display: block; + } + } +} + +.sidebar-section { + isolation: isolate; // Prevent the z-index below from leaking out of this container + margin-bottom: 1.5rem; + + &__tabs-header { + margin-top: 1.25rem; + margin-bottom: 0.5rem; + } + + &__button-link { + // The global font-weight for .btn elements is 500, which also applies to + // link-like buttons. But in this context we want it to be 400. + font-weight: 300; + color: var(--vscode-textLink-foreground) !important; + font-size: var(--vscode-editor-font-size) !important; + padding: 0; + margin: 0; + } + + &__collapse-button { + display: flex; + align-items: center; + text-align: left; + width: calc(100% + 0.5rem); // Take full width + account for negative margin + border: none; + padding: 0.25rem; + margin: 0 -0.25rem; + + // Force the button's box-shadow to always show over the sibling element's border + position: relative; + z-index: 1; + } + + &__list { + list-style-type: none; + max-height: 8rem; + overflow: auto; + // Negative margin and positive padding allows focus rings to not be cut off by overflow:auto + margin: 0 -0.125rem; + padding: 0.125rem; + } + + &__file-list { + list-style-type: none; + max-height: 10rem; + overflow: auto; + // Negative margin and positive padding allows focus rings to not be cut off by overflow:auto + margin: 0 -0.125rem; + padding: 0.125rem; + } + + &__cta { + list-style-type: none; + overflow: auto; + min-height: 8rem; + // Negative margin and positive padding allows focus rings to not be cut off by overflow:auto + margin: 0 -0.125rem; + padding: 0.125rem; + } + + &__search-box { + display: block; + margin: 0.25rem 0; + } + + &__list-item { + display: flex; + // font-size: 0.75rem; + font-size: var(--vscode-editor-font-size); + padding: 0.25rem 0.375rem; + border: 0; + border-radius: 3px; + width: 100%; + text-align: left; + overflow-wrap: anywhere; + + &:hover { + background-color: var(--color-bg-2); + text-decoration: none; + } + + &:focus { + text-decoration: none; + } + + &:active { + background-color: var(--color-bg-3); + } + + &--break-words { + word-break: break-all; + } + } + + &__icon { + margin: 0 0.125rem; + } + + &__no-results { + font-size: 0.75rem; + padding: 0.25rem 0.375rem; + } + + &__cta-link { + font-size: 0.6875rem; + padding: 0.25rem 0.375rem; + } + + // A helper class for sections that render custom content + &__footer { + border-top: 1px solid var(--border-color); + padding-top: 0.75rem; + margin: 0; + } +} + +.title { + text-transform: uppercase; + font-size: var(--vscode-font-size) !important; + color: var(--vscode-textPreformat-foreground); +} + +.button, +.button:link, +.button:visited { + font-size: var(--vscode-font-size) !important; + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} diff --git a/client/vscode/src/webview/sidebars/search/SearchSidebarView.tsx b/client/vscode/src/webview/sidebars/search/SearchSidebarView.tsx new file mode 100644 index 00000000000..4bc85ac61cc --- /dev/null +++ b/client/vscode/src/webview/sidebars/search/SearchSidebarView.tsx @@ -0,0 +1,131 @@ +import React, { useMemo } from 'react' + +import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect' +// Disable so we can create a separate store for the VS Code extension. +// eslint-disable-next-line no-restricted-imports +import create from 'zustand' + +import { + InitialParametersSource, + SearchPatternType, + SearchQueryState, + SearchQueryStateStore, + SearchQueryStateStoreProvider, + updateQuery, +} from '@sourcegraph/search' +import { SearchSidebar } from '@sourcegraph/search-ui' +import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { Filter, LATEST_VERSION } from '@sourcegraph/shared/src/search/stream' +import { useObservable } from '@sourcegraph/wildcard' + +import { WebviewPageProps } from '../../platform/context' + +import styles from './SearchSidebarView.module.scss' + +interface SearchSidebarViewProps + extends Pick { + filters?: Filter[] | undefined +} + +export const SearchSidebarView: React.FunctionComponent = React.memo( + ({ settingsCascade, platformContext, extensionCoreAPI, filters }) => { + const useSearchQueryState: SearchQueryStateStore = useMemo( + () => + create((set, get) => ({ + parametersSource: InitialParametersSource.DEFAULT, + queryState: { query: '' }, + searchCaseSensitivity: false, + searchPatternType: SearchPatternType.literal, + searchQueryFromURL: '', + + setQueryState: queryStateUpdate => { + const currentSearchQueryState = get() + const updatedQueryState = + typeof queryStateUpdate === 'function' + ? queryStateUpdate(currentSearchQueryState.queryState) + : queryStateUpdate + + extensionCoreAPI + .emit({ + type: 'sidebar_query_update', + proposedQueryState: { + queryState: updatedQueryState, + searchCaseSensitivity: currentSearchQueryState.searchCaseSensitivity, + searchPatternType: currentSearchQueryState.searchPatternType, + }, + currentQueryState: { + // Don't spread currentSearchQueryState as it contains un-clone-able functions. + queryState: currentSearchQueryState.queryState, + searchCaseSensitivity: currentSearchQueryState.searchCaseSensitivity, + searchPatternType: currentSearchQueryState.searchPatternType, + }, + }) + .catch(error => { + // TODO surface to user + console.error('Error updating search query from Sourcegraph sidebar', error) + }) + + extensionCoreAPI.focusSearchPanel().catch(() => { + // noop. + }) + }, + submitSearch: (_submitSearchParameters, updates = []) => { + const previousSearchQueryState = get() + const updatedQuery = updateQuery(previousSearchQueryState.queryState.query, updates) + extensionCoreAPI + .streamSearch(updatedQuery, { + caseSensitive: previousSearchQueryState.searchCaseSensitivity, + patternType: previousSearchQueryState.searchPatternType, + version: LATEST_VERSION, + trace: undefined, + }) + .catch(error => { + // TODO surface to user + console.error('Error submitting search from Sourcegraph sidebar', error) + }) + + extensionCoreAPI.focusSearchPanel().catch(() => { + // noop. + }) + }, + })), + [extensionCoreAPI] + ) + + const searchQueryStateFromPanel = useObservable( + useMemo(() => wrapRemoteObservable(extensionCoreAPI.observePanelQueryState()), [extensionCoreAPI]) + ) + + useDeepCompareEffectNoCheck(() => { + if (searchQueryStateFromPanel) { + useSearchQueryState.setState({ + queryState: searchQueryStateFromPanel.queryState, + searchCaseSensitivity: searchQueryStateFromPanel.searchCaseSensitivity, + searchPatternType: searchQueryStateFromPanel.searchPatternType, + }) + } + }, [searchQueryStateFromPanel]) + + const patternType = useSearchQueryState(state => state.searchPatternType) + const caseSensitive = useSearchQueryState(state => state.searchCaseSensitivity) + + return ( + + ''} + // Ensure we always render SearchTypeButton which sets zustand state, + // instead of URL state which wouldn't make sense in this webview. + forceButton={true} + caseSensitive={caseSensitive} + patternType={patternType} + settingsCascade={settingsCascade} + telemetryService={platformContext.telemetryService} + className={styles.sidebarContainer} + filters={filters} + // Debt: no selected search context spec + /> + + ) + } +) diff --git a/client/vscode/src/webview/sidebars/search/api.ts b/client/vscode/src/webview/sidebars/search/api.ts new file mode 100644 index 00000000000..f4e86ad29d3 --- /dev/null +++ b/client/vscode/src/webview/sidebars/search/api.ts @@ -0,0 +1,20 @@ +import { of } from 'rxjs' + +import { proxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common' + +import { SearchSidebarAPI } from '../../../contract' +import { WebviewPageProps } from '../../platform/context' + +import { createVSCodeExtensionsController } from './extension-host' + +export function createSearchSidebarAPI( + webviewPageProps: Pick +): SearchSidebarAPI { + return { + ping: () => { + console.log('ping called') + return proxySubscribable(of('pong')) + }, + ...createVSCodeExtensionsController(webviewPageProps), + } +} diff --git a/client/vscode/src/webview/sidebars/search/extension-host/index.ts b/client/vscode/src/webview/sidebars/search/extension-host/index.ts new file mode 100644 index 00000000000..5d4fdf7709e --- /dev/null +++ b/client/vscode/src/webview/sidebars/search/extension-host/index.ts @@ -0,0 +1,57 @@ +import { from } from 'rxjs' +import { switchMap } from 'rxjs/operators' +import { Intersection } from 'utility-types' + +import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract' +import { proxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common' +import { createController as createExtensionsController } from '@sourcegraph/shared/src/extensions/controller' + +import { SearchSidebarAPI } from '../../../../contract' +import { WebviewPageProps } from '../../../platform/context' + +import { createExtensionHost } from './worker' + +export function createVSCodeExtensionsController({ + platformContext, + instanceURL, +}: Pick): Intersection { + const extensionsController = createExtensionsController({ + ...platformContext, + sourcegraphURL: instanceURL, + createExtensionHost: () => Promise.resolve(createExtensionHost()), + }) + + return { + getDefinition: parameters => { + const definitions = from(extensionsController.extHostAPI).pipe( + switchMap(extensionHostAPI => wrapRemoteObservable(extensionHostAPI.getDefinition(parameters))) + ) + + return proxySubscribable(definitions) + }, + getReferences: (parameters, referenceContext) => { + const references = from(extensionsController.extHostAPI).pipe( + switchMap(extensionHostAPI => + wrapRemoteObservable(extensionHostAPI.getReferences(parameters, referenceContext)) + ) + ) + + return proxySubscribable(references) + }, + getHover: parameters => { + const hovers = from(extensionsController.extHostAPI).pipe( + switchMap(extensionHostAPI => wrapRemoteObservable(extensionHostAPI.getHover(parameters))) + ) + + return proxySubscribable(hovers) + }, + + addTextDocumentIfNotExists: textDocumentData => + extensionsController.extHostAPI.then(extensionHostAPI => + extensionHostAPI.addTextDocumentIfNotExists(textDocumentData) + ), + addViewerIfNotExists: viewer => + extensionsController.extHostAPI.then(extensionHostAPI => extensionHostAPI.addViewerIfNotExists(viewer)), + } +} diff --git a/client/vscode/src/webview/sidebars/search/extension-host/main.worker.ts b/client/vscode/src/webview/sidebars/search/extension-host/main.worker.ts new file mode 100644 index 00000000000..cf80b6cefa7 --- /dev/null +++ b/client/vscode/src/webview/sidebars/search/extension-host/main.worker.ts @@ -0,0 +1,25 @@ +import '../../../platform/polyfills' + +import { startExtensionHost } from '@sourcegraph/shared/src/api/extension/extensionHost' + +import { createEndpointsForWebToWeb } from '../../../comlink/webviewEndpoint' + +async function extensionHostMain(): Promise { + try { + const { webview: proxy, worker: expose } = createEndpointsForWebToWeb({ + postMessage: message => self.postMessage(message), + addEventListener: (type, listener, options) => self.addEventListener(type, listener, options), + removeEventListener: (type, listener, options) => self.removeEventListener(type, listener, options), + }) + + const extensionHost = startExtensionHost({ proxy, expose }) + self.addEventListener('unload', () => extensionHost.unsubscribe()) + } catch (error) { + console.error('Error starting the extension host:', error) + self.close() + } + return Promise.resolve() +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +extensionHostMain() diff --git a/client/vscode/src/webview/sidebars/search/extension-host/worker.ts b/client/vscode/src/webview/sidebars/search/extension-host/worker.ts new file mode 100644 index 00000000000..066c91e3ed7 --- /dev/null +++ b/client/vscode/src/webview/sidebars/search/extension-host/worker.ts @@ -0,0 +1,15 @@ +import { Subscription } from 'rxjs' + +import { ClosableEndpointPair } from '@sourcegraph/shared/src/platform/context' + +import { createEndpointsForWebToWeb } from '../../../comlink/webviewEndpoint' + +// eslint-disable-next-line import/extensions +import ExtensionHostWorker from './main.worker.ts' + +export function createExtensionHost(): ClosableEndpointPair { + const worker = new ExtensionHostWorker() + const { webview: expose, worker: proxy } = createEndpointsForWebToWeb(worker) + + return { endpoints: { expose, proxy }, subscription: new Subscription(() => worker.terminate()) } +} diff --git a/client/vscode/src/webview/sidebars/search/index.tsx b/client/vscode/src/webview/sidebars/search/index.tsx new file mode 100644 index 00000000000..bfa625bcefa --- /dev/null +++ b/client/vscode/src/webview/sidebars/search/index.tsx @@ -0,0 +1,129 @@ +import '../../platform/polyfills' + +import React, { useMemo, useState } from 'react' + +import { ShortcutProvider } from '@slimsag/react-shortcuts' +import { VSCodeProgressRing } from '@vscode/webview-ui-toolkit/react' +import * as Comlink from 'comlink' +import { render } from 'react-dom' +import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect' + +import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { Filter } from '@sourcegraph/shared/src/search/stream' +import { AnchorLink, setLinkComponent, useObservable, WildcardThemeContext, Tooltip } from '@sourcegraph/wildcard' + +import { ExtensionCoreAPI } from '../../../contract' +import { createEndpointsForWebToNode } from '../../comlink/webviewEndpoint' +import { createPlatformContext, WebviewPageProps } from '../../platform/context' +import { adaptSourcegraphThemeToEditorTheme } from '../../theming/sourcegraphTheme' +import { AuthSidebarCta, AuthSidebarView } from '../auth/AuthSidebarView' +import { HistoryHomeSidebar } from '../history/HistorySidebarView' + +import { createSearchSidebarAPI } from './api' +import { ContextInvalidatedSidebarView } from './ContextInvalidatedSidebarView' +import { SearchSidebarView } from './SearchSidebarView' + +const vsCodeApi = window.acquireVsCodeApi() + +const { proxy, expose } = createEndpointsForWebToNode(vsCodeApi) + +export const extensionCoreAPI: Comlink.Remote = Comlink.wrap(proxy) + +const platformContext = createPlatformContext(extensionCoreAPI) + +const searchSidebarAPI = createSearchSidebarAPI({ + platformContext, + instanceURL: document.documentElement.dataset.instanceUrl!, +}) +Comlink.expose(searchSidebarAPI, expose) + +setLinkComponent(AnchorLink) + +const themes = adaptSourcegraphThemeToEditorTheme() + +const Main: React.FC = () => { + // Debt: make sure we only rerender on necessary changes + const state = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeState()), [])) + + const [filters, setFilters] = useState(undefined) + useDeepCompareEffectNoCheck(() => { + setFilters(state?.context.searchResults?.filters) + }, [state?.context.searchResults?.filters]) + + const authenticatedUser = useObservable( + useMemo(() => wrapRemoteObservable(extensionCoreAPI.getAuthenticatedUser()), []) + ) + + const instanceURL = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.getInstanceURL()), [])) + + const theme = useObservable(themes) + + const settingsCascade = useObservable( + useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeSourcegraphSettings()), []) + ) + // Do not block rendering on settings unless we observe UI jitter + + // Debt: If init is taking too long, show a message. + // Also check if anything has errored out. + + // If any of the remote values have yet to load. + const initialized = + state !== undefined && + authenticatedUser !== undefined && + instanceURL !== undefined && + theme !== undefined && + settingsCascade !== undefined + if (!initialized) { + return + } + + const webviewPageProps: WebviewPageProps = { + extensionCoreAPI, + platformContext, + theme, + authenticatedUser, + settingsCascade, + instanceURL, + } + + if (state.status === 'context-invalidated') { + return + } + + // If a search hasn't been performed yet + if (state.status === 'search-home' || !state.context.submittedSearchQueryState) { + // TODO: should we hide the access token form permanently if an unauthenticated user + // has performed a search before? Or just for this session? + if (!authenticatedUser) { + return ( + <> + + + + ) + } + return + } + + // is wrapped w/ React.memo so pass only necessary props. + return ( + <> + + + ) +} + +render( + + +
    + + + , + document.querySelector('#root') +) diff --git a/client/vscode/src/webview/theming/highlight.scss b/client/vscode/src/webview/theming/highlight.scss new file mode 100644 index 00000000000..af3c89833ac --- /dev/null +++ b/client/vscode/src/webview/theming/highlight.scss @@ -0,0 +1,1023 @@ +// Default token colors from "Dark (Visual Studio)" and "Light (Visual Studio)". +// It's conservative, meaning it doesn't highlght more specific token types that +// may correspond to different CSS variables across themes. +// We can manually override those for popular themes. +// Some colors are lifted from Dark/Light+ because they have +// corresponding CSS variables for classes that the defaults do not. +.sourcegraph-extension.theme-light, +.sourcegraph-extension.theme-dark { + td.line, + td.line::before { + color: var(--vscode-editorLineNumber-foreground); + } + + .match-highlight, + .match-highlight-sticky, + .selection-highlight, + .selection-highlight-sticky { + background-color: var(--vscode-editor-findMatchHighlightBackground); + } + + .hljs-comment, + .hljs-quote, + .hljs-variable { + color: var(--vscode-editor-foreground); + } + + .hljs-keyword, + .hljs-selector-tag, + .hljs-built_in, + .hljs-name, + .hljs-tag { + color: var(--vscode-debugTokenExpression-name); + } + + .hljs-string, + .hljs-title, + .hljs-section, + .hljs-attribute, + .hljs-literal, + .hljs-template-tag, + .hljs-template-variable, + .hljs-type { + color: var(--vscode-debugTokenExpression-string); + } + + .hljs-number { + color: var(--vscode-debugTokenExpression-number); + } + + .hl-source, + .hl-text { + color: var(--vscode-editor-foreground); + } + + .hl-punctuation.hl-definition { + color: var(--hl-gray-0); + } + + .hl-keyword { + color: var(--vscode-debugTokenExpression-name); + } + + .hl-variable { + color: var(--vscode-editor-foreground); + } + + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-variable.hl-function { + color: var(--vscode-editor-foreground); + } + + // Classes + .hl-support.hl-class, + .hl-entity.hl-name.hl-class, + .hl-meta.hl-class { + color: var(--vscode-editor-foreground); + } + .hl-entity.hl-other.hl-inherited-class { + color: var(--vscode-editor-foreground); + } + + // Storage + .hl-storage { + color: var(--vscode-debugTokenExpression-name); + } + + // Support + .hl-support.hl-function { + color: var(--vscode-editor-foreground); + } + + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: var(--vscode-debugTokenExpression-string); + } + + .hl-number { + color: var(--vscode-debugTokenExpression-number); + } + + // regexp literals + .hl-string.hl-regexp { + color: var(--vscode-textPreformat-foreground); + } + // escape sequences + .hl-constant.hl-character.hl-escape { + color: var(--vscode-textPreformat-foreground); + } + + // Constants + .hl-constant { + color: var(--vscode-editor-foreground); + } + .hl-constant.hl-numeric { + color: var(--vscode-debugTokenExpression-number); + } + .hl-constant.hl-other.hl-color { + color: var(--vscode-editor-foreground); + } + + // Tags + .hl-entity.hl-name.hl-tag { + color: var(--vscode-debugView-valueChangedHighlight); + } + + // Attributes + .hl-entity.hl-other.hl-attribute-name { + color: var(--vscode-debugTokenExpression-string); + } + + // Attribute IDs + .hl-entity.hl-other.hl-attribute-name.hl-id, + .hl-punctuation.hl-definition.hl-entity { + color: var(--vscode-editor-foreground); + } + + // Selector + .hl-entity.hl-other.hl-attribute-name.hl-css, + .hl-entity.hl-other.hl-attribute-name.hl-sass, + .hl-entity.hl-other.hl-attribute-name.hl-less { + color: var(--vscode-textPreformat-foreground); + } + + .hl-source.hl-scss, + .hl-source.hl-css, + .hl-source.hl-less { + .hl-entity.hl-other.hl-attribute-name { + color: var(--vscode-textPreformat-foreground); + } + } + + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: var(--vscode-descriptionForeground); + } + + // Operators + .hl-operator { + color: var(--vscode-editor-foreground); + } + + // debt/v2: special case highlighting for specific languages. + // reference `client/branded/**/highlight.scss` +} + +// Theme-specific overrides. +// Includes top themes from VS Code's Marketplace: +// - https://marketplace.visualstudio.com/search?target=VSCode&category=Themes&sortBy=Installs +// and this blog post: +// - https://about.sourcegraph.com/blog/workspaces-of-sourcegraph/ + +// Vetted themes: +// - Dark+ (overrides: ☑) +// - Light+ (overrides: ☑) +// - Dark (Visual Studio) (overrides: ☑) +// - Light (Visual Studio) (overrides: ☑) +// - Monokai (overrides: ☑) +// - Solarized (overrides: partial) +// - Solarized Dark (overrides: partial) +// - High Contrast (overrides: ☑) +// - One Dark Pro (overrides: ☑) +// - Dracula (overrides: ☑) +// - Dracula Soft(overrides: ☑) +// - GitHub (overrides: no) +// - Atom One Dark (overrides: ☑) +// - Winter is Coming (overrides: no) +// - Monokai Pro (overrides: ☑, only for base filter) +// - Night Owl (overrides: ☑, only for dark) +// - One Monokai (overrides: partial) +// - Cobalt2 (overrides: ☑) +// - Material Theme (overrides: no) +// - SynthWave '84 (overrides: ☑) +// - Panda (overrides: ☑) +// - Hack The Box (overrides: ☑) +// - Hack The Box-Lite (overrides: ☑) + +// Debt: this technique isn't resilient to changes in themes. +// Themes aren't likely to change that often, but it would +// still be better to find a way to automate this. + +// FULLY SUPPORTED THEMES + +// Default Dark VS Code theme. +body[data-vscode-theme-name^='Dark+ (default dark)'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function, + .hl-support.hl-function.hl-any-method, + .hl-variable.hl-function { + color: #dcdcaa; + } + + // Constants + .hl-variable.hl-constant { + color: #4fc1ff; + } + .hl-variable { + color: #9cdcfe; + } + + // Entities + .hl-entity.hl-name.hl-type { + color: #4ec9b0; + } + + .hl-keyword { + color: var(--vscode-debugTokenExpression-name); + } + + .hl-type.hl-support, + .hl-type.hl-primitive { + color: #4ec9b0; + } + + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #6a9955; + } +} + +// Dark Visual Studio (minimalistic) theme +body[data-vscode-theme-name^='Dark (Visual Studio)'].sourcegraph-extension.theme-dark { + .hl-keyword { + color: var(--vscode-debugView-valueChangedHighlight); + } + + // Storage + .hl-storage { + color: var(--vscode-debugView-valueChangedHighlight); + } + + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #6a9955; + } +} + +// Default Light VS Code theme. +body[data-vscode-theme-name^='Light+ (default light)'].sourcegraph-extension.theme-light { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-variable.hl-function { + color: #795e26; + } + + // Constants + .hl-variable.hl-constant { + color: #0070c1; + } + .hl-variable { + color: #001080; + } + + .hl-keyword { + color: var(--vscode-debugTokenExpression-boolean); + } + + .hl-storage { + color: var(--vscode-debugTokenExpression-boolean); + } + + // Property names + .hl-property .hl-name { + color: #001080; + } + + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #008000; + } + + .hl-import .hl-string { + color: var(--vscode-textPreformat-foreground); + } + + .hl-type.hl-primitive, + .hl-type.hl-builtin, + .hl-entity.hl-name.hl-type { + color: #267f99; + } +} + +// Light Visual Studio (minimalistic) theme +body[data-vscode-theme-name^='Light (Visual Studio)'].sourcegraph-extension.theme-light { + .hl-keyword { + color: var(--vscode-debugTokenExpression-boolean); + } + + .hl-storage { + color: var(--vscode-debugTokenExpression-boolean); + } + + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #008000; + } + + // Operators + .hl-operator { + color: var(--vscode-editor-foreground); + } + + .hl-import .hl-string { + color: var(--vscode-textPreformat-foreground); + } +} + +// High contrast (default VS Code dark HC theme) +body[data-vscode-theme-name='High Contrast'].sourcegraph-extension.theme-dark { + .match-highlight, + .match-highlight-sticky, + .selection-highlight, + .selection-highlight-sticky { + background-color: white; + color: black; + } + + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-variable.hl-function { + color: #dcdcaa; + } + + .hl-keyword { + color: #c586c0; + } + + .hl-storage { + color: #569cd6; + } + + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-variable.hl-function { + color: #569cd6; + } + + .hl-type { + color: #569cd6; + } + + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #7ca668; + } +} + +// Monokai +body[data-vscode-theme-name='Monokai'].sourcegraph-extension.theme-dark { + .hl-keyword { + color: #f92672; + } + .hl-import-export-all, + .hl-number { + color: #ae81ff; + } + .hl-storage { + color: #66d9ef; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #e6db74; + } + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-variable.hl-function { + color: #a6e22e; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #88846f; + } + .hl-entity.hl-name.hl-type { + color: #a6e22e; + text-decoration: underline; + text-underline-position: under; + } + .hl-type.hl-primitive, + .hl-type.hl-builtin { + color: #66d9ef; + font-style: italic; + } + .hl-variable.hl-parameter { + color: #fd971f; + font-style: italic; + } +} + +// One Dark Pro +body[data-vscode-theme-name='One Dark Pro'].sourcegraph-extension.theme-dark { + .hl-keyword, + .hl-storage { + color: #c678dd; + } + .hl-variable.hl-readwrite { + color: #e06c75; + } + .hl-variable.hl-parameter { + color: #e06c75; + font-style: italic; + } + .hl-variable.hl-constant, + .hl-variable.hl-object { + color: #e5c07b; + } + .hl-object-literal.hl-key { + color: #e06c75; + .hl-function { + color: #61afef; + } + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: var(--vscode-editor-wordHighlightBorder); + font-style: italic; + } + .hl-type.hl-primitive, + .hl-type.hl-builtin, + .hl-type.hl-name { + color: #e5c07b; + } + .hl-number { + color: #d19a66; + } + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #61afef; + } +} + +// Dracula +body[data-vscode-theme-name='Dracula'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function { + color: #50fa7b; + } + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: var(--vscode-terminal-ansiCyan); + } + + .hl-number, + .hl-constant.hl-numeric, + .hl-constant.hl-other.hl-placeholder { + color: var(--vscode-terminal-ansiBlue); + } + + .hl-keyword, + .hl-storage { + color: #ff79c6; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #f1fa8c; + } + .hl-variable.hl-parameter, + .hl-variable.hl-object, + .hl-entity.hl-type.hl-module { + color: #ffb86c; + } + .hl-variable.hl-object { + color: var(--vscode-editor-foreground); + } + .hl-variable.hl-constant { + color: #bd93f9; + } + .hl-type.hl-primitive, + .hl-type.hl-builtin, + .hl-type.hl-name { + color: #8be9fd; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #6272a4; + } +} + +// Dracula +body[data-vscode-theme-name='Dracula Soft'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #62e884; + } + .hl-keyword, + .hl-storage { + color: #f286c4; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #e7ee98; + } + .hl-variable.hl-parameter, + .hl-variable.hl-object, + .hl-entity.hl-type.hl-module { + color: #ffb86c; + } + .hl-variable.hl-object { + color: var(--vscode-editor-foreground); + } + .hl-variable.hl-constant { + color: #bf9eee; + } + .hl-type.hl-primitive, + .hl-type.hl-builtin, + .hl-type.hl-name { + color: #97e1f1; + font-style: italic; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #7b7f8b; + } +} + +// Atom One Dark +body[data-vscode-theme-name='Atom One Dark'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #61afef; + } + .hl-keyword, + .hl-storage { + color: #c678dd; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #98c379; + } + .hl-number { + color: #d19a66; + } + .hl-variable.hl-alias { + color: #e06c75; + } + .hl-object-literal.hl-key { + color: #e06c75; + .hl-function { + color: #61afef; + } + } + .hl-entity.hl-type.hl-name { + color: #e5c07b; + } + .hl-entity.hl-type.hl-module { + color: #98c379; + } + .hl-variable.hl-constant { + color: #d19a66; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: #5c6370; + } +} + +// Monokai Pro (add filters on requst) +body[data-vscode-theme-name='Monokai Pro'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #a9dc76; + } + .hl-keyword { + color: #ff6188; + } + .hl-storage { + color: #78dce8; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #ffd866; + } + .hl-number { + color: #ab9df2; + } + .hl-type { + color: #78dce8; + // Pimitives/builtins are itliac + &.hl-primitive, + &.hl-builtin { + font-style: italic; + } + } + .hl-variable.hl-constant { + color: #ab9df2; + } + .hl-variable.hl-parameter { + color: #fc9867; + font-style: italic; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: #727072; + } +} + +// Cobalt2 +body[data-vscode-theme-name='Cobalt2'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #ffc600; + } + .hl-storage, + .hl-keyword { + color: #ffc600; + } + .hl-storage.hl-function { + color: #fb94ff; + } + .hl-keyword.hl-export { + color: #ff9d00; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: #0088ff; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #a5ff90; + } + .hl-number { + color: #ff628c; + } + .hl-entity.hl-type.hl-name { + color: #ff68b8; + font-style: italic; + } + .hl-entity.hl-type.hl-module, + .hl-type.hl-primitive, + .hl-type.hl-builtin { + color: #80ffbb; + } +} + +// Panda +body[data-vscode-theme-name='Panda Syntax'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #6fc1ff; + } + .hl-keyword { + color: #ff75b5; + } + .hl-storage { + color: #ffb86c; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #676b79; + } + .hl-type.hl-primitive, + .hl-type.hl-builtin, + .hl-new .hl-entity.hl-type { + color: #ffcc95; + } +} + +// Night Owl TODO use starting match +body[data-vscode-theme-name^='Night Owl'].sourcegraph-extension.theme-dark { + .hl-keyword { + color: #c792ea; + font-style: italic; + } + .hl-storage { + color: #c792ea; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #ecc48d; + } + .hl-variable { + font-style: italic; + &.hl-constant { + color: #82aaff; + } + } + .hl-type.hl-primitive, + .hl-type.hl-builtin, + .hl-entity.hl-type { + color: #ffcb8b; + font-style: italic; + } + .hl-entity.hl-type.hl-module { + color: #c792ea; + font-style: italic; + } + .hl-number { + color: #f78c6c; + } + .hl-boolean { + font-style: italic; + color: #ff5874; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: #637777; + } +} + +// Night Owl (No Italics) +body[data-vscode-theme-name='Night Owl (No Italics)'].sourcegraph-extension.theme-dark { + .hl-keyword { + font-style: normal; + } + .hl-variable { + font-style: normal; + } + .hl-type.hl-primitive, + .hl-type.hl-builtin, + .hl-entity.hl-type { + font-style: normal; + } + .hl-entity.hl-type.hl-module { + font-style: normal; + } + .hl-boolean { + font-style: normal; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: normal; + } +} + +// Hack The Box(-Lite) +body[data-vscode-theme-name^='Hack The Box'].sourcegraph-extension.theme-dark { + .hl-keyword { + color: #cf8dfb; + } + .hl-storage { + color: #ff8484; + } + .hl-entity.hl-name.hl-type { + color: #ff8484; + } + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #ffcc5c; + } + .hl-variable.hl-parameter { + color: #5cb2ff; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: rgba(215, 228, 255, 0.28); + } +} + +// SynthWave '84 +body[data-vscode-theme-name="SynthWave '84"].sourcegraph-extension.theme-dark { + .hl-keyword, + .hl-storage, + .hl-type.hl-storage { + color: #fede5d; + } + .hl-number { + color: #f97e72; + } + .hl-variable { + color: #ff7edb; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #ff8b39; + } + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #36f9f6; + } + .hl-parameter { + font-style: italic; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: #848bbd; + } + .hl-type { + color: #fe4450; + &.hl-punctuation, + &.hl-meta { + color: #bbbbbb; + } + } + .hl-arrow { + color: #fede5d; + } +} + +// PARTIALLY SUPPORTED THEMES + +// One Monokai +body[data-vscode-theme-name='One Monokai'].sourcegraph-extension.theme-dark { + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-support.hl-function, + .hl-variable.hl-function { + color: #98c379; + } + .hl-keyword { + color: #e06c75; + } + .hl-storage { + color: #56b6c2; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #676f7d; + } + .hl-type.hl-name { + color: #61afef; + } +} + +// Solarized Light +body[data-vscode-theme-name='Solarized Light'].sourcegraph-extension.theme-light { + .hl-keyword { + color: #859900; + } + .hl-storage { + color: #073642; + font-weight: bold; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: #93a1a1; + } + .hl-source, + .hl-text, + .hl-variable, + .hl-function { + color: #268bd2; + } + .hl-attribute-name { + color: #93a1a1; + } +} + +// Solarized Dark +body[data-vscode-theme-name='Solarized Dark'].sourcegraph-extension.theme-dark { + .hl-keyword { + color: #859900; + } + .hl-storage { + color: #93a1a1; + font-weight: bold; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + font-style: italic; + color: #657b83; + } + .hl-source, + .hl-text, + .hl-variable, + .hl-function { + color: #268bd2; + } + .hl-attribute-name { + color: #93a1a1; + } +} + +// Material Theme Palenight High Contrast +body[data-vscode-theme-name='Material Theme Palenight High Contrast'].sourcegraph-extension.theme-dark { + // Keyword + .hl-keyword { + color: #89ddff; + } + // Storage + .hl-storage { + color: #a6accd; + } + // Constants + .hl-constant { + color: #c3e88d; + } + .hl-import-export-all, + .hl-number, + .hl-constant.hl-numeric, + .hl-integer, + .hl-decimal { + color: #f78c6c; + } + // Strings + .hl-string, + .hl-constant.hl-other.hl-symbol { + color: #c3e88d; + } + // Functions + .hl-entity.hl-name.hl-function, + .hl-meta.hl-require, + .hl-support.hl-function.hl-any-method, + .hl-variable.hl-function { + color: #82aaff; + } + // Comments + .hl-comment, + .hl-punctuation.hl-definition.hl-comment { + color: #676e95; + font-style: italic; + } + .hl-type.hl-primitive, + .hl-type.hl-builtin { + color: #c792ea; + } + .hl-variable.hl-parameter { + color: #a6accd; + } + .hl-punctuation { + color: #89ddff; + } + .match-highlight, + .match-highlight-sticky, + .selection-highlight, + .selection-highlight-sticky { + background-color: var(--vscode-editor-findMatchHighlightBackground); + border-bottom: 1px solid var(--vscode-editor-findMatchBorder); + } +} diff --git a/client/vscode/src/webview/theming/monaco.scss b/client/vscode/src/webview/theming/monaco.scss new file mode 100644 index 00000000000..0ae85c77214 --- /dev/null +++ b/client/vscode/src/webview/theming/monaco.scss @@ -0,0 +1,10 @@ +// Remove cursor focus shadow. +.monaco-editor textarea:focus { + box-shadow: none !important; +} + +// Used for +.flex-shrink-past-contents { + flex-shrink: 1; + min-width: 0; +} diff --git a/client/vscode/src/webview/theming/monacoTheme.ts b/client/vscode/src/webview/theming/monacoTheme.ts new file mode 100644 index 00000000000..e25799f1320 --- /dev/null +++ b/client/vscode/src/webview/theming/monacoTheme.ts @@ -0,0 +1,184 @@ +import * as monaco from 'monaco-editor' + +let lastThemeName: string | undefined + +export function adaptMonacoThemeToEditorTheme(): void { + let editor: monaco.editor.ICodeEditor | undefined + + // Wait for init to set theme. + monaco.editor.onDidCreateEditor(newEditor => { + editor = newEditor + setMonacoTheme({ editor }) + }) + + const mutationObserver = new MutationObserver(() => { + setMonacoTheme({ editor }) + }) + + mutationObserver.observe(document.documentElement, { childList: false, attributes: true }) +} + +function setMonacoTheme({ editor }: { editor?: monaco.editor.ICodeEditor }): void { + const body = document.querySelector('body') + const themeName = body?.dataset.vscodeThemeName + + if (lastThemeName !== themeName || !lastThemeName) { + const themeKind = body?.dataset.vscodeThemeKind === 'vscode-light' ? 'theme-light' : 'theme-dark' + + const computedStyle = getComputedStyle(document.documentElement) + + const colors: monaco.editor.IColors = {} + + for (const colorId of Object.keys(monacoColorIdWebviewCustomProperties)) { + try { + const customProperty = monacoColorIdWebviewCustomProperties[colorId] + const style = computedStyle.getPropertyValue(customProperty) + + colors[colorId] = rgbaToHex(style) + } catch (error) { + console.error('Error computing style for search box:', error) + } + } + + const rules: monaco.editor.ITokenThemeRule[] = [] + + for (const { token, foregroundProperty } of tokenRuleCustomProperties) { + try { + const style = computedStyle.getPropertyValue(foregroundProperty) + rules.push({ token, foreground: rgbaToHex(style) }) + } catch (error) { + console.error('Error computing style for search box:', error) + } + } + + // Set font size + const fontSize = parseInt(computedStyle.getPropertyValue('--vscode-editor-font-size'), 10) + if (!isNaN(fontSize)) { + editor?.updateOptions({ fontSize }) + } + + monaco.editor.defineTheme(themeKind === 'theme-light' ? 'sourcegraph-light' : 'sourcegraph-dark', { + base: 'vs-dark', + inherit: true, + colors, + rules, + }) + } +} + +// Same for dark and light theme. +const monacoColorIdWebviewCustomProperties: Record = { + background: '--vscode-editorWidget-background', + 'editor.background': '--vscode-editorWidget-background', + 'textLink.activeBackground': '--vscode-inputValidation-infoBackground', // Not avaliable to webview. + 'editor.foreground': '--vscode-editor-foreground', + 'editorCursor.foreground': '--vscode-editorCursor-foreground', + 'editorSuggestWidget.background': '--vscode-editorSuggestWidget-background', + 'editorSuggestWidget.foreground': '--vscode-editorSuggestWidget-foreground', + 'editorSuggestWidget.highlightForeground': '--vscode-editorSuggestWidget-highlightForeground', + 'editorSuggestWidget.selectedBackground': '--vscode-editorSuggestWidget-selectedBackground', + 'list.hoverBackground': '--vscode-list-hoverBackground', + 'editorSuggestWidget.border': '--vscode-editorSuggestWidget-border', + 'editorHoverWidget.background': '--vscode-editorHoverWidget-background', + 'editorHoverWidget.foreground': '--vscode-editorHoverWidget-foreground', + 'editorHoverWidget.border': '--vscode-editorHoverWidget-border', + 'editor.hoverHighlightBackground': '--vscode-editor-hoverHighlightBackground', +} + +const tokenRuleCustomProperties: { token: string; foregroundProperty: string }[] = [ + // Sourcegraph base language tokens + { token: 'identifier', foregroundProperty: '--vscode-foreground' }, + { token: 'field', foregroundProperty: '--vscode-debugTokenExpression-name' }, + { token: 'keyword', foregroundProperty: '--vscode-debugTokenExpression-boolean' }, + { token: 'openingParen', foregroundProperty: '--vscode-debugTokenExpression-boolean' }, + { token: 'closingParen', foregroundProperty: '--vscode-debugTokenExpression-boolean' }, + { token: 'comment', foregroundProperty: '--vscode-debugTokenExpression-string' }, + // Sourcegraph decorated language tokens + { token: 'metaFilterSeparator', foregroundProperty: '--vscode-descriptionForeground' }, + { token: 'metaRepoRevisionSeparator', foregroundProperty: '--vscode-debugTokenExpression-name' }, + { token: 'metaContextPrefix', foregroundProperty: '--vscode-debugTokenExpression-boolean' }, + { token: 'metaPredicateNameAccess', foregroundProperty: '--vscode-debugTokenExpression-boolean' }, + { token: 'metaPredicateDot', foregroundProperty: '--vscode-foreground' }, + { token: 'metaPredicateParenthesis', foregroundProperty: '--vscode-debugTokenExpression-string' }, + // Regexp pattern highlighting + { token: 'metaRegexpDelimited', foregroundProperty: '--vscode-debugTokenExpression-error' }, + { token: 'metaRegexpAssertion', foregroundProperty: '--vscode-debugTokenExpression-error' }, + { token: 'metaRegexpLazyQuantifier', foregroundProperty: '--vscode-debugTokenExpression-error' }, + { token: 'metaRegexpEscapedCharacter', foregroundProperty: '--vscode-debugConsole-warningForeground' }, + { token: 'metaRegexpCharacterSet', foregroundProperty: '--vscode-debugTokenExpression-boolean' }, + { token: 'metaRegexpCharacterClass', foregroundProperty: '--vscode-textLink-foreground' }, + { token: 'metaRegexpCharacterClassMember', foregroundProperty: '--vscode-foreground' }, + { token: 'metaRegexpCharacterClassRange', foregroundProperty: '--vscode-foreground' }, + { token: 'metaRegexpCharacterClassRangeHyphen', foregroundProperty: '--vscode-debugTokenExpression-boolean' }, + { token: 'metaRegexpRangeQuantifier', foregroundProperty: '--vscode-terminal-ansiBrightCyan' }, + { token: 'metaRegexpAlternative', foregroundProperty: '--vscode-terminal-ansiBrightCyan' }, + // Structural pattern highlighting + { token: 'metaStructuralHole', foregroundProperty: '--vscode-debugTokenExpression-error' }, + { token: 'metaStructuralRegexpHole', foregroundProperty: '--vscode-debugTokenExpression-error' }, + { token: 'metaStructuralVariable', foregroundProperty: '--vscode-foreground' }, + { token: 'metaStructuralRegexpSeparator', foregroundProperty: '--vscode-debugTokenExpression-string' }, + // Revision highlighting + { token: 'metaRevisionSeparator', foregroundProperty: '--vscode-debugTokenExpression-string' }, + { token: 'metaRevisionIncludeGlobMarker', foregroundProperty: '--vscode-debugTokenExpression-error' }, + { token: 'metaRevisionExcludeGlobMarker', foregroundProperty: '--vscode-debugTokenExpression-error' }, + { token: 'metaRevisionCommitHash', foregroundProperty: '--vscode-foreground' }, + { token: 'metaRevisionLabel', foregroundProperty: '--vscode-foreground' }, + { token: 'metaRevisionReferencePath', foregroundProperty: '--vscode-foreground' }, + { token: 'metaRevisionWildcard', foregroundProperty: '--vscode-terminal-ansiBrightCyan' }, + // Path-like highlighting + { token: 'metaPathSeparator', foregroundProperty: '--vscode-descriptionForeground' }, +] + +/** + * Converts an rgb(a) color to a hex (#rrggbb(aa)) color. + * Will return the same value if passed a hex color. + * Necessary because Monaco does not accept rgb(a) colors. + * + * Adapted from https://github.com/sindresorhus/rgb-hex + * + * Debt: apply simple alpha compositing to resolve color + * (given background) without reducing opacity. + */ +function rgbaToHex(value: string): string { + value = value.trim() + if (!value.startsWith('rgb')) { + return value + } + + const matches = value.match(/(0?\.?\d{1,3})%?\b/g)?.map(component => Number(component)) + + if (!matches) { + console.error('Invalid RGB value:', value) + return '' + } + let [red, green, blue, alpha] = matches + + if ( + typeof red !== 'number' || + typeof green !== 'number' || + typeof blue !== 'number' || + red > 255 || + green > 255 || + blue > 255 + ) { + console.error('Expected three numbers below 256') + return '' + } + + let hexAlpha = '' + + if (typeof alpha === 'number' && !isNaN(alpha)) { + const isPercent = value.includes('%') + if (!isPercent && alpha >= 0 && alpha <= 1) { + alpha = Math.round(255 * alpha) + } else if (isPercent && alpha >= 0 && alpha <= 100) { + alpha = Math.round((255 * alpha) / 100) + } else { + throw new TypeError(`Expected alpha value (${alpha}) as a fraction or percentage`) + } + + hexAlpha = (alpha | (1 << 8)).toString(16).slice(1) + } + + return `#${(blue | (green << 8) | (red << 16) | (1 << 24)).toString(16).slice(1) + hexAlpha}` +} diff --git a/client/vscode/src/webview/theme.ts b/client/vscode/src/webview/theming/sourcegraphTheme.ts similarity index 81% rename from client/vscode/src/webview/theme.ts rename to client/vscode/src/webview/theming/sourcegraphTheme.ts index b8a84049891..c4f92fc233e 100644 --- a/client/vscode/src/webview/theme.ts +++ b/client/vscode/src/webview/theming/sourcegraphTheme.ts @@ -4,7 +4,7 @@ import { BehaviorSubject, Observable } from 'rxjs' * Adds correct theme class to element, * returns Observable with latest theme. */ -export function adaptToEditorTheme(): Observable<'theme-dark' | 'theme-light' | undefined> { +export function adaptSourcegraphThemeToEditorTheme(): Observable<'theme-dark' | 'theme-light' | undefined> { const body = document.querySelector('body') const themes = new BehaviorSubject<'theme-dark' | 'theme-light' | undefined>(undefined) @@ -15,11 +15,11 @@ export function adaptToEditorTheme(): Observable<'theme-dark' | 'theme-light' | if (sourcegraphThemeClass !== themes.value) { if (sourcegraphThemeClass === 'theme-light') { body?.classList.remove('theme-dark') - body?.classList.add('theme-light') + body?.classList.add('theme-light', 'sourcegraph-extension') themes.next('theme-light') } else { body?.classList.remove('theme-light') - body?.classList.add('theme-dark') + body?.classList.add('theme-dark', 'sourcegraph-extension') themes.next('theme-dark') } } @@ -33,7 +33,5 @@ export function adaptToEditorTheme(): Observable<'theme-dark' | 'theme-light' | mutationObserver.observe(body!, { childList: false, attributes: true }) - // TODO: emit editor font as well for monaco - return themes.asObservable() } diff --git a/client/vscode/tests/installExtension.ts b/client/vscode/tests/installExtension.ts new file mode 100644 index 00000000000..083788f2ef9 --- /dev/null +++ b/client/vscode/tests/installExtension.ts @@ -0,0 +1,106 @@ +import { mkdirSync, WriteStream, createWriteStream } from 'fs' +import path from 'path' +import { Readable } from 'stream' + +import { Entry, open as _openZip, ZipFile } from 'yauzl' + +export function installExtension(extensionPath: string, extensionDirectory: string): Promise { + return openZip(extensionPath, true).then(zipfile => extractZip(zipfile, extensionDirectory)) +} + +function openZip(zipFile: string, lazy: boolean = false): Promise { + return new Promise((resolve, reject) => { + _openZip(zipFile, lazy ? { lazyEntries: true } : {}, (error?: Error, zipfile?: ZipFile) => { + if (error || zipfile === undefined) { + reject(error) + } else { + resolve(zipfile) + } + }) + }) +} + +function openZipStream(zipFile: ZipFile, entry: Entry): Promise { + return new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (error?: Error, stream?: Readable) => { + if (error || stream === undefined) { + reject(error) + } else { + resolve(stream) + } + }) + }) +} + +function extractZip(zipfile: ZipFile, targetPath: string): Promise { + let extractedEntriesCount = 0 + + return new Promise((resolve, reject) => { + const readNextEntry = (): void => { + extractedEntriesCount++ + zipfile.readEntry() + } + + zipfile.once('error', reject) + zipfile.once('close', () => { + if (zipfile.entryCount === extractedEntriesCount) { + resolve() + } else { + reject(new Error('Incomplete extraction.')) + } + }) + zipfile.readEntry() + // eslint-disable-next-line @typescript-eslint/no-misused-promises + zipfile.on('entry', async (entry: Entry) => { + const fileName = entry.fileName // .replace(options.sourcePathRegex, '') + + // directory file names end with '/' + if (fileName.endsWith('/')) { + const targetFileName = path.join(targetPath, fileName) + mkdirSync(targetFileName, { recursive: true }) + readNextEntry() + return + } + + const stream = openZipStream(zipfile, entry) + const mode = modeFromEntry(entry) + + await extractEntry(await stream, fileName, mode, targetPath) + readNextEntry() + }) + }) +} + +function extractEntry(stream: Readable, fileName: string, mode: number, targetPath: string): Promise { + const directoryName = path.dirname(fileName) + const targetDirectoryName = path.join(targetPath, directoryName) + if (!targetDirectoryName.startsWith(targetPath)) { + return Promise.reject(new Error('invalid file')) + } + + const targetFileName = path.join(targetPath, fileName) + + let istream: WriteStream + + mkdirSync(targetDirectoryName, { recursive: true }) + + return new Promise((resolve, reject) => { + try { + istream = createWriteStream(targetFileName, { mode }) + istream.once('close', () => resolve()) + istream.once('error', reject) + stream.once('error', reject) + stream.pipe(istream) + } catch { + reject(new Error('Unknown error')) + } + }) +} + +function modeFromEntry(entry: Entry): number { + const attribute = entry.externalFileAttributes >> 16 || 33188 + + return [448 /* S_IRWXU */, 56 /* S_IRWXG */, 7 /* S_IRWXO */] + .map(mask => attribute & mask) + .reduce((a, b) => a + b, attribute & 61440 /* S_IFMT */) +} diff --git a/client/vscode/tests/runTests.ts b/client/vscode/tests/runTests.ts new file mode 100644 index 00000000000..8cf43418c81 --- /dev/null +++ b/client/vscode/tests/runTests.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import childProcess from 'child_process' +import { mkdtempSync, readFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { downloadAndUnzipVSCode } from '@vscode/test-electron' +import puppeteer, { Page } from 'puppeteer' +import rimraf from 'rimraf' + +import { installExtension } from './installExtension' + +const verbose = process.argv.includes('-v') || process.argv.includes('--verbose') + +const PORT = 29378 + +async function run(): Promise { + let vscodeProcess: null | { kill: () => void } = null + + function cleanupVSCode(runAfter?: () => void): void { + setTimeout(() => { + if (vscodeProcess !== null) { + vscodeProcess.kill() + } + + // eslint-disable-next-line no-void + void delay(1000).then(() => { + rimraf.sync(userDataDirectory) + rimraf.sync(extensionsDirectory) + + runAfter?.() + }) + }, 1000) + } + + const userDataDirectory = mkdtempSync(join(tmpdir(), 'vsce')) + const extensionsDirectory = mkdtempSync(join(tmpdir(), 'vsce')) + try { + const vscodeExecutablePath = await downloadAndUnzipVSCode() + + console.log('Starting VS Code', { verbose, vscodeExecutablePath, userDataDirectory, extensionsDirectory }) + + const extensionVersion: string = JSON.parse(readFileSync('package.json').toString()).version + if (typeof extensionVersion !== 'string' || extensionVersion === '') { + throw new Error('Could not extract extension version from client/vscode/package.json') + } + + const extensionPackage = join(process.cwd(), 'dist', `sourcegraph-${extensionVersion}.vsix`) + + await installExtension(extensionPackage, extensionsDirectory) + + vscodeProcess = launchVSC(vscodeExecutablePath, userDataDirectory, extensionsDirectory, PORT) + + const browserURL = `http://127.0.0.1:${PORT}` + await delay(2000) + console.log(`VSCode started in debug mode on ${browserURL}`) + + const browser = await puppeteer.connect({ + browserURL, + defaultViewport: null, // used to bypass Chrome viewport issue, doesn't work w/ VS code. + slowMo: 50, + }) + + await delay(1000) + + // We look for the VSCode frontend process + const pages = await browser.pages() + let page: null | Page = null + for (const _page of pages) { + const title = await _page.title() + if (title.includes('Get Started')) { + page = _page + } + } + if (page === null) { + throw new Error('Could not find VS Code frontend page') + } + + const frame = await getSearchPanelWebview(page) + + const context = await frame.executionContext() + const textContent: string = await context.evaluate(() => document.body.textContent) + + if (!textContent.includes('Search your code and 2M+ open source repositories')) { + throw new Error('Expected page content to contain a specific string') + } + + console.log('+++ Test successful') + cleanupVSCode() + } catch (error) { + console.error('--- Failed to run tests') + console.error(error) + cleanupVSCode(() => process.exit(1)) + } +} + +// eslint-disable-next-line no-void +void run() + +function launchVSC(executablePath: string, userDataDirectory: string, extensionsDirectory: string, port: number) { + return childProcess.spawn( + executablePath, + [ + `--remote-debugging-port=${port || 9229}`, + `--user-data-dir=${userDataDirectory}`, + `--extensions-dir=${extensionsDirectory}`, + '--enable-logging', + '--skip-release-notes', + // https://github.com/microsoft/vscode-test/issues/120 + '--disable-updates', + // https://github.com/microsoft/vscode/issues/84238 + '--no-sandbox', + '--disable-gpu', + ], + { + env: process.env, + stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'], + } + ) +} + +function delay(timeout: number): Promise { + return new Promise(resolve => setTimeout(resolve, timeout)) +} + +/** + * The VSCode extension currently opens two web views, one is the sidebar content and the other the search page. + * We want to run assertions on the search page. + * To be reused for all search panel test cases. + * + * @param page The VS Code frontend page. + * @returns Search panel webview frame. + */ +async function getSearchPanelWebview(page: puppeteer.Page): Promise { + await page.waitForSelector('[aria-label="Sourcegraph"]') + await page.click('[aria-label="Sourcegraph"]') + + // In the release of VS Code at the time this test harness was written, there was no + // stable unique selector for the search panel webview's outermost iframe ancestor. + // Try all iframes, from last to first. Fail when we can't find the search panel webview. + const outerFrameHandles = (await page?.$$('div[id^="webview"] iframe')).reverse() + + let searchPanelWebviewFrame: puppeteer.Frame | null = null + + // Throw error for the latest step in which a failure occurred. + const errorMessages: string[] = [] + + if (outerFrameHandles.length === 0) { + errorMessages[0] = 'Could not find Sourcegraph search page iframe handle' + } + + for (const outerFrameHandle of outerFrameHandles) { + const outerFrame = await outerFrameHandle.contentFrame() + if (!outerFrame) { + errorMessages[1] = 'Could not find Sourcegraph search page iframe' + continue + } + // The search page web view has another iframe inside it. ¯\_(ツ)_/¯ + const frameHandle = await outerFrame.waitForSelector('iframe') + if (frameHandle === null) { + errorMessages[2] = 'Could not find inner Sourcegraph search page iframe handle' + continue + } + const frame = await frameHandle.contentFrame() + if (frame === null) { + errorMessages[3] = 'Could not find inner Sourcegraph search page iframe' + continue + } + + try { + const brandHeader = await frame.waitForSelector('[data-testid="brand-header"]') + if (!brandHeader) { + throw new Error('Expected search panel to render brand header') + } + } catch { + errorMessages[4] = 'Expected search panel to render brand header' + continue + } + + searchPanelWebviewFrame = frame + break + } + + if (!searchPanelWebviewFrame) { + throw new Error(errorMessages.slice(-1)[0]) + } + + return searchPanelWebviewFrame +} diff --git a/client/vscode/tsconfig.json b/client/vscode/tsconfig.json index 7331d4215d8..a0f6dd9dffb 100644 --- a/client/vscode/tsconfig.json +++ b/client/vscode/tsconfig.json @@ -22,6 +22,6 @@ { "path": "../search" }, { "path": "../search-ui" }, ], - "include": ["**/*", ".*", "**/*.d.ts"], + "include": ["./package.json", "**/*", ".*", "**/*.d.ts"], "exclude": ["node_modules", "../../node_modules", ".vscode-test", "out", "dist"], } diff --git a/client/vscode/webpack.config.js b/client/vscode/webpack.config.js index 1bb4b107110..d41173753b2 100644 --- a/client/vscode/webpack.config.js +++ b/client/vscode/webpack.config.js @@ -4,6 +4,17 @@ const path = require('path') const MiniCssExtractPlugin = require('mini-css-extract-plugin') +const webpack = require('webpack') + +const { + getMonacoWebpackPlugin, + getCSSModulesLoader, + getBasicCSSLoader, + getMonacoCSSRule, + getCSSLoaders, +} = require('@sourcegraph/build-config') + +const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development' /** * The VS Code extension core needs to be built for two targets: @@ -13,10 +24,15 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') * @param {*} targetType See https://webpack.js.org/configuration/target/ */ function getExtensionCoreConfiguration(targetType) { + if (typeof targetType !== 'string') { + return + } return { + context: __dirname, // needed when running `gulp` from the root dir + mode, name: `extension:${targetType}`, target: targetType, - entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ + entry: path.resolve(__dirname, 'src', 'extension.ts'), // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ output: { // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ path: path.resolve(__dirname, 'dist', `${targetType}`), @@ -35,7 +51,15 @@ function getExtensionCoreConfiguration(targetType) { resolve: { // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader extensions: ['.ts', '.tsx', '.js', '.jsx'], - alias: {}, + alias: + targetType === 'webworker' + ? { + path: require.resolve('path-browserify'), + './browserActionsNode': path.resolve(__dirname, 'src', 'link-commands', 'browserActionsWeb'), + } + : { + path: require.resolve('path-browserify'), + }, fallback: targetType === 'webworker' ? { @@ -43,6 +67,8 @@ function getExtensionCoreConfiguration(targetType) { path: require.resolve('path-browserify'), assert: require.resolve('assert'), util: require.resolve('util'), + http: require.resolve('stream-http'), + https: require.resolve('https-browserify'), } : {}, }, @@ -60,6 +86,12 @@ function getExtensionCoreConfiguration(targetType) { }, ], }, + plugins: [ + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser', // provide a shim for the global `process` variable + }), + ], } } @@ -68,35 +100,25 @@ const vscodeWorkspacePath = path.resolve(rootPath, 'client', 'vscode') const vscodeSourcePath = path.resolve(vscodeWorkspacePath, 'src') const webviewSourcePath = path.resolve(vscodeSourcePath, 'webview') -const getCSSLoaders = (...loaders) => [ - MiniCssExtractPlugin.loader, - ...loaders, - { - loader: 'postcss-loader', - }, - { - loader: 'sass-loader', - options: { - sassOptions: { - includePaths: [path.resolve(rootPath, 'node_modules'), path.resolve(rootPath, 'client')], - }, - }, - }, -] - const searchPanelWebviewPath = path.resolve(webviewSourcePath, 'search-panel') -const searchSidebarWebviewPath = path.resolve(webviewSourcePath, 'search-sidebar') +const searchSidebarWebviewPath = path.resolve(webviewSourcePath, 'sidebars', 'search') +const helpSidebarWebviewPath = path.resolve(webviewSourcePath, 'sidebars', 'help') const extensionHostWorker = /main\.worker\.ts$/ +const MONACO_EDITOR_PATH = path.resolve(rootPath, 'node_modules', 'monaco-editor') + /** @type {import('webpack').Configuration}*/ const webviewConfig = { + context: __dirname, // needed when running `gulp` from the root dir + mode, name: 'webviews', target: 'web', entry: { searchPanel: [path.resolve(searchPanelWebviewPath, 'index.tsx')], searchSidebar: [path.resolve(searchSidebarWebviewPath, 'index.tsx')], + helpSidebar: [path.resolve(helpSidebarWebviewPath, 'index.tsx')], style: path.join(webviewSourcePath, 'index.scss'), }, devtool: 'source-map', @@ -104,18 +126,34 @@ const webviewConfig = { path: path.resolve(__dirname, 'dist/webview'), filename: '[name].js', }, - plugins: [new MiniCssExtractPlugin()], + plugins: [ + new MiniCssExtractPlugin(), + getMonacoWebpackPlugin(), + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser', // provide a shim for the global `process` variable + }), + ], externals: { // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ vscode: 'commonjs vscode', }, resolve: { - alias: {}, + alias: { + path: require.resolve('path-browserify'), + './Link': path.resolve(__dirname, 'src', 'webview', 'search-panel', 'alias', 'Link'), // Replace web app Link component from @sourcegraph/wildcard with the Link component built for VSCE + './SearchResult': path.resolve(__dirname, 'src', 'webview', 'search-panel', 'alias', 'SearchResult'), + './FileMatchChildren': path.resolve(__dirname, 'src', 'webview', 'search-panel', 'alias', 'FileMatchChildren'), + './RepoFileLink': path.resolve(__dirname, 'src', 'webview', 'search-panel', 'alias', 'RepoFileLink'), + '../documentation/ModalVideo': path.resolve(__dirname, 'src', 'webview', 'search-panel', 'alias', 'ModalVideo'), + }, // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader extensions: ['.ts', '.tsx', '.js', '.jsx'], fallback: { path: require.resolve('path-browserify'), process: require.resolve('process/browser'), + http: require.resolve('stream-http'), // for stream search - event source polyfills + https: require.resolve('https-browserify'), // for stream search - event source polyfills }, }, module: { @@ -129,28 +167,6 @@ const webviewConfig = { }, ], }, - // SCSS rule for our own styles and Bootstrap - { - test: /\.(css|sass|scss)$/, - exclude: /\.module\.(sass|scss)$/, - use: getCSSLoaders({ loader: 'css-loader', options: { url: false } }), - }, - // For CSS modules - { - test: /\.(css|sass|scss)$/, - include: /\.module\.(sass|scss)$/, - use: getCSSLoaders({ - loader: 'css-loader', - options: { - sourceMap: false, - modules: { - exportLocalsConvention: 'camelCase', - localIdentName: '[name]__[local]_[hash:base64:5]', - }, - url: false, - }, - }), - }, { test: extensionHostWorker, use: [ @@ -161,10 +177,37 @@ const webviewConfig = { 'ts-loader', ], }, + { + test: /\.(sass|scss)$/, + // CSS Modules loaders are only applied when the file is explicitly named as CSS module stylesheet using the extension `.module.scss`. + include: /\.module\.(sass|scss)$/, + use: getCSSLoaders(MiniCssExtractPlugin.loader, getCSSModulesLoader({})), + }, + { + test: /\.(sass|scss)$/, + exclude: /\.module\.(sass|scss)$/, + use: getCSSLoaders(MiniCssExtractPlugin.loader, getBasicCSSLoader()), + }, + getMonacoCSSRule(), + // Don't use shared getMonacoTFFRule(); we want to retain its name + // to reference path in the extension when we load the font ourselves. + { + test: /\.ttf$/, + include: [MONACO_EDITOR_PATH], + type: 'asset/resource', + generator: { + filename: '[name][ext]', + }, + }, ], }, } module.exports = function () { + if (process.env.TARGET_TYPE) { + return Promise.all([getExtensionCoreConfiguration(process.env.TARGET_TYPE), webviewConfig]) + } + + // If target type isn't specified, build both. return Promise.all([getExtensionCoreConfiguration('node'), getExtensionCoreConfiguration('webworker'), webviewConfig]) } diff --git a/client/web/src/IdeExtensionTracker.test.tsx b/client/web/src/IdeExtensionTracker.test.tsx index de6eab0d4c4..8c32597b4bf 100644 --- a/client/web/src/IdeExtensionTracker.test.tsx +++ b/client/web/src/IdeExtensionTracker.test.tsx @@ -21,7 +21,7 @@ describe('IdeExtensionTracker', () => { 'vscode', ], [ - 'https://sourcegraph.com/sign-up?editor=vscode&utm_medium=VSCIDE&utm_source=sidebar&utm_campaign=vsce-sign-up&utm_content=sign-up', + 'https://sourcegraph.com/sign-up?editor=vscode&utm_medium=VSCODE&utm_source=sidebar&utm_campaign=vsce-sign-up&utm_content=sign-up', 'vscode', ], ['https://sourcegraph.com/?something=different', null], diff --git a/client/web/src/IdeExtensionTracker.tsx b/client/web/src/IdeExtensionTracker.tsx index 6e379bdb7d7..e486af942af 100644 --- a/client/web/src/IdeExtensionTracker.tsx +++ b/client/web/src/IdeExtensionTracker.tsx @@ -24,7 +24,7 @@ export const IdeExtensionTracker: React.FunctionComponent = () => { if (utmProductName === 'IntelliJ IDEA') { setLastJetBrainsDetection(Date.now()) - } else if (utmMedium === 'VSCIDE' || utmSource?.toLowerCase().startsWith('vscode')) { + } else if (utmMedium === 'VSCODE' || utmSource?.toLowerCase().startsWith('vscode')) { setLastVSCodeDetection(Date.now()) } diff --git a/client/web/src/Layout.tsx b/client/web/src/Layout.tsx index 11998e6b412..0c094eb85dc 100644 --- a/client/web/src/Layout.tsx +++ b/client/web/src/Layout.tsx @@ -44,7 +44,6 @@ import { GlobalNavbar } from './nav/GlobalNavbar' import { useExtensionAlertAnimation } from './nav/UserNavItem' import { OrgAreaRoute } from './org/area/OrgArea' import { OrgAreaHeaderNavItem } from './org/area/OrgHeader' -import { fetchHighlightedFileLineRanges } from './repo/backend' import { RepoContainerRoute } from './repo/RepoContainer' import { RepoHeaderActionButton } from './repo/RepoHeader' import { RepoRevisionContainerRoute } from './repo/RepoRevisionContainer' @@ -275,7 +274,7 @@ export const Layout: React.FunctionComponent = props => { {...props} {...themeProps} repoName={`git://${parseBrowserRepoURL(props.location.pathname).repoName}`} - fetchHighlightedFileLineRanges={fetchHighlightedFileLineRanges} + fetchHighlightedFileLineRanges={props.fetchHighlightedFileLineRanges} /> )} diff --git a/client/web/src/SourcegraphWebApp.tsx b/client/web/src/SourcegraphWebApp.tsx index c1b7f16e53e..0e2975d0d3c 100644 --- a/client/web/src/SourcegraphWebApp.tsx +++ b/client/web/src/SourcegraphWebApp.tsx @@ -8,7 +8,7 @@ import { createBrowserHistory } from 'history' import ServerIcon from 'mdi-react/ServerIcon' import { Route, Router } from 'react-router' import { ScrollManager } from 'react-scroll-manager' -import { combineLatest, from, Subscription, fromEvent, of, Subject } from 'rxjs' +import { combineLatest, from, Subscription, fromEvent, of, Subject, Observable } from 'rxjs' import { catchError, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators' import * as uuid from 'uuid' @@ -31,6 +31,8 @@ import { import { getEnabledExtensions } from '@sourcegraph/shared/src/api/client/enabledExtensions' import { preloadExtensions } from '@sourcegraph/shared/src/api/client/preload' import { NotificationType } from '@sourcegraph/shared/src/api/extension/extensionHostApi' +import { fetchHighlightedFileLineRanges } from '@sourcegraph/shared/src/backend/file' +import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' import { Controller as ExtensionsController, createController as createExtensionsController, @@ -46,6 +48,7 @@ import { aggregateStreamingSearch } from '@sourcegraph/shared/src/search/stream' import { EMPTY_SETTINGS_CASCADE, SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' import { TemporarySettingsProvider } from '@sourcegraph/shared/src/settings/temporary/TemporarySettingsProvider' import { TemporarySettingsStorage } from '@sourcegraph/shared/src/settings/temporary/TemporarySettingsStorage' +import { globbingEnabledFromSettings } from '@sourcegraph/shared/src/util/globbing' import { // This is the root Tooltip usage // eslint-disable-next-line no-restricted-imports @@ -79,7 +82,6 @@ import { blockToGQLInput } from './notebooks/serialize' import { OrgAreaRoute } from './org/area/OrgArea' import { OrgAreaHeaderNavItem } from './org/area/OrgHeader' import { createPlatformContext } from './platform/context' -import { fetchHighlightedFileLineRanges } from './repo/backend' import { RepoContainerRoute } from './repo/RepoContainer' import { RepoHeaderActionButton } from './repo/RepoHeader' import { RepoRevisionContainerRoute } from './repo/RepoRevisionContainer' @@ -109,7 +111,6 @@ import { UserAreaHeaderNavItem } from './user/area/UserAreaHeader' import { UserSettingsAreaRoute } from './user/settings/UserSettingsArea' import { UserSettingsSidebarItems } from './user/settings/UserSettingsSidebar' import { UserSessionStores } from './UserSessionStores' -import { globbingEnabledFromSettings } from './util/globbing' import { observeLocation } from './util/location' import { siteSubjectNoAdmin, viewerSubjectFromSettings } from './util/settings' @@ -426,7 +427,7 @@ export class SourcegraphWebApp extends React.Component => + fetchHighlightedFileLineRanges({ ...parameters, platformContext: this.platformContext }, force) } diff --git a/client/web/src/integration/graphQlResponseHelpers.ts b/client/web/src/integration/graphQlResponseHelpers.ts index 75305c6ede5..d85500a9216 100644 --- a/client/web/src/integration/graphQlResponseHelpers.ts +++ b/client/web/src/integration/graphQlResponseHelpers.ts @@ -1,7 +1,7 @@ import { encodeURIPathComponent } from '@sourcegraph/common' +import { TreeEntriesResult } from '@sourcegraph/shared/src/graphql-operations' import { - TreeEntriesResult, BlobResult, FileExternalLinksResult, RepositoryRedirectResult, diff --git a/client/web/src/integration/streaming-search-mocks.ts b/client/web/src/integration/streaming-search-mocks.ts index 81987bd0a51..c0af3f42e0c 100644 --- a/client/web/src/integration/streaming-search-mocks.ts +++ b/client/web/src/integration/streaming-search-mocks.ts @@ -1,5 +1,6 @@ /* eslint-disable no-template-curly-in-string */ import { SearchGraphQlOperations } from '@sourcegraph/search' +import { SharedGraphQlOperations } from '@sourcegraph/shared/src/graphql-operations' import { SearchEvent } from '@sourcegraph/shared/src/search/stream' import { SymbolKind, WebGraphQlOperations } from '../graphql-operations' @@ -218,7 +219,7 @@ export const mixedSearchStreamEvents: SearchEvent[] = [ { type: 'done', data: {} }, ] -export const highlightFileResult: Partial = { +export const highlightFileResult: Partial = { HighlightedFile: () => ({ repository: { commit: { diff --git a/client/web/src/notebooks/blocks/query/NotebookQueryBlock.tsx b/client/web/src/notebooks/blocks/query/NotebookQueryBlock.tsx index 018e66c7c60..54bff752b0a 100644 --- a/client/web/src/notebooks/blocks/query/NotebookQueryBlock.tsx +++ b/client/web/src/notebooks/blocks/query/NotebookQueryBlock.tsx @@ -5,13 +5,12 @@ import { noop } from 'lodash' import OpenInNewIcon from 'mdi-react/OpenInNewIcon' import PlayCircleOutlineIcon from 'mdi-react/PlayCircleOutlineIcon' import * as Monaco from 'monaco-editor' -import { useLocation } from 'react-router' import { Observable, of } from 'rxjs' import { HoverMerged } from '@sourcegraph/client-api' import { Hoverifier } from '@sourcegraph/codeintellify' -import { SearchContextProps, useQueryDiagnostics } from '@sourcegraph/search' -import { StreamingSearchResultsList } from '@sourcegraph/search-ui' +import { SearchContextProps } from '@sourcegraph/search' +import { StreamingSearchResultsList, useQueryDiagnostics } from '@sourcegraph/search-ui' import { ActionItemAction } from '@sourcegraph/shared/src/actions/ActionItem' import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' import { MonacoEditor } from '@sourcegraph/shared/src/components/MonacoEditor' @@ -74,7 +73,6 @@ export const NotebookQueryBlock: React.FunctionComponent features.showSearchContext ?? false) const [editor, setEditor] = useState() const searchResults = useObservable(output ?? of(undefined)) - const location = useLocation() const [executedQuery, setExecutedQuery] = useState(input.query) const onInputChange = useCallback( @@ -167,7 +165,6 @@ export const NotebookQueryBlock: React.FunctionComponent + fetchHighlightedFileLineRangesShared( + { + ...parameters, + platformContext, + }, + force + ), + [platformContext] + ) + return (
    {notebookOrError === LOADING && ( @@ -66,7 +77,6 @@ export const EmbeddedNotebookPage: React.FunctionComponent = ({ telemetryService, searchContextsEnabled, isSourcegraphDotCom, - location, fetchHighlightedFileLineRanges, authenticatedUser, showSearchContext, @@ -283,7 +282,6 @@ export const NotebookPage: React.FunctionComponent = ({ telemetryService={telemetryService} searchContextsEnabled={searchContextsEnabled} isSourcegraphDotCom={isSourcegraphDotCom} - location={location} fetchHighlightedFileLineRanges={fetchHighlightedFileLineRanges} authenticatedUser={authenticatedUser} showSearchContext={showSearchContext} diff --git a/client/web/src/repo/backend.ts b/client/web/src/repo/backend.ts index 26a252aa7ab..60fe305e6ec 100644 --- a/client/web/src/repo/backend.ts +++ b/client/web/src/repo/backend.ts @@ -9,9 +9,7 @@ import { RepoSeeOtherError, RevisionNotFoundError, } from '@sourcegraph/shared/src/backend/errors' -import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' import { - AbsoluteRepoFile, makeRepoURI, RepoRevision, RevisionSpec, @@ -21,7 +19,6 @@ import { import { queryGraphQL, requestGraphQL } from '../backend/graphql' import { - TreeFields, ExternalLinkFields, RepositoryRedirectResult, RepositoryRedirectVariables, @@ -170,54 +167,6 @@ export const resolveRevision = memoizeObservable( makeRepoURI ) -/** - * Fetches the specified highlighted file line ranges (`FetchFileParameters.ranges`) and returns - * them as a list of ranges, each describing a list of lines in the form of HTML table '...'. - */ -export const fetchHighlightedFileLineRanges = memoizeObservable( - (context: FetchFileParameters, force?: boolean): Observable => - queryGraphQL( - gql` - query HighlightedFile( - $repoName: String! - $commitID: String! - $filePath: String! - $disableTimeout: Boolean! - $ranges: [HighlightLineRange!]! - ) { - repository(name: $repoName) { - commit(rev: $commitID) { - file(path: $filePath) { - isDirectory - highlight(disableTimeout: $disableTimeout) { - aborted - lineRanges(ranges: $ranges) - } - } - } - } - } - `, - context - ).pipe( - map(({ data, errors }) => { - if (!data?.repository?.commit?.file?.highlight) { - throw createAggregateError(errors) - } - const file = data.repository.commit.file - if (file.isDirectory) { - return [] - } - return file.highlight.lineRanges - }) - ), - context => - makeRepoURI(context) + - `?disableTimeout=${String(context.disableTimeout)}&ranges=${context.ranges - .map(range => `${range.startLine}:${range.endLine}`) - .join(',')}` -) - export const fetchFileExternalLinks = memoizeObservable( (context: RepoRevision & { filePath: string }): Observable => queryGraphQL( @@ -247,53 +196,3 @@ export const fetchFileExternalLinks = memoizeObservable( ), makeRepoURI ) - -export const fetchTreeEntries = memoizeObservable( - (args: AbsoluteRepoFile & { first?: number }): Observable => - queryGraphQL( - gql` - query TreeEntries( - $repoName: String! - $revision: String! - $commitID: String! - $filePath: String! - $first: Int - ) { - repository(name: $repoName) { - commit(rev: $commitID, inputRevspec: $revision) { - tree(path: $filePath) { - ...TreeFields - } - } - } - } - fragment TreeFields on GitTree { - isRoot - url - entries(first: $first, recursiveSingleChild: true) { - ...TreeEntryFields - } - } - fragment TreeEntryFields on TreeEntry { - name - path - isDirectory - url - submodule { - url - commit - } - isSingleChild - } - `, - args - ).pipe( - map(({ data, errors }) => { - if (errors || !data?.repository?.commit?.tree) { - throw createAggregateError(errors) - } - return data.repository.commit.tree - }) - ), - ({ first, ...args }) => `${makeRepoURI(args)}:first-${String(first)}` -) diff --git a/client/web/src/repo/tree/TabNavigation.tsx b/client/web/src/repo/tree/TabNavigation.tsx index 9a7a3407cf5..00fc736e109 100644 --- a/client/web/src/repo/tree/TabNavigation.tsx +++ b/client/web/src/repo/tree/TabNavigation.tsx @@ -10,10 +10,11 @@ import SourceCommitIcon from 'mdi-react/SourceCommitIcon' import TagIcon from 'mdi-react/TagIcon' import { encodeURIPathComponent } from '@sourcegraph/common' +import { TreeFields } from '@sourcegraph/shared/src/graphql-operations' import { Button, ButtonGroup, Icon, Link } from '@sourcegraph/wildcard' import { RepoBatchChangesButton } from '../../batches/RepoBatchChangesButton' -import { TreeFields, TreePageRepositoryFields } from '../../graphql-operations' +import { TreePageRepositoryFields } from '../../graphql-operations' import { useExperimentalFeatures } from '../../stores' interface TabNavigationProps { diff --git a/client/web/src/repo/tree/TreeNavigation.tsx b/client/web/src/repo/tree/TreeNavigation.tsx index 8f66c364740..ecef02bf42b 100644 --- a/client/web/src/repo/tree/TreeNavigation.tsx +++ b/client/web/src/repo/tree/TreeNavigation.tsx @@ -10,10 +10,11 @@ import SourceCommitIcon from 'mdi-react/SourceCommitIcon' import TagIcon from 'mdi-react/TagIcon' import { encodeURIPathComponent } from '@sourcegraph/common' +import { TreeFields } from '@sourcegraph/shared/src/graphql-operations' import { Button, ButtonGroup, Icon, Link } from '@sourcegraph/wildcard' import { RepoBatchChangesButton } from '../../batches/RepoBatchChangesButton' -import { TreeFields, TreePageRepositoryFields } from '../../graphql-operations' +import { TreePageRepositoryFields } from '../../graphql-operations' import { useExperimentalFeatures } from '../../stores' interface TreeNavigationProps { diff --git a/client/web/src/repo/tree/TreePage.tsx b/client/web/src/repo/tree/TreePage.tsx index f350da924db..80c6fefa8e9 100644 --- a/client/web/src/repo/tree/TreePage.tsx +++ b/client/web/src/repo/tree/TreePage.tsx @@ -13,9 +13,11 @@ import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts' import { asError, encodeURIPathComponent, ErrorLike, isErrorLike } from '@sourcegraph/common' import { gql } from '@sourcegraph/http-client' import { SearchContextProps } from '@sourcegraph/search' +import { fetchTreeEntries } from '@sourcegraph/shared/src/backend/repo' import { ActivationProps } from '@sourcegraph/shared/src/components/activation/Activation' import { displayRepoName } from '@sourcegraph/shared/src/components/RepoFileLink' import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { TreeFields } from '@sourcegraph/shared/src/graphql-operations' import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context' import { Settings } from '@sourcegraph/shared/src/schema/settings.schema' import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' @@ -41,9 +43,8 @@ import { BreadcrumbSetters } from '../../components/Breadcrumbs' import { PageTitle } from '../../components/PageTitle' import { ActionItemsBarProps } from '../../extensions/components/ActionItemsBar' import { FeatureFlagProps } from '../../featureFlags/featureFlags' -import { RepositoryFields, TreeFields } from '../../graphql-operations' +import { RepositoryFields } from '../../graphql-operations' import { basename } from '../../util/path' -import { fetchTreeEntries } from '../backend' import { RepositoryCompareArea } from '../compare/RepositoryCompareArea' import { RepoRevisionWrapper } from '../components/RepoRevision' import { FilePathBreadcrumbs } from '../FilePathBreadcrumbs' @@ -151,8 +152,9 @@ export const TreePage: React.FunctionComponent = ({ revision, filePath, first: 2500, + requestGraphQL: props.platformContext.requestGraphQL, }).pipe(catchError((error): [ErrorLike] => [asError(error)])), - [repo.name, commitID, revision, filePath] + [repo.name, commitID, revision, filePath, props.platformContext] ) ) diff --git a/client/web/src/repo/tree/TreePageContent.tsx b/client/web/src/repo/tree/TreePageContent.tsx index a75b6542883..5b4fb7d5155 100644 --- a/client/web/src/repo/tree/TreePageContent.tsx +++ b/client/web/src/repo/tree/TreePageContent.tsx @@ -13,6 +13,7 @@ import { ActionItem } from '@sourcegraph/shared/src/actions/ActionItem' import { ActionsContainer } from '@sourcegraph/shared/src/actions/ActionsContainer' import { FileDecorationsByPath } from '@sourcegraph/shared/src/api/extension/extensionHostApi' import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { TreeFields } from '@sourcegraph/shared/src/graphql-operations' import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context' import * as GQL from '@sourcegraph/shared/src/schema' import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' @@ -22,7 +23,7 @@ import { Button, useObservable } from '@sourcegraph/wildcard' import { getFileDecorations } from '../../backend/features' import { queryGraphQL } from '../../backend/graphql' import { FilteredConnection } from '../../components/FilteredConnection' -import { GitCommitFields, Scalars, TreeFields, TreePageRepositoryFields } from '../../graphql-operations' +import { GitCommitFields, Scalars, TreePageRepositoryFields } from '../../graphql-operations' import { GitCommitNodeProps, GitCommitNode } from '../commits/GitCommitNode' import { gitCommitFragment } from '../commits/RepositoryCommitsPage' diff --git a/client/web/src/repo/tree/TreeTabList.tsx b/client/web/src/repo/tree/TreeTabList.tsx index fbe652e8dc8..b7c7caad3dc 100644 --- a/client/web/src/repo/tree/TreeTabList.tsx +++ b/client/web/src/repo/tree/TreeTabList.tsx @@ -9,10 +9,9 @@ import SourceBranchIcon from 'mdi-react/SourceBranchIcon' import SourceCommitIcon from 'mdi-react/SourceCommitIcon' import TagIcon from 'mdi-react/TagIcon' +import { TreeFields } from '@sourcegraph/shared/src/graphql-operations' import { Icon, Link } from '@sourcegraph/wildcard' -import { TreeFields } from '../../graphql-operations' - interface TreeTabList { tree: TreeFields selectedTab: string diff --git a/client/web/src/search/SearchConsolePage.tsx b/client/web/src/search/SearchConsolePage.tsx index eab013f50a7..c048e39b2c5 100644 --- a/client/web/src/search/SearchConsolePage.tsx +++ b/client/web/src/search/SearchConsolePage.tsx @@ -7,11 +7,16 @@ import * as Monaco from 'monaco-editor' import { BehaviorSubject } from 'rxjs' import { debounceTime } from 'rxjs/operators' -import { useQueryIntelligence, useQueryDiagnostics } from '@sourcegraph/search' -import { StreamingSearchResultsList, StreamingSearchResultsListProps } from '@sourcegraph/search-ui' +import { + StreamingSearchResultsList, + StreamingSearchResultsListProps, + useQueryIntelligence, + useQueryDiagnostics, +} from '@sourcegraph/search-ui' import { transformSearchQuery } from '@sourcegraph/shared/src/api/client/search' import { MonacoEditor } from '@sourcegraph/shared/src/components/MonacoEditor' import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { LATEST_VERSION } from '@sourcegraph/shared/src/search/stream' import { fetchStreamSuggestions } from '@sourcegraph/shared/src/search/suggestions' import { LoadingSpinner, Button, useObservable } from '@sourcegraph/wildcard' @@ -20,8 +25,6 @@ import { SearchPatternType } from '../graphql-operations' import { useExperimentalFeatures } from '../stores' import { SearchUserNeedsCodeHost } from '../user/settings/codeHosts/OrgUserNeedsCodeHost' -import { LATEST_VERSION } from './results/StreamingSearchResults' - import { parseSearchURLQuery, parseSearchURLPatternType, SearchStreamingProps } from '.' import styles from './SearchConsolePage.module.scss' diff --git a/client/web/src/search/results/StreamingSearchResults.tsx b/client/web/src/search/results/StreamingSearchResults.tsx index c7356cc3b5c..126e475d0e7 100644 --- a/client/web/src/search/results/StreamingSearchResults.tsx +++ b/client/web/src/search/results/StreamingSearchResults.tsx @@ -15,7 +15,7 @@ import { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations' import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context' import { collectMetrics } from '@sourcegraph/shared/src/search/query/metrics' import { sanitizeQueryForTelemetry, updateFilters } from '@sourcegraph/shared/src/search/query/transformer' -import { StreamSearchOptions } from '@sourcegraph/shared/src/search/stream' +import { LATEST_VERSION, StreamSearchOptions } from '@sourcegraph/shared/src/search/stream' import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/useTemporarySetting' import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' @@ -74,11 +74,6 @@ export interface StreamingSearchResultsProps fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable } -// The latest supported version of our search syntax. Users should never be able to determine the search version. -// The version is set based on the release tag of the instance. Anything before 3.9.0 will not pass a version parameter, -// and will therefore default to V1. -export const LATEST_VERSION = 'V2' - const CTA_ALERTS_CADENCE_KEY = 'SearchResultCtaAlerts.pageViews' const CTA_ALERT_DISPLAY_CADENCE = 6 const IDE_CTA_CADENCE_SHIFT = 3 diff --git a/client/web/src/tracking/eventLogger.ts b/client/web/src/tracking/eventLogger.ts index 0b444f59b86..b21ba6eba3b 100644 --- a/client/web/src/tracking/eventLogger.ts +++ b/client/web/src/tracking/eventLogger.ts @@ -305,8 +305,8 @@ function pageViewQueryParameters(url: string): UTMMarker { ].includes(utmSource ?? '') ) { eventLogger.log('UTMCodeHostIntegration', utmProps, utmProps) - } else if (utmMedium === 'VSCIDE' && utmCampaign === 'vsce-sign-up') { - eventLogger.log('VSCIDESignUpLinkClicked', utmProps, utmProps) + } else if (utmMedium === 'VSCODE' && utmCampaign === 'vsce-sign-up') { + eventLogger.log('VSCODESignUpLinkClicked', utmProps, utmProps) } return utmProps diff --git a/client/web/src/tree/TreeLayer.tsx b/client/web/src/tree/TreeLayer.tsx index e267568fe83..0a84cb2a861 100644 --- a/client/web/src/tree/TreeLayer.tsx +++ b/client/web/src/tree/TreeLayer.tsx @@ -18,14 +18,15 @@ import { FileDecoration } from 'sourcegraph' import { asError, ErrorLike, isErrorLike } from '@sourcegraph/common' import { FileDecorationsByPath } from '@sourcegraph/shared/src/api/extension/extensionHostApi' +import { fetchTreeEntries } from '@sourcegraph/shared/src/backend/repo' import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { TreeFields } from '@sourcegraph/shared/src/graphql-operations' import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' import { ThemeProps } from '@sourcegraph/shared/src/theme' import { AbsoluteRepo } from '@sourcegraph/shared/src/util/url' import { getFileDecorations } from '../backend/features' -import { TreeFields } from '../graphql-operations' -import { fetchTreeEntries } from '../repo/backend' +import { requestGraphQL } from '../backend/graphql' import { ChildTreeLayer } from './ChildTreeLayer' import { TreeLayerCell, TreeLayerTable, TreeRowAlert } from './components' @@ -113,6 +114,7 @@ export class TreeLayer extends React.Component { commitID: props.commitID, filePath: props.parentPath || '', first: maxEntries, + requestGraphQL: ({ request, variables }) => requestGraphQL(request, variables), }).pipe( catchError(error => [asError(error)]), share() @@ -175,6 +177,7 @@ export class TreeLayer extends React.Component { commitID: this.props.commitID, filePath: path, first: maxEntries, + requestGraphQL: ({ request, variables }) => requestGraphQL(request, variables), }).pipe(catchError(error => [asError(error)])) ) ) diff --git a/client/web/src/tree/TreeRoot.tsx b/client/web/src/tree/TreeRoot.tsx index 8a79a32367e..216a71a94b3 100644 --- a/client/web/src/tree/TreeRoot.tsx +++ b/client/web/src/tree/TreeRoot.tsx @@ -17,15 +17,16 @@ import { import { asError, ErrorLike, isErrorLike } from '@sourcegraph/common' import { FileDecorationsByPath } from '@sourcegraph/shared/src/api/extension/extensionHostApi' +import { fetchTreeEntries } from '@sourcegraph/shared/src/backend/repo' import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { TreeFields } from '@sourcegraph/shared/src/graphql-operations' import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' import { ThemeProps } from '@sourcegraph/shared/src/theme' import { AbsoluteRepo } from '@sourcegraph/shared/src/util/url' import { LoadingSpinner } from '@sourcegraph/wildcard' import { getFileDecorations } from '../backend/features' -import { TreeFields } from '../graphql-operations' -import { fetchTreeEntries } from '../repo/backend' +import { requestGraphQL } from '../backend/graphql' import { ChildTreeLayer } from './ChildTreeLayer' import { TreeLayerTable, TreeLayerCell, TreeRowAlert } from './components' @@ -105,6 +106,7 @@ export class TreeRoot extends React.Component { commitID: props.commitID, filePath: props.parentPath || '', first: maxEntries, + requestGraphQL: ({ request, variables }) => requestGraphQL(request, variables), }).pipe( catchError(error => [asError(error)]), share() @@ -157,6 +159,7 @@ export class TreeRoot extends React.Component { commitID: this.props.commitID, filePath: path, first: maxEntries, + requestGraphQL: ({ request, variables }) => requestGraphQL(request, variables), }).pipe(catchError(error => [asError(error)])) ) ) diff --git a/client/web/src/tree/util.tsx b/client/web/src/tree/util.tsx index 776ec7a6747..94466bf4969 100644 --- a/client/web/src/tree/util.tsx +++ b/client/web/src/tree/util.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { TreeEntryFields } from '../graphql-operations' +import { TreeEntryFields } from '@sourcegraph/shared/src/graphql-operations' /** TreeEntryInfo is the information we need to render an entry in the file tree */ export interface TreeEntryInfo { diff --git a/client/web/src/util/url.ts b/client/web/src/util/url.ts index b242ec76f0b..438ca49013a 100644 --- a/client/web/src/util/url.ts +++ b/client/web/src/util/url.ts @@ -2,8 +2,10 @@ import { LineOrPositionOrRange, lprToRange, toPositionHashComponent } from '@sou import { Position, Range } from '@sourcegraph/extension-api-types' import { encodeRepoRevision, + ParsedRepoRevision, ParsedRepoURI, parseQueryAndHash, + parseRepoRevision, RepoDocumentation, RepoFile, } from '@sourcegraph/shared/src/util/url' @@ -131,26 +133,3 @@ export function parseBrowserRepoURL(href: string): ParsedRepoURI & Pick=0.3.0 <0.4" + chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -8623,6 +8784,17 @@ check-error@^1.0.2: resolved "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= +cheerio-select@^1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.5.0.tgz#faf3daeb31b17c5e1a9dabcee288aaf8aafa5823" + integrity sha512-qocaHPv5ypefh6YNxvnbABM07KMxExbtbfuJoIie3iZXX1ERwYmJcIiRrr9H05ucQP1k28dav8rpdDgjQd8drg== + dependencies: + css-select "^4.1.3" + css-what "^5.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + domutils "^2.7.0" + cheerio@1.0.0-rc.3: version "1.0.0-rc.3" resolved "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.3.tgz#094636d425b2e9c0f4eb91a46c05630c9a1a8bf6" @@ -8635,6 +8807,19 @@ cheerio@1.0.0-rc.3: lodash "^4.15.0" parse5 "^3.0.1" +cheerio@^1.0.0-rc.9: + version "1.0.0-rc.10" + resolved "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz#2ba3dcdfcc26e7956fc1f440e61d51c643379f3e" + integrity sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw== + dependencies: + cheerio-select "^1.5.0" + dom-serializer "^1.3.2" + domhandler "^4.2.0" + htmlparser2 "^6.1.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + tslib "^2.2.0" + chokidar-cli@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-2.1.0.tgz#2491df133bd62cd145227b1746fbd94f2733e1bc" @@ -9078,7 +9263,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^1.9.0, color-convert@^1.9.1: +color-convert@^1.9.0, color-convert@^1.9.3: version "1.9.3" resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -9102,10 +9287,10 @@ color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.2: - version "1.5.5" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014" - integrity sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg== +color-string@^1.6.0: + version "1.9.0" + resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz#63b6ebd1bec11999d1df3a79a7569451ac2be8aa" + integrity sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" @@ -9116,12 +9301,12 @@ color-support@^1.1.3: integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== color@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" - integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w== + version "3.2.1" + resolved "https://registry.npmjs.org/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" + integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== dependencies: - color-convert "^1.9.1" - color-string "^1.5.2" + color-convert "^1.9.3" + color-string "^1.6.0" colord@^2.9.2: version "2.9.2" @@ -9580,12 +9765,12 @@ cross-fetch@3.0.6: dependencies: node-fetch "2.6.1" -cross-fetch@^3.0.4, cross-fetch@^3.0.6, cross-fetch@^3.1.4: - version "3.1.4" - resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" - integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== +cross-fetch@3.1.5, cross-fetch@^3.0.4, cross-fetch@^3.0.6, cross-fetch@^3.1.4: + version "3.1.5" + resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== dependencies: - node-fetch "2.6.1" + node-fetch "2.6.7" cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" @@ -9791,10 +9976,10 @@ css-what@^3.2.1: resolved "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== -css-what@^5.0.0: - version "5.0.1" - resolved "https://registry.npmjs.org/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad" - integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg== +css-what@^5.0.0, css-what@^5.0.1: + version "5.1.0" + resolved "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz#3f7b707aadf633baf62c2ceb8579b545bb40f7fe" + integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw== css.escape@1.5.0: version "1.5.0" @@ -10571,7 +10756,7 @@ debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, de dependencies: ms "2.0.0" -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.3: +debug@4, debug@4.3.3, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.3: version "4.3.3" resolved "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== @@ -10891,6 +11076,11 @@ detect-indent@^6.0.0: resolved "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== +detect-libc@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" + integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -10927,10 +11117,10 @@ devtools-protocol@0.0.901419: resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.901419.tgz#79b5459c48fe7e1c5563c02bd72f8fec3e0cebcd" integrity sha512-4INMPwNm9XRpBukhNbF7OB6fNTTCaI8pzy/fXg0xQzAy5h3zL1P8xT3QazgKqBrb/hAYwIBizqDBZ7GtJE74QQ== -devtools-protocol@0.0.937139: - version "0.0.937139" - resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.937139.tgz#bdee3751fdfdb81cb701fd3afa94b1065dafafcf" - integrity sha512-daj+rzR3QSxsPRy5vjjthn58axO8c11j58uY0lG5vvlJk/EiOdCWOptGdkXDjtuRHr78emKq0udHCXM4trhoDQ== +devtools-protocol@0.0.969999: + version "0.0.969999" + resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.969999.tgz#3d6be0a126b3607bb399ae2719b471dda71f3478" + integrity sha512-6GfzuDWU0OFAuOvBokXpXPLxjOJ5DZ157Ue3sGQQM3LgAamb8m0R0ruSfN0DDu+XG5XJgT50i6zZ/0o8RglreQ== dezalgo@^1.0.0: version "1.0.3" @@ -11076,13 +11266,13 @@ dom-serializer@0, dom-serializer@~0.1.1: domelementtype "^1.3.0" entities "^1.1.1" -dom-serializer@^1.0.1: - version "1.3.1" - resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz#d845a1565d7c041a95e5dab62184ab41e3a519be" - integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q== +dom-serializer@^1.0.1, dom-serializer@^1.3.2: + version "1.3.2" + resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91" + integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig== dependencies: domelementtype "^2.0.1" - domhandler "^4.0.0" + domhandler "^4.2.0" entities "^2.0.0" dom-walk@^0.1.0: @@ -11142,10 +11332,10 @@ domutils@^1.5.1, domutils@^1.7.0: dom-serializer "0" domelementtype "1" -domutils@^2.5.2, domutils@^2.6.0: - version "2.7.0" - resolved "https://registry.npmjs.org/domutils/-/domutils-2.7.0.tgz#8ebaf0c41ebafcf55b0b72ec31c56323712c5442" - integrity sha512-8eaHa17IwJUPAiB+SoTYBo5mCdeMgdcAoXJ59m6DT1vw+5iLS3gNoqYaRowaBKtGVrOF1Jz4yDTgYKLK2kvfJg== +domutils@^2.5.2, domutils@^2.6.0, domutils@^2.7.0: + version "2.8.0" + resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== dependencies: dom-serializer "^1.0.1" domelementtype "^2.2.0" @@ -11248,6 +11438,13 @@ dtrace-provider@~0.8: dependencies: nan "^2.10.0" +duplexer2@~0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" + integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= + dependencies: + readable-stream "^2.0.2" + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -11423,10 +11620,10 @@ engine.io@~3.4.0: engine.io-parser "~2.2.0" ws "^7.1.2" -enhanced-resolve@^5.0.0, enhanced-resolve@^5.8.0: - version "5.8.3" - resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz#6d552d465cce0423f5b3d718511ea53826a7b2f0" - integrity sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.2: + version "5.9.2" + resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.2.tgz#0224dcd6a43389ebfb2d55efee517e5466772dd9" + integrity sha512-GIm3fQfwLJ8YZx2smuHpBKkXC1yOk+OBEmKckVyL0i/ea8mqDEykK3ld5dgH1QYPNyT/lIllxV2LULnxCHaHkA== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -11482,22 +11679,26 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.1.1" -es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.4, es-abstract@^1.17.5, es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.4.3, es-abstract@^1.9.0: - version "1.18.3" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.3.tgz#25c4c3380a27aa203c44b2b685bba94da31b63e0" - integrity sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw== +es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.4, es-abstract@^1.17.5, es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.19.1, es-abstract@^1.4.3, es-abstract@^1.9.0: + version "1.19.1" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3" + integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w== dependencies: call-bind "^1.0.2" es-to-primitive "^1.2.1" function-bind "^1.1.1" get-intrinsic "^1.1.1" + get-symbol-description "^1.0.0" has "^1.0.3" has-symbols "^1.0.2" - is-callable "^1.2.3" + internal-slot "^1.0.3" + is-callable "^1.2.4" is-negative-zero "^2.0.1" - is-regex "^1.1.3" - is-string "^1.0.6" - object-inspect "^1.10.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.1" + is-string "^1.0.7" + is-weakref "^1.0.1" + object-inspect "^1.11.0" object-keys "^1.1.1" object.assign "^4.1.2" string.prototype.trimend "^1.0.4" @@ -11522,10 +11723,10 @@ es-get-iterator@^1.0.2: is-string "^1.0.5" isarray "^2.0.5" -es-module-lexer@^0.7.1: - version "0.7.1" - resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.7.1.tgz#c2c8e0f46f2df06274cdaf0dd3f3b33e0a0b267d" - integrity sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw== +es-module-lexer@^0.9.0: + version "0.9.3" + resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" + integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== es-to-primitive@^1.2.1: version "1.2.1" @@ -12197,6 +12398,13 @@ events@^3.2.0: resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz#00e8ca7c92109e94b0ddf32dac677d841028cfaf" + integrity sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg== + dependencies: + original "^1.0.0" + exec-sh@^0.3.2: version "0.3.2" resolved "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.2.tgz#6738de2eb7c8e671d0366aea0b0db8c6f7d7391b" @@ -12265,6 +12473,11 @@ execall@^2.0.0: dependencies: clone-regexp "^2.1.0" +exenv-es6@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/exenv-es6/-/exenv-es6-1.0.0.tgz#bd459136369af17cf33f959b5af58803d4068c80" + integrity sha512-fcG/TX8Ruv9Ma6PBaiNsUrHRJzVzuFMP6LtPn/9iqR+nr9mcLeEOGzXQGLC5CVQSXGE98HtzW2mTZkrCA3XrDg== + exit-hook@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" @@ -12293,6 +12506,11 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expand-tilde@^2.0.0, expand-tilde@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" @@ -13127,6 +13345,16 @@ fsevents@^2.1.2, fsevents@^2.3.2, fsevents@~2.3.1: resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fstream@^1.0.12: + version "1.0.12" + resolved "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" + integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + function-bind@^1.0.2, function-bind@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -13233,7 +13461,7 @@ get-func-name@^2.0.0: resolved "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= -get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== @@ -13309,6 +13537,14 @@ get-stream@^6.0.0: resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== +get-symbol-description@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" + integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.1" + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -13321,6 +13557,11 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + glob-base@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -13396,7 +13637,7 @@ glob-watcher@^5.0.3: just-debounce "^1.0.0" object.defaults "^1.1.0" -glob@7.1.6, glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.6: +glob@7.1.6, glob@^7.0.0, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.6: version "7.1.6" resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== @@ -13685,7 +13926,7 @@ gql2ts@^1.10.1: commander "^2.9.0" graphql ">= 0.10 <15" -graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: +graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.9" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== @@ -14004,6 +14245,13 @@ has-symbols@^1.0.0, has-symbols@^1.0.1, has-symbols@^1.0.2: resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== +has-tostringtag@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" + integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== + dependencies: + has-symbols "^1.0.2" + has-unicode@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -14203,6 +14451,13 @@ hosted-git-info@^3.0.6: dependencies: lru-cache "^6.0.0" +hosted-git-info@^4.0.2: + version "4.1.0" + resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" + integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== + dependencies: + lru-cache "^6.0.0" + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -14442,6 +14697,11 @@ http2-wrapper@^1.0.0-beta.5.0: quick-lru "^5.1.1" resolve-alpn "^1.0.0" +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" + integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= + https-proxy-agent@5.0.0, https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" @@ -14652,7 +14912,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -14725,14 +14985,14 @@ internal-ip@^6.2.0: is-ip "^3.1.0" p-event "^4.2.0" -internal-slot@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3" - integrity sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g== +internal-slot@^1.0.2, internal-slot@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" + integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA== dependencies: - es-abstract "^1.17.0-next.1" + get-intrinsic "^1.1.0" has "^1.0.3" - side-channel "^1.0.2" + side-channel "^1.0.4" "internmap@1 - 2", internmap@^1.0.0: version "1.0.1" @@ -14908,10 +15168,10 @@ is-buffer@^2.0.0, is-buffer@^2.0.2: resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-callable@^1.1.4, is-callable@^1.2.3: - version "1.2.3" - resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" - integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== +is-callable@^1.1.4, is-callable@^1.2.4: + version "1.2.4" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" + integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== is-ci@^2.0.0: version "2.0.0" @@ -15254,13 +15514,13 @@ is-redirect@^1.0.0: resolved "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= -is-regex@^1.0.4, is-regex@^1.1.2, is-regex@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.3.tgz#d029f9aff6448b93ebbe3f33dac71511fdcbef9f" - integrity sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ== +is-regex@^1.0.4, is-regex@^1.1.2, is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== dependencies: call-bind "^1.0.2" - has-symbols "^1.0.2" + has-tostringtag "^1.0.0" is-regexp@^2.0.0: version "2.1.0" @@ -15299,6 +15559,11 @@ is-set@^2.0.1: resolved "https://registry.npmjs.org/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== +is-shared-array-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6" + integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA== + is-stream@^1.0.0, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -15309,10 +15574,12 @@ is-stream@^2.0.0: resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== -is-string@^1.0.4, is-string@^1.0.5, is-string@^1.0.6: - version "1.0.6" - resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz#3fe5d5992fb0d93404f32584d4b0179a71b54a5f" - integrity sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w== +is-string@^1.0.4, is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.3" @@ -15350,6 +15617,13 @@ is-valid-glob@^1.0.0: resolved "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz#29bf3eff701be2d4d315dbacc39bc39fe8f601aa" integrity sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao= +is-weakref@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + is-whitespace-character@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7" @@ -16556,6 +16830,14 @@ jws@^3.1.5, jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +keytar@^7.7.0: + version "7.9.0" + resolved "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz#4c6225708f51b50cbf77c5aae81721964c2918cb" + integrity sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ== + dependencies: + node-addon-api "^4.3.0" + prebuild-install "^7.0.1" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -16804,6 +17086,11 @@ linkify-it@^3.0.1: dependencies: uc.micro "^1.0.1" +listenercount@~1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" + integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc= + listr-silent-renderer@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" @@ -17325,7 +17612,7 @@ markdown-escapes@^1.0.0: resolved "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== -markdown-it@^12.2.0: +markdown-it@^12.2.0, markdown-it@^12.3.2: version "12.3.2" resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz#bf92ac92283fe983fe4de8ff8abfb5ad72cd0c90" integrity sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg== @@ -17661,7 +17948,7 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.28, mime-types@^2.1.30, dependencies: mime-db "1.49.0" -mime@1.6.0: +mime@1.6.0, mime@^1.3.4: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -17728,7 +18015,7 @@ minimalistic-assert@^1.0.0: resolved "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4: +"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== @@ -17756,7 +18043,7 @@ minimist@0.0.8: resolved "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -17810,7 +18097,7 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mkdirp-classic@^0.5.2: +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== @@ -17827,7 +18114,7 @@ mkdirp@1.0.4, mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: +"mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -17982,7 +18269,7 @@ mute-stream@0.0.7: resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= -mute-stream@0.0.8: +mute-stream@0.0.8, mute-stream@~0.0.4: version "0.0.8" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== @@ -18042,6 +18329,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + native-url@^0.2.6: version "0.2.6" resolved "https://registry.npmjs.org/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" @@ -18118,6 +18410,18 @@ nocache@^2.1.0: resolved "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz#120c9ffec43b5729b1d5de88cd71aa75a0ba491f" integrity sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q== +node-abi@^3.3.0: + version "3.8.0" + resolved "https://registry.npmjs.org/node-abi/-/node-abi-3.8.0.tgz#679957dc8e7aa47b0a02589dbfde4f77b29ccb32" + integrity sha512-tzua9qWWi7iW4I42vUPKM+SfaF0vQSLAm4yO5J83mSwB7GeoWrDKC/K+8YCnYNwqP5duwazbw2X9l4m8SC2cUw== + dependencies: + semver "^7.3.5" + +node-addon-api@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" + integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== + node-ask@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/node-ask/-/node-ask-1.0.1.tgz#caaa1076cc58e0364267a0903e3eadfac158396b" @@ -18157,7 +18461,7 @@ node-fetch@2.6.5: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== @@ -18303,7 +18607,7 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -npmlog@^4.1.2: +npmlog@^4.0.1, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -18409,10 +18713,10 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-inspect@^1.10.3, object-inspect@^1.9.0: - version "1.10.3" - resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" - integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== +object-inspect@^1.11.0, object-inspect@^1.9.0: + version "1.12.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" + integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== object-is@^1.0.1: version "1.1.5" @@ -18479,13 +18783,13 @@ object.entries@^1.1.0, object.entries@^1.1.2: has "^1.0.3" object.getownpropertydescriptors@^2.0.3: - version "2.1.2" - resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz#1bd63aeacf0d5d2d2f31b5e393b03a7c601a23f7" - integrity sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ== + version "2.1.3" + resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz#b223cf38e17fefb97a63c10c91df72ccb386df9e" + integrity sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw== dependencies: call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.0-next.2" + es-abstract "^1.19.1" object.map@^1.0.0: version "1.0.1" @@ -18661,6 +18965,13 @@ ordered-read-streams@^1.0.0: dependencies: readable-stream "^2.0.1" +original@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" + integrity sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg== + dependencies: + url-parse "^1.4.3" + os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" @@ -18985,12 +19296,26 @@ parse-passwd@^1.0.0: resolved "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= +parse-semver@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz#9a4afd6df063dc4826f93fba4a99cf223f666cb8" + integrity sha1-mkr9bfBj3Egm+T+6SpnPIj9mbLg= + dependencies: + semver "^5.1.0" + parse-srcset@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE= -parse5@6.0.1, parse5@^6.0.0: +parse5-htmlparser2-tree-adapter@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== + dependencies: + parse5 "^6.0.1" + +parse5@6.0.1, parse5@^6.0.0, parse5@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== @@ -19184,6 +19509,11 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picocolors@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" + integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -19799,13 +20129,12 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^ integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== "postcss@5 - 7", postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.36, postcss@^7.0.5, postcss@^7.0.6: - version "7.0.36" - resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.36.tgz#056f8cffa939662a8f5905950c07d5285644dfcb" - integrity sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw== + version "7.0.39" + resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309" + integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA== dependencies: - chalk "^2.4.2" + picocolors "^0.2.1" source-map "^0.6.1" - supports-color "^6.1.0" postcss@6.0.1, postcss@^6.0.1: version "6.0.1" @@ -19842,6 +20171,25 @@ preact-render-to-string@^5.1.19: dependencies: pretty-format "^3.8.0" +prebuild-install@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.0.1.tgz#c10075727c318efe72412f333e0ef625beaf3870" + integrity sha512-QBSab31WqkyxpnMWQxubYAHR5S9B2+r81ucocew34Fkl98FhvKIF50jIJnNOBmAZfyNV7vE5T6gd3hTVWgY6tg== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^3.3.0" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -20148,24 +20496,6 @@ puppeteer-firefox@^0.5.1: rimraf "^2.6.1" ws "^6.1.0" -puppeteer@12.0.1: - version "12.0.1" - resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-12.0.1.tgz#ae79d0e174a07563e0bf2e05c94ccafce3e70033" - integrity sha512-YQ3GRiyZW0ddxTW+iiQcv2/8TT5c3+FcRUCg7F8q2gHqxd5akZN400VRXr9cHQKLWGukmJLDiE72MrcLK9tFHQ== - dependencies: - debug "4.3.2" - devtools-protocol "0.0.937139" - extract-zip "2.0.1" - https-proxy-agent "5.0.0" - node-fetch "2.6.5" - pkg-dir "4.2.0" - progress "2.0.3" - proxy-from-env "1.1.0" - rimraf "3.0.2" - tar-fs "2.1.1" - unbzip2-stream "1.4.3" - ws "8.2.3" - puppeteer@^11.0.0: version "11.0.0" resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-11.0.0.tgz#0808719c38e15315ecc1b1c28911f1c9054d201f" @@ -20184,6 +20514,24 @@ puppeteer@^11.0.0: unbzip2-stream "1.4.3" ws "8.2.3" +puppeteer@^13.5.1: + version "13.5.1" + resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-13.5.1.tgz#d0f751bf36120efc2ebf74c7562a204a84e500e9" + integrity sha512-wWxO//vMiqxlvuzHMAJ0pRJeDHvDtM7DQpW1GKdStz2nZo2G42kOXBDgkmQ+zqjwMCFofKGesBeeKxIkX9BO+w== + dependencies: + cross-fetch "3.1.5" + debug "4.3.3" + devtools-protocol "0.0.969999" + extract-zip "2.0.1" + https-proxy-agent "5.0.0" + pkg-dir "4.2.0" + progress "2.0.3" + proxy-from-env "1.1.0" + rimraf "3.0.2" + tar-fs "2.1.1" + unbzip2-stream "1.4.3" + ws "8.5.0" + pvtsutils@^1.1.2, pvtsutils@^1.1.6, pvtsutils@^1.1.7: version "1.1.7" resolved "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.1.7.tgz#39a65ccb3b7448c974f6a6141ce2aad037b3f13c" @@ -20206,10 +20554,10 @@ qs@6.7.0: resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.10.0, qs@^6.7.0: - version "6.10.1" - resolved "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" - integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== +qs@^6.10.0, qs@^6.7.0, qs@^6.9.1: + version "6.10.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" + integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== dependencies: side-channel "^1.0.4" @@ -20308,7 +20656,7 @@ raw-loader@^4.0.2: loader-utils "^2.0.0" schema-utils "^3.0.0" -rc@^1.2.8: +rc@^1.2.7, rc@^1.2.8: version "1.2.8" resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -20855,6 +21203,13 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" +read@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" + integrity sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ= + dependencies: + mute-stream "~0.0.4" + readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" @@ -21488,7 +21843,7 @@ right-pad@^1.0.1: resolved "https://registry.npmjs.org/right-pad/-/right-pad-1.0.1.tgz#8ca08c2cbb5b55e74dafa96bf7fd1a27d568c8d0" integrity sha1-jKCMLLtbVedNr6lr9/0aJ9VoyNA= -rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@~2.6.2: +rimraf@2, rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@~2.6.2: version "2.6.3" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== @@ -21730,7 +22085,7 @@ semver-greatest-satisfied-range@^1.1.0: dependencies: sver-compat "^1.5.0" -"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: +"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -21867,7 +22222,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.5: +setimmediate@^1.0.5, setimmediate@~1.0.4: version "1.0.5" resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -22019,6 +22374,20 @@ simmerjs@^0.5.6: lodash.take "^4.1.1" lodash.takeright "^4.1.1" +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" @@ -22536,6 +22905,16 @@ stream-exhaust@^1.0.1: resolved "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz#acdac8da59ef2bc1e17a2c0ccf6c320d120e555d" integrity sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw== +stream-http@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz#1872dfcf24cb15752677e40e5c3f9cc1926028b5" + integrity sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A== + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.4" + readable-stream "^3.6.0" + xtend "^4.0.2" + stream-parser@~0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz#1618548694420021a1182ff0af1911c129761773" @@ -22968,13 +23347,6 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - supports-color@^7.0.0, supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -23090,10 +23462,10 @@ tabbable@^4.0.0: resolved "https://registry.npmjs.org/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261" integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ== -tabbable@^5.1.5: - version "5.1.5" - resolved "https://registry.npmjs.org/tabbable/-/tabbable-5.1.5.tgz#efec48ede268d511c261e3b81facbb4782a35147" - integrity sha512-oVAPrWgLLqrbvQE8XqcU7CVBq6SQbaIbHkhOca3u7/jzuQvyZycrUKPCGr04qpEIUslmUlULbSeN+m3QrKEykA== +tabbable@^5.1.5, tabbable@^5.2.0: + version "5.2.1" + resolved "https://registry.npmjs.org/tabbable/-/tabbable-5.2.1.tgz#e3fda7367ddbb172dcda9f871c0fdb36d1c4cd9c" + integrity sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ== table@^5.2.3: version "5.4.6" @@ -23131,7 +23503,7 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.0.tgz#5c373d281d9c672848213d0e037d1c4165ab426b" integrity sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw== -tar-fs@2.1.1: +tar-fs@2.1.1, tar-fs@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== @@ -23392,7 +23764,7 @@ tmp-promise@3.0.2: dependencies: tmp "^0.2.0" -tmp@0.2.1, tmp@^0.2.0: +tmp@0.2.1, tmp@^0.2.0, tmp@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== @@ -23534,6 +23906,11 @@ traverse@0.4.x: resolved "https://registry.npmjs.org/traverse/-/traverse-0.4.6.tgz#d04b2280e4c792a5815429ef7b8b60c64c9ccc34" integrity sha1-0EsigOTHkqWBVCnve4tgxkyczDQ= +"traverse@>=0.3.0 <0.4": + version "0.3.9" + resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" + integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk= + tree-kill@^1.2.1, tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -23630,7 +24007,26 @@ ts-morph@^8.1.0: "@ts-morph/common" "~0.6.0" code-block-writer "^10.1.0" -ts-node@^9, ts-node@^9.1.1: +ts-node@^10.7.0: + version "10.7.0" + resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz#35d503d0fab3e2baa672a0e94f4b40653c2463f5" + integrity sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A== + dependencies: + "@cspotcode/source-map-support" "0.7.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.0" + yn "3.1.1" + +ts-node@^9: version "9.1.1" resolved "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d" integrity sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg== @@ -23657,7 +24053,7 @@ tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@2.1.0, tslib@^1.0.0, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3, tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@~2.0.0, tslib@~2.0.1, tslib@~2.2.0, tslib@~2.3.0: +tslib@2.1.0, tslib@^1.0.0, tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3, tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@~2.0.0, tslib@~2.0.1, tslib@~2.2.0, tslib@~2.3.0: version "2.1.0" resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== @@ -23758,6 +24154,15 @@ type@^2.0.0: resolved "https://registry.npmjs.org/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== +typed-rest-client@^1.8.4: + version "1.8.6" + resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.6.tgz#d8facd6abd98cbd8ad14cccf056ca5cc306919d7" + integrity sha512-xcQpTEAJw2DP7GqVNECh4dD+riS+C1qndXLfBCJ3xk0kqprtGN491P5KlmrDbKdtuW8NEcP/5ChxiJI3S9WYTA== + dependencies: + qs "^6.9.1" + tunnel "0.0.6" + underscore "^1.12.1" + typed-scss-modules@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/typed-scss-modules/-/typed-scss-modules-4.1.1.tgz#eab12f25511a329f8e4837842c3b484ffd66b449" @@ -23872,11 +24277,16 @@ unc-path-regex@^0.1.2: resolved "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= -underscore@1.6.0, underscore@>=1.5.0: +underscore@1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8" integrity sha1-izixDKze9jM3uLJOT/htRa6lKag= +underscore@>=1.5.0, underscore@^1.12.1: + version "1.13.2" + resolved "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz#276cea1e8b9722a8dbed0100a407dda572125881" + integrity sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g== + undertaker-registry@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/undertaker-registry/-/undertaker-registry-1.0.1.tgz#5e4bda308e4a8a2ae584f9b9a4359a499825cc50" @@ -24116,6 +24526,22 @@ unzip-response@^2.0.1: resolved "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= +unzipper@^0.10.11: + version "0.10.11" + resolved "https://registry.npmjs.org/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" + integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw== + dependencies: + big-integer "^1.6.17" + binary "~0.3.0" + bluebird "~3.4.1" + buffer-indexof-polyfill "~1.0.0" + duplexer2 "~0.1.4" + fstream "^1.0.12" + graceful-fs "^4.2.2" + listenercount "~1.0.1" + readable-stream "~2.3.6" + setimmediate "~1.0.4" + upath@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" @@ -24209,6 +24635,11 @@ urix@^0.1.0: resolved "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= +url-join@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7" + integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA== + url-loader@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz#28505e905cae158cf07c92ca622d7f237e70a4e2" @@ -24232,10 +24663,10 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" -url-parse@^1.4.7: - version "1.5.1" - resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b" - integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q== +url-parse@^1.4.3, url-parse@^1.4.7: + version "1.5.4" + resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.4.tgz#e4f645a7e2a0852cc8a66b14b292a3e9a11a97fd" + integrity sha512-ITeAByWWoqutFClc/lRZnFplgXgEZr3WJ6XngMM/N9DMIm4K8zXPCZ1Jdu0rERwO84w1WC5wkle2ubwTA4NTBg== dependencies: querystringify "^2.1.1" requires-port "^1.0.0" @@ -24381,6 +24812,11 @@ uuid@^8.3.0, uuid@^8.3.1, uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +v8-compile-cache-lib@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz#0582bcb1c74f3a2ee46487ceecf372e46bce53e8" + integrity sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA== + v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0, v8-compile-cache@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" @@ -24521,6 +24957,32 @@ vinyl@^2.0.0: remove-trailing-separator "^1.0.1" replace-ext "^1.0.0" +vsce@^2.7.0: + version "2.7.0" + resolved "https://registry.npmjs.org/vsce/-/vsce-2.7.0.tgz#7be8deebd1e673b996238d608e7f7324c98744ed" + integrity sha512-CKU34wrQlbKDeJCRBkd1a8iwF9EvNxcYMg9hAUH6AxFGR6Wo2IKWwt3cJIcusHxx6XdjDHWlfAS/fJN30uvVnA== + dependencies: + azure-devops-node-api "^11.0.1" + chalk "^2.4.2" + cheerio "^1.0.0-rc.9" + commander "^6.1.0" + glob "^7.0.6" + hosted-git-info "^4.0.2" + keytar "^7.7.0" + leven "^3.1.0" + markdown-it "^12.3.2" + mime "^1.3.4" + minimatch "^3.0.3" + parse-semver "^1.1.1" + read "^1.0.7" + semver "^5.1.0" + tmp "^0.2.1" + typed-rest-client "^1.8.4" + url-join "^4.0.1" + xml2js "^0.4.23" + yauzl "^2.3.1" + yazl "^2.2.2" + vscode-jsonrpc@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-5.0.1.tgz#9bab9c330d89f43fc8c1e8702b5c36e058a01794" @@ -24610,10 +25072,10 @@ watchpack@1.7.5: chokidar "^3.4.1" watchpack-chokidar2 "^2.0.1" -watchpack@^2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.2.0.tgz#47d78f5415fe550ecd740f99fe2882323a58b1ce" - integrity sha512-up4YAn/XHgZHIxFBVCdlMiWDj6WaLKpwVeGQk2I5thdYxF/KmF0aaz6TfJZ/hfl1h/XlcDr7k1KH7ThDagpFaA== +watchpack@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" + integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -24883,7 +25345,7 @@ webpack-sources@^1.4.3: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^2.2.0, webpack-sources@^2.3.0: +webpack-sources@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.0.tgz#9ed2de69b25143a4c18847586ad9eccb19278cfa" integrity sha512-WyOdtwSvOML1kbgtXbTDnEW0jkJ7hZr/bDByIwszhWd/4XX1A3XMkrbFMsuH4+/MfLlZCUzlAdg4r7jaGKEIgQ== @@ -24891,6 +25353,11 @@ webpack-sources@^2.2.0, webpack-sources@^2.3.0: source-list-map "^2.0.1" source-map "^0.6.1" +webpack-sources@^3.2.3: + version "3.2.3" + resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" + integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== + webpack-virtual-modules@^0.2.2: version "0.2.2" resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz#20863dc3cb6bb2104729fff951fbe14b18bd0299" @@ -24904,24 +25371,25 @@ webpack-virtual-modules@^0.4.1: integrity sha512-5NUqC2JquIL2pBAAo/VfBP6KuGkHIZQXW/lNKupLPfhViwh8wNsu0BObtl09yuKZszeEUfbXz8xhrHvSG16Nqw== webpack@4, webpack@5, webpack@^5, webpack@^5.1.0, webpack@^5.37.0, webpack@^5.38.1, webpack@^5.45.1, webpack@^5.51.0, webpack@^5.9.0: - version "5.45.1" - resolved "https://registry.npmjs.org/webpack/-/webpack-5.45.1.tgz#d78dcbeda18a872dc62b0455d3ed3dcfd1c886bb" - integrity sha512-68VT2ZgG9EHs6h6UxfV2SEYewA9BA3SOLSnC2NEbJJiEwbAiueDL033R1xX0jzjmXvMh0oSeKnKgbO2bDXIEyQ== + version "5.70.0" + resolved "https://registry.npmjs.org/webpack/-/webpack-5.70.0.tgz#3461e6287a72b5e6e2f4872700bc8de0d7500e6d" + integrity sha512-ZMWWy8CeuTTjCxbeaQI21xSswseF2oNOwc70QSKNePvmxE7XW36i7vpBMYZFAUHPwQiEbNGCEYIOOlyRbdGmxw== dependencies: - "@types/eslint-scope" "^3.7.0" - "@types/estree" "^0.0.50" + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^0.0.51" "@webassemblyjs/ast" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1" acorn "^8.4.1" + acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.8.0" - es-module-lexer "^0.7.1" + enhanced-resolve "^5.9.2" + es-module-lexer "^0.9.0" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" - graceful-fs "^4.2.4" + graceful-fs "^4.2.9" json-parse-better-errors "^1.0.2" loader-runner "^4.2.0" mime-types "^2.1.27" @@ -24929,8 +25397,8 @@ webpack@4, webpack@5, webpack@^5, webpack@^5.1.0, webpack@^5.37.0, webpack@^5.38 schema-utils "^3.1.0" tapable "^2.1.1" terser-webpack-plugin "^5.1.3" - watchpack "^2.2.0" - webpack-sources "^2.3.0" + watchpack "^2.3.1" + webpack-sources "^3.2.3" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: version "0.7.4" @@ -25230,6 +25698,11 @@ ws@8.2.3: resolved "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== +ws@8.5.0, ws@^8.0.0, ws@^8.1.0, ws@^8.3.0: + version "8.5.0" + resolved "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" + integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== + "ws@^5.2.0 || ^6.0.0 || ^7.0.0", ws@^7.1.2, ws@^7.3.1, ws@^7.4.6: version "7.5.7" resolved "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz#9e0ac77ee50af70d58326ecff7e85eb3fa375e67" @@ -25242,11 +25715,6 @@ ws@^6.1.0, ws@~6.1.0: dependencies: async-limiter "~1.0.0" -ws@^8.0.0, ws@^8.1.0, ws@^8.3.0: - version "8.5.0" - resolved "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" - integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== - xdg-basedir@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" @@ -25262,13 +25730,13 @@ xml-name-validator@^3.0.0: resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== -xml2js@~0.4.4: - version "0.4.19" - resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" - integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== +xml2js@^0.4.23, xml2js@~0.4.4: + version "0.4.23" + resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== dependencies: sax ">=0.6.0" - xmlbuilder "~9.0.1" + xmlbuilder "~11.0.0" xml@^1.0.1: version "1.0.1" @@ -25280,10 +25748,10 @@ xmlbuilder@^10.0.0: resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz#8cae6688cc9b38d850b7c8d3c0a4161dcaf475b0" integrity sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg== -xmlbuilder@~9.0.1: - version "9.0.7" - resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" - integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== xmlchars@^2.2.0: version "2.2.0" @@ -25295,7 +25763,7 @@ xmlhttprequest-ssl@~1.5.4: resolved "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e" integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4= -xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: +xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== @@ -25478,7 +25946,7 @@ yarn-or-npm@^3.0.1: cross-spawn "^6.0.5" pkg-dir "^4.2.0" -yauzl@2.10.0, yauzl@^2.10.0: +yauzl@2.10.0, yauzl@^2.10.0, yauzl@^2.3.1: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= @@ -25493,10 +25961,10 @@ yauzl@2.4.1: dependencies: fd-slicer "~1.0.1" -yazl@^2.3.1: - version "2.4.3" - resolved "https://registry.npmjs.org/yazl/-/yazl-2.4.3.tgz#ec26e5cc87d5601b9df8432dbdd3cd2e5173a071" - integrity sha1-7CblzIfVYBud+EMtvdPNLlFzoHE= +yazl@^2.2.2, yazl@^2.3.1: + version "2.5.1" + resolved "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz#a3d65d3dd659a5b0937850e8609f22fffa2b5c35" + integrity sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw== dependencies: buffer-crc32 "~0.2.3"