vsce: create vscode package, establish architecture (#29997)

Co-authored-by: Beatrix <68532117+abeatrix@users.noreply.github.com>
This commit is contained in:
TJ Kandala 2022-01-20 19:06:56 -05:00 committed by GitHub
parent c8e2c2cb98
commit ca014d035d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1779 additions and 56 deletions

View File

@ -23,6 +23,7 @@ jobs:
- client/shared
- client/search
- client/search-ui
- client/vscode
- client/web
- client/wildcard
- client/common

26
.vscode/launch.json vendored
View File

@ -7,6 +7,32 @@
"request": "attach",
"mode": "local",
},
{
"name": "Launch VS Code Extension",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceRoot}/client/vscode",
"--disable-extension=kandalatj.sourcegraph-preview",
],
"stopOnEntry": false,
"sourceMaps": true,
"outFiles": ["${workspaceRoot}/client/vscode/dist/node/*.js"],
},
{
"name": "Launch VS Code Web Extension",
"type": "pwa-extensionHost",
"debugWebWorkerHost": true,
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceRoot}/client/vscode",
"--disable-extension=kandalatj.sourcegraph-preview",
"--extensionDevelopmentKind=web",
"--disable-web-security",
],
"outFiles": ["${workspaceRoot}/client/vscode/dist/webworker/*.js"],
},
{
"type": "node",
"request": "launch",

View File

@ -63,6 +63,7 @@
"./client/shared",
"./client/search",
"./client/search-ui",
"./client/vscode",
"./client/http-client",
"./client/branded",
"./client/wildcard",

View File

@ -0,0 +1,40 @@
import { gql } from '@sourcegraph/http-client'
const settingsCascadeFragment = gql`
fragment SettingsCascadeFields on SettingsCascade {
subjects {
__typename
... on Org {
id
name
displayName
}
... on User {
id
username
displayName
}
... on Site {
id
siteID
allowSiteSettingsEdits
}
latestSettings {
id
contents
}
settingsURL
viewerCanAdminister
}
final
}
`
export const viewerSettingsQuery = gql`
query ViewerSettings {
viewerSettings {
...SettingsCascadeFields
}
}
${settingsCascadeFragment}
`

View File

@ -0,0 +1,2 @@
dist/
package.json

View File

@ -0,0 +1,12 @@
// @ts-check
const baseConfig = require('../../.eslintrc.js')
module.exports = {
extends: '../../.eslintrc.js',
parserOptions: {
...baseConfig.parserOptions,
project: [__dirname + '/tsconfig.json'],
},
overrides: baseConfig.overrides,
}

2
client/vscode/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
*.vsix

View File

@ -0,0 +1,14 @@
.editorconfig
.github/**
.gitignore
.prettierignore
.vscode-test/**
.vscode/**
**/*.map
out/test/**
prettier.config.json
src/**
test/**
tsconfig.json
.eslintrc.js
.eslintignore

25
client/vscode/globals.d.ts vendored Normal file
View File

@ -0,0 +1,25 @@
declare module '*.scss' {
const cssModule: string
export default cssModule
}
declare module '*.css' {
const cssModule: string
export default cssModule
}
/**
* For Web Worker entrypoints using Webpack's worker-loader.
*
* See https://github.com/webpack-contrib/worker-loader#integrating-with-typescript.
*/
declare module '*.worker.ts' {
class WebpackWorker extends Worker {
constructor()
}
export default WebpackWorker
}
/**
* Set by shared/dev/jest-environment.js
*/
declare var jsdom: import('jsdom').JSDOM

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1 @@
<svg width="256" height="262" viewBox="0 0 256 262" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="m74.203 33.602 56.952 208.194c3.827 13.991 18.27 22.23 32.254 18.4 13.993-3.833 22.224-18.28 18.394-32.27l-56.962-208.2c-3.83-13.987-18.27-22.228-32.254-18.394C78.607 5.157 70.373 19.609 74.203 33.6v.002Z" fill="#F96316"/><path d="M179.662 32.813 37.046 193.858c-9.621 10.86-8.616 27.464 2.233 37.087 10.85 9.62 27.438 8.617 37.059-2.238L218.954 67.665c9.621-10.86 8.616-27.459-2.233-37.083-10.854-9.63-27.446-8.624-37.059 2.23v.001Z" fill="#B200F8"/><path d="m18.065 122.054 203.387 67.293c13.765 4.552 28.615-2.92 33.167-16.696 4.562-13.774-2.911-28.63-16.681-33.189L34.556 72.175C20.786 67.62 5.942 75.091 1.387 88.867c-4.55 13.775 2.924 28.635 16.682 33.187h-.004Z" fill="#00B4F2"/></svg>

After

Width:  |  Height:  |  Size: 820 B

109
client/vscode/package.json Normal file
View File

@ -0,0 +1,109 @@
{
"private": true,
"name": "sourcegraph-preview",
"displayName": "Sourcegraph - preview",
"version": "0.0.2",
"description": "Sourcegraph for VS Code",
"publisher": "kandalatj",
"sideEffects": false,
"license": "Apache-2.0",
"icon": "images/logo.png",
"repository": {
"type": "git",
"url": "https://github.com/sourcegraph/sourcegraph.git"
},
"engines": {
"vscode": "^1.61.0"
},
"categories": [
"Other"
],
"activationEvents": [
"onCommand:sourcegraph.search",
"onView:sourcegraph.searchSidebar",
"onWebviewPanel:sourcegraphSearch"
],
"main": "./dist/node/extension.js",
"browser": "./dist/webworker/extension.js",
"contributes": {
"commands": [
{
"command": "sourcegraph.search",
"category": "Sourcegraph",
"title": "Open Sourcegraph Search Tab"
}
],
"viewsContainers": {
"activitybar": [
{
"id": "sourcegraph-view",
"title": "Sourcegraph",
"icon": "images/logo.svg"
}
]
},
"views": {
"sourcegraph-view": [
{
"type": "webview",
"id": "sourcegraph.searchSidebar",
"name": "Sourcegraph Search",
"visibility": "visible"
},
{
"id": "sourcegraph.files",
"name": "Files",
"visibility": "visible",
"when": "sourcegraph.state == 'remote-browsing'"
}
]
},
"viewsWelcome": [
{
"view": "sourcegraph.files",
"contents": "No open files."
}
],
"configuration": {
"type": "object",
"title": "Sourcegraph extension configuration",
"properties": {
"sourcegraph.url": {
"type": [
"string"
],
"default": "https://sourcegraph.com",
"description": "The base URL of the Sourcegraph instance to use."
},
"sourcegraph.accessToken": {
"type": [
"string"
],
"default": "",
"description": "The access token to query the Sourcegraph API. Create a new access token at ${SOURCEGRAPH_URL}/users/settings/tokens"
}
}
},
"keybindings": [
{
"command": "sourcegraph.search",
"key": "ctrl+shift+8",
"mac": "cmd+shift+8"
}
],
"menus": {
"editor/context": [
]
}
},
"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"
}
}

