Skip to content
This repository was archived by the owner on Jan 22, 2019. It is now read-only.

Commit 5793ce0

Browse files
committed
feat: add back client settings (for unauthed users only)
Unauthenticated users can use client settings (again, partially reverted from 288706e). This means that unauthed users can use extensions that modify settings. Client settings are IGNORED for authed users; all settings reads and writes go directly to the associated Sourcegraph user account on their instance. This is to reduce UX complexity.
1 parent bbb29e0 commit 5793ce0

File tree

8 files changed

+308
-18
lines changed

8 files changed

+308
-18
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@
113113
"dependencies": {
114114
"@slimsag/react-shortcuts": "^1.2.1",
115115
"@sourcegraph/codeintellify": "^3.9.1",
116-
"@sourcegraph/extensions-client-common": "^10.4.2",
116+
"@sourcegraph/extensions-client-common": "^10.5.1",
117117
"@sourcegraph/react-loading-spinner": "0.0.6",
118118
"@sqs/jsonc-parser": "^1.0.3",
119119
"@types/uglifyjs-webpack-plugin": "1.1.0",

src/browser/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export interface StorageItems {
6767
*/
6868
featureFlags: FeatureFlags
6969
clientConfiguration: ClientConfigurationDetails
70+
/**
71+
* Overrides settings from Sourcegraph.
72+
*/
73+
clientSettings: string
7074
}
7175

7276
interface ClientConfigurationDetails {
@@ -106,6 +110,7 @@ export const defaultStorageItems: StorageItems = {
106110
url: 'https://sourcegraph.com',
107111
},
108112
},
113+
clientSettings: '',
109114
}
110115

111116
export type StorageChange = { [key in keyof StorageItems]: chrome.storage.StorageChange }

