mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
vsce: create vscode package, establish architecture (#29997)
Co-authored-by: Beatrix <68532117+abeatrix@users.noreply.github.com>
This commit is contained in:
parent
c8e2c2cb98
commit
ca014d035d
1
.github/workflows/lsif-ts.yml
vendored
1
.github/workflows/lsif-ts.yml
vendored
@ -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
26
.vscode/launch.json
vendored
@ -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",
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -63,6 +63,7 @@
|
||||
"./client/shared",
|
||||
"./client/search",
|
||||
"./client/search-ui",
|
||||
"./client/vscode",
|
||||
"./client/http-client",
|
||||
"./client/branded",
|
||||
"./client/wildcard",
|
||||
|
||||
40
client/shared/src/backend/settings.ts
Normal file
40
client/shared/src/backend/settings.ts
Normal 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}
|
||||
`
|
||||
2
client/vscode/.eslintignore
Normal file
2
client/vscode/.eslintignore
Normal file
@ -0,0 +1,2 @@
|
||||
dist/
|
||||
package.json
|
||||
12
client/vscode/.eslintrc.js
Normal file
12
client/vscode/.eslintrc.js
Normal 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
2
client/vscode/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
dist/
|
||||
*.vsix
|
||||
14
client/vscode/.vscodeignore
Normal file
14
client/vscode/.vscodeignore
Normal 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
25
client/vscode/globals.d.ts
vendored
Normal 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
|
||||
BIN
client/vscode/images/logo.png
Normal file
BIN
client/vscode/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
1
client/vscode/images/logo.svg
Normal file
1
client/vscode/images/logo.svg
Normal 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
109
client/vscode/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
62
client/vscode/src/backend/requestGraphQl.ts
Normal file
62
client/vscode/src/backend/requestGraphQl.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
56
client/vscode/src/backend/sourcegraphSettings.ts
Normal file
56
client/vscode/src/backend/sourcegraphSettings.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
28
client/vscode/src/contract.ts
Normal file
28
client/vscode/src/contract.ts
Normal 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
|
||||
}
|
||||
77
client/vscode/src/extension.ts
Normal file
77
client/vscode/src/extension.ts
Normal 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()
|
||||
}
|
||||
37
client/vscode/src/settings/accessTokenSetting.ts
Normal file
37
client/vscode/src/settings/accessTokenSetting.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
client/vscode/src/settings/endpointSetting.ts
Normal file
79
client/vscode/src/settings/endpointSetting.ts
Normal 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
|
||||
}
|
||||
5
client/vscode/src/settings/readConfiguration.ts
Normal file
5
client/vscode/src/settings/readConfiguration.ts
Normal 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
191
client/vscode/src/state.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
15
client/vscode/src/vsCodeApi.ts
Normal file
15
client/vscode/src/vsCodeApi.ts
Normal 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
|
||||
}
|
||||
127
client/vscode/src/webview/comlink/extensionEndpoint.ts
Normal file
127
client/vscode/src/webview/comlink/extensionEndpoint.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
52
client/vscode/src/webview/comlink/index.ts
Normal file
52
client/vscode/src/webview/comlink/index.ts
Normal 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]
|
||||
}
|
||||
202
client/vscode/src/webview/comlink/webviewEndpoint.ts
Normal file
202
client/vscode/src/webview/comlink/webviewEndpoint.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
127
client/vscode/src/webview/commands.ts
Normal file
127
client/vscode/src/webview/commands.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
3
client/vscode/src/webview/index.scss
Normal file
3
client/vscode/src/webview/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
@import '../../../branded/src/global-styles/index.scss';
|
||||
|
||||
// TODO: adapt to editor styles
|
||||
149
client/vscode/src/webview/initialize.ts
Normal file
149
client/vscode/src/webview/initialize.ts
Normal 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
|
||||
}
|
||||
48
client/vscode/src/webview/search-panel/index.tsx
Normal file
48
client/vscode/src/webview/search-panel/index.tsx
Normal 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'))
|
||||
47
client/vscode/src/webview/search-sidebar/index.tsx
Normal file
47
client/vscode/src/webview/search-sidebar/index.tsx
Normal 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'))
|
||||
26
client/vscode/tsconfig.json
Normal file
26
client/vscode/tsconfig.json
Normal 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"],
|
||||
}
|
||||
170
client/vscode/webpack.config.js
Normal file
170
client/vscode/webpack.config.js
Normal 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])
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
50
yarn.lock
50
yarn.lock
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user