diff --git a/config/webpack/base.config.ts b/config/webpack/base.config.ts index eb4670d5..addac39c 100644 --- a/config/webpack/base.config.ts +++ b/config/webpack/base.config.ts @@ -22,7 +22,7 @@ export default { phabricator: buildEntry(pageEntry, '../../src/libs/phabricator/extension.tsx'), bootstrap: path.join(__dirname, '../../node_modules/bootstrap/dist/css/bootstrap.css'), - style: path.join(__dirname, '../../src/shared/app.scss'), + style: path.join(__dirname, '../../src/app.scss'), }, output: { path: path.join(__dirname, '../../build/dist/js'), diff --git a/package.json b/package.json index d006dc58..fde64aca 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "cypress:open": "yarn run build && cypress open", "test:e2e": "yarn run build && cypress run --browser chrome", "prettier": "prettier '**/{*.{js?(on),ts?(x),graphql,md,scss},.*.js?(on)}' --write --list-different --config prettier.config.js", - "storybook": "start-storybook -c ./config/storybook -p 6006", - "build-storybook": "build-storybook -c ./config/storybook", - "test": "mocha --require ts-node/register --watch --watch-extensions ts './src/**/*.test.ts?(x)'", - "test:ci": "mocha --require ts-node/register './src/**/*.test.ts?(x)'" + "test": "mocha --require ts-node/register --require jsdom-global/register --watch --watch-extensions 'ts?(x)' './src/**/*.test.ts?(x)'", + "test:ci": "mocha --require ts-node/register --require jsdom-global/register './src/**/*.test.ts?(x)'", + "storybook": "start-storybook -s ./src/extension/assets -c ./config/storybook -p 6006", + "build-storybook": "build-storybook -c ./config/storybook" }, "husky": { "hooks": { @@ -87,11 +87,15 @@ "get-graphql-schema": "^2.1.1", "gql2ts": "^1.2.1", "husky": "^1.1.0", + "jsdom": "^12.2.0", + "jsdom-global": "^3.0.2", "mocha": "^5.2.0", "node-sass": "^4.9.0", "postcss-loader": "^3.0.0", "prettier": "1.14.0", + "raf": "^3.4.0", "raw-loader": "^0.5.1", + "react-testing-library": "^5.2.1", "sass-loader": "^6.0.6", "semantic-release": "^15.9.9", "semantic-release-chrome": "^1.1.0", @@ -116,6 +120,7 @@ "@sourcegraph/extensions-client-common": "^11.0.1", "@sourcegraph/react-loading-spinner": "0.0.6", "@sqs/jsonc-parser": "^1.0.3", + "@types/sinon": "^5.0.5", "@types/uglifyjs-webpack-plugin": "1.1.0", "bootstrap": "^4.0.0", "cypress-browser-extension-plugin": "^0.1.0", @@ -136,6 +141,7 @@ "react-router-dom": "^4.3.1", "reactstrap": "^5.0.0-beta.2", "rxjs": "^6.3.2", + "sinon": "^7.1.0", "socket.io-client": "^2.1.1", "sourcegraph": "^18.4.0", "string-score": "^1.0.1", diff --git a/src/shared/app.scss b/src/app.scss similarity index 91% rename from src/shared/app.scss rename to src/app.scss index 2b30737e..b18f1847 100644 --- a/src/shared/app.scss +++ b/src/app.scss @@ -79,15 +79,16 @@ $theme-colors-light: ( @import 'bootstrap/scss/popover'; @import './global-styles/card'; @import './global-styles/icons'; -@import './repo/tooltips'; +@import './shared/repo/tooltips'; @import './highlight'; @import './options'; -@import './components/alerts'; -@import './components/symbols'; -@import './components/codeIntelStatusIndicator'; -@import './components/CodeViewToolbar.scss'; +@import './shared/components/alerts'; +@import './shared/components/symbols'; +@import './shared/components/codeIntelStatusIndicator'; +@import './shared/components/CodeViewToolbar.scss'; +@import './shared/components/JSONEditor'; @import '@sourcegraph/codeintellify/lib/HoverOverlay.scss'; -@import '../libs/code_intelligence/HoverOverlay.scss'; +@import './libs/code_intelligence/HoverOverlay.scss'; @import '@sourcegraph/react-loading-spinner/lib/LoadingSpinner.css'; @import './extensions-client-common'; diff --git a/src/browser/runtime.ts b/src/browser/runtime.ts index b039032e..148f3055 100644 --- a/src/browser/runtime.ts +++ b/src/browser/runtime.ts @@ -1,4 +1,4 @@ -import { isBackground } from '../shared/context' +import { isBackground } from '../context' import { getURL } from './extension' import safariMessager from './safari/SafariMessager' diff --git a/src/browser/storage.ts b/src/browser/storage.ts index 10e8ac4c..14bb97ec 100644 --- a/src/browser/storage.ts +++ b/src/browser/storage.ts @@ -8,7 +8,13 @@ export { StorageItems, defaultStorageItems } from './types' type MigrateFunc = ( items: StorageItems, set: (items: Partial) => void, - remove: (key: keyof StorageItems) => void + /** + * Remove an item from storage. + * + * @param key the key of the item you'd like to remove. We accept arbitary + * strings so we can remove items that are no longer in our types. + */ + remove: (key: string) => void ) => void export interface Storage { diff --git a/src/browser/types.ts b/src/browser/types.ts index 3060c3fc..14d1882d 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -11,13 +11,73 @@ export interface PhabricatorMapping { * The feature flags available. */ export interface FeatureFlags { - newTooltips: boolean + /** + * Whether or not to render [Mermaid](https://mermaidjs.github.io/) graphs + * in markdown files viewed on GitHub. + * + * @duration permanent + */ + renderMermaidGraphsEnabled: boolean + /** + * Open files from the fuzzy file finder omnibar tool (src :f ) + * on Sourcegraph or the codehost. + * + * @duration permanent + */ + openFileOnSourcegraph: boolean + /** + * Whether or not to use the new inject method for code intelligence. + * + * @duration temporary - to be removed November first. + */ newInject: boolean + /** + * Enable the use of Sourcegraph extensions. + * + * @duration temporary - to be removed by @chris when extensions are stable and out of + * beta. + */ + useExtensions: boolean + /** + * Enable inline symbol search by typing `!symbolQueryText` inside of GitHub PR comments (requires reload after toggling). + * + * @duration temporary - needs feedback from users. + */ + inlineSymbolSearchEnabled: boolean + /** + * Whether or not to execute a search on Sourcegraph when a search is + * executed on the code host. + * + * @duration permanent + */ + executeSearchEnabled: boolean + /** + * Display the Sourcegraph file tree in the code host when viewing a repository. + * + * @duration permanent + */ + repositoryFileTreeEnabled: boolean } export const featureFlagDefaults: FeatureFlags = { - newTooltips: true, newInject: false, + renderMermaidGraphsEnabled: false, + useExtensions: false, + openFileOnSourcegraph: true, + inlineSymbolSearchEnabled: true, + executeSearchEnabled: false, + repositoryFileTreeEnabled: true, +} + +/** A map determining whether a feature flag is configurable by users or not. */ +export const configurableFeatureFlags = { + newInject: false, + renderMermaidGraphsEnabled: true, + useExtensions: true, + openFileOnSourcegraph: true, + inlineSymbolSearchEnabled: true, + executeSearchEnabled: false, + repositoryFileTreeEnabled: true, } export interface AccessToken { @@ -42,8 +102,6 @@ export interface StorageItems { gitHubEnterpriseURL: string phabricatorURL: string - inlineSymbolSearchEnabled: boolean - renderMermaidGraphsEnabled: boolean identity: string serverUrls: string[] enterpriseUrls: string[] @@ -51,15 +109,10 @@ export interface StorageItems { hasSeenServerModal: boolean repoLocations: RepoLocations phabricatorMappings: PhabricatorMapping[] - openFileOnSourcegraph: boolean sourcegraphAnonymousUid: string disableExtension: boolean /** - * Enable the use of Sourcegraph extensions. - */ - useExtensions: boolean - /** - * Storage for feature flags + * Storage for feature flags. */ featureFlags: FeatureFlags clientConfiguration: ClientConfigurationDetails @@ -83,18 +136,14 @@ export const defaultStorageItems: StorageItems = { serverUrls: ['https://sourcegraph.com'], gitHubEnterpriseURL: '', phabricatorURL: '', - inlineSymbolSearchEnabled: true, - renderMermaidGraphsEnabled: false, identity: '', enterpriseUrls: [], serverUserId: '', hasSeenServerModal: false, repoLocations: {}, phabricatorMappings: [], - openFileOnSourcegraph: true, sourcegraphAnonymousUid: '', disableExtension: false, - useExtensions: false, featureFlags: featureFlagDefaults, clientConfiguration: { contentScriptUrls: [], diff --git a/src/shared/context.ts b/src/context.ts similarity index 100% rename from src/shared/context.ts rename to src/context.ts diff --git a/src/extension/scripts/background.tsx b/src/extension/scripts/background.tsx index c8eb2c8c..07310755 100644 --- a/src/extension/scripts/background.tsx +++ b/src/extension/scripts/background.tsx @@ -13,9 +13,10 @@ import * as permissions from '../../browser/permissions' import * as runtime from '../../browser/runtime' import storage, { defaultStorageItems } from '../../browser/storage' import * as tabs from '../../browser/tabs' +import { featureFlagDefaults } from '../../browser/types' import initializeCli from '../../libs/cli' +import { ExtensionConnectionInfo, onFirstMessage } from '../../messaging' import { resolveClientConfiguration } from '../../shared/backend/server' -import { ExtensionConnectionInfo, onFirstMessage } from '../../shared/messaging' import { DEFAULT_SOURCEGRAPH_URL, setSourcegraphUrl, sourcegraphUrl } from '../../shared/util/context' import { assertEnv } from '../envAssertion' @@ -161,6 +162,7 @@ permissions.onRemoved(permissions => { }) }) +// Ensure access tokens are in storage and they are in the correct shape.jj storage.addSyncMigration((items, set, remove) => { if (!items.accessTokens) { set({ accessTokens: {} }) @@ -178,50 +180,22 @@ storage.addSyncMigration((items, set, remove) => { set({ accessTokens }) } +}) - if (items.phabricatorURL) { - remove('phabricatorURL') - - const newItems: { - enterpriseUrls?: string[] - } = {} - - if (items.enterpriseUrls && !items.enterpriseUrls.find(u => u === items.phabricatorURL)) { - newItems.enterpriseUrls = items.enterpriseUrls.concat(items.phabricatorURL) - } else if (!items.enterpriseUrls) { - newItems.enterpriseUrls = [items.phabricatorURL] +// Ensure all feature flags are in storage. +storage.addSyncMigration((items, set, remove) => { + for (const key of Object.keys(featureFlagDefaults)) { + if (typeof items.featureFlags[key] === 'undefined') { + remove(key) + set({ featureFlags: { ...featureFlagDefaults, ...items.featureFlags, [key]: featureFlagDefaults[key] } }) } - - set(newItems) - } - - if (!items.repoLocations) { - set({ repoLocations: {} }) - } - - if (items.openFileOnSourcegraph === undefined) { - set({ openFileOnSourcegraph: true }) - } - - if (items.featureFlags && !items.featureFlags.newInject) { - set({ featureFlags: { ...items.featureFlags, newInject: true } }) - } - - if (!items.inlineSymbolSearchEnabled) { - set({ inlineSymbolSearchEnabled: true }) } +}) - if (items.serverUrls) { - if (items.sourcegraphURL) { - if (items.sourcegraphURL === DEFAULT_SOURCEGRAPH_URL) { - const urls = without(items.serverUrls, DEFAULT_SOURCEGRAPH_URL) - if (urls.length) { - set({ sourcegraphURL: urls[0], serverUrls: [urls[0]] }) - } - } else { - set({ serverUrls: [items.sourcegraphURL] }) - } - } +// Add access tokens to storage. +storage.addSyncMigration((items, set) => { + if (!items.accessTokens) { + set({ accessTokens: {} }) } }) @@ -371,12 +345,9 @@ function handleManagedPermissionRequest(managedUrls: string[]): void { function setDefaultBrowserAction(): void { browserAction.setBadgeText({ text: '' }) + browserAction.setPopup({ popup: 'options.html?popup=true' }) } -browserAction.onClicked(() => { - runtime.openOptionsPage() -}) - /** * Fetches JavaScript from a URL and runs it in a web worker. */ diff --git a/src/extension/scripts/inject.tsx b/src/extension/scripts/inject.tsx index 9dbe29a1..32882f3d 100644 --- a/src/extension/scripts/inject.tsx +++ b/src/extension/scripts/inject.tsx @@ -74,12 +74,16 @@ function injectApplication(): void { if (isGitHub || isGitHubEnterprise) { setSourcegraphUrl(sourcegraphServerUrl) setRenderMermaidGraphsEnabled( - items.renderMermaidGraphsEnabled === undefined ? false : items.renderMermaidGraphsEnabled + items.featureFlags.renderMermaidGraphsEnabled === undefined + ? false + : items.featureFlags.renderMermaidGraphsEnabled ) setInlineSymbolSearchEnabled( - items.inlineSymbolSearchEnabled === undefined ? false : items.inlineSymbolSearchEnabled + items.featureFlags.inlineSymbolSearchEnabled === undefined + ? false + : items.featureFlags.inlineSymbolSearchEnabled ) - injectGitHubApplication(extensionMarker) + await injectGitHubApplication(extensionMarker) } else if (isSourcegraphServer || /^https?:\/\/(www.)?sourcegraph.com/.test(href)) { setSourcegraphUrl(sourcegraphServerUrl) injectSourcegraphApp(extensionMarker) @@ -100,7 +104,7 @@ function injectApplication(): void { } } - setUseExtensions(items.useExtensions === undefined ? false : items.useExtensions) + setUseExtensions(items.featureFlags.useExtensions === undefined ? false : items.featureFlags.useExtensions) } storage.getSync(handleGetStorage) diff --git a/src/extension/scripts/options.tsx b/src/extension/scripts/options.tsx index cef80472..bac9f303 100644 --- a/src/extension/scripts/options.tsx +++ b/src/extension/scripts/options.tsx @@ -5,7 +5,7 @@ import '../../config/polyfill' import * as React from 'react' import { render } from 'react-dom' import storage from '../../browser/storage' -import { OptionsDashboard } from '../../shared/components/options/OptionsDashboard' +import { OptionsContainer } from '../../libs/options/OptionsContainer' import { assertEnv } from '../envAssertion' assertEnv('OPTIONS') @@ -17,7 +17,7 @@ const inject = () => { document.body.appendChild(injectDOM) storage.getSync(items => { - render(, injectDOM) + render(, injectDOM) }) } diff --git a/src/extension/views/options.html b/src/extension/views/options.html index 70d72cc2..ec56107f 100644 --- a/src/extension/views/options.html +++ b/src/extension/views/options.html @@ -1,12 +1,12 @@ - + Sourcegraph extension - + diff --git a/src/shared/extensions-client-common.scss b/src/extensions-client-common.scss similarity index 100% rename from src/shared/extensions-client-common.scss rename to src/extensions-client-common.scss diff --git a/src/shared/global-styles/card.scss b/src/global-styles/card.scss similarity index 100% rename from src/shared/global-styles/card.scss rename to src/global-styles/card.scss diff --git a/src/shared/global-styles/colors.scss b/src/global-styles/colors.scss similarity index 100% rename from src/shared/global-styles/colors.scss rename to src/global-styles/colors.scss diff --git a/src/shared/global-styles/icons.scss b/src/global-styles/icons.scss similarity index 100% rename from src/shared/global-styles/icons.scss rename to src/global-styles/icons.scss diff --git a/src/shared/highlight.scss b/src/highlight.scss similarity index 100% rename from src/shared/highlight.scss rename to src/highlight.scss diff --git a/src/libs/github/inject.tsx b/src/libs/github/inject.tsx index 0a911cc6..5cfeec5a 100644 --- a/src/libs/github/inject.tsx +++ b/src/libs/github/inject.tsx @@ -84,7 +84,7 @@ const actionsNavItemClassProps = { actionItemClass: 'btn btn-sm tooltipped tooltipped-n BtnGroup-item', } -function refreshModules(): void { +async function refreshModules(): Promise { for (const el of Array.from(document.getElementsByClassName('sourcegraph-app-annotator'))) { el.remove() } @@ -95,16 +95,16 @@ function refreshModules(): void { el.classList.remove('sg-annotated') } hideTooltip() - inject() + await inject() } -window.addEventListener('pjax:end', () => { - refreshModules() +window.addEventListener('pjax:end', async () => { + await refreshModules() }) -export function injectGitHubApplication(marker: HTMLElement): void { +export async function injectGitHubApplication(marker: HTMLElement): Promise { document.body.appendChild(marker) - inject() + await inject() } function injectCodeIntelligence(): void { @@ -271,7 +271,7 @@ function injectCodeIntelligence(): void { ) } -function inject(): void { +async function inject(): Promise { featureFlags .isEnabled('newInject') .then(isEnabled => { diff --git a/src/libs/options/ActionButton.scss b/src/libs/options/ActionButton.scss new file mode 100644 index 00000000..640c4e9a --- /dev/null +++ b/src/libs/options/ActionButton.scss @@ -0,0 +1,8 @@ +.options-action-button { + width: 100%; + background-color: #f2f4f8; + border-radius: 2px; + border: solid 1px #e4e9f1; + padding: 0.5rem 0; + font-size: 14px; +} diff --git a/src/libs/options/ActionButton.tsx b/src/libs/options/ActionButton.tsx new file mode 100644 index 00000000..93dc993f --- /dev/null +++ b/src/libs/options/ActionButton.tsx @@ -0,0 +1,12 @@ +import * as React from 'react' + +export interface OptionsActionButtonProps { + className?: string + onClick: (event: React.MouseEvent) => void +} + +export const OptionsActionButton: React.SFC = ({ children, className, onClick }) => ( + +) diff --git a/src/libs/options/Header.scss b/src/libs/options/Header.scss new file mode 100644 index 00000000..b13275b9 --- /dev/null +++ b/src/libs/options/Header.scss @@ -0,0 +1,30 @@ +.options-header { + display: flex; + align-items: stretch; + justify-content: space-between; + + &__logo { + flex: 1; + max-width: 50%; + } + + &__right { + flex: 1; + display: flex; + justify-content: flex-end; + align-items: center; + + font-weight: 600; + color: $color-light-bg-5; + + &__settings { + background-color: transparent; + color: inherit; + + &:hover, + &:focus { + color: $gray-23; + } + } + } +} diff --git a/src/libs/options/Header.tsx b/src/libs/options/Header.tsx new file mode 100644 index 00000000..c896c9a9 --- /dev/null +++ b/src/libs/options/Header.tsx @@ -0,0 +1,26 @@ +import { SettingsOutlineIcon } from 'mdi-react' +import * as React from 'react' + +export interface OptionsHeaderProps { + className?: string + version: string + onSettingsClick: (event: React.MouseEvent) => void + assetsDir?: string +} + +export const OptionsHeader: React.SFC = ({ + className, + version, + assetsDir, + onSettingsClick, +}: OptionsHeaderProps) => ( +
+ +
+ v{version} + +
+
+) diff --git a/src/libs/options/Menu.scss b/src/libs/options/Menu.scss new file mode 100644 index 00000000..911a787e --- /dev/null +++ b/src/libs/options/Menu.scss @@ -0,0 +1,16 @@ +.options-menu { + min-width: 400px; + + &__section { + padding: 0.85rem 1rem; + border-top: 1px solid #e4e9f1; + + &__button { + margin-top: 1rem; + } + } + + &__no-border { + border-top: none; + } +} diff --git a/src/libs/options/Menu.tsx b/src/libs/options/Menu.tsx new file mode 100644 index 00000000..9d73069f --- /dev/null +++ b/src/libs/options/Menu.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { JSONEditor, JSONEditorProps } from '../../shared/components/JSONEditor' +import { OptionsActionButton } from './ActionButton' +import { OptionsHeader, OptionsHeaderProps } from './Header' +import { ServerURLForm, ServerURLFormProps } from './ServerURLForm' + +export interface OptionsMenuProps + extends OptionsHeaderProps, + Pick>, + Pick> { + sourcegraphURL: ServerURLFormProps['value'] + onURLChange: ServerURLFormProps['onChange'] + onURLSubmit: ServerURLFormProps['onSubmit'] + + isSettingsOpen: boolean + settingsHaveChanged: boolean + settings: JSONEditorProps['value'] + onSettingsChange: JSONEditorProps['onChange'] + onSettingsSave: () => void +} + +export const OptionsMenu: React.SFC = ({ + sourcegraphURL, + onURLChange, + onURLSubmit, + settings, + onSettingsChange, + onSettingsSave, + isSettingsOpen, + settingsHaveChanged, + ...props +}) => ( +
+ + + {isSettingsOpen && ( +
+ + + {settingsHaveChanged && ( + + Update configuration JSON + + )} +
+ )} +
+) diff --git a/src/libs/options/OptionsContainer.test.tsx b/src/libs/options/OptionsContainer.test.tsx new file mode 100644 index 00000000..a9d8614c --- /dev/null +++ b/src/libs/options/OptionsContainer.test.tsx @@ -0,0 +1,58 @@ +import { assert } from 'chai' +import { describe, it } from 'mocha' +import * as React from 'react' +import { render } from 'react-testing-library' +import { noop, Observable, of } from 'rxjs' +import sinon from 'sinon' +import { FeatureFlags } from '../../browser/types' +import { OptionsContainer } from './OptionsContainer' + +describe('OptionsContainer', () => { + const stubGetConfigurableSettings = () => new Observable>() + const stubSetConfigurableSettings = (settings: Observable>) => + new Observable>() + const stub + + it('checks the connection status when it renders', () => { + const fetchSiteSpy = sinon.spy() + const fetchSite = (url: string) => { + fetchSiteSpy(url) + + return of(undefined) + } + + render( + + ) + + assert.isTrue(fetchSiteSpy.calledOnceWith('url')) + }) + + it('handles when an error is thrown checking the site connection', () => { + const fetchSite = () => { + throw new Error('no site, woops') + } + + try { + render( + + ) + } catch (err) { + throw new Error("shouldn't be hit") + } + }) + + it('creates a token when no token exists after connection check', () => {}) +}) diff --git a/src/libs/options/OptionsContainer.tsx b/src/libs/options/OptionsContainer.tsx new file mode 100644 index 00000000..28151f69 --- /dev/null +++ b/src/libs/options/OptionsContainer.tsx @@ -0,0 +1,170 @@ +import { propertyIsDefined } from '@sourcegraph/codeintellify/lib/helpers' +import * as React from 'react' +import { Observable, of, Subject, Subscription } from 'rxjs' +import { catchError, filter, map, switchMap } from 'rxjs/operators' +import { getExtensionVersionSync } from '../../browser/runtime' +import { AccessToken, FeatureFlags } from '../../browser/types' +import { ERAUTHREQUIRED, ErrorLike, isErrorLike } from '../../shared/backend/errors' +import { GQL } from '../../types/gqlschema' +import { OptionsMenu, OptionsMenuProps } from './Menu' +import { ConnectionErrors } from './ServerURLForm' + +export interface OptionsContainerProps { + sourcegraphURL: string + + fetchSite: (url: string) => Observable + fetchCurrentUser: (useToken: boolean) => Observable + + setSourcegraphURL: (url: string) => void + getConfigurableSettings: () => Observable> + setConfigurableSettings: (settings: Observable>) => Observable> + + createAccessToken: (url: string) => Observable + getAccessToken: (url: string) => Observable + setAccessToken: (url: string) => (tokens: Observable) => Observable + fetchAccessTokenIDs: (url: string) => Observable[]> +} + +interface OptionsContainerState + extends Pick< + OptionsMenuProps, + 'isSettingsOpen' | 'status' | 'sourcegraphURL' | 'settings' | 'settingsHaveChanged' | 'connectionError' + > {} + +export class OptionsContainer extends React.Component { + private version = getExtensionVersionSync() + + private urlUpdates = new Subject() + private settingsSaves = new Subject() + + private subscriptions = new Subscription() + + constructor(props: OptionsContainerProps) { + super(props) + + this.state = { + status: 'connecting', + sourcegraphURL: props.sourcegraphURL, + isSettingsOpen: false, + settingsHaveChanged: false, + settings: {}, + connectionError: undefined, + } + + const fetchingSite: Observable = this.urlUpdates.pipe( + switchMap(url => this.props.fetchSite(url).pipe(map(() => url))), + catchError(err => of(err)) + ) + + this.subscriptions.add( + fetchingSite.subscribe(res => { + let url = '' + + if (isErrorLike(res)) { + this.setState({ + status: 'error', + connectionError: + res.code === ERAUTHREQUIRED ? ConnectionErrors.AuthError : ConnectionErrors.UnableToConnect, + }) + url = this.state.sourcegraphURL + } else { + this.setState({ status: 'connected' }) + url = res + } + + props.setSourcegraphURL(url) + }) + ) + + this.subscriptions.add( + // Ensure the site is valid. + fetchingSite + .pipe( + filter(urlOrError => !isErrorLike(urlOrError)), + map(urlOrError => urlOrError as string), + // Get the access token for this server if we have it. + switchMap(url => this.props.getAccessToken(url).pipe(map(token => ({ token, url })))), + switchMap(({ url, token }) => + this.props.fetchCurrentUser(false).pipe(map(user => ({ user, token, url }))) + ), + filter(propertyIsDefined('user')), + // Get the IDs for all access tokens for the user. + switchMap(({ token, user, url }) => + this.props + .fetchAccessTokenIDs(user.id) + .pipe(map(usersTokenIDs => ({ usersTokenIDs, user, token, url }))) + ), + // Make sure the token still exists on the server. If it + // does exits, use it, otherwise create a new one. + switchMap(({ user, token, usersTokenIDs, url }) => { + const tokenExists = token && usersTokenIDs.map(({ id }) => id).includes(token.id) + + return token && tokenExists + ? of(token) + : this.props.createAccessToken(user.id).pipe(this.props.setAccessToken(url)) + }) + ) + .subscribe(() => { + // We don't need to do anything with the token now. We just + // needed to ensure we had one saved. + }) + ) + + this.subscriptions.add( + this.settingsSaves + .pipe(switchMap(settings => props.setConfigurableSettings(settings))) + .subscribe(settings => { + this.setState({ + settings, + settingsHaveChanged: false, + }) + }) + ) + } + + public componentDidMount(): void { + this.props.getConfigurableSettings().subscribe(settings => { + this.setState({ settings }) + }) + + this.urlUpdates.next(this.state.sourcegraphURL) + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + public render(): React.ReactNode { + return ( + + ) + } + + private handleURLChange = (value: string) => { + this.urlUpdates.next(this.state.sourcegraphURL) + } + + private handleURLSubmit = () => { + this.urlUpdates.next(this.state.sourcegraphURL) + } + + private handleSettingsClick = () => { + this.setState(({ isSettingsOpen }) => ({ isSettingsOpen: !isSettingsOpen })) + } + + private handleSettingsChange = (settings: any) => { + this.setState({ settings, settingsHaveChanged: true }) + } + + private handleSettingsSave = () => { + this.settingsSaves.next(this.state.settings) + } +} diff --git a/src/libs/options/ServerURLForm.scss b/src/libs/options/ServerURLForm.scss new file mode 100644 index 00000000..472f8cab --- /dev/null +++ b/src/libs/options/ServerURLForm.scss @@ -0,0 +1,61 @@ +.server-url-form { + &__error { + margin-top: 0.5rem; + } + + &__input-container { + display: flex; + border: solid 1px $color-light-border-2; + border-radius: 2px; + + &__input { + width: 100%; + border: none; + padding-left: 5px; + background-color: $color-light-bg-1; + } + + &__status { + display: flex; + align-items: center; + + background-color: #f2f4f8; + padding: 0.5rem; + + &__text { + font-size: 14px; + font-weight: 600; + font-style: normal; + font-stretch: normal; + line-height: normal; + letter-spacing: normal; + min-height: 14px; + + margin-left: 0.25rem; + } + + &__indicator { + height: 0.5rem; + width: 0.5rem; + + border-radius: 50%; + + &--default { + background-color: #cbd7e1; + } + + &--success { + background-color: $green; + } + + &--warning { + background-color: $yellow; + } + + &--error { + background-color: $red; + } + } + } + } +} diff --git a/src/libs/options/ServerURLForm.test.tsx b/src/libs/options/ServerURLForm.test.tsx new file mode 100644 index 00000000..243ddfa2 --- /dev/null +++ b/src/libs/options/ServerURLForm.test.tsx @@ -0,0 +1,154 @@ +import { assert, expect } from 'chai' +import { describe, it } from 'mocha' +import * as React from 'react' +import { cleanup, fireEvent, render } from 'react-testing-library' +import { EMPTY, merge, noop, of, Subject } from 'rxjs' +import { switchMap, tap } from 'rxjs/operators' +import { TestScheduler } from 'rxjs/testing' +import sinon from 'sinon' + +import { ServerURLForm, ServerURLFormProps } from './ServerURLForm' + +describe('ServerURLForm', () => { + after(cleanup) + + it('fires the onChange prop handler', () => { + const onChange = sinon.spy() + const onSubmit = sinon.spy() + + const { container } = render( + + ) + + const urlInput = container.querySelector('input')! + + fireEvent.change(urlInput, { target: { value: 'https://different.com' } }) + + assert.isTrue(onChange.calledOnce) + assert.isTrue(onChange.calledWith('https://different.com')) + + assert.isTrue(onSubmit.notCalled) + }) + + it('updates the input value when the url changes', () => { + const props: ServerURLFormProps = { + value: 'https://sourcegraph.com', + status: 'connected', + onChange: noop, + onSubmit: noop, + } + + const { container, rerender } = render() + + const urlInput = container.querySelector('input')! + + rerender() + + const newValue = urlInput.value + + expect(newValue).to.equal('https://different.com') + }) + + it('fires the onSubmit prop handler when the form is submitted', () => { + const onSubmit = sinon.spy() + + const { container } = render( + + ) + + const form = container.querySelector('form')! + + fireEvent.submit(form) + + assert.isTrue(onSubmit.calledOnce) + }) + + it('fires the onSubmit prop handler after 5s on inactivity after a change', () => { + const scheduler = new TestScheduler((a, b) => assert.deepEqual(a, b)) + + scheduler.run(({ cold, expectObservable }) => { + const submits = new Subject() + const nextSubmit = () => submits.next() + + const { container } = render( + + ) + + const form = container.querySelector('input')! + + const urls: { [key: string]: string } = { + a: 'https://different.com', + } + + const submitObs = cold('a', urls).pipe( + switchMap(url => { + const emit = of(undefined).pipe( + tap(() => { + fireEvent.change(form, { target: { value: url } }) + }), + switchMap(() => EMPTY) + ) + + return merge(submits, emit) + }) + ) + + expectObservable(submitObs).toBe('5s a', { a: undefined }) + }) + }) + + it("doesn't submit after 5 seconds if the form was submitted manually", () => { + const scheduler = new TestScheduler((a, b) => assert.deepEqual(a, b)) + + scheduler.run(({ cold, expectObservable }) => { + const changes = new Subject() + const nextChange = () => changes.next() + + const submits = new Subject() + const nextSubmit = () => submits.next() + + const props: ServerURLFormProps = { + value: 'https://sourcegraph.com', + status: 'connected', + onChange: nextChange, + onSubmit: nextSubmit, + } + + const { container } = render() + const form = container.querySelector('input')! + + changes.subscribe(url => { + fireEvent.submit(form) + }) + + const urls: { [key: string]: string } = { + a: 'https://different.com', + } + + const submitObs = cold('a', urls).pipe( + switchMap(url => { + const emit = of(undefined).pipe( + tap(() => { + fireEvent.change(form, { target: { value: url } }) + }), + switchMap(() => EMPTY) + ) + + return merge(submits, emit) + }) + ) + + expectObservable(submitObs).toBe('a', { a: undefined }) + }) + }) +}) diff --git a/src/libs/options/ServerURLForm.tsx b/src/libs/options/ServerURLForm.tsx new file mode 100644 index 00000000..de8cc3f8 --- /dev/null +++ b/src/libs/options/ServerURLForm.tsx @@ -0,0 +1,167 @@ +import { upperFirst } from 'lodash' +import * as React from 'react' +import { merge, Subject, Subscription } from 'rxjs' +import { debounceTime, takeUntil } from 'rxjs/operators' + +export enum ConnectionErrors { + AuthError, + UnableToConnect, +} + +interface StatusClassNames { + connecting: 'warning' + connected: 'success' + error: 'error' +} + +const statusClassNames: StatusClassNames = { + connecting: 'warning', + connected: 'success', + error: 'error', +} + +/** + * This is the [Word-Joiner](https://en.wikipedia.org/wiki/Word_joiner) character. + * We are using this as a   that has no width to maintain line height when the + * url is being updated (therefore no text is in the status indicator). + */ +const zeroWidthNbsp = '\u2060' + +export interface ServerURLFormProps { + className?: string + status: keyof StatusClassNames + connectionError?: ConnectionErrors + + value: string + onChange: (value: string) => void + onSubmit: () => void + + /** + * Overrides `this.props.status` and `this.state.isUpdating` in order to + * display the `isUpdating` UI state. This is only intended for use in storybooks. + */ + overrideUpdatingState?: boolean +} + +interface State { + isUpdating: boolean +} + +export class ServerURLForm extends React.Component { + public state: State = { isUpdating: false } + + private inputElement = React.createRef() + + private componentUpdates = new Subject() + private changes = new Subject() + private submits = new Subject() + + private subscriptions = new Subscription() + + constructor(props: ServerURLFormProps) { + super(props) + + this.subscriptions.add( + this.changes.subscribe(value => { + this.props.onChange(value) + this.setState({ isUpdating: true }) + }) + ) + + const submitAfterInactivity = this.changes.pipe(debounceTime(5000), takeUntil(this.submits)) + + this.subscriptions.add( + merge(this.submits, submitAfterInactivity).subscribe(() => { + this.props.onSubmit() + this.setState({ isUpdating: false }) + }) + ) + } + + public componentDidUpdate(): void { + this.componentUpdates.next(this.state) + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + public render(): React.ReactNode { + return ( +
+ +
+
+ + + {this.isUpdating ? zeroWidthNbsp : upperFirst(this.props.status)} + +
+ +
+ {!this.state.isUpdating && + this.props.connectionError === ConnectionErrors.AuthError && ( +
+ Authentication to Sourcegraph failed.{' '} + Sign in to your instance to continue. +
+ )} + {!this.state.isUpdating && + this.props.connectionError === ConnectionErrors.UnableToConnect && ( +
+

+ Unable to connect to {this.props.value}. Ensure the URL + is correct and you are logged in. +

+

+ If you are an admin, please ensure that{' '} + + all users can create access tokens + {' '} + or you have added your code hosts to your{' '} + + corsOrigin setting. + +

+
+ )} +
+ ) + } + + private handleChange = ({ target: { value } }: React.ChangeEvent) => { + this.changes.next(value) + } + + private handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + + this.submits.next() + } + + private get isUpdating(): boolean { + if (typeof this.props.overrideUpdatingState !== 'undefined') { + console.warn( + ' - You are using the `overrideUpdatingState` prop which is ' + + 'only intended for use with storybooks. Keeping this state in multiple places can ' + + 'lead to race conditions and will be hard to maintain.' + ) + + return this.props.overrideUpdatingState + } + + return this.state.isUpdating + } +} diff --git a/src/libs/options/settings.ts b/src/libs/options/settings.ts new file mode 100644 index 00000000..aec57342 --- /dev/null +++ b/src/libs/options/settings.ts @@ -0,0 +1,49 @@ +import { pick } from 'lodash' +import { Observable } from 'rxjs' +import { map, switchMap } from 'rxjs/operators' +import storage from '../../browser/storage' +import { configurableFeatureFlags, FeatureFlags } from '../../browser/types' + +const configurableKeys = Object.keys(configurableFeatureFlags).filter(key => configurableFeatureFlags[key]) + +const getConfigurableSettingsFromObject = (obj: any): Partial => { + console.log('hello', obj, configurableKeys, pick(obj, configurableKeys)) + return pick(obj, configurableKeys) as Partial +} + +export const getConfigurableSettings = (): Observable> => + storage.observeSync('featureFlags').pipe(map(featureFlags => getConfigurableSettingsFromObject(featureFlags))) + +export const setConfigurabelSettings = (settings: Partial): Observable> => + new Observable(observer => + storage.getSync(items => { + storage.setSync( + { + featureFlags: { + ...items.featureFlags, + ...settings, + }, + }, + () => { + observer.next() + observer.complete() + } + ) + }) + ).pipe(switchMap(() => getConfigurableSettings())) + +export const setConfigurabelSettingsPromise = (settings: Partial): Promise> => + new Promise>(resolve => { + storage.getSync(items => { + resolve(items.featureFlags) + }) + }).then( + featureFlags => + new Promise>(resolve => { + const value = { ...featureFlags, ...getConfigurableSettingsFromObject(settings) } as FeatureFlags + + storage.setSync({ featureFlags: value }, () => resolve(value)) + }) + ) + +export const setSourcegraphURL = (sourcegraphURL: string) => storage.setSync({ sourcegraphURL }) diff --git a/src/libs/options/styles.scss b/src/libs/options/styles.scss new file mode 100644 index 00000000..8fc7570d --- /dev/null +++ b/src/libs/options/styles.scss @@ -0,0 +1,4 @@ +@import './ActionButton'; +@import './Header'; +@import './Menu'; +@import './ServerURLForm'; diff --git a/src/libs/phabricator/backend.tsx b/src/libs/phabricator/backend.tsx index ecfbb936..dd14d8bc 100644 --- a/src/libs/phabricator/backend.tsx +++ b/src/libs/phabricator/backend.tsx @@ -2,9 +2,9 @@ import { from, Observable } from 'rxjs' import { ajax } from 'rxjs/ajax' import { map, switchMap } from 'rxjs/operators' import storage from '../../browser/storage' +import { isExtension } from '../../context' import { getContext } from '../../shared/backend/context' import { mutateGraphQL } from '../../shared/backend/graphql' -import { isExtension } from '../../shared/context' import { resolveRepo } from '../../shared/repo/backend' import { DEFAULT_SOURCEGRAPH_URL, sourcegraphUrl } from '../../shared/util/context' import { memoizeObservable } from '../../shared/util/memoize' diff --git a/src/shared/messaging.ts b/src/messaging.ts similarity index 100% rename from src/shared/messaging.ts rename to src/messaging.ts diff --git a/src/shared/options.scss b/src/options.scss similarity index 100% rename from src/shared/options.scss rename to src/options.scss diff --git a/src/shared/backend/context.ts b/src/shared/backend/context.ts index 642d0135..9caa5341 100644 --- a/src/shared/backend/context.ts +++ b/src/shared/backend/context.ts @@ -1,5 +1,5 @@ +import { isInPage, isPhabricator } from '../../context' import { parseURL } from '../../libs/github/util' -import { isInPage, isPhabricator } from '../context' // TODO: Make a code host agnostic parseURL so we don't reach into an application directory from the shared directory. // Let's do this when we make code hosts an interface. Require a `parseURL: () => ParsedRepoURI` /** diff --git a/src/shared/backend/extensions.ts b/src/shared/backend/extensions.ts index 8de0c025..f89214df 100644 --- a/src/shared/backend/extensions.ts +++ b/src/shared/backend/extensions.ts @@ -28,8 +28,8 @@ import { TextDocumentDecoration } from 'sourcegraph/module/protocol/plainTypes' import uuid from 'uuid' import { Disposable } from 'vscode-languageserver' import storage, { StorageItems } from '../../browser/storage' +import { ExtensionConnectionInfo, onFirstMessage } from '../../messaging' import { GQL } from '../../types/gqlschema' -import { ExtensionConnectionInfo, onFirstMessage } from '../messaging' import { canFetchForURL } from '../util/context' import { getContext } from './context' import { createAggregateError, isErrorLike } from './errors' diff --git a/src/shared/backend/headers.tsx b/src/shared/backend/headers.tsx index 2ff6a6bb..e956eb9f 100644 --- a/src/shared/backend/headers.tsx +++ b/src/shared/backend/headers.tsx @@ -1,8 +1,8 @@ import { Observable, of } from 'rxjs' import { map, switchMap } from 'rxjs/operators' +import { isInPage, isPhabricator } from '../../context' import { getAccessToken } from '../auth/access_token' -import { isInPage, isPhabricator } from '../context' import { getExtensionVersionSync, getPlatformName, isSourcegraphDotCom } from '../util/context' /** diff --git a/src/shared/backend/search.tsx b/src/shared/backend/search.tsx index 343c28aa..c0a26f67 100644 --- a/src/shared/backend/search.tsx +++ b/src/shared/backend/search.tsx @@ -79,7 +79,7 @@ export function createSuggestion(item: GQL.SearchSuggestion): Suggestion | null case 'Repository': { return { type: 'repo', - title: item.uri, + title: item.name, url: `/${item.name}`, urlLabel: 'go to repository', } @@ -90,7 +90,7 @@ export function createSuggestion(item: GQL.SearchSuggestion): Suggestion | null if (dir !== undefined && dir !== '.') { descriptionParts.push(`${dir}/`) } - descriptionParts.push(basename(item.repository.uri)) + descriptionParts.push(basename(item.repository.name)) if (item.isDirectory) { return { type: 'dir', @@ -114,7 +114,7 @@ export function createSuggestion(item: GQL.SearchSuggestion): Suggestion | null kind: item.kind, title: item.name, description: `${item.containerName || item.location.resource.path} — ${basename( - item.location.resource.repository.uri + item.location.resource.repository.name )}`, url: item.url, urlLabel: 'go to definition', diff --git a/src/shared/backend/server.ts b/src/shared/backend/server.ts index b3b9fb00..69d2dd78 100644 --- a/src/shared/backend/server.ts +++ b/src/shared/backend/server.ts @@ -2,6 +2,7 @@ import { IClientConfigurationDetails } from '@sourcegraph/extensions-client-comm import { Observable } from 'rxjs' import { catchError, map } from 'rxjs/operators' import { GQL } from '../../types/gqlschema' +import { sourcegraphUrl } from '../util/context' import { getContext } from './context' import { queryGraphQL } from './graphql' @@ -60,7 +61,7 @@ export const fetchCurrentUser = (useAccessToken = true): Observable caught)) ) -export const fetchSite = (): Observable => +export const fetchSite = (url = sourcegraphUrl): Observable => queryGraphQL({ ctx: getContext({ repoKey: '' }), request: `query SiteProductVersion() { @@ -72,6 +73,7 @@ export const fetchSite = (): Observable => }`, retry: false, requestMightContainPrivateInfo: false, + url }).pipe( map(result => { if (!result || !result.data) { diff --git a/src/shared/components/CodeIntelStatusIndicator.tsx b/src/shared/components/CodeIntelStatusIndicator.tsx index b37c502f..58a16cf4 100644 --- a/src/shared/components/CodeIntelStatusIndicator.tsx +++ b/src/shared/components/CodeIntelStatusIndicator.tsx @@ -9,9 +9,9 @@ import React from 'react' import { Button } from 'reactstrap' import { forkJoin, Observable, Subject } from 'rxjs' import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators' +import { isPhabricator } from '../../context' import { asError, ErrorLike, isErrorLike } from '../backend/errors' import { EMODENOTFOUND, SimpleProviderFns } from '../backend/lsp' -import { isPhabricator } from '../context' import { AbsoluteRepoFile } from '../repo' import { fetchLangServer } from '../repo/backend' import { getModeFromPath, sourcegraphUrl } from '../util/context' diff --git a/src/shared/components/JSONEditor.scss b/src/shared/components/JSONEditor.scss new file mode 100644 index 00000000..f699e30c --- /dev/null +++ b/src/shared/components/JSONEditor.scss @@ -0,0 +1,24 @@ +.json-editor { + display: flex; + flex-direction: row; + + border-radius: 2px; + border: solid 1px #e4e9f1; + + font-size: 0.85rem; + + &__lines { + display: flex; + flex-direction: column; + padding: 0 16px; + color: #8e9093; + margin: 0.5rem 0; + } + + &__textarea { + font-family: $code-font-family; + width: 100%; + border: none; + margin-top: 0.5rem; + } +} diff --git a/src/shared/components/JSONEditor.tsx b/src/shared/components/JSONEditor.tsx new file mode 100644 index 00000000..11aa57f0 --- /dev/null +++ b/src/shared/components/JSONEditor.tsx @@ -0,0 +1,97 @@ +import * as JSONC from '@sqs/jsonc-parser' +import { range } from 'lodash' +import * as React from 'react' +import { Subject, Subscription } from 'rxjs' +import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators' + +const countNewLines = (str: string) => Array.from(str).reduce((count, a) => (a === '\n' ? count + 1 : count), 0) + +export interface JSONEditorProps { + value: any + onChange: (value: any) => void +} + +export interface JSONEditorState { + value: string +} + +export class JSONEditor extends React.Component { + public state: JSONEditorState = { value: '' } + + private propUpdates = new Subject() + + private valueChangeEvents = new Subject>() + private nextValueChangeEvent = (event: React.ChangeEvent) => this.valueChangeEvents.next(event) + + private subscriptions = new Subscription() + + constructor(props: JSONEditorProps) { + super(props) + + this.subscriptions.add( + // Set the local state value whenever we get a new value from the + // parent. + this.propUpdates + .pipe(map(({ value }) => JSON.stringify(value, null, 4)), distinctUntilChanged()) + .subscribe(value => this.setState({ value })) + ) + + const newValueFromInput = this.valueChangeEvents.pipe(map(({ target: { value } }) => value)) + + this.subscriptions.add( + // Immediately update local state with new value. + newValueFromInput.subscribe(value => { + this.setState({ value }) + }) + ) + + const parsedInput = newValueFromInput.pipe(map(value => JSONC.parse(value))) + + this.subscriptions.add( + // After 1 second of not typing, send the new value up to the parent. + parsedInput.pipe(debounceTime(500)).subscribe(parsed => { + this.props.onChange(parsed) + }) + ) + + this.subscriptions.add( + // After 3 seconds of not typing, stringify the latest parsed value + // to reformat the text area. + parsedInput.pipe(debounceTime(3000)).subscribe(parsed => { + this.setState({ value: JSON.stringify(parsed, null, 4) }) + }) + ) + } + + public componentDidMount(): void { + this.propUpdates.next(this.props) + } + + public componentDidUpdate(): void { + this.propUpdates.next(this.props) + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + public render(): React.ReactNode { + const rows = countNewLines(this.state.value) + 2 + + return ( +
+
{range(1, rows + 1).map(i => {i})}
+