View File

@ -0,0 +1,62 @@
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'
let invalidated = false
/**
* To be called when Sourcegraph URL changes.
*/
export function invalidateClient(): void {
invalidated = true
}
export const requestGraphQLFromVSCode = async <R, V = object>(
request: string,
variables: V
): Promise<GraphQLResult<R>> => {
if (invalidated) {
throw new Error(
'Sourcegraph GraphQL Client has been invalidated due to instance URL change. Restart VS Code to fix.'
)
}
const nameMatch = request.match(/^\s*(?:query|mutation)\s+(\w+)/)
const apiURL = `${GRAPHQL_URI}${nameMatch ? '?' + nameMatch[1] : ''}`
const headers: HeadersInit = []
const sourcegraphURL = endpointSetting()
const accessToken = accessTokenSetting()
// Add Access Token to request header
if (accessToken) {
headers.push(['Authorization', `token ${accessToken}`])
} else {
headers.push(['Content-Type', 'application/json'])
}
try {
const url = new URL(apiURL, sourcegraphURL).href
const response = checkOk(
await fetch(url, {
body: JSON.stringify({
query: request,
variables,
}),
method: 'POST',
headers,
})
)
// TODO request cancellation w/ VS Code cancellation tokens.
// eslint-disable-next-line @typescript-eslint/return-await
return response.json() as Promise<GraphQLResult<any>>
} catch (error) {
if (isHTTPAuthError(error)) {
await handleAccessTokenError(accessToken ?? '')
}
throw asError(error)
}
}

View File

@ -0,0 +1,56 @@
import { Observable, ReplaySubject, Subject } from 'rxjs'
import { map, switchMap, throttleTime } from 'rxjs/operators'
import * as vscode from 'vscode'
import { createAggregateError } from '@sourcegraph/common'
import { viewerSettingsQuery } from '@sourcegraph/shared/src/backend/settings'
import { ViewerSettingsResult, ViewerSettingsVariables } from '@sourcegraph/shared/src/graphql-operations'
import { ISettingsCascade } from '@sourcegraph/shared/src/schema'
import { gqlToCascade, Settings, SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/settings'
import { requestGraphQLFromVSCode } from './requestGraphQl'
export function initializeSourcegraphSettings({
context,
}: {
context: vscode.ExtensionContext
}): {
settings: Observable<SettingsCascadeOrError<Settings>>
refreshSettings: () => void
} {
const settings = new ReplaySubject<SettingsCascadeOrError<Settings>>(1)
const refreshes = new Subject<void>()
// Throttle refreshes for one hour.
const ONE_HOUR_MS = 60 * 60 * 1000
const subscription = refreshes
.pipe(
throttleTime(ONE_HOUR_MS, undefined, { leading: true, trailing: true }),
switchMap(() =>
requestGraphQLFromVSCode<ViewerSettingsResult, ViewerSettingsVariables>(viewerSettingsQuery, {})
),
map(({ data, errors }) => {
if (!data?.viewerSettings) {
throw createAggregateError(errors)
}
return gqlToCascade(data?.viewerSettings as ISettingsCascade)
})
)
.subscribe(settingsCascade => {
settings.next(settingsCascade)
})
context.subscriptions.push({ dispose: () => subscription.unsubscribe() })
// Initial settings
refreshes.next()
return {
settings: settings.asObservable(),
refreshSettings: () => {
refreshes.next()
},
}
}

View File

@ -0,0 +1,28 @@
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 { SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/settings'
import { 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) => Promise<GraphQLResult<any>>
observeSourcegraphSettings: () => ProxySubscribable<SettingsCascadeOrError>
observeState: () => ProxySubscribable<VSCEState>
emit: VSCEStateMachine['emit']
}
export interface SearchPanelAPI {
// TODO remove once other methods are implemented
ping: () => ProxySubscribable<'pong'>
}
export interface SearchSidebarAPI extends Pick<FlatExtensionHostAPI, 'addTextDocumentIfNotExists'> {
// TODO remove once other methods are implemented
ping: () => ProxySubscribable<'pong'>
// TODO: ExtensionHostAPI methods
}

View File

@ -0,0 +1,77 @@
import 'cross-fetch/polyfill'
import { ReplaySubject } from 'rxjs'
import * as vscode from 'vscode'
import { proxySubscribable } from '@sourcegraph/shared/src/api/extension/api/common'
import { requestGraphQLFromVSCode } from './backend/requestGraphQl'
import { initializeSourcegraphSettings } from './backend/sourcegraphSettings'
import { ExtensionCoreAPI } from './contract'
import { invalidateContextOnEndpointChange } from './settings/endpointSetting'
import { createVSCEStateMachine } from './state'
import { registerWebviews } from './webview/commands'
// Sourcegraph VS Code extension architecture
// -----
//
// ┌──────────────────────────┐
// │ env: Node OR Web Worker │
// ┌───────────┤ VS Code extension "Core" ├───────────────┐
// │ │ (HERE) │ │
// │ └──────────────────────────┘ │
// │ │
// ┌─────────────▼────────────┐ ┌──────────────▼───────────┐
// │ env: Web │ │ env: Web │
// ┌───┤ "search sidebar" webview │ │ "search panel" webview │
// │ │ │ │ │
// │ └──────────────────────────┘ └──────────────────────────┘
// │
// ┌▼───────────────────────────┐
// │ env: Web Worker │
// │ Sourcegraph Extension host │
// │ │
// └────────────────────────────┘
//
// - See './state.ts' for documentation on state management.
// - One state machine that lives in Core
// - See './contract.ts' to see the APIs for the three main components:
// - Core, search sidebar, and search panel.
// - The extension host API is exposed through the search sidebar.
// - See './webview/comlink' for documentation on _how_ communication between contexts works.
// It is _not_ important to understand this layer to add features to the
// VS Code extension (that's why it exists, after all).
export function activate(context: vscode.ExtensionContext): void {
const stateMachine = createVSCEStateMachine()
invalidateContextOnEndpointChange({ context, stateMachine })
const sourcegraphSettings = initializeSourcegraphSettings({ context })
// 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(),
})
// 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<string>(7)
const extensionCoreAPI: ExtensionCoreAPI = {
panelInitialized: panelId => initializedPanelIDs.next(panelId),
observeState: () => proxySubscribable(stateMachine.observeState()),
emit: event => stateMachine.emit(event),
requestGraphQL: requestGraphQLFromVSCode,
observeSourcegraphSettings: () => proxySubscribable(sourcegraphSettings.settings),
}
registerWebviews({ context, extensionCoreAPI, initializedPanelIDs, sourcegraphSettings })
// TODO: registerCodeSharingCommands()
// TODO: registerCodeIntel()
}

