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

Commit 0eb7b95

Browse files
committed
feat: expose workspace roots and file system API
1 parent f5b456e commit 0eb7b95

File tree

12 files changed

+373
-1
lines changed

12 files changed

+373
-1
lines changed

src/client/api/fileSystem.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { FileSystem, FileType } from 'sourcegraph'
2+
import { createProxyAndHandleRequests } from '../../common/proxy'
3+
import { Connection } from '../../protocol/jsonrpc2/connection'
4+
import { URI } from '../../shared/uri'
5+
6+
/** @internal */
7+
export interface ClientFileSystemAPI {
8+
$readDirectory(uri: string): Promise<[string, FileType][]>
9+
$readFile(uri: string): Promise<Uint8Array>
10+
}
11+
12+
/** @internal */
13+
export class ClientFileSystem {
14+
constructor(connection: Connection, private getFileSystem: (uri: string) => FileSystem) {
15+
createProxyAndHandleRequests('fileSystem', connection, this)
16+
}
17+
18+
public $readDirectory(uri: string): Promise<[string, FileType][]> {
19+
return this.getFileSystem(uri).readDirectory(URI.parse(uri))
20+
}
21+
22+
public $readFile(uri: string): Promise<Uint8Array> {
23+
return this.getFileSystem(uri).readFile(URI.parse(uri))
24+
}
25+
26+
public unsubscribe(): void {
27+
// noop
28+
}
29+
}

src/client/api/roots.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Observable, Subscription } from 'rxjs'
2+
import { createProxyAndHandleRequests } from '../../common/proxy'
3+
import { ExtRootsAPI } from '../../extension/api/roots'
4+
import { Connection } from '../../protocol/jsonrpc2/connection'
5+
import { WorkspaceRoot } from '../../protocol/plainTypes'
6+
7+
/** @internal */
8+
export class ClientRoots {
9+
private subscriptions = new Subscription()
10+
private proxy: ExtRootsAPI
11+
12+
constructor(connection: Connection, environmentRoots: Observable<WorkspaceRoot[] | null>) {
13+
this.proxy = createProxyAndHandleRequests('roots', connection, this)
14+
15+
this.subscriptions.add(
16+
environmentRoots.subscribe(roots => {
17+
this.proxy.$acceptRoots(roots || [])
18+
})
19+
)
20+
}
21+
22+
public unsubscribe(): void {
23+
this.subscriptions.unsubscribe()
24+
}
25+
}

src/client/controller.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BehaviorSubject, Observable, Subject, Subscription, Unsubscribable } from 'rxjs'
22
import { distinctUntilChanged, map } from 'rxjs/operators'
3-
import { ContextValues } from 'sourcegraph'
3+
import { ContextValues, FileSystem } from 'sourcegraph'
44
import {
55
ConfigurationCascade,
66
ConfigurationUpdateParams,
@@ -18,7 +18,9 @@ import { ClientCommands } from './api/commands'
1818
import { ClientConfiguration } from './api/configuration'
1919
import { ClientContext } from './api/context'
2020
import { ClientDocuments } from './api/documents'
21+
import { ClientFileSystem } from './api/fileSystem'
2122
import { ClientLanguageFeatures } from './api/languageFeatures'
23+
import { ClientRoots } from './api/roots'
2224
import { Search } from './api/search'
2325
import { ClientViews } from './api/views'
2426
import { ClientWindows } from './api/windows'
@@ -67,6 +69,11 @@ export interface ControllerOptions<X extends Extension, C extends ConfigurationC
6769
* Called before applying the next environment in Controller#setEnvironment. It should have no side effects.
6870
*/
6971
environmentFilter?: (nextEnvironment: Environment<X, C>) => Environment<X, C>
72+
73+
/**
74+
* Called to obtain a {@link module:sourcegraph.FileSystem} to access the file system for a URI.
75+
*/
76+
getFileSystem: (uri: string) => FileSystem
7077
}
7178

