Skip to content
This repository was archived by the owner on Nov 6, 2018. It is now read-only.

Commit a79ccc5

Browse files
committed
feat: createPanelView API for extension panels with custom Markdown
Extensions can use the new `sourcegraph.app.createPanelView` API to add panels to the UI. These panels can contain Markdown.
1 parent 3c629da commit a79ccc5

12 files changed

+412
-6
lines changed

src/client/api/views.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { combineLatest, ReplaySubject, Subject, Subscription } from 'rxjs'
2+
import { map } from 'rxjs/operators'
3+
import { handleRequests } from '../../common/proxy'
4+
import { ContributableViewContainer } from '../../protocol'
5+
import { Connection } from '../../protocol/jsonrpc2/connection'
6+
import * as plain from '../../protocol/plainTypes'
7+
import { ViewProviderRegistry } from '../providers/view'
8+
import { SubscriptionMap } from './common'
9+
10+
/** @internal */
11+
export interface ClientViewsAPI {
12+
$unregister(id: number): void
13+
$registerPanelViewProvider(id: number, provider: { id: string }): void
14+
$acceptPanelViewUpdate(id: number, params: Partial<plain.PanelView>): void
15+
}
16+
17+
interface PanelViewSubjects {
18+
title: Subject<string>
19+
content: Subject<string>
20+
}
21+
22+
/** @internal */
23+
export class ClientViews implements ClientViewsAPI {
24+
private subscriptions = new Subscription()
25+
private panelViews = new Map<number, Record<keyof plain.PanelView, Subject<string>>>()
26+
private registrations = new SubscriptionMap()
27+
28+
constructor(connection: Connection, private viewRegistry: ViewProviderRegistry) {
29+
this.subscriptions.add(this.registrations)
30+
31+
handleRequests(connection, 'views', this)
32+
}
33+
34+
public $unregister(id: number): void {
35+
this.registrations.remove(id)
36+
}
37+
38+
public $registerPanelViewProvider(id: number, provider: { id: string }): void {
39+
const panelView: PanelViewSubjects = {
40+
title: new ReplaySubject<string>(1),
41+
content: new ReplaySubject<string>(1),
42+
}
43+
this.panelViews.set(id, panelView)
44+
const registryUnsubscribable = this.viewRegistry.registerProvider(
45+
{ ...provider, container: ContributableViewContainer.Panel },
46+
combineLatest(panelView.title, panelView.content).pipe(map(([title, content]) => ({ title, content })))
47+
)
48+
this.registrations.add(id, {
49+
unsubscribe: () => {
50+
registryUnsubscribable.unsubscribe()
51+
this.panelViews.delete(id)
52+
},
53+
})
54+
}
55+
56+
public $acceptPanelViewUpdate(id: number, params: { title?: string; content?: string }): void {
57+
const panelView = this.panelViews.get(id)
58+
if (panelView === undefined) {
59+
throw new Error(`no panel view with ID ${id}`)
60+
}
61+
if (params.title !== undefined) {
62+
panelView.title.next(params.title)
63+
}
64+
if (params.content !== undefined) {
65+
panelView.content.next(params.content)
66+
}
67+
}
68+
69+
public unsubscribe(): void {
70+
this.subscriptions.unsubscribe()
71+
}
72+
}