View File

@ -0,0 +1,37 @@
import * as vscode from 'vscode'
import { endpointHostnameSetting } from './endpointSetting'
import { readConfiguration } from './readConfiguration'
const invalidAccessTokens = new Set<string>()
export function accessTokenSetting(): string | undefined {
return readConfiguration().get<string>('accessToken')
}
// Ensure that only one access token error message is shown at a time.
let showingAccessTokenErrorMessage = false
export async function handleAccessTokenError(badToken: string): Promise<void> {
invalidAccessTokens.add(badToken)
const currentValue = readConfiguration().get<string>('accessToken')
if (currentValue === badToken && !showingAccessTokenErrorMessage) {
showingAccessTokenErrorMessage = true
await vscode.window.showErrorMessage('Invalid Sourcegraph Access Token', {
modal: true,
detail: `The server at ${endpointHostnameSetting()} is unable to use the access token ${badToken}.`,
})
showingAccessTokenErrorMessage = false
}
}
export async function updateAccessTokenSetting(newToken: string): Promise<boolean> {
try {
await readConfiguration().update('accessToken', newToken, vscode.ConfigurationTarget.Global)
return true
} catch {
return false
}
}

View File

@ -0,0 +1,79 @@
import * as vscode from 'vscode'
import { invalidateClient } from '../backend/requestGraphQl'
import { VSCEStateMachine } from '../state'
import { readConfiguration } from './readConfiguration'
/**
* Listens for Sourcegraph URL or access token changes and invalidates the GraphQL client
* to prevent data "contamination" (e.g. sending private repo names to Cloud instance).
*/
export function invalidateContextOnEndpointChange({
context,
stateMachine,
}: {
context: vscode.ExtensionContext
stateMachine: VSCEStateMachine
}): void {
function disposeAllResources(): void {
for (const subscription of context.subscriptions) {
subscription.dispose()
}
}
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(config => {
if (config.affectsConfiguration('sourcegraph.accessToken')) {
invalidateClient()
disposeAllResources()
stateMachine.emit({ type: 'access_token_change' })
vscode.window
.showInformationMessage(
'Restart VS Code to use the Sourcegraph extension after access token change.'
)
.then(
() => {},
() => {}
)
}
if (config.affectsConfiguration('sourcegraph.url')) {
invalidateClient()
disposeAllResources()
stateMachine.emit({ type: 'sourcegraph_url_change' })
vscode.window
.showInformationMessage('Restart VS Code to use the Sourcegraph extension after URL change.')
.then(
() => {},
() => {}
)
}
})
)
}
export function endpointSetting(): string {
// has default value
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const url = readConfiguration().get<string>('url')!
if (url.endsWith('/')) {
return url.slice(0, -1)
}
return url
}
export function endpointHostnameSetting(): string {
return new URL(endpointSetting()).hostname
}
export function endpointPortSetting(): number {
const port = new URL(endpointSetting()).port
return port ? parseInt(port, 10) : 443
}
export function endpointAccessTokenSetting(): boolean {
if (readConfiguration().get<string>('accessToken')) {
return true
}
return false
}

View File

@ -0,0 +1,5 @@
import * as vscode from 'vscode'
export function readConfiguration(): vscode.WorkspaceConfiguration {
return vscode.workspace.getConfiguration('sourcegraph')
}

191
client/vscode/src/state.ts Normal file
View File

