@theia/workspace 1.77.0-next.57 → 1.77.0-next.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/browser/test/with-workspace-service-defaults.d.ts +8 -0
- package/lib/browser/test/with-workspace-service-defaults.d.ts.map +1 -0
- package/lib/browser/test/with-workspace-service-defaults.js +28 -0
- package/lib/browser/test/with-workspace-service-defaults.js.map +1 -0
- package/lib/browser/workspace-service.d.ts +85 -0
- package/lib/browser/workspace-service.d.ts.map +1 -1
- package/lib/browser/workspace-service.js +199 -1
- package/lib/browser/workspace-service.js.map +1 -1
- package/lib/browser/workspace-service.spec.d.ts +2 -0
- package/lib/browser/workspace-service.spec.d.ts.map +1 -0
- package/lib/browser/workspace-service.spec.js +232 -0
- package/lib/browser/workspace-service.spec.js.map +1 -0
- package/lib/browser/workspace-uri-contribution.d.ts +14 -0
- package/lib/browser/workspace-uri-contribution.d.ts.map +1 -1
- package/lib/browser/workspace-uri-contribution.js +43 -1
- package/lib/browser/workspace-uri-contribution.js.map +1 -1
- package/lib/browser/workspace-uri-contribution.spec.js +39 -2
- package/lib/browser/workspace-uri-contribution.spec.js.map +1 -1
- package/package.json +5 -5
- package/src/browser/test/with-workspace-service-defaults.ts +26 -0
- package/src/browser/workspace-service.spec.ts +274 -0
- package/src/browser/workspace-service.ts +215 -1
- package/src/browser/workspace-uri-contribution.spec.ts +45 -3
- package/src/browser/workspace-uri-contribution.ts +43 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 STMicroelectronics and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
|
18
|
+
let disableJSDOM = enableJSDOM();
|
|
19
|
+
|
|
20
|
+
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
|
21
|
+
FrontendApplicationConfigProvider.set({});
|
|
22
|
+
|
|
23
|
+
import { expect } from 'chai';
|
|
24
|
+
import URI from '@theia/core/lib/common/uri';
|
|
25
|
+
import { OS } from '@theia/core/lib/common/os';
|
|
26
|
+
import { FileStat } from '@theia/filesystem/lib/common/files';
|
|
27
|
+
import { WorkspaceService } from './workspace-service';
|
|
28
|
+
|
|
29
|
+
disableJSDOM();
|
|
30
|
+
|
|
31
|
+
describe('WorkspaceService root names', () => {
|
|
32
|
+
|
|
33
|
+
let originalIsWindows: boolean;
|
|
34
|
+
before(() => {
|
|
35
|
+
disableJSDOM = enableJSDOM();
|
|
36
|
+
});
|
|
37
|
+
after(() => {
|
|
38
|
+
disableJSDOM();
|
|
39
|
+
});
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
// Paths outside the workspace are rendered with `Path.fsPath()`, which branches on the backend OS.
|
|
42
|
+
originalIsWindows = OS.backend.isWindows;
|
|
43
|
+
OS.backend.isWindows = false;
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
OS.backend.isWindows = originalIsWindows;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const workspaceWith = (...roots: string[]): WorkspaceService => Object.create(WorkspaceService.prototype, {
|
|
50
|
+
tryGetRoots: { value: () => roots.map(root => ({ resource: new URI(root), isDirectory: true })) }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const namesOf = (service: WorkspaceService): Record<string, string> =>
|
|
54
|
+
Object.fromEntries(Array.from(service.getRootNames(), ([name, uri]) => [name, uri.toString()]));
|
|
55
|
+
|
|
56
|
+
describe('getRootNames', () => {
|
|
57
|
+
|
|
58
|
+
it('names each root after its basename', () => {
|
|
59
|
+
expect(namesOf(workspaceWith('file:///home/user/frontend', 'file:///home/user/backend'))).to.deep.equal({
|
|
60
|
+
frontend: 'file:///home/user/frontend',
|
|
61
|
+
backend: 'file:///home/user/backend'
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('names all roots sharing a basename by the shortest tail that tells them apart', () => {
|
|
66
|
+
expect(namesOf(workspaceWith('file:///bob/app', 'file:///alice/app', 'file:///home/other'))).to.deep.equal({
|
|
67
|
+
'bob/app': 'file:///bob/app',
|
|
68
|
+
'alice/app': 'file:///alice/app',
|
|
69
|
+
other: 'file:///home/other'
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('uses as many parent segments as needed', () => {
|
|
74
|
+
expect(namesOf(workspaceWith('file:///a/src/app', 'file:///b/src/app', 'file:///b/lib/app'))).to.deep.equal({
|
|
75
|
+
'a/src/app': 'file:///a/src/app',
|
|
76
|
+
'b/src/app': 'file:///b/src/app',
|
|
77
|
+
'lib/app': 'file:///b/lib/app'
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('names a root with a shorter path than its peer by its whole path', () => {
|
|
82
|
+
expect(namesOf(workspaceWith('file:///app', 'file:///x/app'))).to.deep.equal({
|
|
83
|
+
app: 'file:///app',
|
|
84
|
+
'x/app': 'file:///x/app'
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('qualifies a name that another root name starts with', () => {
|
|
89
|
+
expect(namesOf(workspaceWith('file:///work/alice', 'file:///home/alice/app', 'file:///home/bob/app'))).to.deep.equal({
|
|
90
|
+
'work/alice': 'file:///work/alice',
|
|
91
|
+
'alice/app': 'file:///home/alice/app',
|
|
92
|
+
'bob/app': 'file:///home/bob/app'
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('qualifies the longer name if the shorter one is a whole path', () => {
|
|
97
|
+
expect(namesOf(workspaceWith('file:///alice', 'file:///home/alice/app', 'file:///home/bob/app'))).to.deep.equal({
|
|
98
|
+
alice: 'file:///alice',
|
|
99
|
+
'home/alice/app': 'file:///home/alice/app',
|
|
100
|
+
'bob/app': 'file:///home/bob/app'
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not qualify a name that the name of a root nested in it starts with', () => {
|
|
105
|
+
expect(namesOf(workspaceWith('file:///ws/x', 'file:///ws/x/app', 'file:///other/app'))).to.deep.equal({
|
|
106
|
+
x: 'file:///ws/x',
|
|
107
|
+
'x/app': 'file:///ws/x/app',
|
|
108
|
+
'other/app': 'file:///other/app'
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not depend on the order of the roots', () => {
|
|
113
|
+
expect(namesOf(workspaceWith('file:///alice/app', 'file:///bob/app')))
|
|
114
|
+
.to.deep.equal(namesOf(workspaceWith('file:///bob/app', 'file:///alice/app')));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('names a root listed twice once, after its basename', () => {
|
|
118
|
+
expect(namesOf(workspaceWith('file:///home/user/app', 'file:///home/user/app'))).to.deep.equal({
|
|
119
|
+
app: 'file:///home/user/app'
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns the same names until the roots change', async () => {
|
|
124
|
+
let roots = [FileStat.dir('file:///alice/app')];
|
|
125
|
+
const service = new WorkspaceService();
|
|
126
|
+
service['computeRoots'] = async () => roots;
|
|
127
|
+
await service['updateRoots']();
|
|
128
|
+
const names = service.getRootNames();
|
|
129
|
+
expect(service.getRootNames()).to.equal(names);
|
|
130
|
+
roots = [...roots, FileStat.dir('file:///bob/app')];
|
|
131
|
+
await service['updateRoots']();
|
|
132
|
+
expect(Array.from(service.getRootNames().keys())).to.deep.equal(['alice/app', 'bob/app']);
|
|
133
|
+
expect(service.getRootName(new URI('file:///bob/app'))).to.equal('bob/app');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('falls back to the URI for roots with the same path', () => {
|
|
137
|
+
expect(namesOf(workspaceWith('file:///x/app', 'memfs:/x/app'))).to.deep.equal({
|
|
138
|
+
'file:///x/app': 'file:///x/app',
|
|
139
|
+
'memfs:/x/app': 'memfs:/x/app'
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('getRootPrefixedPath', () => {
|
|
145
|
+
|
|
146
|
+
it('prefixes the path with the name of the root', () => {
|
|
147
|
+
const service = workspaceWith('file:///alice/app', 'file:///bob/app');
|
|
148
|
+
expect(service.getRootPrefixedPath(new URI('file:///bob/app/src/index.ts'))).to.equal('bob/app/src/index.ts');
|
|
149
|
+
expect(service.getRootPrefixedPath(new URI('file:///bob/app'))).to.equal('bob/app');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('uses the deepest root for nested roots', () => {
|
|
153
|
+
const service = workspaceWith('file:///ws/theia', 'file:///ws/theia/packages/core');
|
|
154
|
+
expect(service.getRootPrefixedPath(new URI('file:///ws/theia/packages/core/src/a.ts'))).to.equal('core/src/a.ts');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('returns the absolute path for files outside the workspace', () => {
|
|
158
|
+
const service = workspaceWith('file:///ws/theia');
|
|
159
|
+
expect(service.getRootPrefixedPath(new URI('file:///tmp/a.ts'))).to.equal('/tmp/a.ts');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('resolveSupraRelativePath', () => {
|
|
164
|
+
|
|
165
|
+
const resolve = (service: WorkspaceService, path: string): string[] => service.resolveSupraRelativePath(path).map(uri => uri.toString());
|
|
166
|
+
|
|
167
|
+
it('resolves a path that starts with the basename of a root', () => {
|
|
168
|
+
const service = workspaceWith('file:///home/user/frontend', 'file:///home/user/backend');
|
|
169
|
+
expect(resolve(service, 'backend/src/index.ts')).to.deep.equal(['file:///home/user/backend/src/index.ts']);
|
|
170
|
+
expect(resolve(service, 'backend')).to.deep.equal(['file:///home/user/backend']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('resolves a path prefixed with more of the root path than its name', () => {
|
|
174
|
+
const service = workspaceWith('file:///alice/app');
|
|
175
|
+
expect(resolve(service, 'alice/app/src/index.ts')).to.deep.equal(['file:///alice/app/src/index.ts']);
|
|
176
|
+
expect(resolve(service, 'bob/app/src/index.ts')).to.deep.equal([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('resolves a basename shared by several roots in each of them', () => {
|
|
180
|
+
const service = workspaceWith('file:///alice/app', 'file:///bob/app');
|
|
181
|
+
expect(resolve(service, 'app/src/index.ts')).to.deep.equal(['file:///alice/app/src/index.ts', 'file:///bob/app/src/index.ts']);
|
|
182
|
+
expect(resolve(service, 'bob/app/src/index.ts')).to.deep.equal(['file:///bob/app/src/index.ts']);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('does not resolve absolute paths, paths leaving the root or paths without a root basename', () => {
|
|
186
|
+
const service = workspaceWith('file:///home/user/backend');
|
|
187
|
+
expect(resolve(service, '/home/user/backend/a.ts')).to.deep.equal([]);
|
|
188
|
+
expect(resolve(service, 'C:\\backend\\a.ts')).to.deep.equal([]);
|
|
189
|
+
expect(resolve(service, '../backend/a.ts')).to.deep.equal([]);
|
|
190
|
+
expect(resolve(service, 'src/index.ts')).to.deep.equal([]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('splitRootPrefixedPath', () => {
|
|
195
|
+
|
|
196
|
+
const split = (service: WorkspaceService, path: string): { rootName: string; rootUri: string; rest: string } | undefined => {
|
|
197
|
+
const result = service.splitRootPrefixedPath(path);
|
|
198
|
+
return result && { ...result, rootUri: result.rootUri.toString() };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
it('splits off the longest matching root name and keeps the rest as it is', () => {
|
|
202
|
+
const service = workspaceWith('file:///ws/x', 'file:///ws/x/app', 'file:///other/app');
|
|
203
|
+
expect(split(service, 'x/app/src/**/\\*.ts')).to.deep.equal({ rootName: 'x/app', rootUri: 'file:///ws/x/app', rest: 'src/**/\\*.ts' });
|
|
204
|
+
expect(split(service, 'x/lib/{a,b}.ts')).to.deep.equal({ rootName: 'x', rootUri: 'file:///ws/x', rest: 'lib/{a,b}.ts' });
|
|
205
|
+
expect(split(service, 'other/app')).to.deep.equal({ rootName: 'other/app', rootUri: 'file:///other/app', rest: '' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does not split a path without a root name', () => {
|
|
209
|
+
const service = workspaceWith('file:///alice/app', 'file:///bob/app');
|
|
210
|
+
expect(split(service, 'app/src/index.ts')).to.be.undefined;
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('resolveRootPrefixedPath', () => {
|
|
215
|
+
|
|
216
|
+
const resolve = (service: WorkspaceService, path: string): string | undefined => service.resolveRootPrefixedPath(path)?.toString();
|
|
217
|
+
|
|
218
|
+
it('resolves a path relative to the named root', () => {
|
|
219
|
+
const service = workspaceWith('file:///home/user/frontend', 'file:///home/user/backend');
|
|
220
|
+
expect(resolve(service, 'backend/src/index.ts')).to.equal('file:///home/user/backend/src/index.ts');
|
|
221
|
+
expect(resolve(service, 'backend')).to.equal('file:///home/user/backend');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('resolves parent-qualified root names', () => {
|
|
225
|
+
const service = workspaceWith('file:///alice/app', 'file:///bob/app');
|
|
226
|
+
expect(resolve(service, 'bob/app/src/index.ts')).to.equal('file:///bob/app/src/index.ts');
|
|
227
|
+
expect(resolve(service, 'alice/app')).to.equal('file:///alice/app');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('does not resolve a basename shared by several roots', () => {
|
|
231
|
+
const service = workspaceWith('file:///alice/app', 'file:///bob/app');
|
|
232
|
+
expect(resolve(service, 'app/src/index.ts')).to.be.undefined;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('prefers the longest matching root name', () => {
|
|
236
|
+
const service = workspaceWith('file:///ws/x', 'file:///ws/x/app', 'file:///other/app');
|
|
237
|
+
expect(resolve(service, 'x/app/src/index.ts')).to.equal('file:///ws/x/app/src/index.ts');
|
|
238
|
+
expect(resolve(service, 'x/lib/index.ts')).to.equal('file:///ws/x/lib/index.ts');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('accepts backslashes and redundant segments', () => {
|
|
242
|
+
const service = workspaceWith('file:///home/user/backend');
|
|
243
|
+
expect(resolve(service, 'backend\\src\\index.ts')).to.equal('file:///home/user/backend/src/index.ts');
|
|
244
|
+
expect(resolve(service, './backend/src/../lib/a.ts')).to.equal('file:///home/user/backend/lib/a.ts');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('does not resolve absolute paths, URIs or paths leaving the root', () => {
|
|
248
|
+
const service = workspaceWith('file:///home/user/backend');
|
|
249
|
+
expect(resolve(service, '/home/user/backend/a.ts')).to.be.undefined;
|
|
250
|
+
expect(resolve(service, 'C:\\backend\\a.ts')).to.be.undefined;
|
|
251
|
+
expect(resolve(service, 'file:///home/user/backend/a.ts')).to.be.undefined;
|
|
252
|
+
expect(resolve(service, 'backend/../../etc/passwd')).to.be.undefined;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('does not resolve paths without a root name', () => {
|
|
256
|
+
const service = workspaceWith('file:///home/user/backend');
|
|
257
|
+
expect(resolve(service, 'src/index.ts')).to.be.undefined;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('is the inverse of getRootPrefixedPath', () => {
|
|
261
|
+
const service = workspaceWith('file:///alice/app', 'file:///bob/app', 'file:///ws/theia', 'file:///ws/theia/packages/core');
|
|
262
|
+
for (const file of ['file:///alice/app/a.ts', 'file:///bob/app/a.ts', 'file:///ws/theia/b.ts', 'file:///ws/theia/packages/core/c.ts']) {
|
|
263
|
+
expect(resolve(service, service.getRootPrefixedPath(new URI(file)))).to.equal(file);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('is the inverse of getRootPrefixedPath for a root with a folder named like another root', () => {
|
|
268
|
+
const service = workspaceWith('file:///work/alice', 'file:///home/alice/app', 'file:///home/bob/app');
|
|
269
|
+
for (const file of ['file:///work/alice/app/x.ts', 'file:///home/alice/app/x.ts']) {
|
|
270
|
+
expect(resolve(service, service.getRootPrefixedPath(new URI(file)))).to.equal(file);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { injectable, inject, postConstruct, named } from '@theia/core/shared/inversify';
|
|
18
18
|
import URI from '@theia/core/lib/common/uri';
|
|
19
|
+
import { Path } from '@theia/core/lib/common/path';
|
|
19
20
|
import { WorkspaceServer, UntitledWorkspaceService, WorkspaceFileService } from '../common';
|
|
20
21
|
import { WindowService } from '@theia/core/lib/browser/window/window-service';
|
|
21
22
|
import { DEFAULT_WINDOW_HASH } from '@theia/core/lib/common/window';
|
|
@@ -312,6 +313,7 @@ export class WorkspaceService implements FrontendApplicationContribution, Worksp
|
|
|
312
313
|
}
|
|
313
314
|
if (rootsChanged) {
|
|
314
315
|
this._roots = newRoots;
|
|
316
|
+
this.rootNamesCache = undefined;
|
|
315
317
|
this.deferredRoots.resolve(this._roots); // in order to resolve first
|
|
316
318
|
this.deferredRoots = new Deferred<FileStat[]>();
|
|
317
319
|
this.deferredRoots.resolve(this._roots);
|
|
@@ -596,6 +598,7 @@ export class WorkspaceService implements FrontendApplicationContribution, Worksp
|
|
|
596
598
|
this.windowService.setSafeToShutDown();
|
|
597
599
|
this._workspace = undefined;
|
|
598
600
|
this._roots.length = 0;
|
|
601
|
+
this.rootNamesCache = undefined;
|
|
599
602
|
|
|
600
603
|
if (!this.isRemoteSession()) {
|
|
601
604
|
await this.server.setMostRecentlyUsedWorkspace('');
|
|
@@ -833,7 +836,7 @@ export class WorkspaceService implements FrontendApplicationContribution, Worksp
|
|
|
833
836
|
if (!rootUri) {
|
|
834
837
|
return uri.path.fsPath();
|
|
835
838
|
}
|
|
836
|
-
const rootName = rootUri.path.base;
|
|
839
|
+
const rootName = this.getRootName(rootUri) ?? rootUri.path.base;
|
|
837
840
|
const relative = rootUri.relative(uri);
|
|
838
841
|
if (!relative || relative.toString() === '') {
|
|
839
842
|
return rootName;
|
|
@@ -841,6 +844,217 @@ export class WorkspaceService implements FrontendApplicationContribution, Worksp
|
|
|
841
844
|
return `${rootName}/${relative.toString()}`;
|
|
842
845
|
}
|
|
843
846
|
|
|
847
|
+
/**
|
|
848
|
+
* Returns a unique name for each workspace root, in workspace order.
|
|
849
|
+
*
|
|
850
|
+
* A root is named after its directory basename, e.g. `backend`. Roots that share a basename are
|
|
851
|
+
* treated as peers: each of them is named after the shortest tail of its path that tells them
|
|
852
|
+
* apart, e.g. `alice/app` and `bob/app`. A name that another name starts with, e.g. `alice` next to
|
|
853
|
+
* `alice/app`, is qualified in the same way, e.g. `work/alice`, so that every path resolves to the root
|
|
854
|
+
* it came from. The names are thus always the ending of the real path, so they match what appears in
|
|
855
|
+
* terminal output, compiler errors and the like.
|
|
856
|
+
*
|
|
857
|
+
* These are the root names used by {@link getRootPrefixedPath} and resolved by {@link resolveRootPrefixedPath}.
|
|
858
|
+
*/
|
|
859
|
+
getRootNames(): ReadonlyMap<string, URI> {
|
|
860
|
+
return this.getRootNamesCache().names;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* The root names, and the names by root URI, which depend on each other. Reset whenever the roots change.
|
|
865
|
+
*/
|
|
866
|
+
protected rootNamesCache: { names: ReadonlyMap<string, URI>; namesByRoot: ReadonlyMap<string, string> } | undefined;
|
|
867
|
+
|
|
868
|
+
protected getRootNamesCache(): { names: ReadonlyMap<string, URI>; namesByRoot: ReadonlyMap<string, string> } {
|
|
869
|
+
if (!this.rootNamesCache) {
|
|
870
|
+
const names = this.computeRootNames(this.tryGetRoots());
|
|
871
|
+
const namesByRoot = new Map(Array.from(names, ([name, uri]) => [uri.toString(), name]));
|
|
872
|
+
this.rootNamesCache = { names, namesByRoot };
|
|
873
|
+
}
|
|
874
|
+
return this.rootNamesCache;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
protected computeRootNames(workspaceRoots: FileStat[]): Map<string, URI> {
|
|
878
|
+
const roots: URI[] = [];
|
|
879
|
+
for (const root of workspaceRoots) {
|
|
880
|
+
if (!roots.some(other => other.isEqual(root.resource))) {
|
|
881
|
+
roots.push(root.resource);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const byBasename = new Map<string, URI[]>();
|
|
885
|
+
for (const root of roots) {
|
|
886
|
+
const peers = byBasename.get(root.path.base) ?? [];
|
|
887
|
+
peers.push(root);
|
|
888
|
+
byBasename.set(root.path.base, peers);
|
|
889
|
+
}
|
|
890
|
+
const names = new Map<string, URI>();
|
|
891
|
+
for (const root of roots) {
|
|
892
|
+
names.set(this.computeRootName(root, byBasename.get(root.path.base)!), root);
|
|
893
|
+
}
|
|
894
|
+
return this.qualifyRootNames(names);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Returns the name of the given workspace root, as assigned by {@link getRootNames}, or
|
|
899
|
+
* `undefined` if the URI is not a workspace root.
|
|
900
|
+
*/
|
|
901
|
+
getRootName(rootUri: URI): string | undefined {
|
|
902
|
+
return this.getRootNamesCache().namesByRoot.get(rootUri.toString());
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Resolves a path in the format of {@link getRootPrefixedPath}, i.e. `<rootName>/<relativePath>`
|
|
907
|
+
* or a bare `<rootName>`, to a URI. If more than one root name is a prefix of the path, which is
|
|
908
|
+
* the case when a root named `a` has a subfolder `b` and another root is named `a/b`, the longest
|
|
909
|
+
* one wins.
|
|
910
|
+
*
|
|
911
|
+
* @returns `undefined` if the path is absolute, does not start with a root name or leaves its root.
|
|
912
|
+
*/
|
|
913
|
+
resolveRootPrefixedPath(rootPrefixedPath: string): URI | undefined {
|
|
914
|
+
const path = new Path(Path.normalizePathSeparator(rootPrefixedPath)).normalize();
|
|
915
|
+
if (path.isAbsolute || Path.isDrive(path.toString().split('/')[0])) {
|
|
916
|
+
return undefined;
|
|
917
|
+
}
|
|
918
|
+
const segments = path.toString().split('/').filter(segment => segment.length > 0);
|
|
919
|
+
if (segments.includes('..')) {
|
|
920
|
+
return undefined;
|
|
921
|
+
}
|
|
922
|
+
const match = this.splitRootPrefixedPath(segments.join('/'));
|
|
923
|
+
if (!match) {
|
|
924
|
+
return undefined;
|
|
925
|
+
}
|
|
926
|
+
return match.rest ? match.rootUri.resolve(match.rest) : match.rootUri;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Resolves a path that contains the basename of a workspace root, preceded by nothing or by the
|
|
931
|
+
* trailing segments of the root's parent path, e.g. `bob/app/src` for the root `/home/bob/app`.
|
|
932
|
+
* Unlike {@link resolveRootPrefixedPath}, this does not depend on the current root names, so it
|
|
933
|
+
* also resolves paths that were prefixed with a root name before the workspace roots changed.
|
|
934
|
+
*
|
|
935
|
+
* @returns the distinct candidates in workspace order, which are several if the path is ambiguous,
|
|
936
|
+
* or none if the path is absolute, leaves its root or does not contain the basename of a root.
|
|
937
|
+
*/
|
|
938
|
+
resolveSupraRelativePath(supraRelativePath: string): URI[] {
|
|
939
|
+
const path = new Path(Path.normalizePathSeparator(supraRelativePath)).normalize();
|
|
940
|
+
const segments = path.toString().split('/').filter(segment => segment.length > 0);
|
|
941
|
+
if (path.isAbsolute || Path.isDrive(segments[0] ?? '') || segments.includes('..')) {
|
|
942
|
+
return [];
|
|
943
|
+
}
|
|
944
|
+
const candidates: URI[] = [];
|
|
945
|
+
for (const root of this.tryGetRoots()) {
|
|
946
|
+
const candidate = this.resolveSupraRelativeSegments(root.resource, segments);
|
|
947
|
+
if (candidate && !candidates.some(other => other.isEqual(candidate))) {
|
|
948
|
+
candidates.push(candidate);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return candidates;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Resolves `segments` against the given root as described in {@link resolveSupraRelativePath}.
|
|
956
|
+
*/
|
|
957
|
+
protected resolveSupraRelativeSegments(rootUri: URI, segments: string[]): URI | undefined {
|
|
958
|
+
const rootBasename = rootUri.path.base;
|
|
959
|
+
const rootPathSegments = rootUri.path.toString().split('/').filter(segment => segment.length > 0);
|
|
960
|
+
const rootPrecedingSegments = rootPathSegments.slice(0, rootPathSegments.length - 1);
|
|
961
|
+
for (let i = 0; i < segments.length; i++) {
|
|
962
|
+
if (segments[i] !== rootBasename) {
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
const pathPrecedingSegments = segments.slice(0, i);
|
|
966
|
+
if (pathPrecedingSegments.length > rootPrecedingSegments.length) {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const rootTail = rootPrecedingSegments.slice(rootPrecedingSegments.length - pathPrecedingSegments.length);
|
|
970
|
+
if (pathPrecedingSegments.every((segment, j) => segment === rootTail[j])) {
|
|
971
|
+
const rest = segments.slice(i + 1).join('/');
|
|
972
|
+
return rest ? rootUri.resolve(rest) : rootUri;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return undefined;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Splits a `/`-separated path that starts with a root name, as in {@link getRootPrefixedPath}, into
|
|
980
|
+
* that root and the rest of the path, which is empty for a bare root name. The rest is not normalized,
|
|
981
|
+
* so it may as well be a glob. If more than one root name is a prefix of the path, the longest one wins.
|
|
982
|
+
*
|
|
983
|
+
* @returns `undefined` if the path does not start with a root name.
|
|
984
|
+
*/
|
|
985
|
+
splitRootPrefixedPath(rootPrefixedPath: string): { rootName: string; rootUri: URI; rest: string } | undefined {
|
|
986
|
+
const segments = rootPrefixedPath.split('/');
|
|
987
|
+
let match: { rootName: string; rootUri: URI; length: number } | undefined;
|
|
988
|
+
for (const [rootName, rootUri] of this.getRootNames()) {
|
|
989
|
+
const nameSegments = rootName.split('/');
|
|
990
|
+
const matches = nameSegments.length <= segments.length && nameSegments.every((segment, i) => segment === segments[i]);
|
|
991
|
+
if (matches && (!match || nameSegments.length > match.length)) {
|
|
992
|
+
match = { rootName, rootUri, length: nameSegments.length };
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return match && { rootName: match.rootName, rootUri: match.rootUri, rest: segments.slice(match.length).join('/') };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Computes the name of a root from the roots that share its basename (including itself).
|
|
1000
|
+
*/
|
|
1001
|
+
protected computeRootName(root: URI, peers: URI[]): string {
|
|
1002
|
+
if (peers.length === 1) {
|
|
1003
|
+
return root.path.base || root.path.toString();
|
|
1004
|
+
}
|
|
1005
|
+
const segmentsOf = (uri: URI): string[] => uri.path.toString().split('/').filter(segment => segment.length > 0);
|
|
1006
|
+
const rootSegments = segmentsOf(root);
|
|
1007
|
+
const peerSegments = peers.filter(peer => !peer.isEqual(root)).map(segmentsOf);
|
|
1008
|
+
// A root with a shorter path than a peer, e.g. `/app` next to `/x/app`, is named by its whole path, `app`.
|
|
1009
|
+
const maxLength = Math.max(rootSegments.length, ...peerSegments.map(segments => segments.length));
|
|
1010
|
+
for (let length = 2; length <= maxLength; length++) {
|
|
1011
|
+
const tail = rootSegments.slice(-length).join('/');
|
|
1012
|
+
if (peerSegments.every(segments => segments.slice(-length).join('/') !== tail)) {
|
|
1013
|
+
return tail;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// The paths are the same, e.g. in different URI schemes, so only the full URI tells them apart.
|
|
1017
|
+
return root.toString();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Qualifies a root name that another root name starts with, e.g. `alice` next to `alice/app`, by more
|
|
1022
|
+
* segments of its path, as a path in the one root would otherwise resolve to the other. If the root
|
|
1023
|
+
* named `alice/app` is the `app` folder of the root named `alice`, the names are left as they are:
|
|
1024
|
+
* the nested root is then also the one that {@link getRootPrefixedPath} names its files after.
|
|
1025
|
+
*/
|
|
1026
|
+
protected qualifyRootNames(names: Map<string, URI>): Map<string, URI> {
|
|
1027
|
+
const entries = Array.from(names, ([name, root]) => ({ name, root }));
|
|
1028
|
+
let qualified = true;
|
|
1029
|
+
while (qualified) {
|
|
1030
|
+
qualified = false;
|
|
1031
|
+
for (const shorter of entries) {
|
|
1032
|
+
for (const longer of entries) {
|
|
1033
|
+
if (longer.name.startsWith(shorter.name + '/')
|
|
1034
|
+
&& !shorter.root.resolve(longer.name.substring(shorter.name.length + 1)).isEqual(longer.root)) {
|
|
1035
|
+
qualified = this.qualifyRootName(shorter) || this.qualifyRootName(longer) || qualified;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return new Map(entries.map(({ name, root }) => [name, root]));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Adds the next parent segment to the name of a root, unless the name is its whole path or its URI already.
|
|
1045
|
+
*
|
|
1046
|
+
* @returns whether the name was qualified
|
|
1047
|
+
*/
|
|
1048
|
+
protected qualifyRootName(entry: { name: string; root: URI }): boolean {
|
|
1049
|
+
const segments = entry.root.path.toString().split('/').filter(segment => segment.length > 0);
|
|
1050
|
+
const length = entry.name.split('/').length;
|
|
1051
|
+
if (entry.name === entry.root.toString() || length >= segments.length) {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
entry.name = segments.slice(-(length + 1)).join('/');
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
844
1058
|
areWorkspaceRoots(uris: URI[]): boolean {
|
|
845
1059
|
if (!uris.length) {
|
|
846
1060
|
return false;
|
|
@@ -22,7 +22,7 @@ import * as sinon from 'sinon';
|
|
|
22
22
|
import { Container } from '@theia/core/shared/inversify';
|
|
23
23
|
import { Event } from '@theia/core/lib/common/event';
|
|
24
24
|
import { ApplicationShell, WidgetManager } from '@theia/core/lib/browser';
|
|
25
|
-
import { DefaultUriLabelProviderContribution } from '@theia/core/lib/browser/label-provider';
|
|
25
|
+
import { DefaultUriLabelProviderContribution, DidChangeLabelEvent } from '@theia/core/lib/browser/label-provider';
|
|
26
26
|
import { WorkspaceUriLabelProviderContribution } from './workspace-uri-contribution';
|
|
27
27
|
import URI from '@theia/core/lib/common/uri';
|
|
28
28
|
import { WorkspaceVariableContribution } from './workspace-variable-contribution';
|
|
@@ -40,6 +40,12 @@ after(() => disableJSDOM());
|
|
|
40
40
|
let container: Container;
|
|
41
41
|
let labelProvider: WorkspaceUriLabelProviderContribution;
|
|
42
42
|
let roots: FileStat[];
|
|
43
|
+
let workspaceService: WorkspaceService;
|
|
44
|
+
/** Changes the roots, as `WorkspaceService.updateRoots` does, which resets their names. */
|
|
45
|
+
const setRoots = (newRoots: FileStat[]): void => {
|
|
46
|
+
roots = newRoots;
|
|
47
|
+
workspaceService['rootNamesCache'] = undefined;
|
|
48
|
+
};
|
|
43
49
|
beforeEach(() => {
|
|
44
50
|
roots = [FileStat.dir('file:///workspace')];
|
|
45
51
|
|
|
@@ -53,7 +59,7 @@ beforeEach(() => {
|
|
|
53
59
|
onDidCreateWidget: Event.None
|
|
54
60
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
61
|
} as any);
|
|
56
|
-
|
|
62
|
+
workspaceService = new WorkspaceService();
|
|
57
63
|
workspaceService.tryGetRoots = () => roots;
|
|
58
64
|
container.bind(WorkspaceService).toConstantValue(workspaceService);
|
|
59
65
|
container.bind(WorkspaceVariableContribution).toSelf().inSingletonScope();
|
|
@@ -138,6 +144,42 @@ describe('WorkspaceUriLabelProviderContribution class', () => {
|
|
|
138
144
|
const name = labelProvider.getName(file);
|
|
139
145
|
expect(name).eq('jacques.doc');
|
|
140
146
|
});
|
|
147
|
+
|
|
148
|
+
it('should return the folder name of a workspace root', () => {
|
|
149
|
+
setRoots([FileStat.dir('file:///home/alice/app'), FileStat.dir('file:///home/bob/lib')]);
|
|
150
|
+
expect(labelProvider.getName(FileStat.dir('file:///home/alice/app'))).eq('app');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should return the unique names of workspace roots sharing a folder name', () => {
|
|
154
|
+
setRoots([FileStat.dir('file:///home/alice/app'), FileStat.dir('file:///home/bob/app')]);
|
|
155
|
+
expect(labelProvider.getName(FileStat.dir('file:///home/alice/app'))).eq('alice/app');
|
|
156
|
+
expect(labelProvider.getName(new URI('file:///home/bob/app'))).eq('bob/app');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should return the folder name of a folder that shares its name with a workspace root', () => {
|
|
160
|
+
setRoots([FileStat.dir('file:///home/alice/app'), FileStat.dir('file:///home/bob/app')]);
|
|
161
|
+
expect(labelProvider.getName(FileStat.dir('file:///home/alice/app/app'))).eq('app');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should notify a change of the name of a workspace root when a root sharing its folder name is added', () => {
|
|
165
|
+
setRoots([FileStat.dir('file:///home/alice/app'), FileStat.dir('file:///home/other')]);
|
|
166
|
+
workspaceService['onWorkspaceChangeEmitter'].fire(roots);
|
|
167
|
+
const events: DidChangeLabelEvent[] = [];
|
|
168
|
+
labelProvider.onDidChange(event => events.push(event));
|
|
169
|
+
setRoots([...roots, FileStat.dir('file:///home/bob/app')]);
|
|
170
|
+
workspaceService['onWorkspaceChangeEmitter'].fire(roots);
|
|
171
|
+
expect(events.some(event => event.affects(new URI('file:///home/alice/app')))).to.be.true;
|
|
172
|
+
expect(events.some(event => event.affects(FileStat.dir('file:///home/alice/app')))).to.be.true;
|
|
173
|
+
expect(events.some(event => event.affects(new URI('file:///home/other')))).to.be.false;
|
|
174
|
+
expect(labelProvider.getName(new URI('file:///home/alice/app'))).eq('alice/app');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('getDetails()', () => {
|
|
179
|
+
it('should prefix the path of a file with the unique name of its workspace root', () => {
|
|
180
|
+
setRoots([FileStat.dir('file:///home/alice/app'), FileStat.dir('file:///home/bob/app')]);
|
|
181
|
+
expect(labelProvider.getDetails(new URI('file:///home/bob/app/src/index.ts'))).eq('bob/app • src');
|
|
182
|
+
});
|
|
141
183
|
});
|
|
142
184
|
|
|
143
185
|
describe('getLongName()', () => {
|
|
@@ -176,7 +218,7 @@ describe('WorkspaceUriLabelProviderContribution class', () => {
|
|
|
176
218
|
});
|
|
177
219
|
|
|
178
220
|
it('should return the path of a file if WorkspaceService returns no roots', () => {
|
|
179
|
-
|
|
221
|
+
setRoots([]);
|
|
180
222
|
const file = new URI('file:///tmp/prout.txt');
|
|
181
223
|
const longName = labelProvider.getLongName(file);
|
|
182
224
|
|