src/shared/backend/extensions.ts

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,32 @@ import { UpdateExtensionSettingsArgs } from '@sourcegraph/extensions-client-comm
22
import { Controller as ExtensionsContextController } from '@sourcegraph/extensions-client-common/lib/controller'
33
import { ConfiguredExtension } from '@sourcegraph/extensions-client-common/lib/extensions/extension'
44
import { gql, graphQLContent } from '@sourcegraph/extensions-client-common/lib/graphql'
5-
import { ConfigurationSubject, gqlToCascade, Settings } from '@sourcegraph/extensions-client-common/lib/settings'
5+
import {
6+
ConfigurationCascade,
7+
ConfigurationCascadeOrError,
8+
ConfigurationSubject,
9+
gqlToCascade,
10+
mergeSettings,
11+
Settings,
12+
} from '@sourcegraph/extensions-client-common/lib/settings'
613
import { LoadingSpinner } from '@sourcegraph/react-loading-spinner'
14+
import { applyEdits } from '@sqs/jsonc-parser'
15+
import * as JSONC from '@sqs/jsonc-parser'
16+
import { removeProperty, setProperty } from '@sqs/jsonc-parser/lib/edit'
717
import { isEqual } from 'lodash'
818
import AddIcon from 'mdi-react/AddIcon'
919
import Alert from 'mdi-react/AlertIcon'
1020
import InfoIcon from 'mdi-react/InformationIcon'
1121
import MenuDown from 'mdi-react/MenuDownIcon'
1222
import Menu from 'mdi-react/MenuIcon'
1323
import SettingsIcon from 'mdi-react/SettingsIcon'
14-
import { combineLatest, Observable, Subject, throwError } from 'rxjs'
24+
import { combineLatest, from, Observable, Subject, throwError } from 'rxjs'
1525
import { distinctUntilChanged, map, mapTo, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'
1626
import { MessageTransports } from 'sourcegraph/module/protocol/jsonrpc2/connection'
1727
import { TextDocumentDecoration } from 'sourcegraph/module/protocol/plainTypes'
1828
import uuid from 'uuid'
1929
import { Disposable } from 'vscode-languageserver'
20-
import storage from '../../browser/storage'
30+
import storage, { StorageItems } from '../../browser/storage'
2131
import { GQL } from '../../types/gqlschema'
2232
import { ExtensionConnectionInfo, onFirstMessage } from '../messaging'
2333
import { canFetchForURL } from '../util/context'
@@ -128,6 +138,45 @@ export const applyDecoration = ({
128138
return mergeDisposables(...disposables)
129139
}
130140

141+
const storageConfigurationCascade: Observable<
142+
ConfigurationCascade<ConfigurationSubject, Settings>
143+
> = storage.observeSync('clientSettings').pipe(
144+
map(clientSettingsString => JSONC.parse(clientSettingsString || '')),
145+
map(clientSettings => ({
146+
subjects: [
147+
{
148+
subject: {
149+
id: 'Client',
150+
settingsURL: 'N/A',
151+
viewerCanAdminister: true,
152+
__typename: 'Client',
153+
displayName: 'Client',
154+
} as ConfigurationSubject,
155+
settings: clientSettings,
156+
},
157+
],
158+
merged: clientSettings || {},
159+
}))
160+
)
161+
162+
const mergeCascades = (
163+
cascadeOrError: ConfigurationCascadeOrError<ConfigurationSubject, Settings>,
164+
cascade: ConfigurationCascade<ConfigurationSubject, Settings>
165+
): ConfigurationCascadeOrError<ConfigurationSubject, Settings> => ({
166+
subjects:
167+
cascadeOrError.subjects === null
168+
? cascade.subjects
169+
: isErrorLike(cascadeOrError.subjects)
170+
? cascadeOrError.subjects
171+
: [...cascadeOrError.subjects, ...cascade.subjects],
172+
merged:
173+
cascadeOrError.merged === null
174+
? cascade.merged
175+
: isErrorLike(cascadeOrError.merged)
176+
? cascadeOrError.merged
177+
: mergeSettings([cascadeOrError.merged, cascade.merged]),
178+
})
179+
131180
const configurationCascadeFragment = gql`
132181
fragment ConfigurationCascadeFields on ConfigurationCascade {
133182
subjects {
@@ -169,7 +218,7 @@ const configurationCascadeRefreshes = new Subject<void>()
169218
* Always represents the entire configuration cascade; i.e., it contains the
170219
* individual configs from the various config subjects (orgs, user, etc.).
171220
*/
172-
export const configurationCascade = combineLatest(
221+
export const gqlConfigurationCascade = combineLatest(
173222
storage.observeSync('sourcegraphURL'),
174223
configurationCascadeRefreshes.pipe(
175224
mapTo(null),
@@ -194,23 +243,50 @@ export const configurationCascade = combineLatest(
194243
if (!data || !data.viewerConfiguration) {
195244
throw createAggregateError(errors)
196245
}
246+
247+
for (const subject of data.viewerConfiguration.subjects) {
248+
// User/org/global settings cannot be edited from the
249+
// browser extension (only client settings can).
250+
subject.viewerCanAdminister = false
251+
}
252+
197253
return data.viewerConfiguration
198254
})
199255
)
200256
)
201257
)
202258

259+
const EMPTY_CONFIGURATION_CASCADE: ConfigurationCascade = { subjects: [], merged: {} }
260+
261+
/**
262+
* The active configuration cascade.
263+
*
264+
* - For unauthenticated users, this is the GraphQL settings plus client settings (which are stored locally in the
265+
* browser extension.
266+
* - For authenticated users, this is just the GraphQL settings (client settings are ignored to simplify the UX).
267+
*/
268+
export const configurationCascade: Observable<
269+
ConfigurationCascadeOrError<ConfigurationSubject, Settings>
270+
> = combineLatest(gqlConfigurationCascade, storageConfigurationCascade).pipe(
271+
map(([gqlCascade, storageCascade]) =>
272+
mergeCascades(
273+
gqlToCascade(gqlCascade),
274+
gqlCascade.subjects.some(subject => subject.__typename === 'User')
275+
? EMPTY_CONFIGURATION_CASCADE
276+
: storageCascade
277+
)
278+
),
279+
distinctUntilChanged((a, b) => isEqual(a, b))
280+
)
281+
203282
export function createExtensionsContextController(
204283
sourcegraphUrl: string
205284
): ExtensionsContextController<ConfigurationSubject, Settings> {
206285
const sourcegraphLanguageServerURL = new URL(sourcegraphUrl)
207286
sourcegraphLanguageServerURL.pathname = '.api/xlang'
208287

209288
return new ExtensionsContextController<ConfigurationSubject, Settings>({
210-
configurationCascade: configurationCascade.pipe(
211-
map(gqlCascade => gqlToCascade(gqlCascade)),
212-
distinctUntilChanged((a, b) => isEqual(a, b))
213-
),
289+
configurationCascade,
214290
updateExtensionSettings,
215291
queryGraphQL: (request, variables, requestMightContainPrivateInfo) =>
216292
storage.observeSync('sourcegraphURL').pipe(
@@ -250,11 +326,11 @@ export function createExtensionsContextController(
250326
}
251327

252328
// TODO(sqs): copied from sourcegraph/sourcegraph temporarily
253-
function updateExtensionSettings(subject: string, args: UpdateExtensionSettingsArgs): Observable<void> {
254-
return configurationCascade.pipe(
329+
function updateUserSettings(subject: string, args: UpdateExtensionSettingsArgs): Observable<void> {
330+
return gqlConfigurationCascade.pipe(
255331
take(1),
256-
switchMap(configurationCascade => {
257-
const subjectConfig = configurationCascade.subjects.find(s => s.id === subject)
332+
switchMap(gqlConfigurationCascade => {
333+
const subjectConfig = gqlConfigurationCascade.subjects.find(s => s.id === subject)
258334
if (!subjectConfig) {
259335
throw new Error(`no configuration subject: ${subject}`)
260336
}
@@ -308,3 +384,44 @@ function editConfiguration(subject: GQL.ID, lastID: number | null, edit: GQL.ICo
308384
function toGQLKeyPath(keyPath: (string | number)[]): GQL.IKeyPathSegment[] {
309385
return keyPath.map(v => (typeof v === 'string' ? { property: v } : { index: v }))
310386
}
387+
388+
const updateClientSettings = (subjectID: 'Client', args: UpdateExtensionSettingsArgs): Observable<void> =>
389+
from(
390+
new Promise<StorageItems>(resolve => storage.getSync(storageItems => resolve(storageItems))).then(
391+
storageItems => {
392+
let clientSettings = storageItems.clientSettings
393+
394+
const format = { tabSize: 2, insertSpaces: true, eol: '\n' }
395+
396+
if ('edit' in args && args.edit) {
397+
clientSettings = applyEdits(
398+
clientSettings,
399+
// TODO(chris): remove `.slice()` (which guards against
400+
// mutation) once
401+
// https://github.com/Microsoft/node-jsonc-parser/pull/12
402+
// is merged in.
403+
setProperty(clientSettings, args.edit.path.slice(), args.edit.value, format)
404+
)
405+
} else if ('extensionID' in args) {
406+
clientSettings = applyEdits(
407+
clientSettings,
408+
typeof args.enabled === 'boolean'
409+
? setProperty(clientSettings, ['extensions', args.extensionID], args.enabled, format)
410+
: removeProperty(clientSettings, ['extensions', args.extensionID], format)
411+
)
412+
}
413+
return new Promise<undefined>(resolve =>
414+
storage.setSync({ clientSettings }, () => {
415+
resolve(undefined)
416+
})
417+
)
418+
}
419+
)
420+
)
421+
422+
export const updateExtensionSettings = (subjectID: string, args: UpdateExtensionSettingsArgs): Observable<void> => {
423+
if (subjectID === 'Client') {
424+
return updateClientSettings(subjectID, args)
425+
}
426+
return updateUserSettings(subjectID, args)
427+
}

src/shared/backend/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const fetchCurrentUser = (useAccessToken = true): Observable<GQL.IUser |
4141
username
4242
avatarURL
4343
url
44+
settingsURL
4445
emails {
4546
email
4647
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// We want to polyfill first.
2+
import '../../../config/polyfill'
3+
4+
import * as React from 'react'
5+
import { Button, FormGroup, Input, Label } from 'reactstrap'
6+
import { Subscription } from 'rxjs'
7+
import storage from '../../../browser/storage'
8+
9+
interface State {
10+
clientSettings: string
11+
}
12+
13+
export class BrowserSettingsEditor extends React.Component<{}, State> {
14+
private subscriptions = new Subscription()
15+
16+
constructor(props) {
17+
super(props)
18+
this.state = {
19+
clientSettings: 'Loading...',
20+
}
21+
}
22+
23+
public componentDidMount(): void {
24+
this.subscriptions.add(
25+
storage.observeSync('clientSettings').subscribe(clientSettings => {
26+
this.setState(() => ({ clientSettings }))
27+
})
28+
)
29+
}
30+
31+
public componentWillUnmount(): void {
32+
this.subscriptions.unsubscribe()
33+
}
34+
35+
private saveLocalSettings = () => {
36+
storage.setSync({ clientSettings: this.state.clientSettings })
37+
}
38+
39+
private onSettingsChanged = event => {
40+
const value = event.target.value
41+
this.setState(() => ({ clientSettings: value }))
42+
}
43+
44+
public render(): JSX.Element | null {
45+
return (
46+
<div>
47+
<div className="options__section-contents">
48+
<FormGroup>
49+
<Label className="options__input">
50+
<Input
51+
className="options__input-textarea"
52+
type="textarea"
53+
value={this.state.clientSettings}
54+
onChange={this.onSettingsChanged}
55+
autoComplete="off"
56+
autoCorrect="off"
57+
autoCapitalize="off"
58+
spellCheck={false}
59+
/>
60+
</Label>
61+
<Button className="options__cta" color="primary" onClick={this.saveLocalSettings}>
62+
Save
63+
</Button>
64+
</FormGroup>
65+
</div>
66+
</div>
67+
)
68+
}
69+
}

src/shared/components/options/OptionsConfiguration.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GQL } from '../../../types/gqlschema'
77
import { fetchCurrentUser } from '../../backend/server'
88
import { ConnectionCard } from './ConnectionCard'
99
import { FeatureFlagCard } from './FeatureFlagCard'
10+
import { SettingsCard } from './SettingsCard'
1011

1112
interface Props {}
1213
interface State {
@@ -31,7 +32,7 @@ export class OptionsConfiguration extends React.Component<Props, State> {
3132
}
3233

3334
public componentDidMount(): void {
34-
fetchCurrentUser().subscribe(user => {
35+
fetchCurrentUser(true).subscribe(user => {
3536
this.setState(() => ({ currentUser: user }))
3637
})
3738
storage.onChanged(() => {
@@ -77,6 +78,7 @@ export class OptionsConfiguration extends React.Component<Props, State> {
7778
<div className="options-configuration-page">
7879
<ConnectionCard permissionOrigins={permissionOrigins} storage={storage} currentUser={currentUser} />
7980
<FeatureFlagCard storage={storage} />
81+
{storage.useExtensions && <SettingsCard currentUser={currentUser} />}
8082
</div>
8183
)
8284
}

0 commit comments

Comments
 (0)