@ -0,0 +1,191 @@
import { cloneDeep } from 'lodash'
import { BehaviorSubject, Observable } from 'rxjs'
import { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
// State management in the Sourcegraph VS Code extension
// -----
// This extension runs code in 4 (and counting) different execution contexts.
// Coordinating state between these contexts is a difficult task.
// So, instead of managing shared state in each context, we maintain
// one state machine in the "Core" context (see './extension.ts' for architecure diagram).
// All contexts listen for state updates and emit events on which the state
// machine may transition.
// For example:
// - Commands from VS Code extension core
// - The first submitted search in a session will cause the state machine
// to transition from the `search-home` state to the `search-results` state.
// This new state will be reflected in both the search sidebar and search panel UIs
//
// We represent a hierarchical state machine in a "flat" manner to reduce code complexity
// and because our state machine is simple enough to not necessitate bringing in a library.
// So,
// ┌───►home
// │
// - search
// │
// └───►results
// - remote-browsing
// - idle
// - context-invalidated
// becomes:
// - [search-home, search-results, remote-browsing, idle, context-invalidated]
// Example user flow state transitions:
// - User clicks on Sourcegraph logo in VS Code sidebar.
// - Extension activates with initial state of `search-home`
// - User submits search -> state === `search-results`
// - User clicks on a search result, which opens a file -> state === `remote-browsing`
// - User copies some code, then focuses an editor for a local file -> state === `idle`
export interface VSCEStateMachine {
state: VSCEState
/**
* Returns an Observable that emits the current state and
* on subsequent state updates.
*/
observeState: () => Observable<VSCEState>
emit: (event: VSCEEvent) => void
}
export type VSCEState = SearchHomeState | SearchResultsState | RemoteBrowsingState | IdleState | ContextInvalidatedState
interface SearchHomeState {
status: 'search-home'
context: CommonContext & {}
}
interface SearchResultsState {
status: 'search-results'
context: CommonContext & {}
}
interface RemoteBrowsingState {
status: 'remote-browsing'
context: CommonContext & {}
}
interface IdleState {
status: 'idle'
context: CommonContext & {}
}
interface ContextInvalidatedState {
status: 'context-invalidated'
context: CommonContext & {}
}
interface CommonContext {
authenticatedUser: AuthenticatedUser | null
// Whether a search has already been submitted.
dirty: boolean
}
const INITIAL_STATE: VSCEState = { status: 'search-home', context: { authenticatedUser: null, dirty: false } }
// 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 TabsEvent =
| { type: 'search_panel_unfocused' }
| { type: 'search_panel_focused' }
| { type: 'remote_file_focused' }
| { type: 'remote_file_unfocused' }
type SettingsEvent =
| {
type: 'sourcegraph_url_change'
}
| { type: 'access_token_change' }
export function createVSCEStateMachine(): VSCEStateMachine {
const states = new BehaviorSubject<VSCEState>(INITIAL_STATE)
function reducer(state: VSCEState, event: VSCEEvent): VSCEState {
// End state.
if (state.status === 'context-invalidated') {
return state
}
// Events with the same behavior regardless of current state
if (event.type === 'sourcegraph_url_change' || event.type === 'access_token_change') {
return { status: 'context-invalidated', context: INITIAL_STATE.context }
}
switch (state.status) {
case 'search-home':
case 'search-results':
switch (event.type) {
case 'submit_search_query':
return {
status: 'search-results',
context: {
...state.context,
dirty: true,
},
}
case 'search_panel_unfocused':
return {
...state,
status: 'idle',
}
case 'remote_file_focused':
return {
...state,
status: 'remote-browsing',
}
}
return state
case 'remote-browsing':
switch (event.type) {
case 'search_panel_focused':
return {
...state,
status: state.context.dirty ? 'search-results' : 'search-home',
}
case 'remote_file_unfocused':
return {
...state,
status: 'idle',
}
}
return state
case 'idle':
switch (event.type) {
case 'search_panel_focused':
return {
...state,
status: state.context.dirty ? 'search-results' : 'search-home',
}
case 'remote_file_focused':
return {
...state,
status: 'remote-browsing',
}
}
return state
}
}
return {
get state() {
return cloneDeep(states.value)
},
observeState: () => states.asObservable(),
emit: event => {
const nextState = reducer(states.value, event)
states.next(nextState)
},
}
}

View File

@ -0,0 +1,15 @@
declare global {
// eslint-disable-next-line no-var
var acquireVsCodeApi: <State = any>() => VsCodeApi<State>
}
/**
* Built-in VS Code API exposed to webviews to communicate with the "Core" extension.
* We typically use this as a low-level building block for the APIs used in our webviews
* (wrapped w/ Comlink).
*/
export interface VsCodeApi<State = any> {
postMessage: (message: any) => void
getState: () => State | undefined
setState: (state: State) => void
}

View File

@ -0,0 +1,127 @@
import * as Comlink from 'comlink'
import vscode from 'vscode'
import { EndpointPair } from '@sourcegraph/shared/src/platform/context'
import { generateUUID, isNestedConnection, isProxyMarked, NestedConnectionData, RelationshipType } from '.'
// Used to scope message to panel (and `connectionId` further scopes to function call).
let nextPanelId = 1
const endpointFactories = new Map<string, ((connectionId: string) => Comlink.Endpoint) | undefined>()
const vscodeExtensionProxyTransferHandler: Comlink.TransferHandler<
object,
// Receive panelId (in deserialize), no need to send it.
// Return proxyMarkedValue in serialize so we can expose it in postMessage, but
// only end up sending the nestedConnectionId
{ nestedConnectionId: string; proxyMarkedValue?: object; panelId?: string; relationshipType: RelationshipType }
> = {
canHandle: isProxyMarked,
serialize: proxyMarkedValue => {
const nestedConnectionId = generateUUID()
// Defer endpoint creation/object exposition to `postMessage` (to scope it to panel)
return [{ nestedConnectionId, proxyMarkedValue, relationshipType: 'webToNode' }, []]
},
deserialize: serialized => {
// Create endpoint, return wrapped proxy.
const endpointFactory = endpointFactories.get(serialized.panelId!)!
const endpoint = endpointFactory(serialized.nestedConnectionId)
const proxy = Comlink.wrap(endpoint)
return proxy
},
}
Comlink.transferHandlers.set('proxy', vscodeExtensionProxyTransferHandler)
export function createEndpointsForWebview(
panel: Pick<vscode.WebviewPanel, 'onDidDispose' | 'webview'>
): EndpointPair & { panelId: string } {
const listenerDisposables = new WeakMap<EventListenerOrEventListenerObject, vscode.Disposable>()
const panelId = nextPanelId.toString()
nextPanelId++
let disposed = false
/**
* Handles values sent to webviews that are marked to be proxied.
*/
function toWireValue(value: NestedConnectionData): void {
const proxyMarkedValue = value.proxyMarkedValue!
// The proxyMarkedValue is probably not cloneable, so don't
// send it "over the wire"
delete value.proxyMarkedValue
const endpoint = createEndpoint(value.nestedConnectionId)
Comlink.expose(proxyMarkedValue, endpoint)
}
function createEndpoint(connectionId: string): Comlink.Endpoint {
return {
postMessage: (message: any) => {
const value = message.value
const argumentList = message.argumentList
if (isNestedConnection(value)) {
toWireValue(value)
}
if (Array.isArray(argumentList)) {
for (const argument of argumentList) {
const value = argument.value
if (isNestedConnection(value)) {
toWireValue(value)
}
}
}
if (!disposed) {
panel.webview.postMessage({ ...message, connectionId, panelId }).then(
() => {},
error => console.error('postMessage error', error)
)
}
},
addEventListener: (type, listener) => {
// This event listener will be called for all proxy method calls.
// Comlink will send the message to the appropriate caller
// based on UUID (generated internally).
function onMessage(message: any): void {
if (message?.connectionId === connectionId) {
// Comlink is listening for a message event, only uses the `data` property.
const messageEvent = {
data: message,
} as MessageEvent
return typeof listener === 'function'
? listener(messageEvent)
: listener.handleEvent(messageEvent)
}
}
const disposable = panel.webview.onDidReceiveMessage(onMessage)
listenerDisposables.set(listener, disposable)
},
removeEventListener: (type, listener) => {
const disposable = listenerDisposables.get(listener)
disposable?.dispose()
listenerDisposables.delete(listener)
},
}
}
endpointFactories.set(panelId, createEndpoint)
panel.onDidDispose(() => {
disposed = true
endpointFactories.delete(panelId)
})
const webviewEndpoint = createEndpoint('webview')
const extensionEndpoint = createEndpoint('extension')
return {
proxy: webviewEndpoint,
expose: extensionEndpoint,
panelId,
}
}

View File

@ -0,0 +1,52 @@
import * as Comlink from 'comlink'
import { isObject } from 'lodash'
import { hasProperty } from '@sourcegraph/shared/src/util/types'
// Sourcegraph VS Code extension Comlink "communication layer" documentation
// -----
// NOTE: This is a working work-in-progress. We should be able to swap out this
// "communication layer" with either our own VS Code webview RPC solution
// or by applying this adapter to a fork of Comlink.
// MOTIVATION:
// Comlink is a great library that makes it easy to work with Web Workers: https://github.com/GoogleChromeLabs/comlink.
// We use it to implement a bi-directional communication channel between our web application/browser extension and
// our Sourcegraph extension host. Given the need to sync state between {search panel <- extension "Core" -> search webview},
// Comlink was a natural fit. However, we needed to implement some hacky adapters to get it to work for our needs:
// web <-> web: Default Comlink use case. Depends on ability to transfer `MessageChannel`s, which
// is built into browsers.
// web <-> node: Cannot transfer `MessageChannel`s between VS Code webviews and Node.js extension "Core",
// so we have to hijack Comlink's Proxy transfer handler to manage nested Proxied objects ourselves.
// Since the transfer handler registry is global, a web context that needs to communicate with Node.js will
// have pushed out the default Proxy transfer handler out and therefore needs to manage web to web messages as well.
// Consequently, there are three endpoint generators (all return endpoints for both directions):
// - extension <-> webview (extension context)
// - web <-> extension (webview context)
// - web <-> web (webview context, used for Sourcegraph extension host Web Worker)
export function generateUUID(): string {
return new Array(4)
.fill(0)
.map(() => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(16))
.join('-')
}
export type RelationshipType = 'webToWeb' | 'webToNode'
export interface NestedConnectionData {
nestedConnectionId: string
proxyMarkedValue?: object
panelId: string
relationshipType?: RelationshipType
}
export function isNestedConnection(value: unknown): value is NestedConnectionData {
return isObject(value) && hasProperty('nestedConnectionId')(value) && hasProperty('proxyMarkedValue')(value)
}
export function isProxyMarked(value: unknown): value is Comlink.ProxyMarked {
return isObject(value) && (value as Comlink.ProxyMarked)[Comlink.proxyMarker]
}

View File

@ -0,0 +1,202 @@
import * as Comlink from 'comlink'
import { isObject } from 'lodash'
import { EndpointPair } from '@sourcegraph/shared/src/platform/context'
import { VsCodeApi } from '../../vsCodeApi'
import { generateUUID, isNestedConnection, NestedConnectionData, RelationshipType } from '.'
const panelId = self.document ? self.document.documentElement.dataset.panelId! : 'web-worker'
const endpointFactories: {
webToWeb?: (connectionId: string) => Comlink.Endpoint
webToNode?: (connectionId: string) => Comlink.Endpoint
} = {}
const vscodeWebviewProxyTransferHandler: Comlink.TransferHandler<
object,
// Send panelId (in serialize), no need to receive it.
// Return proxyMarkedValue in serialize so we can expose it in postMessage, but
// only end up sending the nestedConnectionId (and relationshipType for `webToWeb`)
{ nestedConnectionId: string; proxyMarkedValue?: object; relationshipType?: RelationshipType; panelId?: string }
> = {
canHandle: (value): value is Comlink.ProxyMarked =>
isObject(value) && (value as Comlink.ProxyMarked)[Comlink.proxyMarker],
serialize: proxyMarkedValue => {
const nestedConnectionId = generateUUID()
// Add relationshipType in `postMessage`
return [{ nestedConnectionId, proxyMarkedValue, panelId }, []]
},
deserialize: serialized => {
// Get endpoint factory based on relationship type
const endpointFactory = endpointFactories[
serialized.relationshipType === 'webToWeb' ? 'webToWeb' : 'webToNode'
]!
// Create endpoint, return wrapped proxy.
const endpoint = endpointFactory(serialized.nestedConnectionId)
const proxy = Comlink.wrap(endpoint)
return proxy
},
}
Comlink.transferHandlers.set('proxy', vscodeWebviewProxyTransferHandler)
/**
*
* @param target Typically a WebWorker or `self` from a WebWorker (`Endpoint` for main thread).
*/
export function createEndpointsForWebToWeb(
target: Comlink.Endpoint
): {
webview: Comlink.Endpoint
worker: Comlink.Endpoint
} {
const onMessages = new WeakMap<EventListenerOrEventListenerObject, EventListener>()
/**
* Handles values sent to webviews that are marked to be proxied.
*/
function toWireValue(value: NestedConnectionData): void {
const proxyMarkedValue = value.proxyMarkedValue!
// The proxyMarkedValue is probably not cloneable, so don't
// send it "over the wire"
delete value.proxyMarkedValue
value.relationshipType = 'webToWeb'
const endpoint = createEndpoint(value.nestedConnectionId)
Comlink.expose(proxyMarkedValue, endpoint)
}
function createEndpoint(connectionId: string): Comlink.Endpoint {
return {
postMessage: message => {
// Add relationship type to all nested connection values (i.e values to be proxied).
// TODO is the above necessary for this endpoint type?
const value = message.value
const argumentList = message.argumentList
if (isNestedConnection(value)) {
toWireValue(value)
}
if (Array.isArray(argumentList)) {
for (const argument of argumentList) {
const value = argument.value
if (isNestedConnection(value)) {
toWireValue(value)
}
}
}
target.postMessage({ ...message, connectionId, panelId })
},
addEventListener: (type, listener) => {
// This event listener will be called for all proxy method calls.
// Comlink will send the message to the appropriate caller
// based on UUID (generated internally).
function onMessage(event: MessageEvent): void {
if (event.data?.connectionId === connectionId) {
return typeof listener === 'function' ? listener(event) : listener.handleEvent(event)
}
}
onMessages.set(listener, onMessage as EventListener)
target.addEventListener('message', onMessage as EventListener)
},
removeEventListener: (type, listener) => {
const onMessage = onMessages.get(listener)
target.removeEventListener('message', onMessage ?? listener)
},
}
}
endpointFactories.webToWeb = createEndpoint
const webview = createEndpoint('webview')
const worker = createEndpoint('worker')
return {
webview,
worker,
}
}
export function createEndpointsForWebToNode(vscodeApi: VsCodeApi): EndpointPair {
const onMessages = new WeakMap<EventListenerOrEventListenerObject, EventListener>()
/**
* Handles values sent to the VS Code extension that are marked to be proxied.
*/
function toWireValue(value: NestedConnectionData): void {
const proxyMarkedValue = value.proxyMarkedValue!
// The proxyMarkedValue is probably not cloneable, so don't
// send it "over the wire"
delete value.proxyMarkedValue
value.relationshipType = 'webToNode'
const endpoint = createEndpoint(value.nestedConnectionId)
Comlink.expose(proxyMarkedValue, endpoint)
}
function createEndpoint(connectionId: string): Comlink.Endpoint {
return {
postMessage: message => {
// Add relationship type to all nested connection values (i.e values to be proxied).
// TODO is the above necessary for this endpoint type?
const value = message.value
const argumentList = message.argumentList
if (isNestedConnection(value)) {
toWireValue(value)
}
if (Array.isArray(argumentList)) {
for (const argument of argumentList) {
const value = argument.value
if (isNestedConnection(value)) {
toWireValue(value)
}
}
}
vscodeApi.postMessage({ ...message, connectionId, panelId })
},
addEventListener: (type, listener) => {
// This event listener will be called for all proxy method calls.
// Comlink will send the message to the appropriate caller
// based on UUID (generated internally).
function onMessage(event: MessageEvent): void {
if (event.data?.connectionId === connectionId) {
return typeof listener === 'function' ? listener(event) : listener.handleEvent(event)
}
}
onMessages.set(listener, onMessage as EventListener)
window.addEventListener('message', onMessage)
},
removeEventListener: (type, listener) => {
const onMessage = onMessages.get(listener)
window.removeEventListener('message', onMessage ?? listener)
},
}
}
endpointFactories.webToNode = createEndpoint
const extensionEndpoint = createEndpoint('extension')
const webviewEndpoint = createEndpoint('webview')
return {
proxy: extensionEndpoint,
expose: webviewEndpoint,
}
}

View File

@ -0,0 +1,127 @@
import { Observable } from 'rxjs'
import * as vscode from 'vscode'
import { initializeSourcegraphSettings } from '../backend/sourcegraphSettings'
import { ExtensionCoreAPI } from '../contract'
import { initializeSearchPanelWebview, initializeSearchSidebarWebview } from './initialize'
export function registerWebviews({
context,
extensionCoreAPI,
initializedPanelIDs,
sourcegraphSettings,
}: {
context: vscode.ExtensionContext
extensionCoreAPI: ExtensionCoreAPI
initializedPanelIDs: Observable<string>
sourcegraphSettings: ReturnType<typeof initializeSourcegraphSettings>
}): 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.
// Open Sourcegraph search tab on `sourcegraph.search` command.
context.subscriptions.push(
vscode.commands.registerCommand('sourcegraph.search', async () => {
// 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 {
sourcegraphSettings.refreshSettings()
const { webviewPanel } = await initializeSearchPanelWebview({
extensionUri: context.extensionUri,
extensionCoreAPI,
initializedPanelIDs,
})
currentActiveWebviewPanel = webviewPanel
webviewPanel.onDidChangeViewState(() => {
if (webviewPanel.active) {
extensionCoreAPI.emit({ type: 'search_panel_focused' })
focusSearchSidebar()
}
if (!webviewPanel.visible) {
// TODO emit event (should go to idle state if not remote browsing)
extensionCoreAPI.emit({ type: 'search_panel_unfocused' })
}
})
webviewPanel.onDidDispose(() => {
currentActiveWebviewPanel = 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) {
focusFileExplorer()
}
})
}
})
)
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(
'sourcegraph.searchSidebar',
{
// This typically will be called only once since `retainContextWhenHidden` is set to `true`.
resolveWebviewView: (webviewView, _context, _token) => {
initializeSearchSidebarWebview({
extensionUri: context.extensionUri,
extensionCoreAPI,
webviewView,
})
searchSidebarWebviewView = webviewView
// Initialize search panel.
openSearchPanelCommand()
// Bring search panel back if it was previously closed on sidebar visibility change
webviewView.onDidChangeVisibility(() => {
if (webviewView.visible) {
openSearchPanelCommand()
}
})
},
},
{ webviewOptions: { retainContextWhenHidden: true } }
)
)
}
function openSearchPanelCommand(): void {
vscode.commands.executeCommand('sourcegraph.search').then(
() => {},
error => {
console.error(error)
}
)
}
function focusSearchSidebar(): void {
vscode.commands.executeCommand('sourcegraph.searchSidebar.focus').then(
() => {},
error => {
console.error(error)
}
)
}
function focusFileExplorer(): void {
vscode.commands.executeCommand('workbench.view.explorer').then(
() => {},
error => {
console.error(error)
}
)
}