7279
/**
@@ -273,6 +280,16 @@ export class Controller<X extends Extension, C extends ConfigurationCascade> imp
273280
)
274281
subscription.add(new Search(client, this.registries.queryTransformer))
275282
subscription.add(new ClientCommands(client, this.registries.commands))
283+
subscription.add(
284+
new ClientRoots(
285+
client,
286+
this.environment.pipe(
287+
map(({ roots }) => roots),
288+
distinctUntilChanged()
289+
)
290+
)
291+
)
292+
subscription.add(new ClientFileSystem(client, this.options.getFileSystem))
276293
}
277294

278295
public set trace(value: Trace) {

src/client/environment.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ConfigurationCascade } from '../protocol'
2+
import { WorkspaceRoot } from '../protocol/plainTypes'
23
import { Context, EMPTY_CONTEXT } from './context/context'
34
import { Extension } from './extension'
45
import { TextDocumentItem } from './types/textDocument'
@@ -13,6 +14,11 @@ import { TextDocumentItem } from './types/textDocument'
1314
* @template C configuration cascade type
1415
*/
1516
export interface Environment<X extends Extension = Extension, C extends ConfigurationCascade = ConfigurationCascade> {
17+
/**
18+
* The currently open workspace roots (typically a single repository).
19+
*/
20+
readonly roots: WorkspaceRoot[] | null
21+
1622
/**
1723
* The text documents that are currently visible. Each text document is represented to extensions as being
1824
* in its own visible CodeEditor.
@@ -31,6 +37,7 @@ export interface Environment<X extends Extension = Extension, C extends Configur
3137

3238
/** An empty Sourcegraph extension client environment. */
3339
export const EMPTY_ENVIRONMENT: Environment<any, any> = {
40+
roots: null,
3441
visibleTextDocuments: null,
3542
extensions: null,
3643
configuration: { merged: {} },

src/extension/api/fileSystem.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { FileType, URI } from 'sourcegraph'
2+
import { ClientFileSystemAPI } from '../../client/api/fileSystem'
3+
4+
/** @internal */
5+
export class ExtFileSystem {
6+
constructor(private proxy: ClientFileSystemAPI) {}
7+
8+
public readDirectory(uri: URI): Promise<[string, FileType][]> {
9+
return this.proxy.$readDirectory(uri.toString())
10+
}
11+
12+
public readFile(uri: URI): Promise<Uint8Array> {
13+
return this.proxy.$readFile(uri.toString())
14+
}
15+
}

src/extension/api/roots.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Observable, Subject } from 'rxjs'
2+
import * as sourcegraph from 'sourcegraph'
3+
import { WorkspaceRoot as PlainWorkspaceRoot } from '../../protocol/plainTypes'
4+
import { URI } from '../../shared/uri'
5+
6+
/** @internal */
7+
export interface ExtRootsAPI {
8+
$acceptRoots(roots: PlainWorkspaceRoot[]): void
9+
}
10+
11+
/** @internal */
12+
export class ExtRoots implements ExtRootsAPI {
13+
private roots: ReadonlyArray<sourcegraph.WorkspaceRoot> = []
14+
15+
/**
16+
* Returns all workspace roots.
17+
*
18+
* @internal
19+
*/
20+
public getAll(): ReadonlyArray<sourcegraph.WorkspaceRoot> {
21+
return this.roots
22+
}
23+
24+
private changes = new Subject<void>()
25+
public readonly onDidChange: Observable<void> = this.changes
26+
27+
public $acceptRoots(roots: PlainWorkspaceRoot[]): void {
28+
this.roots = Object.freeze(
29+
roots.map(plain => ({ ...plain, uri: URI.parse(plain.uri) } as sourcegraph.WorkspaceRoot))
30+
)
31+
this.changes.next()
32+
}
33+
}

src/extension/extensionHost.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { ExtCommands } from './api/commands'
88
import { ExtConfiguration } from './api/configuration'
99
import { ExtContext } from './api/context'
1010
import { ExtDocuments } from './api/documents'
11+
import { ExtFileSystem } from './api/fileSystem'
1112
import { ExtLanguageFeatures } from './api/languageFeatures'
13+
import { ExtRoots } from './api/roots'
1214
import { ExtSearch } from './api/search'
1315
import { ExtViews } from './api/views'
1416
import { ExtWindows } from './api/windows'
@@ -81,6 +83,12 @@ function createExtensionHandle(initData: InitData, connection: Connection): type
8183
const documents = new ExtDocuments(sync)
8284
handleRequests(connection, 'documents', documents)
8385

86+
const roots = new ExtRoots()
87+
handleRequests(connection, 'roots', roots)
88+
89+
const fileSystem = new ExtFileSystem(proxy('fileSystem'))
90+
handleRequests(connection, 'fileSystem', fileSystem)
91+
8492
const windows = new ExtWindows(proxy('windows'), proxy('codeEditor'), documents)
8593
handleRequests(connection, 'windows', windows)
8694

@@ -109,6 +117,12 @@ function createExtensionHandle(initData: InitData, connection: Connection): type
109117
PlainText: sourcegraph.MarkupKind.PlainText,
110118
Markdown: sourcegraph.MarkupKind.Markdown,
111119
},
120+
FileType: {
121+
Unknown: sourcegraph.FileType.Unknown,
122+
File: sourcegraph.FileType.File,
123+
Directory: sourcegraph.FileType.Directory,
124+
SymbolicLink: sourcegraph.FileType.SymbolicLink,
125+
},
112126

