From c39c57ce7523657748184dd662b3b8bdad1aca40 Mon Sep 17 00:00:00 2001 From: Isaac Snow Date: Wed, 17 Oct 2018 12:14:38 -0700 Subject: [PATCH 1/4] chore: app.scss and other styles shouldn't be nested in shared --- config/webpack/base.config.ts | 2 +- src/{shared => }/app.scss | 12 ++++++------ src/browser/runtime.ts | 2 +- src/{shared => }/context.ts | 0 src/extension/scripts/background.tsx | 2 +- src/{shared => }/extensions-client-common.scss | 0 src/{shared => }/global-styles/card.scss | 0 src/{shared => }/global-styles/colors.scss | 0 src/{shared => }/global-styles/icons.scss | 0 src/{shared => }/highlight.scss | 0 src/libs/options/styles.scss | 1 + src/libs/phabricator/backend.tsx | 2 +- src/{shared => }/messaging.ts | 0 src/{shared => }/options.scss | 0 src/shared/backend/context.ts | 2 +- src/shared/backend/extensions.ts | 2 +- src/shared/backend/headers.tsx | 2 +- src/shared/components/CodeIntelStatusIndicator.tsx | 2 +- src/shared/tracking/EventLogger.tsx | 2 +- src/shared/util/context.tsx | 2 +- src/shared/util/featureFlags.ts | 2 +- stories/global.scss | 2 +- 22 files changed, 19 insertions(+), 18 deletions(-) rename src/{shared => }/app.scss (92%) rename src/{shared => }/context.ts (100%) rename src/{shared => }/extensions-client-common.scss (100%) rename src/{shared => }/global-styles/card.scss (100%) rename src/{shared => }/global-styles/colors.scss (100%) rename src/{shared => }/global-styles/icons.scss (100%) rename src/{shared => }/highlight.scss (100%) create mode 100644 src/libs/options/styles.scss rename src/{shared => }/messaging.ts (100%) rename src/{shared => }/options.scss (100%) 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/src/shared/app.scss b/src/app.scss similarity index 92% rename from src/shared/app.scss rename to src/app.scss index 2b30737e..8a8ea3c2 100644 --- a/src/shared/app.scss +++ b/src/app.scss @@ -79,15 +79,15 @@ $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 '@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/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..8c569544 100644 --- a/src/extension/scripts/background.tsx +++ b/src/extension/scripts/background.tsx @@ -14,8 +14,8 @@ import * as runtime from '../../browser/runtime' import storage, { defaultStorageItems } from '../../browser/storage' import * as tabs from '../../browser/tabs' 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' 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/options/styles.scss b/src/libs/options/styles.scss new file mode 100644 index 00000000..a96c259d --- /dev/null +++ b/src/libs/options/styles.scss @@ -0,0 +1 @@ +@import './ServerStatus'; 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/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/tracking/EventLogger.tsx b/src/shared/tracking/EventLogger.tsx index 4e46f63a..e71a94fc 100644 --- a/src/shared/tracking/EventLogger.tsx +++ b/src/shared/tracking/EventLogger.tsx @@ -1,7 +1,7 @@ import uuid from 'uuid' import storage from '../../browser/storage' +import { isInPage } from '../../context' import { logUserEvent } from '../backend/userEvents' -import { isInPage } from '../context' const uidKey = 'sourcegraphAnonymousUid' diff --git a/src/shared/util/context.tsx b/src/shared/util/context.tsx index ae278a6f..cb38bd38 100644 --- a/src/shared/util/context.tsx +++ b/src/shared/util/context.tsx @@ -1,7 +1,7 @@ import * as path from 'path' import * as runtime from '../../browser/runtime' import storage from '../../browser/storage' -import { isPhabricator } from '../context' +import { isPhabricator } from '../../context' import { EventLogger } from '../tracking/EventLogger' export const DEFAULT_SOURCEGRAPH_URL = 'https://sourcegraph.com' diff --git a/src/shared/util/featureFlags.ts b/src/shared/util/featureFlags.ts index 238d7415..41b55007 100644 --- a/src/shared/util/featureFlags.ts +++ b/src/shared/util/featureFlags.ts @@ -1,6 +1,6 @@ import storage from '../../browser/storage' import { FeatureFlags } from '../../browser/types' -import { isInPage } from '../context' +import { isInPage } from '../../context' interface FeatureFlagsStorage { /** diff --git a/stories/global.scss b/stories/global.scss index 2fb415a6..5ab46f96 100644 --- a/stories/global.scss +++ b/stories/global.scss @@ -1 +1 @@ -@import '../src/shared/app.scss'; +@import '../src/app.scss'; From 84cf373b8a230eb57acedb9dbdef329136e75a49 Mon Sep 17 00:00:00 2001 From: Isaac Snow Date: Thu, 18 Oct 2018 16:28:18 -0700 Subject: [PATCH 2/4] feat: wip feat: JSONEditor feat: options menu story feat: wip feat: wip --- package.json | 12 +- src/app.scss | 1 + src/browser/storage.ts | 8 +- src/browser/types.ts | 77 +++- src/extension/scripts/background.tsx | 59 +-- src/extension/scripts/inject.tsx | 12 +- src/extension/scripts/options.tsx | 4 +- src/extension/views/options.html | 4 +- src/libs/github/inject.tsx | 14 +- src/libs/options/ActionButton.scss | 8 + src/libs/options/ActionButton.tsx | 12 + src/libs/options/Header.scss | 30 ++ src/libs/options/Header.tsx | 26 ++ src/libs/options/Menu.scss | 16 + src/libs/options/Menu.tsx | 54 +++ src/libs/options/OptionsContainer.tsx | 126 ++++++ src/libs/options/ServerURLForm.scss | 61 +++ src/libs/options/ServerURLForm.test.tsx | 172 +++++++++ src/libs/options/ServerURLForm.tsx | 253 ++++++++++++ src/libs/options/settings.ts | 49 +++ src/libs/options/styles.scss | 5 +- src/shared/backend/search.tsx | 6 +- src/shared/backend/server.ts | 4 +- src/shared/components/JSONEditor.scss | 24 ++ src/shared/components/JSONEditor.tsx | 97 +++++ .../components/options/ConfigWarning.tsx | 14 - .../components/options/ConnectionCard.tsx | 353 ----------------- .../components/options/EnterpriseURLList.tsx | 59 --- .../components/options/FeatureFlagCard.tsx | 94 ----- .../options/OptionsConfiguration.tsx | 85 ---- .../components/options/OptionsDashboard.tsx | 14 - .../components/options/OptionsPageSidebar.tsx | 64 --- .../options/PhabricatorMappings.tsx | 151 -------- .../options/PhabricatorSettings.tsx | 24 -- .../components/options/ServerConnection.tsx | 116 ------ .../components/options/ServerInstallation.tsx | 62 --- src/shared/components/options/ServerModal.tsx | 64 --- .../components/options/ServerURLSelection.tsx | 110 ------ .../components/options/SupportedCodeHosts.tsx | 124 ------ .../components/options/TelemetryBanner.tsx | 20 - src/shared/components/options/utils.tsx | 7 - src/shared/util/context.tsx | 8 +- src/temp.test.ts | 6 - src/types/mocha-jsdom/index.d.ts | 3 + stories/JSONEditor.tsx | 25 ++ stories/options/ConfigWarning.tsx | 9 - stories/options/Header.tsx | 14 + stories/options/Menu.tsx | 44 +++ stories/options/ServerURLForm.tsx | 94 +++++ yarn.lock | 364 +++++++++++++++--- 50 files changed, 1534 insertions(+), 1528 deletions(-) create mode 100644 src/libs/options/ActionButton.scss create mode 100644 src/libs/options/ActionButton.tsx create mode 100644 src/libs/options/Header.scss create mode 100644 src/libs/options/Header.tsx create mode 100644 src/libs/options/Menu.scss create mode 100644 src/libs/options/Menu.tsx create mode 100644 src/libs/options/OptionsContainer.tsx create mode 100644 src/libs/options/ServerURLForm.scss create mode 100644 src/libs/options/ServerURLForm.test.tsx create mode 100644 src/libs/options/ServerURLForm.tsx create mode 100644 src/libs/options/settings.ts create mode 100644 src/shared/components/JSONEditor.scss create mode 100644 src/shared/components/JSONEditor.tsx delete mode 100644 src/shared/components/options/ConfigWarning.tsx delete mode 100644 src/shared/components/options/ConnectionCard.tsx delete mode 100644 src/shared/components/options/EnterpriseURLList.tsx delete mode 100644 src/shared/components/options/FeatureFlagCard.tsx delete mode 100644 src/shared/components/options/OptionsConfiguration.tsx delete mode 100644 src/shared/components/options/OptionsDashboard.tsx delete mode 100644 src/shared/components/options/OptionsPageSidebar.tsx delete mode 100644 src/shared/components/options/PhabricatorMappings.tsx delete mode 100644 src/shared/components/options/PhabricatorSettings.tsx delete mode 100644 src/shared/components/options/ServerConnection.tsx delete mode 100644 src/shared/components/options/ServerInstallation.tsx delete mode 100644 src/shared/components/options/ServerModal.tsx delete mode 100644 src/shared/components/options/ServerURLSelection.tsx delete mode 100644 src/shared/components/options/SupportedCodeHosts.tsx delete mode 100644 src/shared/components/options/TelemetryBanner.tsx delete mode 100644 src/shared/components/options/utils.tsx delete mode 100644 src/temp.test.ts create mode 100644 src/types/mocha-jsdom/index.d.ts create mode 100644 stories/JSONEditor.tsx delete mode 100644 stories/options/ConfigWarning.tsx create mode 100644 stories/options/Header.tsx create mode 100644 stories/options/Menu.tsx create mode 100644 stories/options/ServerURLForm.tsx diff --git a/package.json b/package.json index d006dc58..012288e2 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", diff --git a/src/app.scss b/src/app.scss index 8a8ea3c2..b18f1847 100644 --- a/src/app.scss +++ b/src/app.scss @@ -86,6 +86,7 @@ $theme-colors-light: ( @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 '@sourcegraph/react-loading-spinner/lib/LoadingSpinner.css'; 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/extension/scripts/background.tsx b/src/extension/scripts/background.tsx index 8c569544..f65b2e7b 100644 --- a/src/extension/scripts/background.tsx +++ b/src/extension/scripts/background.tsx @@ -13,6 +13,7 @@ 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' @@ -161,6 +162,7 @@ permissions.onRemoved(permissions => { }) }) +// Ensure access tokens are in storage and they are in the correct shape. 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/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.tsx b/src/libs/options/OptionsContainer.tsx new file mode 100644 index 00000000..92aff2b3 --- /dev/null +++ b/src/libs/options/OptionsContainer.tsx @@ -0,0 +1,126 @@ +import * as React from 'react' +import { of, Subject, Subscription } from 'rxjs' +import { catchError, map, switchMap, tap } from 'rxjs/operators' +import { getExtensionVersionSync } from '../../browser/runtime' +import { ERAUTHREQUIRED, isErrorLike } from '../../shared/backend/errors' +import { fetchSite } from '../../shared/backend/server' +import { sourcegraphUrl } from '../../shared/util/context' +import { OptionsMenu, OptionsMenuProps } from './Menu' +import { ConnectionErrors } from './ServerURLForm' +import { getConfigurableSettings, setConfigurabelSettings, setSourcegraphURL } from './settings' + +interface OptionsContainerState + extends Pick< + OptionsMenuProps, + 'isSettingsOpen' | 'status' | 'sourcegraphURL' | 'settings' | 'settingsHaveChanged' | 'connectionError' + > {} + +export class OptionsContainer extends React.Component<{}, OptionsContainerState> { + public state: OptionsContainerState = { + status: 'connecting', + sourcegraphURL: sourcegraphUrl, + isSettingsOpen: false, + settingsHaveChanged: false, + settings: {}, + } + + private version = getExtensionVersionSync() + + private urlUpdates = new Subject() + private settingsSaves = new Subject() + + private subscriptions = new Subscription() + + constructor(props: {}) { + super(props) + + this.subscriptions.add( + this.urlUpdates + .pipe( + tap(a => { + console.log('a', a) + }), + switchMap(url => fetchSite(url).pipe(map(() => url))), + catchError(err => of(err)) + ) + .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 + } + + console.log(res, url) + + setSourcegraphURL(url) + }) + ) + + this.subscriptions.add( + this.settingsSaves.pipe(switchMap(settings => setConfigurabelSettings(settings))).subscribe(settings => { + this.setState({ + settings, + settingsHaveChanged: false, + }) + }) + ) + } + + public componentDidMount(): void { + getConfigurableSettings().subscribe(settings => { + this.setState({ settings }) + }) + + console.log('next url') + 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 = () => { + console.log('submitted', this.state.sourcegraphURL) + 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..844270ed --- /dev/null +++ b/src/libs/options/ServerURLForm.test.tsx @@ -0,0 +1,172 @@ +import * as chai from 'chai' +import { describe, it } from 'mocha' +import * as React from 'react' +import { cleanup, fireEvent, render } from 'react-testing-library' +import { TestScheduler } from 'rxjs/testing' + +import { EMPTY, merge, of, Subject } from 'rxjs' +import { map, switchMap, switchMapTo, tap } from 'rxjs/operators' +import { ServerURLForm } from './ServerURLForm' + +describe('ServerURLForm', () => { + after(cleanup) + + it('the onChange prop handler gets fired', () => { + const scheduler = new TestScheduler((a, b) => chai.assert.deepEqual(a, b)) + + scheduler.run(({ cold, expectObservable }) => { + const urls: { [key: string]: string } = { + a: 'https://different.com', + } + + const urlChanges = cold('a', urls).pipe( + map(url => { + const changes = new Subject() + const nextChange = (value: string) => changes.next(value) + + const submits = new Subject() + const nextSubmit = () => submits.next() + + const { container } = render( + + ) + + const urlInput = container.querySelector('input')! + + return { changes, submits, url, urlInput } + }), + switchMap(({ changes, url, urlInput }) => { + const emit = of(undefined).pipe( + tap(() => { + fireEvent.change(urlInput, { target: { value: url } }) + }), + switchMap(() => EMPTY) + ) + + return merge(changes, emit) + }) + ) + + const values: { [key: string]: string } = { + a: 'https://different.com', + } + + expectObservable(urlChanges).toBe('a', values) + }) + }) + + it('captures the form submit event', () => { + const scheduler = new TestScheduler((a, b) => chai.assert.deepEqual(a, b)) + + scheduler.run(({ cold, expectObservable }) => { + const urls: { [key: string]: string } = { + a: 'https://different.com', + } + + const urlChanges = cold('a', urls).pipe( + map(url => { + const changes = new Subject() + const nextChange = (value: string) => changes.next(value) + + const submits = new Subject() + const nextSubmit = () => submits.next() + + const { container } = render( + + ) + + const urlInput = container.querySelector('input')! + + return { changes, submits, url, urlInput } + }), + switchMap(({ changes, url, urlInput }) => { + const emit = of(undefined).pipe( + tap(() => { + fireEvent.change(urlInput, { target: { value: url } }) + }), + switchMap(() => EMPTY) + ) + + return merge(changes, emit) + }) + ) + + const values: { [key: string]: string } = { + a: 'https://different.com', + } + + expectObservable(urlChanges).toBe('a', values) + }) + }) + + // it('submits after 5 seconds of inactivity after a change', () => { + // const scheduler = new TestScheduler((a, b) => chai.assert.deepEqual(a, b)) + + // scheduler.run(({ cold, expectObservable }) => { + // const urls: { [key: string]: string } = { + // a: 'https://different.com', + // } + + // const inject = (nextChange: (value: string) => void, nextSubmit: () => void) => { + // const { container, rerender } = render( + // + // ) + + // const rr = (value: string) => + // rerender( + // + // ) + + // return { rerender: rr, input: container.querySelector('input')! } + // } + + // const submits = cold('a', urls).pipe( + // map(url => { + // const changes = new Subject() + // const nextChange = (value: string) => changes.next(value) + + // const submits = new Subject() + // const nextSubmit = () => submits.next() + + // const { input, rerender } = inject(nextChange, nextSubmit) + + // return { changes, submits, url, input, rerender } + // }), + // switchMap(({ changes, submits, url, input, rerender }) => { + // // const emit = of(undefined).pipe( + // // tap(() => { + // // fireEvent.change(input, { target: { value: url } }) + // // }), + // // switchMap(() => EMPTY) + // // ) + + // const rerenders = changes.pipe( + // tap(value => { + // rerender(value) + // }), + // switchMapTo(EMPTY) + // ) + + // return merge(submits, rerenders) + // }) + // ) + + // expectObservable(submits).toBe('5s a') + // }) + // }) +}) diff --git a/src/libs/options/ServerURLForm.tsx b/src/libs/options/ServerURLForm.tsx new file mode 100644 index 00000000..3525d8e3 --- /dev/null +++ b/src/libs/options/ServerURLForm.tsx @@ -0,0 +1,253 @@ +import { upperFirst } from 'lodash' +import * as React from 'react' +import { merge, Observable, Subject, Subscription } from 'rxjs' +import { debounceTime, distinctUntilChanged, map, skip, withLatestFrom } 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 componentUpdates = new Subject() + + private inputElement = React.createRef() + + private inputFocuses = new Subject() + private nextInputFocus = () => this.inputFocuses.next() + + private formSubmits = new Subject() + + private subscriptions = new Subscription() + + // constructor(props: ServerURLFormProps) { + // super(props) + + // const propsWhenEventOccured: Observable = this.inputFocuses.pipe( + // withLatestFrom(this.componentUpdates), + // map(([, props]) => props) + // ) + + // const propsWhenFormSubmitted = this.formSubmissions.pipe( + // withLatestFrom(this.componentUpdates), + // map(([, props]) => props) + // ) + + // const newProps = this.componentUpdates.pipe( + // distinctUntilChanged((x, y) => x.value === y.value), + // // Skip the first input from `componentDidMount()` + // skip(1) + // ) + + // const propsWhenBeganUpdating = merge(newProps, propsWhenEventOccured) + + // this.subscriptions.add( + // propsWhenBeganUpdating.subscribe(() => { + // this.setState({ isUpdating: true, error: undefined }) + // }) + // ) + + // const submitAfterInactivity = propsWhenBeganUpdating.pipe(debounceTime(5000)) + + // this.subscriptions.add( + // merge(propsWhenFormSubmitted, submitAfterInactivity) + // .pipe( + // map(({ value }) => value), + // // Prevent double submission when user presses enter. + // distinctUntilChanged() + // ) + // .subscribe(() => { + // console.log('sobmit') + // this.props.onSubmit() + + // if (this.inputElement.current) { + // this.inputElement.current.blur() + // } + + // this.setState({ isUpdating: false }) + // }) + // ) + // } + + constructor(props: ServerURLFormProps) { + super(props) + + const propsWhenEventOccured: Observable = this.inputFocuses.pipe( + withLatestFrom(this.componentUpdates), + map(([, props]) => props) + ) + + const propsWhenFormSubmitted = this.formSubmits.pipe( + withLatestFrom(this.componentUpdates), + map(([, props]) => props) + ) + + const newProps = this.componentUpdates.pipe( + distinctUntilChanged((x, y) => x.value === y.value), + // Skip the first input from `componentDidMount()` + skip(1) + ) + + const propsWhenBeganUpdating = merge(newProps, propsWhenEventOccured) + + this.subscriptions.add( + propsWhenBeganUpdating.subscribe(() => { + this.setState({ isUpdating: true, error: undefined }) + }) + ) + + const submitAfterInactivity = propsWhenBeganUpdating.pipe(debounceTime(5000)) + + this.subscriptions.add( + merge(propsWhenFormSubmitted, submitAfterInactivity) + .pipe( + map(({ value }) => value), + // Prevent double submission when user presses enter. + distinctUntilChanged() + ) + .subscribe(() => { + this.props.onSubmit() + + if (this.inputElement.current) { + this.inputElement.current.blur() + } + + this.setState({ isUpdating: false }) + }) + ) + } + + public componentDidMount(): void { + this.componentUpdates.next(this.props) + } + + public componentDidUpdate(): void { + console.log('did update') + this.componentUpdates.next(this.props) + } + + 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.props.onChange(value) + } + + private handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + + this.formSubmits.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 index a96c259d..8fc7570d 100644 --- a/src/libs/options/styles.scss +++ b/src/libs/options/styles.scss @@ -1 +1,4 @@ -@import './ServerStatus'; +@import './ActionButton'; +@import './Header'; +@import './Menu'; +@import './ServerURLForm'; 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/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})}
+