View File

@ -0,0 +1,3 @@
@import '../../../branded/src/global-styles/index.scss';
// TODO: adapt to editor styles

View File

@ -0,0 +1,149 @@
import * as Comlink from 'comlink'
import { Observable } from 'rxjs'
import { filter, first } from 'rxjs/operators'
import * as vscode from 'vscode'
import { ExtensionCoreAPI, SearchPanelAPI, SearchSidebarAPI } from '../contract'
import { createEndpointsForWebview } from './comlink/extensionEndpoint'
interface SourcegraphWebviewConfig {
extensionUri: vscode.Uri
extensionCoreAPI: ExtensionCoreAPI
}
export async function initializeSearchPanelWebview({
extensionUri,
extensionCoreAPI,
initializedPanelIDs,
}: SourcegraphWebviewConfig & {
initializedPanelIDs: Observable<string>
}): Promise<{
searchPanelAPI: Comlink.Remote<SearchPanelAPI>
webviewPanel: vscode.WebviewPanel
}> {
const panel = vscode.window.createWebviewPanel('sourcegraphSearch', 'Sourcegraph', vscode.ViewColumn.One, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'dist', 'webview')],
})
const webviewPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview')
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 { proxy, expose, panelId } = createEndpointsForWebview(panel)
// Wait for the webview to initialize or else messages will be dropped
const hasInitialized = initializedPanelIDs
.pipe(
filter(initializedPanelId => initializedPanelId === panelId),
first()
)
.toPromise()
// Get a proxy for the search panel API to communicate with the Webview.
const searchPanelAPI = Comlink.wrap<SearchPanelAPI>(proxy)
// Expose the "Core" extension API to the Webview.
Comlink.expose(extensionCoreAPI, expose)
// Use a nonce to only allow specific scripts to be run
const nonce = getNonce()
panel.iconPath = vscode.Uri.joinPath(extensionUri, 'images', 'logo.svg')
// Apply Content-Security-Policy
// panel.webview.cspSource comes from the webview object
panel.webview.html = `<!DOCTYPE html>
<html lang="en" data-panel-id="${panelId}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: vscode-resource: vscode-webview: https:; script-src 'nonce-${nonce}' vscode-webview:; style-src data: ${
panel.webview.cspSource
} vscode-resource: vscode-webview: 'unsafe-inline' http: https: data:; connect-src 'self' vscode-webview: http: https:; frame-src https:; font-src: https: vscode-resource: vscode-webview:;">
<title>Sourcegraph Search</title>
<link rel="stylesheet" href="${styleSource.toString()}" />
<link rel="stylesheet" href="${cssModuleSource.toString()}" />
</head>
<body>
<div id="root" />
<script nonce="${nonce}" src="${scriptSource.toString()}"></script>
</body>
</html>`
await hasInitialized
return {
searchPanelAPI,
webviewPanel: panel,
}
}
// TODO expand CSP for Sourcegraph extension loading
export function initializeSearchSidebarWebview({
extensionUri,
extensionCoreAPI,
webviewView,
}: SourcegraphWebviewConfig & {
webviewView: vscode.WebviewView
}): {
searchSidebarAPI: Comlink.Remote<SearchSidebarAPI>
} {
webviewView.webview.options = {
enableScripts: true,
}
const webviewPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview')
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 { proxy, expose, panelId } = createEndpointsForWebview(webviewView)
// Get a proxy for the Sourcegraph Webview API to communicate with the Webview.
const searchSidebarAPI = Comlink.wrap<SearchSidebarAPI>(proxy)
// 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
webviewView.webview.html = `<!DOCTYPE html>
<html lang="en" data-panel-id="${panelId}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: vscode-webview: vscode-resource: https:; script-src 'nonce-${nonce}' vscode-webview:; style-src data: ${
webviewView.webview.cspSource
} vscode-resource: http: https: data:; connect-src 'self' http: https:; font-src: https: vscode-resource: vscode-webview:;">
<title>Sourcegraph Search</title>
<link rel="stylesheet" href="${styleSource.toString()}" />
<link rel="stylesheet" href="${cssModuleSource.toString()}" />
</head>
<body>
<div id="root" />
<script nonce="${nonce}" src="${scriptSource.toString()}"></script>
</body>
</html>`
return {
searchSidebarAPI,
}
}
export function getNonce(): string {
let text = ''
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
for (let index = 0; index < 32; index++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
}
return text
}