113127
app: {
114128
get activeWindow(): sourcegraph.Window | undefined {
@@ -125,6 +139,11 @@ function createExtensionHandle(initData: InitData, connection: Connection): type
125139
return documents.getAll()
126140
},
127141
onDidOpenTextDocument: documents.onDidOpenTextDocument,
142+
fileSystem,
143+
get roots(): ReadonlyArray<sourcegraph.WorkspaceRoot> {
144+
return roots.getAll()
145+
},
146+
onDidChangeRoots: roots.onDidChange,
128147
},
129148

130149
configuration: {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as assert from 'assert'
2+
import * as sourcegraph from 'sourcegraph'
3+
import { URI } from '../shared/uri'
4+
import { tryCatchPromise } from '../util'
5+
import { integrationTestContext } from './helpers.test'
6+
7+
describe('File system (integration)', () => {
8+
describe('workspace.fileSystem', () => {
9+
it('readDirectory', async () => {
10+
const { extensionHost, ready } = await integrationTestContext()
11+
12+
await ready
13+
assert.deepStrictEqual(await extensionHost.workspace.fileSystem.readDirectory(URI.parse('file:///')), [
14+
['f', 1 as sourcegraph.FileType.File],
15+
] as [string, sourcegraph.FileType][])
16+
})
17+
18+
it('readFile', async () => {
19+
const { extensionHost, ready } = await integrationTestContext()
20+
21+
await ready
22+
assert.deepStrictEqual(
23+
await extensionHost.workspace.fileSystem.readFile(URI.parse('file:///f')),
24+
new Uint8Array([116]) as Uint8Array
25+
)
26+
// tslint:disable-next-line:no-floating-promises
27+
assert.rejects(
28+
tryCatchPromise(() => extensionHost.workspace.fileSystem.readFile(URI.parse('file:///does-not-exist')))
29+
)
30+
})
31+
})
32+
})

src/integration-test/helpers.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { filter, first } from 'rxjs/operators'
22
import * as sourcegraph from 'sourcegraph'
3+
import { TextEncoder } from 'util'
34
import { Controller } from '../client/controller'
45
import { Environment } from '../client/environment'
56
import { createExtensionHost } from '../extension/extensionHost'
67
import { createMessageTransports } from '../protocol/jsonrpc2/helpers.test'
78

89
const FIXTURE_ENVIRONMENT: Environment = {
10+
roots: [{ uri: 'file:///' }],
911
visibleTextDocuments: [
1012
{
1113
uri: 'file:///f',
@@ -38,6 +40,7 @@ export async function integrationTestContext(): Promise<
3840

3941
const clientController = new Controller({
4042
clientOptions: () => ({ createMessageTransports: () => clientTransports }),
43+
getFileSystem: () => memoryFileSystem({ 'file:///f': 't' }),
4144
})
4245
clientController.setEnvironment(FIXTURE_ENVIRONMENT)
4346

@@ -81,6 +84,39 @@ export async function integrationTestContext(): Promise<
8184
}
8285
}
8386

87+
/**
88+
* Creates a new implementation of {@link sourcegraph.FileSystem} backed by an object whose keys are URIs and whose
89+
* values are the file contents.
90+
*
91+
* @todo Support nested file systems.
92+
*
93+
* @param data An object of URI to file contents. All entries are treated as files, and no hierarchy is supported.
94+
*/
95+
export function memoryFileSystem(data: { [uri: string]: string }): sourcegraph.FileSystem {
96+
data = data || {}
97+
return {
98+
readDirectory: dir =>
99+
Promise.resolve(
100+
Object.keys(data).map(
101+
// HACK: Can't use sourcegraph.FileType.File value or else it complains "Cannot find module
102+
// 'sourcegraph'".
103+
uri =>
104+
[uri.toString().replace(dir.toString(), ''), 1 as sourcegraph.FileType.File] as [
105+
string,
106+
sourcegraph.FileType
107+
]
108+
)
109+
),
110+
readFile: uri => {
111+
const contents = data[uri.toString()]
112+
if (contents === undefined) {
113+
throw new Error(`file not found: ${uri}`)
114+
}
115+
return Promise.resolve(new TextEncoder().encode(contents))
116+
},
117+
}
118+
}
119+
84120
/** @internal */
85121
async function ready({ extensionHost }: TestContext): Promise<void> {
86122
await extensionHost.internal.sync()

src/integration-test/roots.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as assert from 'assert'
2+
import { WorkspaceRoot } from 'sourcegraph'
3+
import { URI } from '../shared/uri'
4+
import { collectSubscribableValues, integrationTestContext } from './helpers.test'
5+
6+
describe('Workspace roots (integration)', () => {
7+
describe('workspace.roots', () => {
8+
it('lists roots', async () => {
9+
const { extensionHost, ready } = await integrationTestContext()
10+
11+
await ready
12+
assert.deepStrictEqual(extensionHost.workspace.roots, [{ uri: URI.parse('file:///') }] as WorkspaceRoot[])
13+
})
14+
15+
it('adds new text documents', async () => {
16+
const { clientController, extensionHost, getEnvironment, ready } = await integrationTestContext()
17+
18+
const prevEnvironment = getEnvironment()
19+
clientController.setEnvironment({
20+
...prevEnvironment,
21+
roots: [{ uri: 'file:///a' }, { uri: 'file:///b' }],
22+
})
23+
24+
await ready
25+
assert.deepStrictEqual(extensionHost.workspace.roots, [
26+
{ uri: URI.parse('file:///a') },
27+
{ uri: URI.parse('file:///b') },
28+
] as WorkspaceRoot[])
29+
})
30+
})
31+
32+
describe('workspace.onDidChangeRoots', () => {
33+
it('fires when a root is added or removed', async () => {
34+
const { clientController, extensionHost, getEnvironment, ready } = await integrationTestContext()
35+
36+
await ready
37+
const values = collectSubscribableValues(extensionHost.workspace.onDidChangeRoots)
38+
assert.deepStrictEqual(values, [] as void[])
39+
40+
const prevEnvironment = getEnvironment()
41+
clientController.setEnvironment({
42+
...prevEnvironment,
43+
roots: [{ uri: 'file:///a' }],
44+
})
45+
await extensionHost.internal.sync()
46+
47+
assert.deepStrictEqual(values, [void 0])
48+
})
49+
})
50+
})

src/protocol/plainTypes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import * as sourcegraph from 'sourcegraph'
22

3+
/** The plain properties of a {@link module:sourcegraph.WorkspaceRoot}, without methods and accessors. */
4+
export interface WorkspaceRoot extends Pick<sourcegraph.WorkspaceRoot, 'repository'> {
5+
uri: string
6+
}
7+
38
/** The plain properties of a {@link module:sourcegraph.Position}, without methods and accessors. */
49
export interface Position extends Pick<sourcegraph.Position, 'line' | 'character'> {}
510

0 commit comments

Comments
 (0)