src/client/controller.ts

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ClientContext } from './api/context'
2020
import { ClientDocuments } from './api/documents'
2121
import { ClientLanguageFeatures } from './api/languageFeatures'
2222
import { Search } from './api/search'
23+
import { ClientViews } from './api/views'
2324
import { ClientWindows } from './api/windows'
2425
import { applyContextUpdate, EMPTY_CONTEXT } from './context/context'
2526
import { EMPTY_ENVIRONMENT, Environment } from './environment'
@@ -249,6 +250,7 @@ export class Controller<X extends Extension, C extends ConfigurationCascade> imp
249250
})
250251
)
251252
)
253+
subscription.add(new ClientViews(client, this.registries.views))
252254
subscription.add(new ClientCodeEditor(client, this.registries.textDocumentDecoration))
253255
subscription.add(
254256
new ClientDocuments(

src/client/providers/registry.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { BehaviorSubject, Observable, Unsubscribable } from 'rxjs'
22
import { map } from 'rxjs/operators'
33

44
/** A registry entry for a registered provider. */
5-
interface Entry<O, P> {
5+
export interface Entry<O, P> {
66
registrationOptions: O
77
provider: P
88
}

src/client/providers/view.test.ts

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import * as assert from 'assert'
2+
import { Observable, of, throwError } from 'rxjs'
3+
import { TestScheduler } from 'rxjs/testing'
4+
import { ContributableViewContainer } from '../../protocol'
5+
import * as plain from '../../protocol/plainTypes'
6+
import { Entry } from './registry'
7+
import { getView, getViews, ViewProviderRegistrationOptions } from './view'
8+
9+
const FIXTURE_CONTAINER = ContributableViewContainer.Panel
10+
11+
const FIXTURE_ENTRY_1: Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>> = {
12+
registrationOptions: { container: FIXTURE_CONTAINER, id: '1' },
13+
provider: of<plain.PanelView>({ title: 't1', content: 'c1' }),
14+
}
15+
const FIXTURE_RESULT_1 = { container: FIXTURE_CONTAINER, id: '1', title: 't1', content: 'c1' }
16+
17+
const FIXTURE_ENTRY_2: Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>> = {
18+
registrationOptions: { container: FIXTURE_CONTAINER, id: '2' },
19+
provider: of<plain.PanelView>({ title: 't2', content: 'c2' }),
20+
}
21+
const FIXTURE_RESULT_2 = { container: FIXTURE_CONTAINER, id: '2', title: 't2', content: 'c2' }
22+
23+
const scheduler = () => new TestScheduler((a, b) => assert.deepStrictEqual(a, b))
24+
25+
describe('getView', () => {
26+
describe('0 providers', () => {
27+
it('returns null', () =>
28+
scheduler().run(({ cold, expectObservable }) =>
29+
expectObservable(
30+
getView(
31+
cold<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>('-a-|', { a: [] }),
32+
'1'
33+
)
34+
).toBe('-a-|', {
35+
a: null,
36+
})
37+
))
38+
})
39+
40+
it('returns result from provider', () =>
41+
scheduler().run(({ cold, expectObservable }) =>
42+
expectObservable(
43+
getView(
44+
cold<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>('-a-|', {
45+
a: [FIXTURE_ENTRY_1],
46+
}),
47+
'1'
48+
)
49+
).toBe('-a-|', {
50+
a: FIXTURE_RESULT_1,
51+
})
52+
))
53+
54+
describe('multiple emissions', () => {
55+
it('returns stream of results', () =>
56+
scheduler().run(({ cold, expectObservable }) =>
57+
expectObservable(
58+
getView(
59+
cold<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>('-a-b-|', {
60+
a: [FIXTURE_ENTRY_1],
61+
b: [FIXTURE_ENTRY_1, FIXTURE_ENTRY_2],
62+
}),
63+
'2'
64+
)
65+
).toBe('-a-b-|', {
66+
a: null,
67+
b: FIXTURE_RESULT_2,
68+
})
69+
))
70+
})
71+
})
72+
73+
describe('getViews', () => {
74+
describe('0 providers', () => {
75+
it('returns null', () =>
76+
scheduler().run(({ cold, expectObservable }) =>
77+
expectObservable(
78+
getViews(
79+
cold<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>('-a-|', { a: [] }),
80+
FIXTURE_CONTAINER
81+
)
82+
).toBe('-a-|', {
83+
a: null,
84+
})
85+
))
86+
})
87+
88+
it('returns result from provider', () =>
89+
scheduler().run(({ cold, expectObservable }) =>
90+
expectObservable(
91+
getViews(
92+
cold<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>('-a-|', {
93+
a: [FIXTURE_ENTRY_1],
94+
}),
95+
FIXTURE_CONTAINER
96+
)
97+
).toBe('-a-|', {
98+
a: [FIXTURE_RESULT_1],
99+
})
100+
))
101+
102+
it('continues if provider has error', () =>
103+
scheduler().run(({ cold, expectObservable }) =>
104+
expectObservable(
105+
getViews(
106+
cold<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>('-a-|', {
107+
a: [
108+
{
109+
registrationOptions: { container: FIXTURE_CONTAINER, id: 'err' },
110+
provider: throwError('err'),
111+
},
112+
FIXTURE_ENTRY_1,
113+
],
114+
}),
115+
FIXTURE_CONTAINER
116+
)
117+
).toBe('-a-|', {
118+
a: [FIXTURE_RESULT_1],
119+
})
120+
))
121+
122+
describe('multiple emissions', () => {
123+
it('returns stream of results', () =>
124+
scheduler().run(({ cold, expectObservable }) =>
125+
expectObservable(
126+
getViews(
127+
cold<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>('-a-b-|', {
128+
a: [FIXTURE_ENTRY_1],
129+
b: [FIXTURE_ENTRY_1, FIXTURE_ENTRY_2],
130+
}),
131+
FIXTURE_CONTAINER
132+
)
133+
).toBe('-a-b-|', {
134+
a: [FIXTURE_RESULT_1],
135+
b: [FIXTURE_RESULT_1, FIXTURE_RESULT_2],
136+
})
137+
))
138+
})
139+
})

src/client/providers/view.ts

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { combineLatest, Observable } from 'rxjs'
2+
import { catchError, map, switchMap } from 'rxjs/operators'
3+
import { ContributableViewContainer } from '../../protocol'
4+
import * as plain from '../../protocol/plainTypes'
5+
import { Entry, FeatureProviderRegistry } from './registry'
6+
7+
export interface ViewProviderRegistrationOptions {
8+
id: string
9+
container: ContributableViewContainer
10+
}
11+
12+
export type ProvideViewSignature = Observable<plain.PanelView>
13+
14+
/** Provides views from all extensions. */
15+
export class ViewProviderRegistry extends FeatureProviderRegistry<
16+
ViewProviderRegistrationOptions,
17+
ProvideViewSignature
18+
> {
19+
/**
20+
* Returns an observable that emits the specified view whenever it or the set of registered view providers
21+
* changes. If the provider emits an error, the returned observable also emits an error (and completes).
22+
*/
23+
public getView(id: string): Observable<plain.PanelView | null> {
24+
return getView(this.entries, id)
25+
}
26+
27+
/**
28+
* Returns an observable that emits all views whenever the set of registered view providers or their properties
29+
* change. If any provider emits an error, the error is logged and the provider is omitted from the emission of
30+
* the observable (the observable does not emit the error).
31+
*/
32+
public getViews(
33+
container: ContributableViewContainer
34+
): Observable<(plain.PanelView & ViewProviderRegistrationOptions)[] | null> {
35+
return getViews(this.entries, container)
36+
}
37+
}
38+
39+
/**
40+
* Exported for testing only. Most callers should use {@link ViewProviderRegistry#getView}, which uses the
41+
* registered view providers.
42+
*
43+
* @internal
44+
*/
45+
export function getView(
46+
entries: Observable<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>,
47+
id: string
48+
): Observable<(plain.PanelView & ViewProviderRegistrationOptions) | null> {
49+
return entries.pipe(
50+
map(entries => entries.find(entry => entry.registrationOptions.id === id)),
51+
switchMap(entry => (entry ? addRegistrationOptions(entry) : [null]))
52+
)
53+
}
54+
55+
/**
56+
* Exported for testing only. Most callers should use {@link ViewProviderRegistry#getViews}, which uses the
57+
* registered view providers.
58+
*
59+
* @internal
60+
*/
61+
export function getViews(
62+
entries: Observable<Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>[]>,
63+
container: ContributableViewContainer
64+
): Observable<(plain.PanelView & ViewProviderRegistrationOptions)[] | null> {
65+
return entries.pipe(
66+
switchMap(
67+
entries =>
68+
entries && entries.length > 0
69+
? combineLatest(
70+
entries.filter(e => e.registrationOptions.container === container).map(entry =>
71+
addRegistrationOptions(entry).pipe(
72+
catchError(err => {
73+
console.error(err)
74+
return [null]
75+
})
76+
)
77+
)
78+
).pipe(
79+
map(entries =>
80+
entries.filter(
81+
(result): result is plain.PanelView & ViewProviderRegistrationOptions =>
82+
result !== null
83+
)
84+
)
85+
)
86+
: [null]
87+
)
88+
)
89+
}
90+
91+
function addRegistrationOptions(
92+
entry: Entry<ViewProviderRegistrationOptions, Observable<plain.PanelView>>
93+
): Observable<plain.PanelView & ViewProviderRegistrationOptions> {
94+
return entry.provider.pipe(map(view => ({ ...view, ...entry.registrationOptions })))
95+
}

src/client/registries.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { TextDocumentDecorationProviderRegistry } from './providers/decoration'
88
import { TextDocumentHoverProviderRegistry } from './providers/hover'
99
import { TextDocumentLocationProviderRegistry, TextDocumentReferencesProviderRegistry } from './providers/location'
1010
import { QueryTransformerRegistry } from './providers/queryTransformer'
11+
import { ViewProviderRegistry } from './providers/view'
1112

1213
/**
1314
* Registries is a container for all provider registries.
@@ -27,4 +28,5 @@ export class Registries<X extends Extension, C extends ConfigurationCascade> {
2728
public readonly textDocumentHover = new TextDocumentHoverProviderRegistry()
2829
public readonly textDocumentDecoration = new TextDocumentDecorationProviderRegistry()
2930
public readonly queryTransformer = new QueryTransformerRegistry()
31+
public readonly views = new ViewProviderRegistry()
3032
}

src/extension/api/common.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class ProviderMap<B> {
3838
*/
3939
public get<P extends B>(id: number): P {
4040
const provider = this.map.get(id) as P
41-
if (!provider) {
41+
if (provider === undefined) {
4242
throw new Error(`no provider with ID ${id}`)
4343
}
4444
return provider

0 commit comments

Comments
 (0)