View File

@ -0,0 +1,48 @@
import * as Comlink from 'comlink'
import React, { useMemo } from 'react'
import { render } from 'react-dom'
import { of } from 'rxjs'
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 { ExtensionCoreAPI, SearchPanelAPI } from '../../contract'
import { createEndpointsForWebToNode } from '../comlink/webviewEndpoint'
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<ExtensionCoreAPI> = Comlink.wrap(proxy)
extensionCoreAPI.panelInitialized(document.documentElement.dataset.panelId!).catch(() => {
// noop (TODO?)
})
// TODO create platform context.
setLinkComponent(AnchorLink)
const Main: React.FC = () => {
console.log('rendering webview')
const state = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeState()), []))
return (
<div>
<h1>state: {state?.status}</h1>
</div>
)
}
render(<Main />, document.querySelector('#root'))

View File

@ -0,0 +1,47 @@
import * as Comlink from 'comlink'
import React, { useMemo } from 'react'
import { render } from 'react-dom'
import { of } from 'rxjs'
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 { ExtensionCoreAPI, SearchSidebarAPI } from '../../contract'
import { createEndpointsForWebToNode } from '../comlink/webviewEndpoint'
// TODO: load extension host
const vsCodeApi = window.acquireVsCodeApi()
const searchSidebarAPI: SearchSidebarAPI = {
ping: () => {
console.log('ping called')
return proxySubscribable(of('pong'))
},
addTextDocumentIfNotExists: () => {
console.log('addTextDocumentIfNotExists called')
},
}
const { proxy, expose } = createEndpointsForWebToNode(vsCodeApi)
Comlink.expose(searchSidebarAPI, expose)
export const extensionCoreAPI: Comlink.Remote<ExtensionCoreAPI> = Comlink.wrap(proxy)
setLinkComponent(AnchorLink)
const Main: React.FC = () => {
console.log('rendering webview')
const state = useObservable(useMemo(() => wrapRemoteObservable(extensionCoreAPI.observeState()), []))
return (
<div>
<h1>state: {state?.status}</h1>
</div>
)
}
render(<Main />, document.querySelector('#root'))

View File

@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"lib": ["esnext", "DOM", "DOM.Iterable"],
"sourceMap": true,
"sourceRoot": "src",
"baseUrl": "./src",
"paths": {
"*": ["types/*", "../../shared/src/types/*", "*"],
},
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true,
"jsx": "react",
},
"references": [
{ "path": "../shared" },
{ "path": "../branded" },
{ "path": "../search" },
{ "path": "../search-ui" },
],
"include": ["**/*", ".*", "**/*.d.ts"],
"exclude": ["node_modules", "../../node_modules", ".vscode-test", "out", "dist"],
}

View File

@ -0,0 +1,170 @@
// @ts-check
'use strict'
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
/**
* The VS Code extension core needs to be built for two targets:
* - Node.js for VS Code desktop
* - Web Worker for VS Code web
*
* @param {*} targetType See https://webpack.js.org/configuration/target/
*/
function getExtensionCoreConfiguration(targetType) {
return {
name: `extension:${targetType}`,
target: targetType,
entry: './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}`),
filename: 'extension.js',
library: {
type: 'umd',
},
globalObject: 'globalThis',
devtoolModuleFilenameTemplate: '../[resource-path]',
},
devtool: 'source-map',
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: {
// support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
extensions: ['.ts', '.tsx', '.js', '.jsx'],
alias: {},
fallback:
targetType === 'webworker'
? {
process: require.resolve('process/browser'),
path: require.resolve('path-browserify'),
assert: require.resolve('assert'),
util: require.resolve('util'),
}
: {},
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
{
// TODO(tj): esbuild-loader https://github.com/privatenumber/esbuild-loader
loader: 'ts-loader',
},
],
},
],
},
}
}
const rootPath = path.resolve(__dirname, '../../')
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 extensionHostWorker = /main\.worker\.ts$/
/** @type {import('webpack').Configuration}*/
const webviewConfig = {
name: 'webviews',
target: 'web',
entry: {
searchPanel: [path.resolve(searchPanelWebviewPath, 'index.tsx')],
searchSidebar: [path.resolve(searchSidebarWebviewPath, 'index.tsx')],
style: path.join(webviewSourcePath, 'index.scss'),
},
devtool: 'source-map',
output: {
path: path.resolve(__dirname, 'dist/webview'),
filename: '[name].js',
},
plugins: [new MiniCssExtractPlugin()],
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: {},
// 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'),
},
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: [/node_modules/, extensionHostWorker],
use: [
{
loader: 'ts-loader',
},
],
},
// 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: [
{
loader: 'worker-loader',
options: { inline: 'no-fallback' },
},
'ts-loader',
],
},
],
},
}
module.exports = function () {
return Promise.all([getExtensionCoreConfiguration('node'), getExtensionCoreConfiguration('webworker'), webviewConfig])
}

View File

@ -15,7 +15,7 @@ import { siteGQLID, siteID } from './jscontext'
import { highlightFileResult, mixedSearchStreamEvents } from './streaming-search-mocks'
import { percySnapshotWithVariants } from './utils'
const viewerSettings: Partial<WebGraphQlOperations> = {
const viewerSettings: Partial<WebGraphQlOperations & SharedGraphQlOperations> = {
ViewerSettings: () => ({
viewerSettings: {
__typename: 'SettingsCascade',

View File

@ -42,7 +42,7 @@ describe('Search contexts', () => {
const getSearchFieldValue = (driver: Driver): Promise<string | undefined> =>
driver.page.evaluate(() => document.querySelector<HTMLTextAreaElement>('#monaco-query-input textarea')?.value)
const viewerSettingsWithSearchContexts: Partial<WebGraphQlOperations> = {
const viewerSettingsWithSearchContexts: Partial<WebGraphQlOperations & SharedGraphQlOperations> = {
ViewerSettings: () => ({
viewerSettings: {
__typename: 'SettingsCascade',

View File

@ -2,7 +2,9 @@ import { ApolloQueryResult, ObservableQuery } from '@apollo/client'
import { map, publishReplay, refCount, shareReplay } from 'rxjs/operators'
import { createAggregateError, asError } from '@sourcegraph/common'
import { fromObservableQueryPromise, getDocumentNode, gql } from '@sourcegraph/http-client'
import { fromObservableQueryPromise, getDocumentNode } from '@sourcegraph/http-client'
import { viewerSettingsQuery } from '@sourcegraph/shared/src/backend/settings'
import { ViewerSettingsResult, ViewerSettingsVariables } from '@sourcegraph/shared/src/graphql-operations'
import { PlatformContext } from '@sourcegraph/shared/src/platform/context'
import * as GQL from '@sourcegraph/shared/src/schema'
import { mutateSettings, updateSettings } from '@sourcegraph/shared/src/settings/edit'
@ -20,7 +22,6 @@ import {
import { TooltipController } from '@sourcegraph/wildcard'
import { getWebGraphQLClient, requestGraphQL } from '../backend/graphql'
import { ViewerSettingsResult, ViewerSettingsVariables } from '../graphql-operations'
import { eventLogger } from '../tracking/eventLogger'
/**
@ -106,36 +107,6 @@ function mapViewerSettingsResult({ data, errors }: ApolloQueryResult<ViewerSetti
return data.viewerSettings as GQL.ISettingsCascade
}
const settingsCascadeFragment = gql`
fragment SettingsCascadeFields on SettingsCascade {
subjects {
__typename
... on Org {
id
name
displayName
}
... on User {
id
username
displayName
}
... on Site {
id
siteID
allowSiteSettingsEdits
}
latestSettings {
id
contents
}
settingsURL
viewerCanAdminister
}
final
}
`
/**
* Creates Apollo query watcher for the viewer's settings. Watcher is used instead of the one-time query because we
* want to use cached response if it's available. Callers should use settingsRefreshes#next instead of calling
@ -146,13 +117,6 @@ async function watchViewerSettingsQuery(): Promise<ObservableQuery<ViewerSetting
const graphQLClient = await getWebGraphQLClient()
return graphQLClient.watchQuery<ViewerSettingsResult, ViewerSettingsVariables>({
query: getDocumentNode(gql`
query ViewerSettings {
viewerSettings {
...SettingsCascadeFields
}
}
${settingsCascadeFragment}
`),
query: getDocumentNode(viewerSettingsQuery),
})
}

View File

@ -311,6 +311,7 @@
"term-size": "^2.2.0",
"terser-webpack-plugin": "^5.1.4",
"thread-loader": "^3.0.4",
"ts-loader": "^9.2.6",
"ts-morph": "^8.1.0",
"ts-node": "^9.1.1",
"typed-scss-modules": "^4.1.1",
@ -342,6 +343,7 @@
"@sourcegraph/extension-api-classes": "^1.1.0",
"@sqs/jsonc-parser": "^1.0.3",
"@types/svgo": "2.6.0",
"@types/vscode": "^1.63.1",
"@visx/annotation": "^1.7.2",
"@visx/axis": "^1.7.0",
"@visx/glyph": "^1.7.0",
@ -360,6 +362,7 @@
"comlink": "^4.3.0",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.8.2",
"cross-fetch": "^3.1.4",
"d3-axis": "^1.0.12",
"d3-format": "^2.0.0",
"d3-scale": "^3.2.1",

View File

@ -5497,6 +5497,11 @@
dependencies:
"@types/node" "*"
"@types/vscode@^1.63.1":
version "1.63.1"
resolved "https://registry.npmjs.org/@types/vscode/-/vscode-1.63.1.tgz#b40f9f18055e2c9498ae543d18c59fbd6ef2e8a3"
integrity sha512-Z+ZqjRcnGfHP86dvx/BtSwWyZPKQ/LBdmAVImY82TphyjOw2KgTKcp7Nx92oNwCTsHzlshwexAG/WiY2JuUm3g==
"@types/warning@^3.0.0":
version "3.0.0"
resolved "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
@ -8944,13 +8949,20 @@ cross-env@^7.0.2:
dependencies:
cross-spawn "^7.0.1"
cross-fetch@3.0.6, cross-fetch@^3.0.4, cross-fetch@^3.0.6:
cross-fetch@3.0.6:
version "3.0.6"
resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c"
integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ==
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==
dependencies:
node-fetch "2.6.1"
cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -10738,10 +10750,10 @@ engine.io@~3.4.0:
engine.io-parser "~2.2.0"
ws "^7.1.2"
enhanced-resolve@^5.8.0:
version "5.8.2"
resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz#15ddc779345cbb73e97c611cd00c01c1e7bf4d8b"
integrity sha512-F27oB3WuHDzvR2DOGNTaYy0D5o0cnrv8TeI482VM4kYgQd/FT9lUQwuNsJ0oOHtBUq7eiW5ytqzp7nBFknL+GA==
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==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
@ -16740,13 +16752,13 @@ micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4:
snapdragon "^0.8.1"
to-regex "^3.0.2"
micromatch@^4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259"
integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==
micromatch@^4.0.0, micromatch@^4.0.2:
version "4.0.4"
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==
dependencies:
braces "^3.0.1"
picomatch "^2.0.5"
picomatch "^2.2.3"
mime-db@1.49.0, "mime-db@>= 1.43.0 < 2":
version "1.49.0"
@ -18276,10 +18288,10 @@ picomatch@2.2.2:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.0:
version "2.3.1"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
pify@^2.0.0, pify@^2.3.0:
version "2.3.0"
@ -22673,6 +22685,16 @@ ts-key-enum@^2.0.7:
resolved "https://registry.npmjs.org/ts-key-enum/-/ts-key-enum-2.0.8.tgz#ff83012837e3e7f999eee84dba1a1dcb8c66fbeb"
integrity sha512-ccRzCVr98faP5pKYpX0IlrPvf2VcepEFSH115CWti0eM1anh774ndXf0RlBOFecTuM103gMwaHSo0tDPlQnyNQ==
ts-loader@^9.2.6:
version "9.2.6"
resolved "https://registry.npmjs.org/ts-loader/-/ts-loader-9.2.6.tgz#9937c4dd0a1e3dbbb5e433f8102a6601c6615d74"
integrity sha512-QMTC4UFzHmu9wU2VHZEmWWE9cUajjfcdcws+Gh7FhiO+Dy0RnR1bNz0YCHqhI0yRowCE9arVnNxYHqELOy9Hjw==
dependencies:
chalk "^4.1.0"
enhanced-resolve "^5.0.0"
micromatch "^4.0.0"
semver "^7.3.4"
ts-log@^2.2.3:
version "2.2.3"
resolved "https://registry.npmjs.org/ts-log/-/ts-log-2.2.3.tgz#4da5640fe25a9fb52642cd32391c886721318efb"