deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,31 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - WORKSPACE EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/workspace` extension provides functionality and services to handle workspaces (projects) within the application.
## Additional Information
- [API documentation for `@theia/workspace`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_workspace.html)
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,56 @@
{
"name": "@theia/workspace",
"version": "1.68.0",
"description": "Theia - Workspace Extension",
"dependencies": {
"@theia/core": "1.68.0",
"@theia/filesystem": "1.68.0",
"@theia/variable-resolver": "1.68.0",
"jsonc-parser": "^2.2.0",
"tslib": "^2.6.2",
"valid-filename": "^2.0.1"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontend": "lib/browser/workspace-frontend-module",
"backend": "lib/node/workspace-backend-module"
},
{
"frontendOnly": "lib/browser-only/workspace-frontend-only-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,69 @@
// *****************************************************************************
// Copyright (C) 2023 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceServer } from '../common/workspace-protocol';
import { ILogger, isStringArray } from '@theia/core';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
export const RECENT_WORKSPACES_LOCAL_STORAGE_KEY = 'workspaces';
@injectable()
export class BrowserOnlyWorkspaceServer implements WorkspaceServer {
@inject(ILogger)
protected logger: ILogger;
@inject(FileService)
protected readonly fileService: FileService;
async getRecentWorkspaces(): Promise<string[]> {
const storedWorkspaces = localStorage.getItem(RECENT_WORKSPACES_LOCAL_STORAGE_KEY);
if (!storedWorkspaces) {
return [];
}
try {
const parsedWorkspaces = JSON.parse(storedWorkspaces);
if (isStringArray(parsedWorkspaces)) {
return parsedWorkspaces;
}
} catch (e) {
this.logger.error(e);
return [];
}
return [];
}
async getMostRecentlyUsedWorkspace(): Promise<string | undefined> {
const workspaces = await this.getRecentWorkspaces();
return workspaces[0];
}
async setMostRecentlyUsedWorkspace(uri: string): Promise<void> {
const workspaces = await this.getRecentWorkspaces();
if (workspaces.includes(uri)) {
workspaces.splice(workspaces.indexOf(uri), 1);
}
localStorage.setItem(RECENT_WORKSPACES_LOCAL_STORAGE_KEY, JSON.stringify([uri, ...workspaces]));
}
async removeRecentWorkspace(uri: string): Promise<void> {
const workspaces = await this.getRecentWorkspaces();
if (workspaces.includes(uri)) {
workspaces.splice(workspaces.indexOf(uri), 1);
}
localStorage.setItem(RECENT_WORKSPACES_LOCAL_STORAGE_KEY, JSON.stringify(workspaces));
}
}

View File

@@ -0,0 +1,28 @@
// *****************************************************************************
// Copyright (C) 2023 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { BrowserOnlyWorkspaceServer } from './browser-only-workspace-server';
import { WorkspaceServer } from '../common';
export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => {
bind(BrowserOnlyWorkspaceServer).toSelf().inSingletonScope();
if (isBound(WorkspaceServer)) {
rebind(WorkspaceServer).toService(BrowserOnlyWorkspaceServer);
} else {
bind(WorkspaceServer).toService(BrowserOnlyWorkspaceServer);
}
});

View File

@@ -0,0 +1,57 @@
// *****************************************************************************
// Copyright (C) 2023 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { CancellationToken, URI } from '@theia/core/lib/common';
import { injectable } from '@theia/core/shared/inversify';
import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol';
export interface CanonicalUriProvider extends Disposable {
provideCanonicalUri(uri: URI, targetScheme: string, token: CancellationToken): Promise<URI | undefined>;
}
@injectable()
export class CanonicalUriService {
private providers = new Map<string, CanonicalUriProvider>();
registerCanonicalUriProvider(scheme: string, provider: CanonicalUriProvider): Disposable {
if (this.providers.has(scheme)) {
throw new Error(`Canonical URI provider for scheme: '${scheme}' already exists`);
}
this.providers.set(scheme, provider);
return Disposable.create(() => { this.removeCanonicalUriProvider(scheme); });
}
private removeCanonicalUriProvider(scheme: string): void {
const provider = this.providers.get(scheme);
if (!provider) {
throw new Error(`No Canonical URI provider for scheme: '${scheme}' exists`);
}
this.providers.delete(scheme);
provider.dispose();
}
async provideCanonicalUri(uri: URI, targetScheme: string, token: CancellationToken = CancellationToken.None): Promise<URI | undefined> {
const provider = this.providers.get(uri.scheme);
if (!provider) {
console.warn(`No Canonical URI provider for scheme: '${uri.scheme}' exists`);
return undefined;
}
return provider.provideCanonicalUri(uri, targetScheme, token);
}
}

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { DiffUris } from '@theia/core/lib/browser/diff-uris';
import { open, OpenerService, OpenerOptions } from '@theia/core/lib/browser';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { nls } from '@theia/core';
@injectable()
export class DiffService {
@inject(FileService) protected readonly fileService: FileService;
@inject(OpenerService) protected readonly openerService: OpenerService;
@inject(MessageService) protected readonly messageService: MessageService;
public async openDiffEditor(left: URI, right: URI, label?: string, options?: OpenerOptions): Promise<void> {
if (left.scheme === 'file' && right.scheme === 'file') {
const [resolvedLeft, resolvedRight] = await this.fileService.resolveAll([{ resource: left }, { resource: right }]);
if (resolvedLeft.success && resolvedRight.success) {
const leftStat = resolvedLeft.stat;
const rightStat = resolvedRight.stat;
if (leftStat && rightStat) {
if (!leftStat.isDirectory && !rightStat.isDirectory) {
const uri = DiffUris.encode(left, right, label);
await open(this.openerService, uri, options);
} else {
const details =
leftStat.isDirectory && rightStat.isDirectory ?
nls.localize('theia/workspace/bothAreDirectories', 'Both resources are directories.') :
nls.localize('theia/workspace/isDirectory', "'{0}' is a directory.", leftStat.isDirectory ? left.path.base : right.path.base);
this.messageService.warn(nls.localize('theia/workspace/directoriesCannotBeCompared', 'Directories cannot be compared. {0}', details));
}
}
}
} else {
const uri = DiffUris.encode(left, right, label);
await open(this.openerService, uri, options);
}
}
}

View File

@@ -0,0 +1,23 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './workspace-commands';
export * from './workspace-service';
export * from './canonical-uri-service';
export * from './workspace-frontend-contribution';
export * from './workspace-frontend-module';
export * from './workspace-trust-service';
export * from './metadata-storage';

View File

@@ -0,0 +1,23 @@
/********************************************************************************
* Copyright (C) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
export { WorkspaceMetadataStore, WorkspaceMetadataStoreImpl } from './workspace-metadata-store';
export {
WorkspaceMetadataStorageService,
WorkspaceMetadataStorageServiceImpl,
WorkspaceMetadataIndex,
WorkspaceMetadataStoreFactory
} from './workspace-metadata-storage-service';

View File

@@ -0,0 +1,342 @@
/********************************************************************************
* Copyright (C) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});
import { Container } from '@theia/core/shared/inversify';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { ILogger } from '@theia/core/lib/common/logger';
import { URI } from '@theia/core/lib/common/uri';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { FileContent, FileStat, FileStatWithMetadata } from '@theia/filesystem/lib/common/files';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WorkspaceService } from '../workspace-service';
import { WorkspaceMetadataStorageServiceImpl, WorkspaceMetadataStoreFactory } from './workspace-metadata-storage-service';
import { WorkspaceMetadataStoreImpl } from './workspace-metadata-store';
import * as uuid from '@theia/core/lib/common/uuid';
disableJSDOM();
before(() => disableJSDOM = enableJSDOM());
after(() => disableJSDOM());
describe('WorkspaceMetadataStorageService', () => {
let service: WorkspaceMetadataStorageServiceImpl;
let fileService: sinon.SinonStubbedInstance<FileService>;
let workspaceService: WorkspaceService;
let envVariableServer: sinon.SinonStubbedInstance<EnvVariablesServer>;
let logger: sinon.SinonStubbedInstance<ILogger>;
let container: Container;
let generateUuidStub: sinon.SinonStub;
const configDir = '/home/user/.theia';
const workspaceRootPath = '/home/user/my-workspace';
const workspaceRootUri = new URI(`file://${workspaceRootPath}`);
beforeEach(() => {
// Create container for DI
container = new Container();
// Create mocks
fileService = {
exists: sinon.stub(),
readFile: sinon.stub(),
writeFile: sinon.stub(),
createFolder: sinon.stub(),
delete: sinon.stub(),
} as sinon.SinonStubbedInstance<FileService>;
// Create workspace service with stubs
workspaceService = new WorkspaceService();
sinon.stub(workspaceService, 'tryGetRoots').returns([{
resource: workspaceRootUri,
isDirectory: true
} as FileStat]);
envVariableServer = {
getConfigDirUri: sinon.stub().resolves(`file://${configDir}`)
} as unknown as sinon.SinonStubbedInstance<EnvVariablesServer>;
logger = {
debug: sinon.stub(),
info: sinon.stub(),
warn: sinon.stub(),
error: sinon.stub(),
} as unknown as sinon.SinonStubbedInstance<ILogger>;
// Bind to container
container.bind(FileService).toConstantValue(fileService as unknown as FileService);
container.bind(WorkspaceService).toConstantValue(workspaceService);
container.bind(EnvVariablesServer).toConstantValue(envVariableServer as unknown as EnvVariablesServer);
container.bind(ILogger).toConstantValue(logger as unknown as ILogger).whenTargetNamed('WorkspaceMetadataStorage');
container.bind(WorkspaceMetadataStoreImpl).toSelf();
container.bind(WorkspaceMetadataStoreFactory).toFactory(ctx => () => ctx.container.get(WorkspaceMetadataStoreImpl));
container.bind(WorkspaceMetadataStorageServiceImpl).toSelf();
service = container.get(WorkspaceMetadataStorageServiceImpl);
// Stub UUID generation
generateUuidStub = sinon.stub(uuid, 'generateUuid');
// Default file service behavior
fileService.exists.resolves(false);
fileService.createFolder.resolves({
resource: new URI('file:///dummy'),
isFile: false,
isDirectory: true,
isSymbolicLink: false,
mtime: Date.now(),
ctime: Date.now(),
etag: 'dummy',
size: 0
} as FileStatWithMetadata);
fileService.writeFile.resolves({
resource: new URI('file:///dummy'),
isFile: true,
isDirectory: false,
isSymbolicLink: false,
mtime: Date.now(),
ctime: Date.now(),
etag: 'dummy',
size: 0
} as FileStatWithMetadata);
});
afterEach(() => {
sinon.restore();
});
describe('getOrCreateStore', () => {
it('should create a new store with a unique key', async () => {
const testUuid = 'test-uuid-1234';
generateUuidStub.returns(testUuid);
const store = await service.getOrCreateStore('my-feature');
expect(store).to.exist;
expect(store.key).to.equal('my-feature');
expect(store.location.toString()).to.equal(`file://${configDir}/workspace-metadata/${testUuid}/my-feature`);
});
it('should return existing store if key already exists', async () => {
const testUuid = 'test-uuid-1234';
generateUuidStub.returns(testUuid);
const store1 = await service.getOrCreateStore('my-feature');
const store2 = await service.getOrCreateStore('my-feature');
expect(store1).to.equal(store2);
});
it('should throw error if no workspace is open', async () => {
(workspaceService.tryGetRoots as sinon.SinonStub).returns([]);
try {
await service.getOrCreateStore('my-feature');
expect.fail('Should have thrown error for no workspace');
} catch (error) {
expect(error.message).to.contain('no workspace is currently open');
}
});
it('should mangle keys with special characters', async () => {
const testUuid = 'test-uuid-1234';
generateUuidStub.returns(testUuid);
const store = await service.getOrCreateStore('my/feature.name');
expect(store.key).to.equal('my-feature-name');
expect(store.location.toString()).to.equal(`file://${configDir}/workspace-metadata/${testUuid}/my-feature-name`);
});
it('should generate and store UUID for new workspace', async () => {
const testUuid = 'test-uuid-1234';
generateUuidStub.returns(testUuid);
await service.getOrCreateStore('my-feature');
// Check that writeFile was called to save the index
expect(fileService.writeFile.calledOnce).to.be.true;
const writeCall = fileService.writeFile.getCall(0);
const indexUri = writeCall.args[0] as URI;
const content = (writeCall.args[1] as BinaryBuffer).toString();
expect(indexUri.toString()).to.equal(`file://${configDir}/workspace-metadata/index.json`);
const index = JSON.parse(content);
expect(index[workspaceRootPath]).to.equal(testUuid);
});
it('should reuse existing UUID for known workspace', async () => {
const existingUuid = 'existing-uuid-5678';
const indexContent = JSON.stringify({
[workspaceRootPath]: existingUuid
});
fileService.exists.resolves(true);
fileService.readFile.resolves({
resource: new URI(`file://${configDir}/workspace-metadata/index.json`),
value: BinaryBuffer.fromString(indexContent)
} as FileContent);
const store = await service.getOrCreateStore('my-feature');
expect(store.location.toString()).to.equal(`file://${configDir}/workspace-metadata/${existingUuid}/my-feature`);
// Should not write index again since UUID already existed
expect(fileService.writeFile.called).to.be.false;
});
it('should handle multiple stores with different keys', async () => {
generateUuidStub.returns('test-uuid-1234');
const store1 = await service.getOrCreateStore('feature-1');
const store2 = await service.getOrCreateStore('feature-2');
expect(store1.key).to.equal('feature-1');
expect(store2.key).to.equal('feature-2');
expect(store1.location.toString()).to.not.equal(store2.location.toString());
});
it('should allow recreating store with same key after disposal', async () => {
generateUuidStub.returns('test-uuid-1234');
const store1 = await service.getOrCreateStore('my-feature');
expect(store1.key).to.equal('my-feature');
store1.dispose();
// Should not throw - the key should be available again
const store2 = await service.getOrCreateStore('my-feature');
expect(store2.key).to.equal('my-feature');
expect(store2).to.not.equal(store1);
});
});
describe('key mangling', () => {
beforeEach(() => {
generateUuidStub.returns('test-uuid');
});
it('should replace forward slashes with hyphens', async () => {
const store = await service.getOrCreateStore('path/to/feature');
expect(store.key).to.equal('path-to-feature');
});
it('should replace dots with hyphens', async () => {
const store = await service.getOrCreateStore('my.feature.name');
expect(store.key).to.equal('my-feature-name');
});
it('should replace spaces with hyphens', async () => {
const store = await service.getOrCreateStore('my feature name');
expect(store.key).to.equal('my-feature-name');
});
it('should preserve alphanumeric characters, hyphens, and underscores', async () => {
const store = await service.getOrCreateStore('My_Feature-123');
expect(store.key).to.equal('My_Feature-123');
});
it('should replace multiple special characters', async () => {
const store = await service.getOrCreateStore('!@#$%^&*()');
expect(store.key).to.equal('----------');
});
});
describe('index management', () => {
it('should handle missing index file', async () => {
generateUuidStub.returns('new-uuid');
fileService.exists.resolves(false);
const store = await service.getOrCreateStore('feature');
expect(store).to.exist;
expect(fileService.writeFile.calledOnce).to.be.true;
});
it('should handle corrupted index file', async () => {
generateUuidStub.returns('new-uuid');
fileService.exists.resolves(true);
fileService.readFile.resolves({
resource: new URI(`file://${configDir}/workspace-metadata/index.json`),
value: BinaryBuffer.fromString('{ invalid json')
} as FileContent);
const store = await service.getOrCreateStore('feature');
expect(store).to.exist;
expect(logger.warn.calledOnce).to.be.true;
});
it('should create metadata root directory when saving index', async () => {
generateUuidStub.returns('test-uuid');
await service.getOrCreateStore('feature');
expect(fileService.createFolder.calledOnce).to.be.true;
const createCall = fileService.createFolder.getCall(0);
const createdUri = createCall.args[0] as URI;
expect(createdUri.toString()).to.equal(`file://${configDir}/workspace-metadata`);
});
});
describe('workspace changes', () => {
it('should update store location when workspace root changes', async () => {
const uuid1 = 'workspace-1-uuid';
const uuid2 = 'workspace-2-uuid';
let uuidCallCount = 0;
generateUuidStub.callsFake(() => {
uuidCallCount++;
return uuidCallCount === 1 ? uuid1 : uuid2;
});
const store = await service.getOrCreateStore('feature');
const initialLocation = store.location.toString();
// Simulate workspace change
const newWorkspaceRoot = new URI('file:///home/user/other-workspace');
(workspaceService.tryGetRoots as sinon.SinonStub).returns([{
resource: newWorkspaceRoot,
isDirectory: true
} as FileStat]);
// Track location changes
let locationChanged = false;
let newLocation: URI | undefined;
store.onDidChangeLocation(uri => {
locationChanged = true;
newLocation = uri;
});
// Trigger workspace change via the protected emitter
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(workspaceService as any)['onWorkspaceChangeEmitter'].fire([]);
// Wait for async updates
await new Promise(resolve => setTimeout(resolve, 10));
expect(locationChanged).to.be.true;
expect(newLocation?.toString()).to.not.equal(initialLocation);
expect(newLocation?.toString()).to.contain(uuid2);
});
});
});

View File

@@ -0,0 +1,244 @@
/********************************************************************************
* Copyright (C) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { ILogger } from '@theia/core/lib/common/logger';
import { URI } from '@theia/core/lib/common/uri';
import { generateUuid } from '@theia/core/lib/common/uuid';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { WorkspaceService } from '../workspace-service';
import { WorkspaceMetadataStore, WorkspaceMetadataStoreImpl } from './workspace-metadata-store';
export const WorkspaceMetadataStoreFactory = Symbol('WorkspaceMetadataStoreFactory');
export type WorkspaceMetadataStoreFactory = () => WorkspaceMetadataStoreImpl;
/**
* Index mapping workspace root paths to UUIDs.
* Stored at $CONFIGDIR/workspace-metadata/index.json
*/
export interface WorkspaceMetadataIndex {
[workspacePath: string]: string; // workspace path -> UUID
}
/**
* Service for managing workspace-specific metadata storage.
* Provides isolated storage directories for different features within a workspace.
*
* This is different to the `WorkspaceStorageService` in that it is an unlimited free-form
* storage area _in the filesystem_ and not in the browser's local storage.
*/
export const WorkspaceMetadataStorageService = Symbol('WorkspaceMetadataStorageService');
export interface WorkspaceMetadataStorageService {
/**
* Gets an existing metadata store for the given key, or creates a new one if it doesn't exist.
*
* @param key A unique identifier for the metadata store. Special characters will be replaced with hyphens.
* @returns The existing or newly created WorkspaceMetadataStore instance
* @throws Error if no workspace is currently open
*/
getOrCreateStore(key: string): Promise<WorkspaceMetadataStore>;
}
@injectable()
export class WorkspaceMetadataStorageServiceImpl implements WorkspaceMetadataStorageService {
@inject(FileService)
protected readonly fileService: FileService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(EnvVariablesServer)
protected readonly envVariableServer: EnvVariablesServer;
@inject(ILogger) @named('WorkspaceMetadataStorage')
protected readonly logger: ILogger;
@inject(WorkspaceMetadataStoreFactory)
protected readonly storeFactory: WorkspaceMetadataStoreFactory;
/**
* Registry of created stores by their mangled keys
*/
protected readonly stores = new Map<string, WorkspaceMetadataStore>();
/**
* Cached metadata root directory (e.g., file://$CONFIGDIR/workspace-metadata/)
*/
protected metadataRoot?: URI;
/**
* Cached index file location
*/
protected indexFile?: URI;
async getOrCreateStore(key: string): Promise<WorkspaceMetadataStore> {
const mangledKey = this.mangleKey(key);
const existingStore = this.stores.get(mangledKey);
if (existingStore) {
this.logger.debug(`Returning existing metadata store for key '${key}'`, {
mangledKey,
location: existingStore.location.toString()
});
return existingStore;
}
return this.doCreateStore(key, mangledKey);
}
protected async doCreateStore(key: string, mangledKey: string): Promise<WorkspaceMetadataStore> {
const workspaceRoot = this.getFirstWorkspaceRoot();
if (!workspaceRoot) {
throw new Error('Cannot create metadata store: no workspace is currently open');
}
const workspaceUuid = await this.getOrCreateWorkspaceUUID(workspaceRoot);
const storeLocation = await this.getStoreLocation(workspaceUuid, mangledKey);
const store = this.storeFactory();
store.initialize(
mangledKey,
storeLocation,
async () => this.resolveStoreLocation(mangledKey),
() => this.stores.delete(mangledKey)
);
this.stores.set(mangledKey, store);
this.logger.debug(`Created metadata store for key '${key}'`, {
mangledKey,
location: storeLocation.toString()
});
return store;
}
/**
* Mangles a key to make it safe for use as a directory name.
* Replaces all characters except alphanumerics, hyphens, and underscores with hyphens.
*/
protected mangleKey(key: string): string {
return key.replace(/[^a-zA-Z0-9-_]/g, '-');
}
protected getFirstWorkspaceRoot(): URI | undefined {
const roots = this.workspaceService.tryGetRoots();
return roots.length > 0 ? roots[0].resource : undefined;
}
/**
* Gets or creates a UUID for the given workspace root.
* UUIDs are stored in an index file and reused if the same workspace is opened again.
*/
protected async getOrCreateWorkspaceUUID(workspaceRoot: URI): Promise<string> {
const index = await this.loadIndex();
const workspacePath = workspaceRoot.path.toString();
if (index[workspacePath]) {
return index[workspacePath];
}
const newUuid = generateUuid();
index[workspacePath] = newUuid;
await this.saveIndex(index);
this.logger.debug('Generated new UUID for workspace', {
workspacePath,
uuid: newUuid
});
return newUuid;
}
protected async loadIndex(): Promise<WorkspaceMetadataIndex> {
const indexFileUri = await this.getIndexFile();
try {
const exists = await this.fileService.exists(indexFileUri);
if (!exists) {
return {};
}
const content = await this.fileService.readFile(indexFileUri);
return JSON.parse(content.value.toString()) as WorkspaceMetadataIndex;
} catch (error) {
this.logger.warn('Failed to load workspace metadata index, using empty index', error);
return {};
}
}
protected async saveIndex(index: WorkspaceMetadataIndex): Promise<void> {
const indexFileUri = await this.getIndexFile();
try {
// Ensure metadata root exists
const metadataRootUri = await this.getMetadataRoot();
await this.fileService.createFolder(metadataRootUri);
// Write index file
const content = JSON.stringify(index, undefined, 2);
await this.fileService.writeFile(
indexFileUri,
BinaryBuffer.fromString(content)
);
} catch (error) {
this.logger.error('Failed to save workspace metadata index', error);
throw error;
}
}
protected async getMetadataRoot(): Promise<URI> {
if (!this.metadataRoot) {
const configDirUri = await this.envVariableServer.getConfigDirUri();
this.metadataRoot = new URI(configDirUri).resolve('workspace-metadata');
}
return this.metadataRoot;
}
protected async getIndexFile(): Promise<URI> {
if (!this.indexFile) {
const metadataRoot = await this.getMetadataRoot();
this.indexFile = metadataRoot.resolve('index.json');
}
return this.indexFile;
}
/**
* Gets the location for a store given a workspace UUID and mangled key.
*/
protected async getStoreLocation(workspaceUuid: string, mangledKey: string): Promise<URI> {
const metadataRoot = await this.getMetadataRoot();
return metadataRoot.resolve(workspaceUuid).resolve(mangledKey);
}
/**
* Resolves the current store location for a given mangled key.
* Used when workspace changes to get the new location.
*/
protected async resolveStoreLocation(mangledKey: string): Promise<URI> {
const workspaceRoot = this.getFirstWorkspaceRoot();
if (!workspaceRoot) {
throw new Error('No workspace is currently open');
}
const workspaceUuid = await this.getOrCreateWorkspaceUUID(workspaceRoot);
return this.getStoreLocation(workspaceUuid, mangledKey);
}
}

View File

@@ -0,0 +1,172 @@
/********************************************************************************
* Copyright (C) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { ILogger, Emitter, Event, Disposable, DisposableCollection, URI } from '@theia/core/lib/common';
import { WorkspaceService } from '../workspace-service';
/**
* Represents a metadata store for a specific key within a workspace.
* The store provides access to a dedicated directory for storing workspace-specific metadata.
*/
export interface WorkspaceMetadataStore extends Disposable {
/**
* The key identifying this metadata store.
*/
readonly key: string;
/**
* The URI location of the metadata store directory.
*/
readonly location: URI;
/**
* Event that fires when the location of the metadata store changes.
* It is the client's responsibility to reload or reinitialize any metadata from
* or in the new location.
*/
readonly onDidChangeLocation: Event<URI>;
/**
* Ensures that the metadata store directory exists on disk.
* Creates the directory if it doesn't exist.
*/
ensureExists(): Promise<void>;
/**
* Deletes the metadata store directory and all of its contents.
*/
delete(): Promise<void>;
}
/**
* Implementation of WorkspaceMetadataStore.
* @internal
*/
@injectable()
export class WorkspaceMetadataStoreImpl implements WorkspaceMetadataStore {
@inject(FileService)
protected readonly fileService: FileService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(ILogger) @named('WorkspaceMetadataStorage')
protected readonly logger: ILogger;
protected readonly toDispose = new DisposableCollection();
protected readonly onDidChangeLocationEmitter = new Emitter<URI>();
readonly onDidChangeLocation: Event<URI> = this.onDidChangeLocationEmitter.event;
protected _location: URI;
protected _key: string;
protected currentWorkspaceRoot?: URI;
protected locationProvider: () => Promise<URI>;
protected onDisposeCallback?: () => void;
get location(): URI {
return this._location;
}
get key(): string {
return this._key;
}
/**
* Initializes the WorkspaceMetadataStore.
* @param key The key identifying this store
* @param initialLocation The initial location URI
* @param locationProvider Function to resolve the current location based on workspace changes
* @param onDispose Callback invoked when the store is disposed
*/
initialize(key: string, initialLocation: URI, locationProvider: () => Promise<URI>, onDispose?: () => void): void {
this._key = key;
this._location = initialLocation;
this.locationProvider = locationProvider;
this.onDisposeCallback = onDispose;
this.currentWorkspaceRoot = this.getFirstWorkspaceRoot();
}
@postConstruct()
protected init(): void {
this.toDispose.push(this.onDidChangeLocationEmitter);
this.toDispose.push(
this.workspaceService.onWorkspaceChanged(() => this.handleWorkspaceChange())
);
}
protected async handleWorkspaceChange(): Promise<void> {
const newWorkspaceRoot = this.getFirstWorkspaceRoot();
// Check if the first workspace root actually changed
if (this.currentWorkspaceRoot?.toString() !== newWorkspaceRoot?.toString()) {
this.currentWorkspaceRoot = newWorkspaceRoot;
try {
const newLocation = await this.locationProvider();
if (this._location.toString() !== newLocation.toString()) {
this._location = newLocation;
this.onDidChangeLocationEmitter.fire(newLocation);
this.logger.debug(`Metadata store location changed for key '${this._key}'`, {
newLocation: newLocation.toString()
});
}
} catch (error) {
this.logger.error(`Failed to update location for metadata store '${this._key}'`, error);
}
}
}
protected getFirstWorkspaceRoot(): URI | undefined {
const roots = this.workspaceService.tryGetRoots();
return roots.length > 0 ? roots[0].resource : undefined;
}
async ensureExists(): Promise<void> {
try {
await this.fileService.createFolder(this._location);
this.logger.debug(`Ensured metadata store exists for key '${this._key}'`, {
location: this._location.toString()
});
} catch (error) {
this.logger.error(`Failed to create metadata store directory for key '${this._key}'`, error);
throw error;
}
}
async delete(): Promise<void> {
try {
const exists = await this.fileService.exists(this._location);
if (exists) {
await this.fileService.delete(this._location, { recursive: true, useTrash: false });
this.logger.debug(`Deleted metadata store for key '${this._key}'`, {
location: this._location.toString()
});
}
} catch (error) {
this.logger.error(`Failed to delete metadata store directory for key '${this._key}'`, error);
throw error;
}
}
dispose(): void {
this.toDispose.dispose();
this.onDisposeCallback?.();
}
}

View File

@@ -0,0 +1,98 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, optional, named } from '@theia/core/shared/inversify';
import { QuickPickItem, LabelProvider, QuickInputService, QuickInputButton, QuickPickSeparator } from '@theia/core/lib/browser';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { WorkspaceOpenHandlerContribution, WorkspaceService } from './workspace-service';
import URI from '@theia/core/lib/common/uri';
import { ContributionProvider, nls, Path } from '@theia/core/lib/common';
import { UntitledWorkspaceService } from '../common/untitled-workspace-service';
interface RecentlyOpenedPick extends QuickPickItem {
resource?: URI
}
@injectable()
export class QuickOpenWorkspace {
protected opened: boolean;
@inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(EnvVariablesServer) protected readonly envServer: EnvVariablesServer;
@inject(UntitledWorkspaceService) protected untitledWorkspaceService: UntitledWorkspaceService;
@inject(ContributionProvider) @named(WorkspaceOpenHandlerContribution)
protected readonly workspaceOpenHandlers: ContributionProvider<WorkspaceOpenHandlerContribution>;
protected readonly removeRecentWorkspaceButton: QuickInputButton = {
iconClass: 'codicon-remove-close',
tooltip: nls.localizeByDefault('Remove from Recently Opened')
};
async open(workspaces: string[]): Promise<void> {
const homeDirUri = await this.envServer.getHomeDirUri();
const home = new URI(homeDirUri).path.fsPath();
const items: (RecentlyOpenedPick | QuickPickSeparator)[] = [{
type: 'separator',
label: nls.localizeByDefault('folders & workspaces')
}];
for (const workspace of workspaces) {
const uri = new URI(workspace);
const label = await this.workspaceOpenHandlers.getContributions()
.find(handler => handler.getWorkspaceLabel && handler.canHandle(uri))?.getWorkspaceLabel?.(uri) ?? uri.path.base;
if (!label || this.untitledWorkspaceService.isUntitledWorkspace(uri)) {
continue; // skip temporary workspace files & empty workspace names
}
items.push({
label: label,
description: Path.tildify(uri.path.fsPath(), home),
buttons: [this.removeRecentWorkspaceButton],
resource: uri,
execute: () => {
const current = this.workspaceService.workspace;
if ((current && current.resource.toString() !== workspace) || !current) {
this.workspaceService.open(uri);
}
}
});
}
this.quickInputService?.showQuickPick(items, {
placeholder: nls.localize(
'theia/workspace/openRecentPlaceholder',
'Type the name of the workspace you want to open'),
onDidTriggerItemButton: async context => {
const resource = (context.item as RecentlyOpenedPick).resource;
if (resource) {
await this.workspaceService.removeRecentWorkspace(resource.toString());
context.removeItem();
}
}
});
}
select(): void {
this.opened = this.workspaceService.opened;
this.workspaceService.recentWorkspaces().then(workspaceRoots => {
if (workspaceRoots) {
this.open(workspaceRoots);
}
});
}
}

View File

@@ -0,0 +1,75 @@
/********************************************************************************
* Copyright (C) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
********************************************************************************/
/* Workspace Trust Dialog Styles */
.workspace-trust-content {
display: flex;
flex-direction: column;
gap: calc(var(--theia-ui-padding) * 3);
padding: calc(var(--theia-ui-padding) * 2);
}
.workspace-trust-header {
display: flex;
align-items: center;
gap: calc(var(--theia-ui-padding) * 2);
}
.workspace-trust-header i {
font-size: calc(var(--theia-ui-font-size3) * 2.5) !important;
color: var(--theia-button-background);
}
.workspace-trust-title {
font-size: var(--theia-ui-font-size2);
font-weight: 600;
line-height: var(--theia-content-line-height);
}
.workspace-trust-description,
.workspace-trust-folder {
margin-left: calc(var(--theia-ui-font-size3) * 2.5 + var(--theia-ui-padding) * 2);
}
.workspace-trust-dialog .dialogControl {
margin-left: calc(var(--theia-ui-font-size3) * 2.5 + var(--theia-ui-padding) * 4);
padding-bottom: calc(var(--theia-ui-padding) * 4) !important;
justify-content: flex-start !important;
}
.workspace-trust-description {
color: var(--theia-descriptionForeground);
line-height: var(--theia-content-line-height);
}
.workspace-trust-folder-list {
padding-inline-start: 15px;
margin-block: 0;
}
.workspace-trust-folder {
font-family: var(--theia-code-font-family);
font-size: var(--theia-code-font-size);
color: var(--theia-foreground);
background-color: var(--theia-editor-background);
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 1.5);
border-radius: 4px;
}
.workspace-trust-dialog .dialogControl .theia-button.secondary {
margin-left: 0;
}

View File

@@ -0,0 +1,70 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { nls } from '@theia/core';
import { inject } from '@theia/core/shared/inversify';
import { AbstractDialog, Dialog, DialogProps, Message } from '@theia/core/lib/browser';
export class UntitledWorkspaceExitDialog extends AbstractDialog<UntitledWorkspaceExitDialog.Options> {
protected readonly dontSaveButton: HTMLButtonElement;
protected _value: UntitledWorkspaceExitDialog.Options = 'Cancel';
get value(): UntitledWorkspaceExitDialog.Options {
return this._value;
}
constructor(
@inject(DialogProps) props: DialogProps
) {
super(props);
const messageNode = document.createElement('div');
messageNode.textContent = nls.localizeByDefault('Save your workspace if you plan to open it again.');
this.contentNode.appendChild(messageNode);
this.dontSaveButton = this.createButton(nls.localizeByDefault(UntitledWorkspaceExitDialog.Values["Don't Save"]));
this.dontSaveButton.classList.add('secondary');
this.controlPanel.appendChild(this.dontSaveButton);
this.appendCloseButton(Dialog.CANCEL);
this.appendAcceptButton(nls.localizeByDefault(UntitledWorkspaceExitDialog.Values.Save));
}
protected override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.addAction(this.dontSaveButton, () => this.dontSave(), 'click');
}
protected override addAcceptAction<K extends keyof HTMLElementEventMap>(element: HTMLElement, ...additionalEventTypes: K[]): void {
this.addAction(element, () => this.doSave(), 'click');
}
protected dontSave(): void {
this._value = UntitledWorkspaceExitDialog.Values["Don't Save"];
this.accept();
}
protected doSave(): void {
this._value = UntitledWorkspaceExitDialog.Values.Save;
this.accept();
}
}
export namespace UntitledWorkspaceExitDialog {
export const enum Values {
"Don't Save" = "Don't Save",
Cancel = 'Cancel',
Save = 'Save',
};
export type Options = keyof typeof Values;
}

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { FilepathBreadcrumb } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumb';
import { FilepathBreadcrumbClassNameFactory, FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceService } from './workspace-service';
import URI from '@theia/core/lib/common/uri';
@injectable()
export class WorkspaceBreadcrumbsContribution extends FilepathBreadcrumbsContribution {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
override getContainerClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory {
const workspaceRoot = this.workspaceService.getWorkspaceRootUri(fileURI);
return (location, index) => {
if (location.isEqual(fileURI)) {
return 'file';
} else if (workspaceRoot?.isEqual(location)) {
return 'root_folder';
}
return 'folder';
};
}
override getIconClassCreator(fileURI: URI): FilepathBreadcrumbClassNameFactory {
const workspaceRoot = this.workspaceService.getWorkspaceRootUri(fileURI);
return (location, index) => {
if (location.isEqual(fileURI) || workspaceRoot?.isEqual(location)) {
return this.labelProvider.getIcon(location) + ' file-icon';
}
return '';
};
}
protected override filterBreadcrumbs(uri: URI, breadcrumb: FilepathBreadcrumb): boolean {
const workspaceRootUri = this.workspaceService.getWorkspaceRootUri(uri);
const firstCrumbToHide = this.workspaceService.isMultiRootWorkspaceOpened ? workspaceRootUri?.parent : workspaceRootUri;
return super.filterBreadcrumbs(uri, breadcrumb) && (!firstCrumbToHide || !breadcrumb.uri.isEqualOrParent(firstCrumbToHide));
}
}

View File

@@ -0,0 +1,153 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});
import { expect } from 'chai';
import URI from '@theia/core/lib/common/uri';
import { Container } from '@theia/core/shared/inversify';
import { FileDialogService } from '@theia/filesystem/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { LabelProvider, OpenerService, FrontendApplication } from '@theia/core/lib/browser';
import { MessageService, OS } from '@theia/core/lib/common';
import { SelectionService } from '@theia/core/lib/common/selection-service';
import { WorkspaceCommandContribution } from './workspace-commands';
import { WorkspaceCompareHandler } from './workspace-compare-handler';
import { WorkspaceDeleteHandler } from './workspace-delete-handler';
import { WorkspaceDuplicateHandler } from './workspace-duplicate-handler';
import { WorkspacePreferences } from '../common/workspace-preferences';
import { WorkspaceService } from './workspace-service';
import { ApplicationServer } from '@theia/core/lib/common/application-protocol';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
disableJSDOM();
describe('workspace-commands', () => {
let commands: WorkspaceCommandContribution;
const childStat: FileStat = {
isFile: true,
isDirectory: false,
isSymbolicLink: false,
isReadonly: false,
resource: new URI('foo/bar'),
name: 'bar',
};
const parent: FileStat = {
isFile: false,
isDirectory: true,
isSymbolicLink: false,
isReadonly: false,
resource: new URI('foo'),
name: 'foo',
children: [
childStat
]
};
before(() => disableJSDOM = enableJSDOM());
after(() => disableJSDOM());
beforeEach(() => {
const container = new Container();
container.bind(FileDialogService).toConstantValue(<FileDialogService>{});
container.bind(FileService).toConstantValue(<FileService>{
async exists(resource: URI): Promise<boolean> {
return resource.path.base.includes('bar'); // 'bar' exists for test purposes.
}
});
container.bind(FrontendApplication).toConstantValue(<FrontendApplication>{});
container.bind(LabelProvider).toConstantValue(<LabelProvider>{});
container.bind(MessageService).toConstantValue(<MessageService>{});
container.bind(OpenerService).toConstantValue(<OpenerService>{});
container.bind(SelectionService).toConstantValue(<SelectionService>{});
container.bind(WorkspaceCommandContribution).toSelf().inSingletonScope();
container.bind(WorkspaceCompareHandler).toConstantValue(<WorkspaceCompareHandler>{});
container.bind(WorkspaceDeleteHandler).toConstantValue(<WorkspaceDeleteHandler>{});
container.bind(WorkspaceDuplicateHandler).toConstantValue(<WorkspaceDuplicateHandler>{});
container.bind(WorkspacePreferences).toConstantValue(<WorkspacePreferences>{});
container.bind(WorkspaceService).toConstantValue(<WorkspaceService>{});
container.bind(ClipboardService).toConstantValue(<ClipboardService>{});
container.bind(ApplicationServer).toConstantValue(<ApplicationServer>{
getBackendOS(): Promise<OS.Type> {
return Promise.resolve(OS.type());
}
});
commands = container.get(WorkspaceCommandContribution);
});
describe('#validateFileName', () => {
it('should not validate an empty file name', async () => {
const message = await commands['validateFileName']('', parent);
expect(message).to.equal('');
});
it('should accept the resource does not exist', async () => {
const message = await commands['validateFileName']('a.ts', parent);
expect(message).to.equal('');
});
it('should not accept if the resource exists', async () => {
const message = await commands['validateFileName']('bar', parent);
expect(message).to.not.equal(''); // a non empty message indicates an error.
});
it('should not accept invalid filenames', async () => {
let message = await commands['validateFileName']('.', parent, true); // invalid filename.
expect(message).to.not.equal('');
message = await commands['validateFileName']('/a', parent, true); // invalid starts-with `\`.
expect(message).to.not.equal('');
message = await commands['validateFileName'](' a', parent, true); // invalid leading whitespace.
expect(message).to.not.equal('');
message = await commands['validateFileName']('a ', parent, true); // invalid trailing whitespace.
expect(message).to.not.equal('');
});
});
describe('#validateFileRename', () => {
it('should accept if the resource exists case-insensitively', async () => {
const oldName: string = 'bar';
const newName = 'Bar';
const message = await commands['validateFileRename'](oldName, newName, parent);
expect(message).to.equal('');
});
it('should accept if the resource does not exist case-insensitively', async () => {
const oldName: string = 'bar';
const newName = 'foo';
const message = await commands['validateFileRename'](oldName, newName, parent);
expect(message).to.equal('');
});
});
});

View File

@@ -0,0 +1,564 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { SelectionService } from '@theia/core/lib/common/selection-service';
import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command';
import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu';
import { CommonMenus } from '@theia/core/lib/browser/common-menus';
import { FileDialogService } from '@theia/filesystem/lib/browser';
import { SingleTextInputDialog, ConfirmDialog, Dialog } from '@theia/core/lib/browser/dialogs';
import { OpenerService, OpenHandler, open, FrontendApplication, LabelProvider, CommonCommands } from '@theia/core/lib/browser';
import { UriCommandHandler, UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import { WorkspaceService } from './workspace-service';
import { MessageService } from '@theia/core/lib/common/message-service';
import { WorkspacePreferences } from '../common/workspace-preferences';
import { WorkspaceDeleteHandler } from './workspace-delete-handler';
import { WorkspaceDuplicateHandler } from './workspace-duplicate-handler';
import { FileSystemUtils } from '@theia/filesystem/lib/common';
import { WorkspaceCompareHandler } from './workspace-compare-handler';
import { FileDownloadCommands } from '@theia/filesystem/lib/browser/download/file-download-command-contribution';
import { FileSystemCommands } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution';
import { WorkspaceInputDialog } from './workspace-input-dialog';
import { Emitter, EOL, Event, OS } from '@theia/core/lib/common';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { nls } from '@theia/core/lib/common/nls';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
const validFilename: (arg: string) => boolean = require('valid-filename');
export namespace WorkspaceCommands {
const WORKSPACE_CATEGORY = 'Workspaces';
const FILE_CATEGORY = CommonCommands.FILE_CATEGORY;
// On Linux and Windows, both files and folders cannot be opened at the same time in electron.
// `OPEN_FILE` and `OPEN_FOLDER` must be available only on Linux and Windows in electron.
// `OPEN` must *not* be available on Windows and Linux in electron.
// VS Code does the same. See: https://github.com/eclipse-theia/theia/pull/3202#issuecomment-430585357
export const OPEN: Command & { dialogLabel: string } = {
...Command.toDefaultLocalizedCommand({
id: 'workspace:open',
category: CommonCommands.FILE_CATEGORY,
label: 'Open...'
}),
dialogLabel: nls.localizeByDefault('Open')
};
// No `label`. Otherwise, it shows up in the `Command Palette`.
export const OPEN_FILE: Command & { dialogLabel: string } = {
id: 'workspace:openFile',
originalCategory: FILE_CATEGORY,
category: nls.localizeByDefault(CommonCommands.FILE_CATEGORY),
dialogLabel: nls.localizeByDefault('Open File')
};
export const OPEN_FOLDER: Command & { dialogLabel: string } = {
id: 'workspace:openFolder',
dialogLabel: nls.localizeByDefault('Open Folder') // No `label`. Otherwise, it shows up in the `Command Palette`.
};
export const OPEN_WORKSPACE: Command & { dialogLabel: string } = {
...Command.toDefaultLocalizedCommand({
id: 'workspace:openWorkspace',
category: CommonCommands.FILE_CATEGORY,
label: 'Open Workspace from File...',
}),
dialogLabel: nls.localizeByDefault('Open Workspace from File')
};
export const OPEN_RECENT_WORKSPACE = Command.toLocalizedCommand({
id: 'workspace:openRecent',
category: FILE_CATEGORY,
label: 'Open Recent Workspace...'
}, 'theia/workspace/openRecentWorkspace', CommonCommands.FILE_CATEGORY_KEY);
export const CLOSE = Command.toDefaultLocalizedCommand({
id: 'workspace:close',
category: WORKSPACE_CATEGORY,
label: 'Close Workspace'
});
export const NEW_FILE = Command.toDefaultLocalizedCommand({
id: 'file.newFile',
category: FILE_CATEGORY,
label: 'New File...'
});
export const NEW_FOLDER = Command.toDefaultLocalizedCommand({
id: 'file.newFolder',
category: FILE_CATEGORY,
label: 'New Folder...'
});
/** @deprecated Use the `OpenWithService` instead */
export const FILE_OPEN_WITH = (opener: OpenHandler): Command => ({
id: `file.openWith.${opener.id}`
});
export const FILE_RENAME = Command.toDefaultLocalizedCommand({
id: 'file.rename',
category: FILE_CATEGORY,
label: 'Rename'
});
export const FILE_DELETE = Command.toDefaultLocalizedCommand({
id: 'file.delete',
category: FILE_CATEGORY,
label: 'Delete'
});
export const FILE_DUPLICATE = Command.toLocalizedCommand({
id: 'file.duplicate',
category: FILE_CATEGORY,
label: 'Duplicate'
}, 'theia/workspace/duplicate', CommonCommands.FILE_CATEGORY_KEY);
export const FILE_COMPARE = Command.toLocalizedCommand({
id: 'file.compare',
category: FILE_CATEGORY,
label: 'Compare with Each Other'
}, 'theia/workspace/compareWithEachOther', CommonCommands.FILE_CATEGORY_KEY);
export const ADD_FOLDER = Command.toDefaultLocalizedCommand({
id: 'workspace:addFolder',
category: WORKSPACE_CATEGORY,
label: 'Add Folder to Workspace...'
});
export const REMOVE_FOLDER = Command.toDefaultLocalizedCommand({
id: 'workspace:removeFolder',
category: WORKSPACE_CATEGORY,
label: 'Remove Folder from Workspace'
});
export const SAVE_WORKSPACE_AS = Command.toDefaultLocalizedCommand({
id: 'workspace:saveAs',
category: WORKSPACE_CATEGORY,
label: 'Save Workspace As...'
});
export const OPEN_WORKSPACE_FILE = Command.toDefaultLocalizedCommand({
id: 'workspace:openConfigFile',
category: WORKSPACE_CATEGORY,
label: 'Open Workspace Configuration File'
});
/** @deprecated @since 1.24.0 Use `CommonCommands.SAVE_AS` instead */
export const SAVE_AS = CommonCommands.SAVE_AS;
export const COPY_RELATIVE_FILE_PATH = Command.toDefaultLocalizedCommand({
id: 'navigator.copyRelativeFilePath',
label: 'Copy Relative Path'
});
export const MANAGE_WORKSPACE_TRUST = Command.toDefaultLocalizedCommand({
id: 'workspace:manageTrust',
category: WORKSPACE_CATEGORY,
label: 'Manage Workspace Trust'
});
}
@injectable()
export class FileMenuContribution implements MenuContribution {
registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(CommonMenus.FILE_NEW_TEXT, {
commandId: WorkspaceCommands.NEW_FOLDER.id,
order: 'b'
});
const downloadUploadMenu = [...CommonMenus.FILE, '4_downloadupload'];
registry.registerMenuAction(downloadUploadMenu, {
commandId: FileSystemCommands.UPLOAD.id,
order: 'a'
});
registry.registerMenuAction(downloadUploadMenu, {
commandId: FileDownloadCommands.DOWNLOAD.id,
order: 'b'
});
}
}
@injectable()
export class EditMenuContribution implements MenuContribution {
registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(CommonMenus.EDIT_CLIPBOARD, {
commandId: FileDownloadCommands.COPY_DOWNLOAD_LINK.id,
order: '9999'
});
}
}
export interface DidCreateNewResourceEvent {
uri: URI
parent: URI
}
@injectable()
export class WorkspaceCommandContribution implements CommandContribution {
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(FileService) protected readonly fileService: FileService;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(SelectionService) protected readonly selectionService: SelectionService;
@inject(OpenerService) protected readonly openerService: OpenerService;
@inject(FrontendApplication) protected readonly app: FrontendApplication;
@inject(MessageService) protected readonly messageService: MessageService;
@inject(WorkspacePreferences) protected readonly preferences: WorkspacePreferences;
@inject(FileDialogService) protected readonly fileDialogService: FileDialogService;
@inject(WorkspaceDeleteHandler) protected readonly deleteHandler: WorkspaceDeleteHandler;
@inject(WorkspaceDuplicateHandler) protected readonly duplicateHandler: WorkspaceDuplicateHandler;
@inject(WorkspaceCompareHandler) protected readonly compareHandler: WorkspaceCompareHandler;
@inject(ClipboardService) protected readonly clipboardService: ClipboardService;
private readonly onDidCreateNewFileEmitter = new Emitter<DidCreateNewResourceEvent>();
private readonly onDidCreateNewFolderEmitter = new Emitter<DidCreateNewResourceEvent>();
get onDidCreateNewFile(): Event<DidCreateNewResourceEvent> {
return this.onDidCreateNewFileEmitter.event;
}
get onDidCreateNewFolder(): Event<DidCreateNewResourceEvent> {
return this.onDidCreateNewFolderEmitter.event;
}
protected fireCreateNewFile(uri: DidCreateNewResourceEvent): void {
this.onDidCreateNewFileEmitter.fire(uri);
}
protected fireCreateNewFolder(uri: DidCreateNewResourceEvent): void {
this.onDidCreateNewFolderEmitter.fire(uri);
}
registerCommands(registry: CommandRegistry): void {
registry.registerCommand(WorkspaceCommands.NEW_FILE, this.newWorkspaceRootUriAwareCommandHandler({
execute: uri => this.getDirectory(uri).then(parent => {
if (parent) {
const parentUri = parent.resource;
const { fileName, fileExtension } = this.getDefaultFileConfig();
const targetUri = parentUri.resolve(fileName + fileExtension);
const vacantChildUri = FileSystemUtils.generateUniqueResourceURI(parent, targetUri, false);
const dialog = new WorkspaceInputDialog({
title: nls.localizeByDefault('New File...'),
maxWidth: 400,
parentUri: parentUri,
initialValue: vacantChildUri.path.base,
placeholder: nls.localize('theia/workspace/newFilePlaceholder', 'File Name'),
validate: name => this.validateFileName(name, parent, true)
}, this.labelProvider);
dialog.open().then(async name => {
if (name) {
const fileUri = parentUri.resolve(name);
await this.fileService.create(fileUri);
this.fireCreateNewFile({ parent: parentUri, uri: fileUri });
open(this.openerService, fileUri);
}
});
}
})
}));
registry.registerCommand(WorkspaceCommands.NEW_FOLDER, this.newWorkspaceRootUriAwareCommandHandler({
execute: uri => this.getDirectory(uri).then(parent => {
if (parent) {
const parentUri = parent.resource;
const targetUri = parentUri.resolve('Untitled');
const vacantChildUri = FileSystemUtils.generateUniqueResourceURI(parent, targetUri, true);
const dialog = new WorkspaceInputDialog({
title: nls.localizeByDefault('New Folder...'),
maxWidth: 400,
parentUri: parentUri,
initialValue: vacantChildUri.path.base,
placeholder: nls.localize('theia/workspace/newFolderPlaceholder', 'Folder Name'),
validate: name => this.validateFileName(name, parent, true)
}, this.labelProvider);
dialog.open().then(async name => {
if (name) {
const folderUri = parentUri.resolve(name);
await this.fileService.createFolder(folderUri);
this.fireCreateNewFile({ parent: parentUri, uri: folderUri });
}
});
}
})
}));
registry.registerCommand(WorkspaceCommands.FILE_RENAME, this.newMultiUriAwareCommandHandler({
isEnabled: uris => uris.some(uri => !this.isWorkspaceRoot(uri)) && uris.length === 1,
isVisible: uris => uris.some(uri => !this.isWorkspaceRoot(uri)) && uris.length === 1,
execute: async uris => {
const uri = uris[0]; /* Since there is only one item in the array. */
const parent = await this.getParent(uri);
if (parent) {
const oldName = uri.path.base;
const dialog = new SingleTextInputDialog({
title: nls.localizeByDefault('Rename'),
maxWidth: 400,
initialValue: oldName,
initialSelectionRange: {
start: 0,
end: uri.path.name.length
},
validate: async (newName, mode) => {
if (oldName === newName && mode === 'preview') {
return false;
}
return this.validateFileRename(oldName, newName, parent);
}
});
const fileName = await dialog.open();
if (fileName) {
const oldUri = uri;
const newUri = uri.parent.resolve(fileName);
return this.fileService.move(oldUri, newUri);
}
}
}
}));
registry.registerCommand(WorkspaceCommands.FILE_DUPLICATE, this.newMultiUriAwareCommandHandler(this.duplicateHandler));
registry.registerCommand(WorkspaceCommands.FILE_DELETE, this.newMultiUriAwareCommandHandler(this.deleteHandler));
registry.registerCommand(WorkspaceCommands.FILE_COMPARE, this.newMultiUriAwareCommandHandler(this.compareHandler));
registry.registerCommand(WorkspaceCommands.COPY_RELATIVE_FILE_PATH, UriAwareCommandHandler.MultiSelect(this.selectionService, {
isEnabled: uris => !!uris.length,
isVisible: uris => !!uris.length,
execute: async uris => {
const lineDelimiter = EOL;
const text = uris.map((uri: URI) => {
const workspaceRoot = this.workspaceService.getWorkspaceRootUri(uri);
if (workspaceRoot) {
return workspaceRoot.relative(uri)?.fsPath();
} else {
return uri.path.fsPath();
}
}).join(lineDelimiter);
await this.clipboardService.writeText(text);
}
}));
registry.registerCommand(WorkspaceCommands.ADD_FOLDER, {
isEnabled: () => this.workspaceService.opened,
isVisible: () => this.workspaceService.opened,
execute: async () => {
const selection = await this.fileDialogService.showOpenDialog({
title: WorkspaceCommands.ADD_FOLDER.label!,
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: true,
});
if (!selection) {
return;
}
const uris = Array.isArray(selection) ? selection : [selection];
const workspaceSavedBeforeAdding = this.workspaceService.saved;
await this.addFolderToWorkspace(...uris);
if (!workspaceSavedBeforeAdding) {
this.saveWorkspaceWithPrompt(registry);
}
}
});
registry.registerCommand(WorkspaceCommands.REMOVE_FOLDER, this.newMultiUriAwareCommandHandler({
execute: uris => this.removeFolderFromWorkspace(uris),
isEnabled: () => this.workspaceService.isMultiRootWorkspaceOpened,
isVisible: uris => this.areWorkspaceRoots(uris) && this.workspaceService.saved
}));
}
protected newUriAwareCommandHandler(handler: UriCommandHandler<URI>): UriAwareCommandHandler<URI> {
return UriAwareCommandHandler.MonoSelect(this.selectionService, handler);
}
protected newMultiUriAwareCommandHandler(handler: UriCommandHandler<URI[]>): UriAwareCommandHandler<URI[]> {
return UriAwareCommandHandler.MultiSelect(this.selectionService, handler);
}
protected newWorkspaceRootUriAwareCommandHandler(handler: UriCommandHandler<URI>): WorkspaceRootUriAwareCommandHandler {
return new WorkspaceRootUriAwareCommandHandler(this.workspaceService, this.selectionService, handler);
}
protected async validateFileRename(oldName: string, newName: string, parent: FileStat): Promise<string> {
if (OS.backend.isWindows && parent.resource.resolve(newName).isEqual(parent.resource.resolve(oldName), false)) {
return '';
}
return this.validateFileName(newName, parent, false);
}
/**
* Returns an error message if the file name is invalid. Otherwise, an empty string.
*
* @param name the simple file name of the file to validate.
* @param parent the parent directory's file stat.
* @param allowNested allow file or folder creation using recursive path
*/
protected async validateFileName(name: string, parent: FileStat, allowNested: boolean = false): Promise<string> {
if (!name) {
return '';
}
// do not allow recursive rename
if (!allowNested && !validFilename(name)) {
return nls.localizeByDefault('The name **{0}** is not valid as a file or folder name. Please choose a different name.', this.trimFileName(name))
.replace(/\*\*/g, '');
}
if (name.startsWith('/')) {
return nls.localizeByDefault('A file or folder name cannot start with a slash.');
} else if (name.startsWith(' ') || name.endsWith(' ')) {
return nls.localizeByDefault('Leading or trailing whitespace detected in file or folder name.');
}
// check and validate each sub-paths
if (name.split(/[\\/]/).some(file => !file || !validFilename(file) || /^\s+$/.test(file))) {
return nls.localizeByDefault('\'{0}\' is not a valid file name', this.trimFileName(name));
}
const childUri = parent.resource.resolve(name);
const exists = await this.fileService.exists(childUri);
if (exists) {
return nls.localizeByDefault('A file or folder **{0}** already exists at this location. Please choose a different name.', this.trimFileName(name))
.replace(/\*\*/g, '');
}
return '';
}
protected trimFileName(name: string): string {
if (name && name.length > 30) {
return `${name.substring(0, 30)}...`;
}
return name;
}
protected async getDirectory(candidate: URI): Promise<FileStat | undefined> {
let stat: FileStat | undefined;
try {
stat = await this.fileService.resolve(candidate);
} catch { }
if (stat && stat.isDirectory) {
return stat;
}
return this.getParent(candidate);
}
protected async getParent(candidate: URI): Promise<FileStat | undefined> {
try {
return await this.fileService.resolve(candidate.parent);
} catch {
return undefined;
}
}
protected async addFolderToWorkspace(...uris: URI[]): Promise<void> {
if (uris.length) {
const foldersToAdd = [];
try {
for (const uri of uris) {
const stat = await this.fileService.resolve(uri);
if (stat.isDirectory) {
foldersToAdd.push(uri);
}
}
await this.workspaceService.addRoot(foldersToAdd);
} catch { }
}
}
protected areWorkspaceRoots(uris: URI[]): boolean {
return this.workspaceService.areWorkspaceRoots(uris);
}
protected isWorkspaceRoot(uri: URI): boolean {
const rootUris = new Set(this.workspaceService.tryGetRoots().map(root => root.resource.toString()));
return rootUris.has(uri.toString());
}
protected getDefaultFileConfig(): { fileName: string, fileExtension: string } {
return {
fileName: 'Untitled',
fileExtension: '.txt'
};
}
/**
* Removes the list of folders from the workspace upon confirmation from the user.
* @param uris the list of folder uris to remove.
*/
protected async removeFolderFromWorkspace(uris: URI[]): Promise<void> {
const roots = new Set(this.workspaceService.tryGetRoots().map(root => root.resource.toString()));
const toRemove = uris.filter(uri => roots.has(uri.toString()));
if (toRemove.length > 0) {
const messageContainer = document.createElement('div');
if (toRemove.length > 1) {
messageContainer.textContent = nls.localize('theia/workspace/removeFolders',
'Are you sure you want to remove the following folders from the workspace?');
} else {
messageContainer.textContent = nls.localize('theia/workspace/removeFolder',
'Are you sure you want to remove the following folder from the workspace?');
}
messageContainer.title = nls.localize('theia/workspace/noErasure', 'Note: Nothing will be erased from disk');
const list = document.createElement('div');
list.classList.add('theia-dialog-node');
toRemove.forEach(uri => {
const listItem = document.createElement('div');
listItem.classList.add('theia-dialog-node-content');
const folderIcon = document.createElement('span');
folderIcon.classList.add('codicon', 'codicon-root-folder', 'theia-dialog-icon');
listItem.appendChild(folderIcon);
listItem.title = this.labelProvider.getLongName(uri);
const listContent = document.createElement('span');
listContent.classList.add('theia-dialog-node-segment');
listContent.appendChild(document.createTextNode(this.labelProvider.getName(uri)));
listItem.appendChild(listContent);
list.appendChild(listItem);
});
messageContainer.appendChild(list);
const dialog = new ConfirmDialog({
title: nls.localizeByDefault('Remove Folder from Workspace'),
msg: messageContainer
});
if (await dialog.open()) {
await this.workspaceService.removeRoots(toRemove);
}
}
}
async saveWorkspaceWithPrompt(registry: CommandRegistry): Promise<void> {
const saveCommand = registry.getCommand(WorkspaceCommands.SAVE_WORKSPACE_AS.id);
if (saveCommand && await new ConfirmDialog({
title: nls.localize('theia/workspace/workspaceFolderAddedTitle', 'Folder added to Workspace'),
msg: nls.localize('theia/workspace/workspaceFolderAdded',
'A workspace with multiple roots was created. Do you want to save your workspace configuration as a file?'),
ok: Dialog.YES,
cancel: Dialog.NO
}).open()) {
return registry.executeCommand(saveCommand.id);
}
}
}
export class WorkspaceRootUriAwareCommandHandler extends UriAwareCommandHandler<URI> {
constructor(
protected readonly workspaceService: WorkspaceService,
selectionService: SelectionService,
handler: UriCommandHandler<URI>
) {
super(selectionService, handler);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public override isEnabled(...args: any[]): boolean {
return super.isEnabled(...args) && !!this.workspaceService.tryGetRoots().length;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public override isVisible(...args: any[]): boolean {
return super.isVisible(...args) && !!this.workspaceService.tryGetRoots().length;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override getUri(...args: any[]): URI | undefined {
const uri = super.getUri(...args);
// Return the `uri` immediately if the resource exists in any of the workspace roots.
if (uri && this.workspaceService.getWorkspaceRootUri(uri)) {
return uri;
}
// Return the first root if available.
if (!!this.workspaceService.tryGetRoots().length) {
return this.workspaceService.tryGetRoots()[0].resource;
}
}
}

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { UriCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import { DiffService } from './diff-service';
@injectable()
export class WorkspaceCompareHandler implements UriCommandHandler<URI[]> {
@inject(DiffService) protected readonly diffService: DiffService;
/**
* Determine if the command is visible.
*
* @param uris URIs of selected resources.
* @returns `true` if the command is visible.
*/
isVisible(uris: URI[]): boolean {
return uris.length === 2;
}
/**
* Determine if the command is enabled.
*
* @param uris URIs of selected resources.
* @returns `true` if the command is enabled.
*/
isEnabled(uris: URI[]): boolean {
return uris.length === 2;
}
/**
* Execute the command.
*
* @param uris URIs of selected resources.
*/
async execute(uris: URI[]): Promise<void> {
const [left, right] = uris;
await this.diffService.openDiffEditor(left, right);
}
}

View File

@@ -0,0 +1,212 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { ConfirmDialog, ApplicationShell, SaveableWidget, NavigatableWidget } from '@theia/core/lib/browser';
import { UriCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import { WorkspaceService } from './workspace-service';
import { WorkspaceUtils } from './workspace-utils';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileSystemPreferences } from '@theia/filesystem/lib/common/filesystem-preferences';
import { FileDeleteOptions, FileSystemProviderCapabilities } from '@theia/filesystem/lib/common/files';
import { nls } from '@theia/core/lib/common/nls';
@injectable()
export class WorkspaceDeleteHandler implements UriCommandHandler<URI[]> {
@inject(FileService)
protected readonly fileService: FileService;
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@inject(WorkspaceUtils)
protected readonly workspaceUtils: WorkspaceUtils;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(FileSystemPreferences)
protected readonly fsPreferences: FileSystemPreferences;
/**
* Determine if the command is visible.
*
* @param uris URIs of selected resources.
* @returns `true` if the command is visible.
*/
isVisible(uris: URI[]): boolean {
return !!uris.length && !this.workspaceUtils.containsRootDirectory(uris);
}
/**
* Determine if the command is enabled.
*
* @param uris URIs of selected resources.
* @returns `true` if the command is enabled.
*/
isEnabled(uris: URI[]): boolean {
return !!uris.length && !this.workspaceUtils.containsRootDirectory(uris);
}
/**
* Execute the command.
*
* @param uris URIs of selected resources.
*/
async execute(uris: URI[]): Promise<void> {
const distinctUris = URI.getDistinctParents(uris);
const resolved: FileDeleteOptions = {
recursive: true,
useTrash: this.fsPreferences['files.enableTrash'] && distinctUris[0] && this.fileService.hasCapability(distinctUris[0], FileSystemProviderCapabilities.Trash)
};
if (await this.confirm(distinctUris, resolved)) {
await Promise.all(distinctUris.map(uri => this.delete(uri, resolved)));
}
}
/**
* Display dialog to confirm deletion.
*
* @param uris URIs of selected resources.
*/
protected confirm(uris: URI[], options: FileDeleteOptions): Promise<boolean | undefined> {
let title = uris.length === 1 ? nls.localizeByDefault('File') : nls.localizeByDefault('Files');
if (options.useTrash) {
title = nls.localize('theia/workspace/trashTitle', 'Move {0} to Trash', title);
} else {
title = nls.localizeByDefault('Delete {0}', title);
}
return new ConfirmDialog({
title,
msg: this.getConfirmMessage(uris)
}).open();
}
/**
* Get the dialog confirmation message for deletion.
*
* @param uris URIs of selected resources.
*/
protected getConfirmMessage(uris: URI[]): string | HTMLElement {
const dirty = this.getDirty(uris);
if (dirty.length) {
if (dirty.length === 1) {
return nls.localize('theia/workspace/confirmMessage.dirtySingle', 'Do you really want to delete {0} with unsaved changes?', dirty[0].path.base);
}
return nls.localize('theia/workspace/confirmMessage.dirtyMultiple', 'Do you really want to delete {0} files with unsaved changes?', dirty.length);
}
if (uris.length === 1) {
return nls.localize('theia/workspace/confirmMessage.uriSingle', 'Do you really want to delete {0}?', uris[0].path.base);
}
if (uris.length > 10) {
return nls.localize('theia/workspace/confirmMessage.uriMultiple', 'Do you really want to delete all the {0} selected files?', uris.length);
}
const messageContainer = document.createElement('div');
messageContainer.textContent = nls.localize('theia/workspace/confirmMessage.delete', 'Do you really want to delete the following files?');
const list = document.createElement('ul');
list.style.listStyleType = 'none';
for (const uri of uris) {
const listItem = document.createElement('li');
listItem.textContent = uri.path.base;
list.appendChild(listItem);
}
messageContainer.appendChild(list);
return messageContainer;
}
/**
* Get which URI are presently dirty.
*
* @param uris URIs of selected resources.
* @returns An array of dirty URI.
*/
protected getDirty(uris: URI[]): URI[] {
const dirty = new Map<string, URI>();
const widgets = NavigatableWidget.getAffected(SaveableWidget.getDirty(this.shell.widgets), uris);
for (const [resourceUri] of widgets) {
dirty.set(resourceUri.toString(), resourceUri);
}
return [...dirty.values()];
}
/**
* Perform deletion of a given URI.
*
* @param uri URI of selected resource.
* @param options deletion options.
*/
protected async delete(uri: URI, options: FileDeleteOptions): Promise<void> {
try {
await Promise.all([
this.closeWithoutSaving(uri),
options.useTrash ? this.moveFileToTrash(uri, options) : this.deleteFilePermanently(uri, options)
]);
} catch (e) {
console.error(e);
}
}
protected async deleteFilePermanently(uri: URI, options: FileDeleteOptions): Promise<void> {
this.fileService.delete(uri, { ...options, useTrash: false });
}
protected async moveFileToTrash(uri: URI, options: FileDeleteOptions): Promise<void> {
try {
await this.fileService.delete(uri, { ...options, useTrash: true });
} catch (error) {
console.error('Error deleting with trash:', error);
if (await this.confirmDeletePermanently(uri)) {
return this.deleteFilePermanently(uri, options);
}
}
}
/**
* Display dialog to confirm the permanent deletion of a file.
*
* @param uri URI of selected resource.
*/
protected async confirmDeletePermanently(uri: URI): Promise<boolean> {
const title = nls.localize('theia/workspace/confirmDeletePermanently.title', 'Error deleting file');
const msg = document.createElement('div');
const question = document.createElement('p');
question.textContent = nls.localize('theia/workspace/confirmDeletePermanently.description',
'Failed to delete "{0}" using the Trash. Do you want to permanently delete instead?',
uri.path.base);
msg.append(question);
const info = document.createElement('p');
info.textContent = nls.localize('theia/workspace/confirmDeletePermanently.solution', 'You can disable the use of Trash in the preferences.');
msg.append(info);
const response = await new ConfirmDialog({ title, msg }).open();
return response || false;
}
/**
* Close widget without saving changes.
*
* @param uri URI of a selected resource.
*/
protected async closeWithoutSaving(uri: URI): Promise<void> {
const toClose = [...NavigatableWidget.getAffected(this.shell.widgets, uri)].map(([, widget]) => widget);
await this.shell.closeMany(toClose, { save: false });
}
}

View File

@@ -0,0 +1,75 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import URI from '@theia/core/lib/common/uri';
import { injectable, inject } from '@theia/core/shared/inversify';
import { WorkspaceUtils } from './workspace-utils';
import { WorkspaceService } from './workspace-service';
import { UriCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import { FileSystemUtils } from '@theia/filesystem/lib/common/filesystem-utils';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
@injectable()
export class WorkspaceDuplicateHandler implements UriCommandHandler<URI[]> {
@inject(FileService)
protected readonly fileService: FileService;
@inject(WorkspaceUtils)
protected readonly workspaceUtils: WorkspaceUtils;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
/**
* Determine if the command is visible.
*
* @param uris URIs of selected resources.
* @returns `true` if the command is visible.
*/
isVisible(uris: URI[]): boolean {
return !!uris.length && !this.workspaceUtils.containsRootDirectory(uris);
}
/**
* Determine if the command is enabled.
*
* @param uris URIs of selected resources.
* @returns `true` if the command is enabled.
*/
isEnabled(uris: URI[]): boolean {
return !!uris.length && !this.workspaceUtils.containsRootDirectory(uris);
}
/**
* Execute the command.
*
* @param uris URIs of selected resources.
*/
async execute(uris: URI[]): Promise<void> {
await Promise.all(uris.map(async uri => {
try {
const parent = await this.fileService.resolve(uri.parent);
const targetFileStat = await this.fileService.resolve(uri);
const target = FileSystemUtils.generateUniqueResourceURI(parent, uri, targetFileStat.isDirectory, 'copy');
await this.fileService.copy(uri, target);
} catch (e) {
console.error(e);
}
}));
}
}

View File

@@ -0,0 +1,580 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, MessageService, isWindows, MaybeArray } from '@theia/core/lib/common';
import { isOSX, environment } from '@theia/core';
import {
open, OpenerService, CommonMenus, KeybindingRegistry, KeybindingContribution,
FrontendApplicationContribution, SHELL_TABBAR_CONTEXT_COPY, OnWillStopAction, Navigatable, SaveableSource, Widget,
QuickInputService, QuickPickItem
} from '@theia/core/lib/browser';
import { FileDialogService, OpenFileDialogProps, FileDialogTreeFilters } from '@theia/filesystem/lib/browser';
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { WorkspaceService } from './workspace-service';
import { WorkspaceFileService, THEIA_EXT, VSCODE_EXT } from '../common';
import { WorkspaceCommands } from './workspace-commands';
import { WorkspaceTrustService } from './workspace-trust-service';
import { QuickOpenWorkspace } from './quick-open-workspace';
import URI from '@theia/core/lib/common/uri';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { EncodingRegistry } from '@theia/core/lib/browser/encoding-registry';
import { UTF8 } from '@theia/core/lib/common/encodings';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { PreferenceConfigurations } from '@theia/core/lib/common/preferences/preference-configurations';
import { nls } from '@theia/core/lib/common/nls';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { UntitledWorkspaceExitDialog } from './untitled-workspace-exit-dialog';
import { FilesystemSaveableService } from '@theia/filesystem/lib/browser/filesystem-saveable-service';
import { StopReason } from '@theia/core/lib/common/frontend-application-state';
export enum WorkspaceStates {
/**
* The state is `empty` when no workspace is opened.
*/
empty = 'empty',
/**
* The state is `workspace` when a workspace is opened.
*/
workspace = 'workspace',
/**
* The state is `folder` when a folder is opened. (1 folder)
*/
folder = 'folder',
};
export type WorkspaceState = keyof typeof WorkspaceStates;
export type WorkbenchState = keyof typeof WorkspaceStates;
/** Create the workspace section after open {@link CommonMenus.FILE_OPEN}. */
export const FILE_WORKSPACE = [...CommonMenus.FILE, '2_workspace'];
@injectable()
export class WorkspaceFrontendContribution implements CommandContribution, KeybindingContribution, MenuContribution, FrontendApplicationContribution {
@inject(MessageService) protected readonly messageService: MessageService;
@inject(FileService) protected readonly fileService: FileService;
@inject(OpenerService) protected readonly openerService: OpenerService;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(QuickOpenWorkspace) protected readonly quickOpenWorkspace: QuickOpenWorkspace;
@inject(FileDialogService) protected readonly fileDialogService: FileDialogService;
@inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService;
@inject(EncodingRegistry) protected readonly encodingRegistry: EncodingRegistry;
@inject(PreferenceConfigurations) protected readonly preferenceConfigurations: PreferenceConfigurations;
@inject(FilesystemSaveableService) protected readonly saveService: FilesystemSaveableService;
@inject(WorkspaceFileService) protected readonly workspaceFileService: WorkspaceFileService;
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
@inject(WorkspaceTrustService)
protected readonly workspaceTrustService: WorkspaceTrustService;
configure(): void {
const workspaceExtensions = this.workspaceFileService.getWorkspaceFileExtensions();
for (const extension of workspaceExtensions) {
this.encodingRegistry.registerOverride({ encoding: UTF8, extension });
}
this.updateEncodingOverrides();
const workspaceFolderCountKey = this.contextKeyService.createKey<number>('workspaceFolderCount', 0);
const updateWorkspaceFolderCountKey = () => workspaceFolderCountKey.set(this.workspaceService.tryGetRoots().length);
updateWorkspaceFolderCountKey();
const workspaceStateKey = this.contextKeyService.createKey<WorkspaceState>('workspaceState', 'empty');
const updateWorkspaceStateKey = () => workspaceStateKey.set(this.updateWorkspaceStateKey());
updateWorkspaceStateKey();
const workbenchStateKey = this.contextKeyService.createKey<WorkbenchState>('workbenchState', 'empty');
const updateWorkbenchStateKey = () => workbenchStateKey.set(this.updateWorkbenchStateKey());
updateWorkbenchStateKey();
this.updateStyles();
this.workspaceService.onWorkspaceChanged(() => {
this.updateEncodingOverrides();
updateWorkspaceFolderCountKey();
updateWorkspaceStateKey();
updateWorkbenchStateKey();
this.updateStyles();
});
}
protected readonly toDisposeOnUpdateEncodingOverrides = new DisposableCollection();
protected updateEncodingOverrides(): void {
this.toDisposeOnUpdateEncodingOverrides.dispose();
for (const root of this.workspaceService.tryGetRoots()) {
for (const configPath of this.preferenceConfigurations.getPaths()) {
const parent = root.resource.resolve(configPath);
this.toDisposeOnUpdateEncodingOverrides.push(this.encodingRegistry.registerOverride({ encoding: UTF8, parent }));
}
}
}
protected updateStyles(): void {
document.body.classList.remove('theia-no-open-workspace');
// Display the 'no workspace opened' theme color when no folders are opened (single-root).
if (!this.workspaceService.isMultiRootWorkspaceOpened &&
!this.workspaceService.tryGetRoots().length) {
document.body.classList.add('theia-no-open-workspace');
}
}
registerCommands(commands: CommandRegistry): void {
// Not visible/enabled on Windows/Linux in electron.
commands.registerCommand(WorkspaceCommands.OPEN, {
isEnabled: () => isOSX || !this.isElectron(),
isVisible: () => isOSX || !this.isElectron(),
execute: () => this.doOpen()
});
// Visible/enabled only on Windows/Linux in electron.
commands.registerCommand(WorkspaceCommands.OPEN_FILE, {
isEnabled: () => true,
execute: () => this.doOpenFile()
});
// Visible/enabled only on Windows/Linux in electron.
commands.registerCommand(WorkspaceCommands.OPEN_FOLDER, {
isEnabled: () => true,
execute: () => this.doOpenFolder()
});
commands.registerCommand(WorkspaceCommands.OPEN_WORKSPACE, {
isEnabled: () => true,
execute: () => this.doOpenWorkspace()
});
commands.registerCommand(WorkspaceCommands.CLOSE, {
isEnabled: () => this.workspaceService.opened,
execute: () => this.closeWorkspace()
});
commands.registerCommand(WorkspaceCommands.OPEN_RECENT_WORKSPACE, {
execute: () => this.quickOpenWorkspace.select()
});
commands.registerCommand(WorkspaceCommands.SAVE_WORKSPACE_AS, {
isVisible: () => this.workspaceService.opened,
isEnabled: () => this.workspaceService.opened,
execute: () => this.saveWorkspaceAs()
});
commands.registerCommand(WorkspaceCommands.OPEN_WORKSPACE_FILE, {
isEnabled: () => this.workspaceService.saved,
execute: () => {
if (this.workspaceService.saved && this.workspaceService.workspace) {
open(this.openerService, this.workspaceService.workspace.resource);
}
}
});
commands.registerCommand(WorkspaceCommands.MANAGE_WORKSPACE_TRUST, {
execute: () => this.manageWorkspaceTrust()
});
}
registerMenus(menus: MenuModelRegistry): void {
if (isOSX || !this.isElectron()) {
menus.registerMenuAction(CommonMenus.FILE_OPEN, {
commandId: WorkspaceCommands.OPEN.id,
order: 'a00'
});
}
if (!isOSX && this.isElectron()) {
menus.registerMenuAction(CommonMenus.FILE_OPEN, {
commandId: WorkspaceCommands.OPEN_FILE.id,
label: `${WorkspaceCommands.OPEN_FILE.dialogLabel}...`,
order: 'a01'
});
menus.registerMenuAction(CommonMenus.FILE_OPEN, {
commandId: WorkspaceCommands.OPEN_FOLDER.id,
label: `${WorkspaceCommands.OPEN_FOLDER.dialogLabel}...`,
order: 'a02'
});
}
menus.registerMenuAction(CommonMenus.FILE_OPEN, {
commandId: WorkspaceCommands.OPEN_WORKSPACE.id,
order: 'a10'
});
menus.registerMenuAction(CommonMenus.FILE_OPEN, {
commandId: WorkspaceCommands.OPEN_RECENT_WORKSPACE.id,
order: 'a20'
});
menus.registerMenuAction(FILE_WORKSPACE, {
commandId: WorkspaceCommands.ADD_FOLDER.id,
order: 'a10'
});
menus.registerMenuAction(FILE_WORKSPACE, {
commandId: WorkspaceCommands.SAVE_WORKSPACE_AS.id,
order: 'a20'
});
menus.registerMenuAction(CommonMenus.FILE_CLOSE, {
commandId: WorkspaceCommands.CLOSE.id
});
menus.registerMenuAction(CommonMenus.FILE_SAVE, {
commandId: WorkspaceCommands.SAVE_AS.id,
});
menus.registerMenuAction(SHELL_TABBAR_CONTEXT_COPY, {
commandId: WorkspaceCommands.COPY_RELATIVE_FILE_PATH.id,
label: WorkspaceCommands.COPY_RELATIVE_FILE_PATH.label,
});
}
registerKeybindings(keybindings: KeybindingRegistry): void {
keybindings.registerKeybinding({
command: isOSX || !this.isElectron() ? WorkspaceCommands.OPEN.id : WorkspaceCommands.OPEN_FILE.id,
keybinding: this.isElectron() ? 'ctrlcmd+o' : 'ctrlcmd+alt+o',
});
if (!isOSX && this.isElectron()) {
keybindings.registerKeybinding({
command: WorkspaceCommands.OPEN_FOLDER.id,
keybinding: 'ctrl+k ctrl+o',
});
}
keybindings.registerKeybinding({
command: WorkspaceCommands.OPEN_WORKSPACE.id,
keybinding: 'ctrlcmd+alt+w',
});
keybindings.registerKeybinding({
command: WorkspaceCommands.OPEN_RECENT_WORKSPACE.id,
keybinding: 'ctrlcmd+alt+r',
});
keybindings.registerKeybinding({
command: WorkspaceCommands.SAVE_AS.id,
keybinding: 'ctrlcmd+shift+s',
});
keybindings.registerKeybinding({
command: WorkspaceCommands.COPY_RELATIVE_FILE_PATH.id,
keybinding: isWindows ? 'ctrl+k ctrl+shift+c' : 'ctrlcmd+shift+alt+c',
when: '!editorFocus'
});
}
/**
* This is the generic `Open` method. Opens files and directories too. Resolves to the opened URI.
* Except when you are on either Windows or Linux `AND` running in electron. If so, it opens a file.
*/
protected async doOpen(): Promise<URI[] | undefined> {
if (!isOSX && this.isElectron()) {
return this.doOpenFile();
}
const [rootStat] = await this.workspaceService.roots;
let selectedUris = await this.fileDialogService.showOpenDialog({
title: WorkspaceCommands.OPEN.dialogLabel,
canSelectFolders: true,
canSelectFiles: true,
canSelectMany: true
}, rootStat);
if (selectedUris) {
if (!Array.isArray(selectedUris)) {
selectedUris = [selectedUris];
}
const folders: URI[] = [];
// Only open files then open all folders in a new workspace, as done with Electron see doOpenFolder.
for (const uri of selectedUris) {
const destination = await this.fileService.resolve(uri);
if (destination.isDirectory) {
if (this.getCurrentWorkspaceUri()?.toString() !== uri.toString()) {
folders.push(uri);
}
} else {
await open(this.openerService, uri);
}
}
if (folders.length > 0) {
const openableURI = await this.getOpenableWorkspaceUri(folders);
if (openableURI && (!this.workspaceService.workspace || !openableURI.isEqual(this.workspaceService.workspace.resource))) {
this.workspaceService.open(openableURI);
}
}
return selectedUris;
}
return undefined;
}
/**
* Opens a set of files after prompting the `Open File` dialog. Resolves to `undefined`, if
* - the workspace root is not set,
* - the file to open does not exist, or
* - it was not a file, but a directory.
*
* Otherwise, resolves to the set of URIs of the files.
*/
protected async doOpenFile(): Promise<URI[] | undefined> {
const props: OpenFileDialogProps = {
title: WorkspaceCommands.OPEN_FILE.dialogLabel,
canSelectFolders: false,
canSelectFiles: true,
canSelectMany: true
};
const [rootStat] = await this.workspaceService.roots;
let selectedFilesUris: MaybeArray<URI> | undefined = await this.fileDialogService.showOpenDialog(props, rootStat);
if (selectedFilesUris) {
if (!Array.isArray(selectedFilesUris)) {
selectedFilesUris = [selectedFilesUris];
}
const result = [];
for (const uri of selectedFilesUris) {
const destination = await this.fileService.resolve(uri);
if (destination.isFile) {
await open(this.openerService, uri);
result.push(uri);
}
}
return result;
}
return undefined;
}
/**
* Opens one or more folders after prompting the `Open Folder` dialog. Resolves to `undefined`, if
* - the user's selection is empty or contains only files.
* - the new workspace is equal to the old workspace.
*
* Otherwise, resolves to the URI of the new workspace:
* - a single folder if a single folder was selected.
* - a new, untitled workspace file if multiple folders were selected.
*/
protected async doOpenFolder(): Promise<URI | undefined> {
const props: OpenFileDialogProps = {
title: WorkspaceCommands.OPEN_FOLDER.dialogLabel,
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: true,
};
const [rootStat] = await this.workspaceService.roots;
const targetFolders = await this.fileDialogService.showOpenDialog(props, rootStat);
if (targetFolders) {
const openableUri = await this.getOpenableWorkspaceUri(targetFolders);
if (openableUri) {
if (!this.workspaceService.workspace || !openableUri.isEqual(this.workspaceService.workspace.resource)) {
this.workspaceService.open(openableUri);
return openableUri;
}
};
}
return undefined;
}
protected async getOpenableWorkspaceUri(uris: MaybeArray<URI>): Promise<URI | undefined> {
if (Array.isArray(uris)) {
if (uris.length < 2) {
return uris[0];
} else {
const foldersToOpen = (await Promise.all(uris.map(uri => this.fileService.resolve(uri))))
.filter(fileStat => !!fileStat?.isDirectory);
if (foldersToOpen.length === 1) {
return foldersToOpen[0].resource;
} else {
return this.createMultiRootWorkspace(foldersToOpen);
}
}
} else {
return uris;
}
}
protected async createMultiRootWorkspace(roots: FileStat[]): Promise<URI> {
const untitledWorkspace = await this.workspaceService.getUntitledWorkspace();
const folders = Array.from(new Set(roots.map(stat => stat.resource.path.toString())), path => ({ path }));
const workspaceStat = await this.fileService.createFile(
untitledWorkspace,
BinaryBuffer.fromString(JSON.stringify({ folders }, null, 4)), // eslint-disable-line no-null/no-null
{ overwrite: true }
);
return workspaceStat.resource;
}
/**
* Opens a workspace after raising the `Open Workspace` dialog. Resolves to the URI of the recently opened workspace,
* if it was successful. Otherwise, resolves to `undefined`.
*/
protected async doOpenWorkspace(): Promise<URI | undefined> {
const props = {
title: WorkspaceCommands.OPEN_WORKSPACE.dialogLabel,
canSelectFiles: true,
canSelectFolders: false,
filters: this.getWorkspaceDialogFileFilters()
};
const [rootStat] = await this.workspaceService.roots;
const workspaceFileUri = await this.fileDialogService.showOpenDialog(props, rootStat);
if (workspaceFileUri &&
this.getCurrentWorkspaceUri()?.toString() !== workspaceFileUri.toString()) {
if (await this.fileService.exists(workspaceFileUri)) {
this.workspaceService.open(workspaceFileUri);
return workspaceFileUri;
}
}
return undefined;
}
protected async closeWorkspace(): Promise<void> {
await this.workspaceService.close();
}
/**
* @returns whether the file was successfully saved.
*/
protected async saveWorkspaceAs(): Promise<boolean> {
let exist: boolean = false;
let overwrite: boolean = false;
let selected: URI | undefined;
do {
selected = await this.fileDialogService.showSaveDialog({
title: WorkspaceCommands.SAVE_WORKSPACE_AS.label!,
filters: this.getWorkspaceDialogFileFilters()
});
if (selected) {
const displayName = selected.displayName;
const extensions = this.workspaceFileService.getWorkspaceFileExtensions(true);
if (!extensions.some(ext => displayName.endsWith(ext))) {
const defaultExtension = extensions[this.workspaceFileService.defaultFileTypeIndex];
selected = selected.parent.resolve(`${displayName}${defaultExtension}`);
}
exist = await this.fileService.exists(selected);
if (exist) {
overwrite = await this.saveService.confirmOverwrite(selected);
}
}
} while (selected && exist && !overwrite);
if (selected) {
try {
await this.workspaceService.save(selected);
return true;
} catch {
this.messageService.error(nls.localizeByDefault("Unable to save workspace '{0}'", selected.path.fsPath()));
}
}
return false;
}
canBeSavedAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable {
return this.saveService.canSaveAs(widget);
}
async saveAs(widget: Widget & SaveableSource & Navigatable): Promise<void> {
await this.saveService.saveAs(widget);
}
protected updateWorkspaceStateKey(): WorkspaceState {
return this.doUpdateState();
}
protected updateWorkbenchStateKey(): WorkbenchState {
return this.doUpdateState();
}
protected doUpdateState(): WorkspaceState | WorkbenchState {
if (this.workspaceService.opened) {
return this.workspaceService.isMultiRootWorkspaceOpened ? 'workspace' : 'folder';
}
return 'empty';
}
protected getWorkspaceDialogFileFilters(): FileDialogTreeFilters {
const filters: FileDialogTreeFilters = {};
for (const fileType of this.workspaceFileService.getWorkspaceFileTypes()) {
filters[`${nls.localizeByDefault('{0} workspace', fileType.name)} (*.${fileType.extension})`] = [fileType.extension];
}
return filters;
}
private isElectron(): boolean {
return environment.electron.is();
}
/**
* Get the current workspace URI.
*
* @returns the current workspace URI.
*/
private getCurrentWorkspaceUri(): URI | undefined {
return this.workspaceService.workspace?.resource;
}
protected async manageWorkspaceTrust(): Promise<void> {
const currentTrust = await this.workspaceTrustService.getWorkspaceTrust();
const trust = nls.localizeByDefault('Trust');
const dontTrust = nls.localizeByDefault("Don't Trust");
const currentSuffix = `(${nls.localizeByDefault('Current')})`;
const items: QuickPickItem[] = [
{
label: trust,
description: currentTrust ? currentSuffix : undefined
},
{
label: dontTrust,
description: !currentTrust ? currentSuffix : undefined
}
];
const selected = await this.quickInputService.showQuickPick(items, {
title: nls.localizeByDefault('Manage Workspace Trust'),
placeholder: nls.localize('theia/workspace/manageTrustPlaceholder', 'Select trust state for this workspace')
});
if (selected) {
const newTrust = selected.label === trust;
if (newTrust !== currentTrust) {
this.workspaceTrustService.setWorkspaceTrust(newTrust);
if (newTrust) {
await this.workspaceTrustService.addToTrustedFolders();
} else {
await this.workspaceTrustService.removeFromTrustedFolders();
}
}
}
}
onWillStop(): OnWillStopAction<boolean> | undefined {
const { workspace } = this.workspaceService;
if (workspace && this.workspaceService.isUntitledWorkspace(workspace.resource)) {
return {
prepare: async reason => reason === StopReason.Reload && this.workspaceService.isSafeToReload(workspace.resource),
action: async alreadyConfirmedSafe => {
if (alreadyConfirmedSafe) {
return true;
}
const shouldSaveFile = await new UntitledWorkspaceExitDialog({
title: nls.localizeByDefault('Do you want to save your workspace configuration as a file?')
}).open();
if (shouldSaveFile === "Don't Save") {
return true;
} else if (shouldSaveFile === 'Save') {
return this.saveWorkspaceAs();
}
return false; // If cancel, prevent exit.
},
reason: 'Untitled workspace.',
// Since deleting the workspace would hobble any future functionality, run this late.
priority: 100,
};
}
}
}
export namespace WorkspaceFrontendContribution {
/**
* File filter for all Theia and VS Code workspace file types.
*
* @deprecated Since 1.39.0 Use `WorkspaceFrontendContribution#getWorkspaceDialogFileFilters` instead.
*/
export const DEFAULT_FILE_FILTER: FileDialogTreeFilters = {
'Theia Workspace (*.theia-workspace)': [THEIA_EXT],
'VS Code Workspace (*.code-workspace)': [VSCODE_EXT]
};
}

View File

@@ -0,0 +1,130 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import '../../src/browser/style/index.css';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { CommandContribution, MenuContribution, bindContributionProvider } from '@theia/core/lib/common';
import { WebSocketConnectionProvider, FrontendApplicationContribution, KeybindingContribution } from '@theia/core/lib/browser';
import {
OpenFileDialogFactory,
SaveFileDialogFactory,
OpenFileDialogProps,
SaveFileDialogProps,
createOpenFileDialogContainer,
createSaveFileDialogContainer,
OpenFileDialog,
SaveFileDialog,
} from '@theia/filesystem/lib/browser';
import { StorageService } from '@theia/core/lib/browser/storage-service';
import { LabelProviderContribution } from '@theia/core/lib/browser/label-provider';
import { VariableContribution } from '@theia/variable-resolver/lib/browser';
import { WorkspaceServer, workspacePath, UntitledWorkspaceService, WorkspaceFileService } from '../common';
import { WorkspaceFrontendContribution } from './workspace-frontend-contribution';
import { WorkspaceOpenHandlerContribution, WorkspaceService } from './workspace-service';
import { WorkspaceCommandContribution, FileMenuContribution, EditMenuContribution } from './workspace-commands';
import { WorkspaceVariableContribution } from './workspace-variable-contribution';
import { WorkspaceStorageService } from './workspace-storage-service';
import { WorkspaceUriLabelProviderContribution } from './workspace-uri-contribution';
import { bindWorkspacePreferences } from '../common/workspace-preferences';
import { QuickOpenWorkspace } from './quick-open-workspace';
import { WorkspaceDeleteHandler } from './workspace-delete-handler';
import { WorkspaceDuplicateHandler } from './workspace-duplicate-handler';
import { WorkspaceUtils } from './workspace-utils';
import { WorkspaceCompareHandler } from './workspace-compare-handler';
import { DiffService } from './diff-service';
import { JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-store';
import { WorkspaceSchemaUpdater } from './workspace-schema-updater';
import { WorkspaceBreadcrumbsContribution } from './workspace-breadcrumbs-contribution';
import { FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution';
import { WorkspaceTrustService, WorkspaceRestrictionContribution } from './workspace-trust-service';
import { bindWorkspaceTrustPreferences } from '../common/workspace-trust-preferences';
import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider';
import { WorkspaceUserWorkingDirectoryProvider } from './workspace-user-working-directory-provider';
import { WindowTitleUpdater } from '@theia/core/lib/browser/window/window-title-updater';
import { WorkspaceWindowTitleUpdater } from './workspace-window-title-updater';
import { CanonicalUriService } from './canonical-uri-service';
import { WorkspaceMetadataStorageService, WorkspaceMetadataStorageServiceImpl, WorkspaceMetadataStoreFactory } from './metadata-storage';
import { WorkspaceMetadataStoreImpl } from './metadata-storage/workspace-metadata-store';
export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => {
bindWorkspacePreferences(bind);
bindWorkspaceTrustPreferences(bind);
bindContributionProvider(bind, WorkspaceOpenHandlerContribution);
bind(WorkspaceService).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(WorkspaceService);
bind(CanonicalUriService).toSelf().inSingletonScope();
bind(WorkspaceServer).toDynamicValue(ctx => {
const provider = ctx.container.get(WebSocketConnectionProvider);
return provider.createProxy<WorkspaceServer>(workspacePath);
}).inSingletonScope();
bind(WorkspaceFrontendContribution).toSelf().inSingletonScope();
for (const identifier of [FrontendApplicationContribution, CommandContribution, KeybindingContribution, MenuContribution]) {
bind(identifier).toService(WorkspaceFrontendContribution);
}
bind(OpenFileDialogFactory).toFactory(ctx =>
(props: OpenFileDialogProps) =>
createOpenFileDialogContainer(ctx.container, props).get(OpenFileDialog)
);
bind(SaveFileDialogFactory).toFactory(ctx =>
(props: SaveFileDialogProps) =>
createSaveFileDialogContainer(ctx.container, props).get(SaveFileDialog)
);
bind(WorkspaceCommandContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(WorkspaceCommandContribution);
bind(FileMenuContribution).toSelf().inSingletonScope();
bind(MenuContribution).toService(FileMenuContribution);
bind(EditMenuContribution).toSelf().inSingletonScope();
bind(MenuContribution).toService(EditMenuContribution);
bind(WorkspaceDeleteHandler).toSelf().inSingletonScope();
bind(WorkspaceDuplicateHandler).toSelf().inSingletonScope();
bind(WorkspaceCompareHandler).toSelf().inSingletonScope();
bind(DiffService).toSelf().inSingletonScope();
bind(WorkspaceStorageService).toSelf().inSingletonScope();
rebind(StorageService).toService(WorkspaceStorageService);
bind(WorkspaceMetadataStoreImpl).toSelf();
bind(WorkspaceMetadataStoreFactory).toFactory(ctx => () => ctx.container.get(WorkspaceMetadataStoreImpl));
bind(WorkspaceMetadataStorageServiceImpl).toSelf().inSingletonScope();
bind(WorkspaceMetadataStorageService).toService(WorkspaceMetadataStorageServiceImpl);
bind(LabelProviderContribution).to(WorkspaceUriLabelProviderContribution).inSingletonScope();
bind(WorkspaceVariableContribution).toSelf().inSingletonScope();
bind(VariableContribution).toService(WorkspaceVariableContribution);
bind(QuickOpenWorkspace).toSelf().inSingletonScope();
bind(WorkspaceUtils).toSelf().inSingletonScope();
bind(WorkspaceFileService).toSelf().inSingletonScope();
bind(UntitledWorkspaceService).toSelf().inSingletonScope();
bind(WorkspaceSchemaUpdater).toSelf().inSingletonScope();
bind(JsonSchemaContribution).toService(WorkspaceSchemaUpdater);
rebind(FilepathBreadcrumbsContribution).to(WorkspaceBreadcrumbsContribution).inSingletonScope();
bindContributionProvider(bind, WorkspaceRestrictionContribution);
bind(WorkspaceTrustService).toSelf().inSingletonScope();
rebind(UserWorkingDirectoryProvider).to(WorkspaceUserWorkingDirectoryProvider).inSingletonScope();
rebind(WindowTitleUpdater).to(WorkspaceWindowTitleUpdater).inSingletonScope();
});

View File

@@ -0,0 +1,61 @@
// *****************************************************************************
// Copyright (C) 2019 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { SingleTextInputDialog, SingleTextInputDialogProps, LabelProvider, codiconArray } from '@theia/core/lib/browser';
@injectable()
export class WorkspaceInputDialogProps extends SingleTextInputDialogProps {
/**
* The parent `URI` for the selection present in the explorer.
* Used to display the path in which the file/folder is created at.
*/
parentUri: URI;
}
export class WorkspaceInputDialog extends SingleTextInputDialog {
constructor(
@inject(WorkspaceInputDialogProps) protected override readonly props: WorkspaceInputDialogProps,
@inject(LabelProvider) protected readonly labelProvider: LabelProvider,
) {
super(props);
this.appendParentPath();
}
/**
* Append the human-readable parent `path` to the dialog.
* When possible, display the relative path, else display the full path (ex: workspace root).
*/
protected appendParentPath(): void {
// Compute the label for the parent URI.
const label = this.labelProvider.getLongName(this.props.parentUri);
const element = document.createElement('div');
// Create the `folder` icon.
const icon = document.createElement('i');
icon.classList.add(...codiconArray('folder'));
icon.style.marginRight = '0.5em';
icon.style.verticalAlign = 'middle';
element.style.verticalAlign = 'middle';
element.style.paddingBottom = '1em';
element.title = this.props.parentUri.path.fsPath();
element.appendChild(icon);
element.appendChild(document.createTextNode(label));
// Add the path and icon div before the `inputField`.
this.contentNode.insertBefore(element, this.inputField);
}
}

View File

@@ -0,0 +1,150 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { JsonSchemaContribution, JsonSchemaDataStore, JsonSchemaRegisterContext } from '@theia/core/lib/browser/json-schema-store';
import { isArray, isObject, nls } from '@theia/core/lib/common';
import { IJSONSchema } from '@theia/core/lib/common/json-schema';
import URI from '@theia/core/lib/common/uri';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { WorkspaceFileService } from '../common';
export interface SchemaUpdateMessage {
key: string,
schema?: IJSONSchema,
deferred: Deferred<boolean>;
}
export namespace AddKeyMessage {
export const is = (message: SchemaUpdateMessage | undefined): message is Required<SchemaUpdateMessage> => !!message && message.schema !== undefined;
}
@injectable()
export class WorkspaceSchemaUpdater implements JsonSchemaContribution {
protected readonly uri = new URI(workspaceSchemaId);
protected readonly editQueue: SchemaUpdateMessage[] = [];
protected safeToHandleQueue = new Deferred();
@inject(JsonSchemaDataStore) protected readonly jsonSchemaData: JsonSchemaDataStore;
@inject(WorkspaceFileService) protected readonly workspaceFileService: WorkspaceFileService;
@postConstruct()
protected init(): void {
this.jsonSchemaData.setSchema(this.uri, workspaceSchema);
this.safeToHandleQueue.resolve();
}
registerSchemas(context: JsonSchemaRegisterContext): void {
context.registerSchema({
fileMatch: this.workspaceFileService.getWorkspaceFileExtensions(true),
url: this.uri.toString()
});
}
protected async retrieveCurrent(): Promise<WorkspaceSchema> {
const current = this.jsonSchemaData.getSchema(this.uri);
const content = JSON.parse(current || '');
if (!WorkspaceSchema.is(content)) {
throw new Error('Failed to retrieve current workspace schema.');
}
return content;
}
async updateSchema(message: Omit<SchemaUpdateMessage, 'deferred'>): Promise<boolean> {
const doHandle = this.editQueue.length === 0;
const deferred = new Deferred<boolean>();
this.editQueue.push({ ...message, deferred });
if (doHandle) {
this.handleQueue();
}
return deferred.promise;
}
protected async handleQueue(): Promise<void> {
await this.safeToHandleQueue.promise;
this.safeToHandleQueue = new Deferred();
const cache = await this.retrieveCurrent();
while (this.editQueue.length) {
const nextMessage = this.editQueue.shift();
if (AddKeyMessage.is(nextMessage)) {
this.addKey(nextMessage, cache);
} else if (nextMessage) {
this.removeKey(nextMessage, cache);
}
}
this.jsonSchemaData.setSchema(this.uri, cache);
this.safeToHandleQueue.resolve();
}
protected addKey({ key, schema, deferred }: Required<SchemaUpdateMessage>, cache: WorkspaceSchema): void {
if (key in cache.properties) {
return deferred.resolve(false);
}
cache.properties[key] = schema;
deferred.resolve(true);
}
protected removeKey({ key, deferred }: SchemaUpdateMessage, cache: WorkspaceSchema): void {
const canDelete = !cache.required.includes(key);
if (!canDelete) {
return deferred.resolve(false);
}
const keyPresent = delete cache.properties[key];
deferred.resolve(keyPresent);
}
}
export type WorkspaceSchema = Required<Pick<IJSONSchema, 'properties' | 'required'>>;
export namespace WorkspaceSchema {
export function is(candidate: unknown): candidate is WorkspaceSchema {
return isObject<WorkspaceSchema>(candidate)
&& typeof candidate.properties === 'object'
&& isArray(candidate.required);
}
}
export const workspaceSchemaId = 'vscode://schemas/workspace';
export const workspaceSchema: IJSONSchema = {
$id: workspaceSchemaId,
type: 'object',
title: nls.localize('theia/workspace/schema/title', 'Workspace File'),
required: ['folders'],
default: { folders: [{ path: '' }], settings: {} },
properties: {
folders: {
description: nls.localize('theia/workspace/schema/folders/description', 'Root folders in the workspace'),
type: 'array',
items: {
type: 'object',
properties: {
path: {
type: 'string',
}
},
required: ['path']
}
}
},
allowComments: true,
allowTrailingCommas: true,
};

View File

@@ -0,0 +1,830 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct, named } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { WorkspaceServer, UntitledWorkspaceService, WorkspaceFileService } from '../common';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { DEFAULT_WINDOW_HASH } from '@theia/core/lib/common/window';
import {
FrontendApplicationContribution, LabelProvider
} from '@theia/core/lib/browser';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { ILogger, Disposable, DisposableCollection, Emitter, Event, MaybePromise, MessageService, nls, ContributionProvider } from '@theia/core';
import { WorkspacePreferences } from '../common/workspace-preferences';
import * as jsoncparser from 'jsonc-parser';
import * as Ajv from '@theia/core/shared/ajv';
import { FileStat, BaseStat } from '@theia/filesystem/lib/common/files';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WindowTitleService } from '@theia/core/lib/browser/window/window-title-service';
import { FileSystemPreferences } from '@theia/filesystem/lib/common';
import { workspaceSchema, WorkspaceSchemaUpdater } from './workspace-schema-updater';
import { IJSONSchema } from '@theia/core/lib/common/json-schema';
import { StopReason } from '@theia/core/lib/common/frontend-application-state';
import { PreferenceSchemaService, PreferenceScope, PreferenceService } from '@theia/core/lib/common/preferences';
export const WorkspaceOpenHandlerContribution = Symbol('WorkspaceOpenHandlerContribution');
export interface WorkspaceOpenHandlerContribution {
canHandle(uri: URI): MaybePromise<boolean>;
openWorkspace(uri: URI, options?: WorkspaceInput): MaybePromise<void>;
getWorkspaceLabel?(uri: URI): MaybePromise<string | undefined>;
}
/**
* The workspace service.
*/
@injectable()
export class WorkspaceService implements FrontendApplicationContribution, WorkspaceOpenHandlerContribution {
protected _workspace: FileStat | undefined;
protected _roots: FileStat[] = [];
protected deferredRoots = new Deferred<FileStat[]>();
@inject(FileService)
protected readonly fileService: FileService;
@inject(WorkspaceServer)
protected readonly server: WorkspaceServer;
@inject(WindowService)
protected readonly windowService: WindowService;
@inject(ILogger)
protected logger: ILogger;
@inject(WorkspacePreferences)
protected preferences: WorkspacePreferences;
@inject(PreferenceService)
protected readonly preferenceImpl: PreferenceService;
@inject(PreferenceSchemaService)
protected readonly schemaService: PreferenceSchemaService;
@inject(EnvVariablesServer)
protected readonly envVariableServer: EnvVariablesServer;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(FileSystemPreferences)
protected readonly fsPreferences: FileSystemPreferences;
@inject(WorkspaceSchemaUpdater)
protected readonly schemaUpdater: WorkspaceSchemaUpdater;
@inject(UntitledWorkspaceService)
protected readonly untitledWorkspaceService: UntitledWorkspaceService;
@inject(WorkspaceFileService)
protected readonly workspaceFileService: WorkspaceFileService;
@inject(WindowTitleService)
protected readonly windowTitleService: WindowTitleService;
@inject(ContributionProvider) @named(WorkspaceOpenHandlerContribution)
protected readonly openHandlerContribution: ContributionProvider<WorkspaceOpenHandlerContribution>;
protected _ready = new Deferred<void>();
get ready(): Promise<void> {
return this._ready.promise;
}
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
const wsUriString = await this.getDefaultWorkspaceUri();
const wsStat = await this.toFileStat(wsUriString);
await this.setWorkspace(wsStat);
this.fileService.onDidFilesChange(event => {
if (this._workspace && this._workspace.isFile && event.contains(this._workspace.resource)) {
this.updateWorkspace();
}
});
this.fsPreferences.onPreferenceChanged(event => {
if (event.preferenceName === 'files.watcherExclude') {
this.refreshRootWatchers();
}
});
this._ready.resolve();
}
/**
* Resolves to the default workspace URI as string.
*
* The default implementation tries to extract the default workspace location
* from the `window.location.hash`, then falls-back to the most recently
* used workspace root from the server.
*
* It is not ensured that the resolved workspace URI is valid, it can point
* to a non-existing location.
*/
protected getDefaultWorkspaceUri(): MaybePromise<string | undefined> {
return this.doGetDefaultWorkspaceUri();
}
protected async doGetDefaultWorkspaceUri(): Promise<string | undefined> {
// If an empty window is explicitly requested do not restore a previous workspace.
// Note: `window.location.hash` includes leading "#" if non-empty.
if (window.location.hash === `#${DEFAULT_WINDOW_HASH}`) {
window.location.hash = '';
return undefined;
}
// Prefer the workspace path specified as the URL fragment, if present.
if (window.location.hash.length > 1) {
// Remove the leading # and decode the URI.
const wpPath = decodeURI(window.location.hash.substring(1));
let workspaceUri: URI;
if (wpPath.startsWith('//')) {
const unc = wpPath.slice(2);
const firstSlash = unc.indexOf('/');
const authority = firstSlash >= 0 ? unc.slice(0, firstSlash) : unc;
const path = firstSlash >= 0 ? unc.slice(firstSlash) : '/';
workspaceUri = new URI().withPath(path).withAuthority(authority).withScheme('file');
} else {
workspaceUri = new URI().withPath(wpPath).withScheme('file');
}
let workspaceStat: FileStat | undefined;
try {
workspaceStat = await this.fileService.resolve(workspaceUri);
} catch { }
if (workspaceStat && !workspaceStat.isDirectory && !this.isWorkspaceFile(workspaceStat)) {
this.messageService.error(nls.localize('theia/workspace/notWorkspaceFile', 'Not a valid workspace file: {0}', this.labelProvider.getLongName(workspaceUri)));
return undefined;
}
return workspaceUri.toString();
} else {
// Else, ask the server for its suggested workspace (usually the one
// specified on the CLI, or the most recent).
return this.server.getMostRecentlyUsedWorkspace();
}
}
/**
* Set the URL fragment to the given workspace path.
*/
protected setURLFragment(workspacePath: string): void {
window.location.hash = encodeURI(workspacePath);
}
protected getWorkspacePath(resource: URI): string {
return resource.authority
? `//${resource.authority}${resource.path.toString()}`
: resource.path.toString();
}
get roots(): Promise<FileStat[]> {
return this.deferredRoots.promise;
}
tryGetRoots(): FileStat[] {
return this._roots;
}
get workspace(): FileStat | undefined {
return this._workspace;
}
protected readonly onWorkspaceChangeEmitter = new Emitter<FileStat[]>();
get onWorkspaceChanged(): Event<FileStat[]> {
return this.onWorkspaceChangeEmitter.event;
}
protected readonly onWorkspaceLocationChangedEmitter = new Emitter<FileStat | undefined>();
get onWorkspaceLocationChanged(): Event<FileStat | undefined> {
return this.onWorkspaceLocationChangedEmitter.event;
}
protected readonly toDisposeOnWorkspace = new DisposableCollection();
protected async setWorkspace(workspaceStat: FileStat | undefined): Promise<void> {
if (this._workspace && workspaceStat &&
this._workspace.resource === workspaceStat.resource &&
this._workspace.mtime === workspaceStat.mtime &&
this._workspace.etag === workspaceStat.etag &&
this._workspace.size === workspaceStat.size) {
return;
}
this.toDisposeOnWorkspace.dispose();
this._workspace = workspaceStat;
if (this._workspace) {
const uri = this._workspace.resource;
if (this._workspace.isFile) {
this.toDisposeOnWorkspace.push(this.fileService.watch(uri));
this.onWorkspaceLocationChangedEmitter.fire(this._workspace);
}
this.setURLFragment(this.getWorkspacePath(uri));
} else {
this.setURLFragment('');
}
this.updateTitle();
await this.server.setMostRecentlyUsedWorkspace(this._workspace ? this._workspace.resource.toString() : '');
await this.updateWorkspace();
}
protected async updateWorkspace(): Promise<void> {
await this.updateRoots();
this.watchRoots();
}
protected async updateRoots(): Promise<void> {
const newRoots = await this.computeRoots();
let rootsChanged = false;
if (newRoots.length !== this._roots.length || newRoots.length === 0) {
rootsChanged = true;
} else {
for (const newRoot of newRoots) {
if (!this._roots.some(r => r.resource.toString() === newRoot.resource.toString())) {
rootsChanged = true;
break;
}
}
}
if (rootsChanged) {
this._roots = newRoots;
this.deferredRoots.resolve(this._roots); // in order to resolve first
this.deferredRoots = new Deferred<FileStat[]>();
this.deferredRoots.resolve(this._roots);
this.onWorkspaceChangeEmitter.fire(this._roots);
}
}
protected async computeRoots(): Promise<FileStat[]> {
const roots: FileStat[] = [];
if (this._workspace) {
if (this._workspace.isDirectory) {
return [this._workspace];
}
const workspaceData = await this.getWorkspaceDataFromFile();
if (workspaceData) {
for (const { path } of workspaceData.folders) {
const valid = await this.toValidRoot(path);
if (valid) {
roots.push(valid);
} else {
roots.push(FileStat.dir(path));
}
}
}
}
return roots;
}
protected async getWorkspaceDataFromFile(): Promise<WorkspaceData | undefined> {
if (this._workspace && await this.fileService.exists(this._workspace.resource)) {
if (this._workspace.isDirectory) {
return {
folders: [{ path: this._workspace.resource.toString() }]
};
} else if (this.isWorkspaceFile(this._workspace)) {
const stat = await this.fileService.read(this._workspace.resource);
const strippedContent = jsoncparser.stripComments(stat.value);
const data = jsoncparser.parse(strippedContent);
if (data && WorkspaceData.is(data)) {
return WorkspaceData.transformToAbsolute(data, stat);
}
this.logger.error(`Unable to retrieve workspace data from the file: '${this.labelProvider.getLongName(this._workspace)}'. Please check if the file is corrupted.`);
} else {
this.logger.warn(`Not a valid workspace file: ${this.labelProvider.getLongName(this._workspace)}`);
}
}
}
protected updateTitle(): void {
let rootName: string | undefined;
let rootPath: string | undefined;
if (this._workspace) {
const displayName = this._workspace.name;
const fullName = this._workspace.resource.path.toString();
if (this.isWorkspaceFile(this._workspace)) {
if (this.isUntitledWorkspace(this._workspace.resource)) {
const untitled = nls.localizeByDefault('Untitled (Workspace)');
rootName = untitled;
rootPath = untitled;
} else {
rootName = displayName.slice(0, displayName.lastIndexOf('.'));
rootPath = fullName.slice(0, fullName.lastIndexOf('.'));
}
} else {
rootName = displayName;
rootPath = fullName;
}
}
this.windowTitleService.update({
rootName,
rootPath
});
}
/**
* on unload, we set our workspace root as the last recently used on the backend.
*/
onStop(): void {
this.server.setMostRecentlyUsedWorkspace(this._workspace ? this._workspace.resource.toString() : '');
}
async recentWorkspaces(): Promise<string[]> {
return this.server.getRecentWorkspaces();
}
async removeRecentWorkspace(uri: string): Promise<void> {
return this.server.removeRecentWorkspace(uri);
}
/**
* Returns `true` if theia has an opened workspace or folder
* @returns {boolean}
*/
get opened(): boolean {
return !!this._workspace;
}
/**
* Returns `true` if a multiple-root workspace is currently open.
* @returns {boolean}
*/
get isMultiRootWorkspaceOpened(): boolean {
return !!this.workspace && !this.workspace.isDirectory;
}
/**
* Opens directory, or recreates a workspace from the file that `uri` points to.
*/
open(uri: URI, options?: WorkspaceInput): void {
this.doOpen(uri, options);
}
protected async doOpen(uri: URI, options?: WorkspaceInput): Promise<void> {
for (const handler of [...this.openHandlerContribution.getContributions(), this]) {
if (await handler.canHandle(uri)) {
handler.openWorkspace(uri, options);
return;
}
}
throw new Error(`Could not find a handler to open the workspace with uri ${uri.toString()}.`);
}
async canHandle(uri: URI): Promise<boolean> {
return uri.scheme === 'file';
}
async openWorkspace(uri: URI, options?: WorkspaceInput): Promise<void> {
const stat = await this.toFileStat(uri);
if (stat) {
if (!stat.isDirectory && !this.isWorkspaceFile(stat)) {
const message = nls.localize('theia/workspace/notWorkspaceFile', 'Not a valid workspace file: {0}', this.labelProvider.getLongName(uri));
this.messageService.error(message);
throw new Error(message);
}
// The same window has to be preserved too (instead of opening a new one), if the workspace root is not yet available and we are setting it for the first time.
// Option passed as parameter has the highest priority (for api developers), then the preference, then the default.
await this.roots;
const { preserveWindow } = {
preserveWindow: this.preferences['workspace.preserveWindow'] || !this.opened,
...options
};
this.openWindow(stat, Object.assign(options ?? {}, { preserveWindow }));
return;
}
throw new Error('Invalid workspace root URI. Expected an existing directory or workspace file.');
}
/**
* Adds root folder(s) to the workspace
* @param uris URI or URIs of the root folder(s) to add
*/
async addRoot(uris: URI[] | URI): Promise<void> {
const toAdd = Array.isArray(uris) ? uris : [uris];
await this.spliceRoots(this._roots.length, 0, ...toAdd);
}
/**
* Removes root folder(s) from workspace.
*/
async removeRoots(uris: URI[]): Promise<void> {
if (!this.opened) {
throw new Error('Folder cannot be removed as there is no active folder in the current workspace.');
}
if (this._workspace) {
const workspaceData = await this.getWorkspaceDataFromFile();
this._workspace = await this.writeWorkspaceFile(this._workspace,
WorkspaceData.buildWorkspaceData(
this._roots.filter(root => uris.findIndex(u => u.toString() === root.resource.toString()) < 0),
workspaceData
)
);
await this.updateWorkspace();
}
}
async spliceRoots(start: number, deleteCount?: number, ...rootsToAdd: URI[]): Promise<URI[]> {
if (!this._workspace || this._workspace.isDirectory) {
const untitledWorkspace = await this.getUntitledWorkspace();
await this.save(untitledWorkspace);
if (!this._workspace) {
throw new Error('Could not create new untitled workspace');
}
}
const dedup = new Set<string>();
const roots = this._roots.map(root => (dedup.add(root.resource.toString()), root.resource.toString()));
const toAdd: string[] = [];
for (const root of rootsToAdd) {
const uri = root.toString();
if (!dedup.has(uri)) {
dedup.add(uri);
toAdd.push(uri);
}
}
const toRemove = roots.splice(start, deleteCount || 0, ...toAdd);
if (!toRemove.length && !toAdd.length) {
return [];
}
const currentData = await this.getWorkspaceDataFromFile();
const newData = WorkspaceData.buildWorkspaceData(roots, currentData);
await this.writeWorkspaceFile(this._workspace, newData);
await this.updateWorkspace();
return toRemove.map(root => new URI(root));
}
async getUntitledWorkspace(): Promise<URI> {
const configDirURI = new URI(await this.envVariableServer.getConfigDirUri());
return this.untitledWorkspaceService.getUntitledWorkspaceUri(
configDirURI,
uri => this.fileService.exists(uri).then(exists => !exists),
() => this.messageService.warn(nls.localize(
'theia/workspace/untitled-cleanup',
'There appear to be many untitled workspace files. Please check {0} and remove any unused files.',
configDirURI.resolve('workspaces').path.fsPath())
),
);
}
protected async writeWorkspaceFile(workspaceFile: FileStat | undefined, workspaceData: WorkspaceData): Promise<FileStat | undefined> {
if (workspaceFile) {
const data = JSON.stringify(WorkspaceData.transformToRelative(workspaceData, workspaceFile));
const edits = jsoncparser.format(data, undefined, { tabSize: 2, insertSpaces: true, eol: '' });
const result = jsoncparser.applyEdits(data, edits);
await this.fileService.write(workspaceFile.resource, result);
return this.fileService.resolve(workspaceFile.resource);
}
}
/**
* Clears current workspace root.
*/
async close(): Promise<void> {
if (await this.windowService.isSafeToShutDown(StopReason.Reload)) {
this.windowService.setSafeToShutDown();
this._workspace = undefined;
this._roots.length = 0;
await this.server.setMostRecentlyUsedWorkspace('');
this.reloadWindow('');
}
}
/**
* returns a FileStat if the argument URI points to an existing directory. Otherwise, `undefined`.
*/
protected async toValidRoot(uri: URI | string | undefined): Promise<FileStat | undefined> {
const fileStat = await this.toFileStat(uri);
if (fileStat && fileStat.isDirectory) {
return fileStat;
}
return undefined;
}
/**
* returns a FileStat if the argument URI points to a file or directory. Otherwise, `undefined`.
*/
protected async toFileStat(uri: URI | string | undefined): Promise<FileStat | undefined> {
if (!uri) {
return undefined;
}
let uriStr = uri.toString();
try {
if (uriStr.endsWith('/')) {
uriStr = uriStr.slice(0, -1);
}
const normalizedUri = new URI(uriStr).normalizePath();
return await this.fileService.resolve(normalizedUri);
} catch (error) {
return undefined;
}
}
protected openWindow(uri: FileStat, options?: WorkspaceInput): void {
const workspacePath = this.getWorkspacePath(uri.resource);
if (this.shouldPreserveWindow(options)) {
this.reloadWindow(workspacePath, options);
} else {
try {
this.openNewWindow(workspacePath, options);
} catch (error) {
// Fall back to reloading the current window in case the browser has blocked the new window
this.logger.error(error.toString()).then(() => this.reloadWindow(workspacePath));
}
}
}
protected reloadWindow(workspacePath: string, options?: WorkspaceInput): void {
// Set the new workspace path as the URL fragment.
this.setURLFragment(workspacePath);
this.windowService.reload();
}
protected openNewWindow(workspacePath: string, options?: WorkspaceInput): void {
const url = new URL(window.location.href);
url.hash = encodeURI(workspacePath);
this.windowService.openNewWindow(url.toString());
}
protected shouldPreserveWindow(options?: WorkspaceInput): boolean {
return options !== undefined && !!options.preserveWindow;
}
/**
* Return true if one of the paths in paths array is present in the workspace
* NOTE: You should always explicitly use `/` as the separator between the path segments.
*/
async containsSome(paths: string[]): Promise<boolean> {
await this.roots;
if (this.opened) {
for (const root of this._roots) {
const uri = root.resource;
for (const path of paths) {
const fileUri = uri.resolve(path);
const exists = await this.fileService.exists(fileUri);
if (exists) {
return exists;
}
}
}
}
return false;
}
/**
* `true` if the current workspace is configured using a configuration file.
*
* `false` if there is no workspace or the workspace is simply a folder.
*/
get saved(): boolean {
return !!this._workspace && !this._workspace.isDirectory;
}
/**
* Save workspace data into a file
* @param uri URI or FileStat of the workspace file
*/
async save(uri: URI | FileStat): Promise<void> {
const resource = uri instanceof URI ? uri : uri.resource;
if (!await this.fileService.exists(resource)) {
await this.fileService.create(resource);
}
const workspaceData: WorkspaceData = { folders: [], settings: {} };
if (!this.saved) {
for (const p of Object.keys(this.schemaService.getJSONSchema(PreferenceScope.Workspace).properties!)) {
// The goal is to ensure that workspace-scoped preferences are preserved in the new workspace.
// Preferences valid in folder scope will take effect in their folders without being copied.
if (this.schemaService.isValidInScope(p, PreferenceScope.Folder)) {
continue;
}
const preferences = this.preferenceImpl.inspect(p);
if (preferences && preferences.workspaceValue) {
workspaceData.settings![p] = preferences.workspaceValue;
}
}
}
let stat = await this.toFileStat(resource);
Object.assign(workspaceData, await this.getWorkspaceDataFromFile());
stat = await this.writeWorkspaceFile(stat, WorkspaceData.buildWorkspaceData(this._roots, workspaceData));
await this.server.setMostRecentlyUsedWorkspace(resource.toString());
// If saving a workspace based on an untitled workspace, delete the old file.
const toDelete = this.isUntitledWorkspace(this.workspace?.resource) && this.workspace!.resource;
await this.setWorkspace(stat);
if (toDelete && stat && !toDelete.isEqual(stat.resource)) {
await this.fileService.delete(toDelete).catch(() => { });
}
this.onWorkspaceLocationChangedEmitter.fire(stat);
}
protected readonly rootWatchers = new Map<string, Disposable>();
protected async watchRoots(): Promise<void> {
const rootUris = new Set(this._roots.map(r => r.resource.toString()));
for (const [uri, watcher] of this.rootWatchers.entries()) {
if (!rootUris.has(uri)) {
watcher.dispose();
}
}
for (const root of this._roots) {
this.watchRoot(root);
}
}
protected async refreshRootWatchers(): Promise<void> {
for (const watcher of this.rootWatchers.values()) {
watcher.dispose();
}
await this.watchRoots();
}
protected async watchRoot(root: FileStat): Promise<void> {
const uriStr = root.resource.toString();
if (this.rootWatchers.has(uriStr)) {
return;
}
const excludes = this.getExcludes(uriStr);
const watcher = this.fileService.watch(new URI(uriStr), {
recursive: true,
excludes
});
this.rootWatchers.set(uriStr, new DisposableCollection(
watcher,
Disposable.create(() => this.rootWatchers.delete(uriStr))
));
}
protected getExcludes(uri: string): string[] {
const patterns = this.fsPreferences.get('files.watcherExclude', undefined, uri);
return Object.keys(patterns).filter(pattern => patterns[pattern]);
}
/**
* Returns the workspace root uri that the given file belongs to.
* In case that the file is found in more than one workspace roots, returns the root that is closest to the file.
* If the file is not from the current workspace, returns `undefined`.
* @param uri URI of the file
*/
getWorkspaceRootUri(uri: URI | undefined): URI | undefined {
if (!uri) {
const root = this.tryGetRoots()[0];
if (root) {
return root.resource;
}
return undefined;
}
const rootUris: URI[] = [];
for (const root of this.tryGetRoots()) {
const rootUri = root.resource;
if (rootUri && rootUri.scheme === uri.scheme && rootUri.isEqualOrParent(uri)) {
rootUris.push(rootUri);
}
}
return rootUris.sort((r1, r2) => r2.toString().length - r1.toString().length)[0];
}
/**
* Returns the relative path of the given file to the workspace root.
* @param uri URI of the file
* @see getWorkspaceRootUri(uri)
*/
async getWorkspaceRelativePath(uri: URI): Promise<string> {
const wsUri = this.getWorkspaceRootUri(uri);
if (wsUri) {
const wsRelative = wsUri.relative(uri);
if (wsRelative) {
return wsRelative.toString();
}
}
return uri.path.fsPath();
}
areWorkspaceRoots(uris: URI[]): boolean {
if (!uris.length) {
return false;
}
const rootUris = new Set(this.tryGetRoots().map(root => root.resource.toString()));
return uris.every(uri => rootUris.has(uri.toString()));
}
/**
* Check if the file should be considered as a workspace file.
*
* Example: We should not try to read the contents of an .exe file.
*/
protected isWorkspaceFile(candidate: FileStat | URI): boolean {
return this.workspaceFileService.isWorkspaceFile(candidate);
}
isUntitledWorkspace(candidate?: URI): boolean {
return this.untitledWorkspaceService.isUntitledWorkspace(candidate);
}
async isSafeToReload(withURI?: URI): Promise<boolean> {
return !withURI || !this.untitledWorkspaceService.isUntitledWorkspace(withURI) || new URI(await this.getDefaultWorkspaceUri()).isEqual(withURI);
}
/**
*
* @param key the property key under which to store the schema (e.g. tasks, launch)
* @param schema the schema for the property. If none is supplied, the update is treated as a deletion.
*/
async updateSchema(key: string, schema?: IJSONSchema): Promise<boolean> {
return this.schemaUpdater.updateSchema({ key, schema });
}
}
export interface WorkspaceInput {
/**
* Tests whether the same window should be used or a new one has to be opened after setting the workspace root. By default it is `false`.
*/
preserveWindow?: boolean;
}
export interface WorkspaceData {
folders: Array<{ path: string, name?: string }>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: { [id: string]: any };
}
export namespace WorkspaceData {
const validateSchema = new Ajv().compile(workspaceSchema);
export function is(data: unknown): data is WorkspaceData {
return !!validateSchema(data);
}
export function buildWorkspaceData(folders: string[] | FileStat[], additionalFields?: Partial<WorkspaceData>): WorkspaceData {
const roots = new Set<string>();
if (folders.length > 0) {
if (typeof folders[0] !== 'string') {
(<FileStat[]>folders).forEach(folder => roots.add(folder.resource.toString()));
} else {
(<string[]>folders).forEach(folder => roots.add(folder));
}
}
const data: WorkspaceData = {
folders: Array.from(roots, folder => ({ path: folder }))
};
if (additionalFields) {
delete additionalFields.folders;
Object.assign(data, additionalFields);
}
return data;
}
export function transformToRelative(data: WorkspaceData, workspaceFile?: FileStat): WorkspaceData {
const folderUris: string[] = [];
const workspaceFileUri = new URI(workspaceFile ? workspaceFile.resource.toString() : '').withScheme('file');
for (const { path } of data.folders) {
const folderUri = new URI(path).withScheme('file');
const rel = workspaceFileUri.parent.relative(folderUri);
if (rel) {
folderUris.push(rel.toString());
} else {
folderUris.push(folderUri.toString());
}
}
return buildWorkspaceData(folderUris, data);
}
export function transformToAbsolute(data: WorkspaceData, workspaceFile?: BaseStat): WorkspaceData {
if (workspaceFile) {
const folders: string[] = [];
for (const folder of data.folders) {
const path = folder.path;
if (path.startsWith('file:///')) {
folders.push(path);
} else {
const absolutePath = workspaceFile.resource.withScheme('file').parent.resolveToAbsolute(path)?.toString();
if (absolutePath) {
folders.push(absolutePath.toString());
}
}
}
return Object.assign(data, buildWorkspaceData(folders, data));
}
return data;
}
}

View File

@@ -0,0 +1,67 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { LocalStorageService, StorageService } from '@theia/core/lib/browser/storage-service';
import { WorkspaceService } from './workspace-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
/*
* Prefixes any stored data with the current workspace path.
*/
@injectable()
export class WorkspaceStorageService implements StorageService {
private prefix: string;
private initialized: Promise<void>;
@inject(LocalStorageService) protected storageService: StorageService;
@inject(WorkspaceService) protected workspaceService: WorkspaceService;
@postConstruct()
protected init(): void {
this.initialized = this.workspaceService.roots.then(() => {
this.updatePrefix();
this.workspaceService.onWorkspaceLocationChanged(() => this.updatePrefix());
});
}
async setData<T>(key: string, data: T): Promise<void> {
if (!this.prefix) {
await this.initialized;
}
const fullKey = this.prefixWorkspaceURI(key);
return this.storageService.setData(fullKey, data);
}
async getData<T>(key: string, defaultValue?: T): Promise<T | undefined> {
await this.initialized;
const fullKey = this.prefixWorkspaceURI(key);
return this.storageService.getData(fullKey, defaultValue);
}
protected prefixWorkspaceURI(originalKey: string): string {
return `${this.prefix}:${originalKey}`;
}
protected getPrefix(workspaceStat: FileStat | undefined): string {
return workspaceStat ? workspaceStat.resource.toString() : '_global_';
}
private updatePrefix(): void {
this.prefix = this.getPrefix(this.workspaceService.workspace);
}
}

View File

@@ -0,0 +1,90 @@
// *****************************************************************************
// Copyright (C) 2026 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { nls } from '@theia/core';
import { codicon } from '@theia/core/lib/browser';
import { ReactDialog } from '@theia/core/lib/browser/dialogs/react-dialog';
import * as React from '@theia/core/shared/react';
import URI from '@theia/core/lib/common/uri';
export class WorkspaceTrustDialog extends ReactDialog<boolean> {
protected confirmed = true;
constructor(protected readonly folderUris: URI[]) {
super({
title: '',
maxWidth: 500
});
this.node.classList.add('workspace-trust-dialog');
this.appendCloseButton(nls.localizeByDefault("No, I don't trust the authors"));
this.appendAcceptButton(nls.localizeByDefault('Yes, I trust the authors'));
this.controlPanel.removeChild(this.errorMessageNode);
}
get value(): boolean {
return this.confirmed;
}
protected override handleEscape(): boolean | void {
this.confirmed = false;
this.accept();
}
override close(): void {
this.confirmed = false;
this.accept();
}
protected render(): React.ReactNode {
return (
<div className="workspace-trust-content">
<div className="workspace-trust-header">
<i className={codicon('shield')}></i>
<div className="workspace-trust-title">
{nls.localizeByDefault('Do you trust the authors of the files in this folder?')}
</div>
</div>
<div className="workspace-trust-description">
{nls.localize(
'theia/workspace/trustDialogMessage',
`If you trust the authors, code in this folder may be executed.
If not, some features will be disabled.
The workspace trust feature is currently under development in Theia; not all features are integrated with workspace trust yet.
Check the 'Restricted Mode' indicator in the status bar for details.`
)}
</div>
{this.folderUris.length > 0 && (
<div className="workspace-trust-folder">
<ul className="workspace-trust-folder-list">
{this.folderUris.map(uri => {
const stringified = uri.path.fsPath();
return (
<li key={stringified}>
{stringified}
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
}

View File

@@ -0,0 +1,462 @@
// *****************************************************************************
// Copyright (C) 2026 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { expect } from 'chai';
import * as sinon from 'sinon';
import { PreferenceChange, PreferenceScope } from '@theia/core/lib/common/preferences';
import { WorkspaceTrustService } from './workspace-trust-service';
import {
WORKSPACE_TRUST_EMPTY_WINDOW,
WORKSPACE_TRUST_ENABLED,
WORKSPACE_TRUST_STARTUP_PROMPT,
WORKSPACE_TRUST_TRUSTED_FOLDERS,
WorkspaceTrustPrompt
} from '../common/workspace-trust-preferences';
import URI from '@theia/core/lib/common/uri';
class TestableWorkspaceTrustService extends WorkspaceTrustService {
public async testHandlePreferenceChange(change: PreferenceChange): Promise<void> {
return this.handlePreferenceChange(change);
}
public async testHandleWorkspaceChanged(): Promise<void> {
return this.handleWorkspaceChanged();
}
public setCurrentTrust(trust: boolean | undefined): void {
this.currentTrust = trust;
}
public getCurrentTrust(): boolean | undefined {
return this.currentTrust;
}
public override isWorkspaceTrustResolved(): boolean {
return super.isWorkspaceTrustResolved();
}
public async testCalculateWorkspaceTrust(): Promise<boolean | undefined> {
return this.calculateWorkspaceTrust();
}
}
describe('WorkspaceTrustService', () => {
let service: TestableWorkspaceTrustService;
beforeEach(() => {
service = new TestableWorkspaceTrustService();
});
afterEach(() => {
sinon.restore();
});
describe('calculateWorkspaceTrust', () => {
let workspaceTrustPrefStub: { [key: string]: unknown };
let workspaceServiceStub: {
tryGetRoots: () => Array<{ resource: URI }>;
workspace: { resource: URI } | undefined;
saved: boolean;
};
let untitledWorkspaceServiceStub: {
isUntitledWorkspace: (uri?: URI, configDirUri?: URI) => boolean;
};
let envVariablesServerStub: {
getConfigDirUri: () => Promise<string>;
};
beforeEach(() => {
workspaceTrustPrefStub = {
[WORKSPACE_TRUST_ENABLED]: true,
[WORKSPACE_TRUST_EMPTY_WINDOW]: false,
[WORKSPACE_TRUST_STARTUP_PROMPT]: WorkspaceTrustPrompt.NEVER,
[WORKSPACE_TRUST_TRUSTED_FOLDERS]: []
};
workspaceServiceStub = {
tryGetRoots: () => [],
workspace: undefined,
saved: false
};
untitledWorkspaceServiceStub = {
isUntitledWorkspace: () => false
};
envVariablesServerStub = {
getConfigDirUri: async () => 'file:///home/user/.theia'
};
(service as unknown as { workspaceTrustPref: typeof workspaceTrustPrefStub }).workspaceTrustPref = workspaceTrustPrefStub;
(service as unknown as { workspaceService: typeof workspaceServiceStub }).workspaceService = workspaceServiceStub;
(service as unknown as { untitledWorkspaceService: typeof untitledWorkspaceServiceStub }).untitledWorkspaceService = untitledWorkspaceServiceStub;
(service as unknown as { envVariablesServer: typeof envVariablesServerStub }).envVariablesServer = envVariablesServerStub;
});
it('should return true when trust is disabled', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_ENABLED] = false;
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
describe('empty workspace', () => {
it('should return emptyWindow setting when no workspace is open', async () => {
workspaceServiceStub.workspace = undefined;
workspaceTrustPrefStub[WORKSPACE_TRUST_EMPTY_WINDOW] = true;
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
it('should return false when emptyWindow is false and no workspace', async () => {
workspaceServiceStub.workspace = undefined;
workspaceTrustPrefStub[WORKSPACE_TRUST_EMPTY_WINDOW] = false;
expect(await service.testCalculateWorkspaceTrust()).to.be.false;
});
it('should return emptyWindow setting for untitled workspace with no folders', async () => {
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/.theia/workspaces/Untitled-123.theia-workspace') };
workspaceServiceStub.tryGetRoots = () => [];
untitledWorkspaceServiceStub.isUntitledWorkspace = () => true;
workspaceTrustPrefStub[WORKSPACE_TRUST_EMPTY_WINDOW] = true;
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
it('should not treat saved workspace with no folders as empty', async () => {
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/my.theia-workspace') };
workspaceServiceStub.tryGetRoots = () => [];
workspaceServiceStub.saved = true;
untitledWorkspaceServiceStub.isUntitledWorkspace = () => false;
workspaceTrustPrefStub[WORKSPACE_TRUST_EMPTY_WINDOW] = true;
// Should return false because saved workspace with 0 folders is not "empty"
// and the workspace file is not trusted
expect(await service.testCalculateWorkspaceTrust()).to.be.false;
});
});
describe('single-root workspace', () => {
it('should return true when folder is in trusted folders', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/user/project'];
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/project') };
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
it('should return true when parent folder is trusted', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/user'];
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/project') };
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
it('should return false when folder is not trusted', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/other'];
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/project') };
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.false;
});
});
describe('multi-root workspace', () => {
it('should return true when all folders are trusted', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = [
'file:///home/user/project1',
'file:///home/user/project2'
];
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/my.theia-workspace') };
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project1') },
{ resource: new URI('file:///home/user/project2') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
it('should return false when one folder is not trusted', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/user/project1'];
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/my.theia-workspace') };
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project1') },
{ resource: new URI('file:///home/user/project2') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.false;
});
it('should return true when parent folder covers all roots', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/user'];
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/my.theia-workspace') };
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project1') },
{ resource: new URI('file:///home/user/project2') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
});
describe('saved workspace file trust', () => {
it('should require workspace file to be trusted for saved workspaces', async () => {
// Folder is trusted but workspace file location is not
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/user/project'];
workspaceServiceStub.workspace = { resource: new URI('file:///other/location/my.theia-workspace') };
workspaceServiceStub.saved = true;
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.false;
});
it('should return true when both folder and workspace file are trusted', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/user'];
workspaceServiceStub.workspace = { resource: new URI('file:///home/user/my.theia-workspace') };
workspaceServiceStub.saved = true;
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
it('should not require workspace file trust for unsaved workspaces', async () => {
workspaceTrustPrefStub[WORKSPACE_TRUST_TRUSTED_FOLDERS] = ['file:///home/user/project'];
workspaceServiceStub.workspace = { resource: new URI('file:///tmp/untitled.theia-workspace') };
workspaceServiceStub.saved = false;
workspaceServiceStub.tryGetRoots = () => [
{ resource: new URI('file:///home/user/project') }
];
expect(await service.testCalculateWorkspaceTrust()).to.be.true;
});
});
});
describe('handleWorkspaceChanged', () => {
let resolveWorkspaceTrustStub: sinon.SinonStub;
let getWorkspaceTrustStub: sinon.SinonStub;
let updateRestrictedModeIndicatorStub: sinon.SinonStub;
beforeEach(() => {
resolveWorkspaceTrustStub = sinon.stub(service as unknown as { resolveWorkspaceTrust: () => Promise<void> }, 'resolveWorkspaceTrust').resolves();
getWorkspaceTrustStub = sinon.stub(service, 'getWorkspaceTrust').resolves(true);
updateRestrictedModeIndicatorStub = sinon.stub(
service as unknown as { updateRestrictedModeIndicator: (trust: boolean) => void },
'updateRestrictedModeIndicator'
);
});
it('should reset trust state when workspace changes', async () => {
service.setCurrentTrust(true);
await service.testHandleWorkspaceChanged();
expect(service.getCurrentTrust()).to.be.undefined;
});
it('should re-evaluate trust when workspace changes', async () => {
service.setCurrentTrust(true);
await service.testHandleWorkspaceChanged();
expect(resolveWorkspaceTrustStub.calledOnce).to.be.true;
});
it('should update restricted mode indicator after workspace change if not trusted', async () => {
getWorkspaceTrustStub.resolves(false);
await service.testHandleWorkspaceChanged();
expect(updateRestrictedModeIndicatorStub.calledOnceWith(false)).to.be.true;
});
it('should reset workspaceTrust deferred to unresolved state', async () => {
// First resolve the trust
service.setCurrentTrust(true);
await service.testHandleWorkspaceChanged();
// After workspace change, it should be reset and resolved again via resolveWorkspaceTrust
expect(resolveWorkspaceTrustStub.calledOnce).to.be.true;
});
});
describe('handlePreferenceChange', () => {
let areAllWorkspaceUrisTrustedStub: sinon.SinonStub;
let setWorkspaceTrustStub: sinon.SinonStub;
let isEmptyWorkspaceStub: sinon.SinonStub;
let workspaceTrustPrefStub: { [key: string]: unknown };
beforeEach(() => {
areAllWorkspaceUrisTrustedStub = sinon.stub(service as unknown as { areAllWorkspaceUrisTrusted: () => Promise<boolean> }, 'areAllWorkspaceUrisTrusted');
setWorkspaceTrustStub = sinon.stub(service, 'setWorkspaceTrust');
isEmptyWorkspaceStub = sinon.stub(service as unknown as { isEmptyWorkspace: () => Promise<boolean> }, 'isEmptyWorkspace');
// Mock workspaceTrustPref - default emptyWindow to false so trusted folders logic runs
workspaceTrustPrefStub = { [WORKSPACE_TRUST_EMPTY_WINDOW]: false };
(service as unknown as { workspaceTrustPref: { [key: string]: unknown } }).workspaceTrustPref = workspaceTrustPrefStub;
// Default to non-empty workspace
isEmptyWorkspaceStub.resolves(false);
});
it('should update trust to true when all workspace URIs become trusted', async () => {
service.setCurrentTrust(false);
areAllWorkspaceUrisTrustedStub.resolves(true);
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_TRUSTED_FOLDERS,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.calledOnceWith(true)).to.be.true;
});
it('should update trust to false when not all workspace URIs are trusted', async () => {
service.setCurrentTrust(true);
areAllWorkspaceUrisTrustedStub.resolves(false);
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_TRUSTED_FOLDERS,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.calledOnceWith(false)).to.be.true;
});
it('should not update trust when trustedFolders change does not affect trust status', async () => {
service.setCurrentTrust(false);
areAllWorkspaceUrisTrustedStub.resolves(false);
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_TRUSTED_FOLDERS,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.called).to.be.false;
});
describe('emptyWindow setting changes', () => {
beforeEach(() => {
// Reset to empty workspace for empty window tests
isEmptyWorkspaceStub.resolves(true);
});
it('should update trust to true when emptyWindow setting changes to true for empty window', async () => {
service.setCurrentTrust(false);
workspaceTrustPrefStub[WORKSPACE_TRUST_EMPTY_WINDOW] = true;
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_EMPTY_WINDOW,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.calledOnceWith(true)).to.be.true;
});
it('should update trust to false when emptyWindow setting changes to false for empty window', async () => {
service.setCurrentTrust(true);
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_EMPTY_WINDOW,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.calledOnceWith(false)).to.be.true;
});
it('should not update trust when emptyWindow setting changes but workspace has roots', async () => {
service.setCurrentTrust(false);
isEmptyWorkspaceStub.resolves(false);
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_EMPTY_WINDOW,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.called).to.be.false;
});
it('should not update trust when emptyWindow setting changes but trust already matches', async () => {
service.setCurrentTrust(true);
workspaceTrustPrefStub[WORKSPACE_TRUST_EMPTY_WINDOW] = true;
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_EMPTY_WINDOW,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.called).to.be.false;
});
});
describe('trustedFolders change for empty window with emptyWindow enabled', () => {
beforeEach(() => {
isEmptyWorkspaceStub.resolves(true);
workspaceTrustPrefStub[WORKSPACE_TRUST_EMPTY_WINDOW] = true;
});
it('should not change trust when trustedFolders change for empty window with emptyWindow enabled', async () => {
service.setCurrentTrust(true);
const change: PreferenceChange = {
preferenceName: WORKSPACE_TRUST_TRUSTED_FOLDERS,
scope: PreferenceScope.User,
domain: [],
affects: () => true
};
await service.testHandlePreferenceChange(change);
expect(setWorkspaceTrustStub.called).to.be.false;
});
});
});
});

View File

@@ -0,0 +1,519 @@
// *****************************************************************************
// Copyright (C) 2021 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ConfirmDialog, Dialog, StorageService } from '@theia/core/lib/browser';
import { MarkdownString, MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
import { StatusBar, StatusBarAlignment } from '@theia/core/lib/browser/status-bar/status-bar';
import { OS, ContributionProvider, DisposableCollection } from '@theia/core';
import { Emitter, Event } from '@theia/core/lib/common';
import URI from '@theia/core/lib/common/uri';
import { PreferenceChange, PreferenceSchemaService, PreferenceScope, PreferenceService } from '@theia/core/lib/common/preferences';
import { MessageService } from '@theia/core/lib/common/message-service';
import { nls } from '@theia/core/lib/common/nls';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { inject, injectable, named, postConstruct, preDestroy } from '@theia/core/shared/inversify';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import {
WorkspaceTrustPreferences, WORKSPACE_TRUST_EMPTY_WINDOW, WORKSPACE_TRUST_ENABLED, WORKSPACE_TRUST_STARTUP_PROMPT, WORKSPACE_TRUST_TRUSTED_FOLDERS, WorkspaceTrustPrompt
} from '../common/workspace-trust-preferences';
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
import { WorkspaceService } from './workspace-service';
import { WorkspaceCommands } from './workspace-commands';
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { WorkspaceTrustDialog } from './workspace-trust-dialog';
import { UntitledWorkspaceService } from '../common/untitled-workspace-service';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
const STORAGE_TRUSTED = 'trusted';
export const WORKSPACE_TRUST_STATUS_BAR_ID = 'workspace-trust-status';
/**
* Contribution interface for features that are restricted in untrusted workspaces.
* Implementations can provide information about what is being restricted.
*/
export const WorkspaceRestrictionContribution = Symbol('WorkspaceRestrictionContribution');
export interface WorkspaceRestrictionContribution {
/**
* Returns the restrictions currently active due to workspace trust.
* Called when building the restricted mode status bar tooltip.
*/
getRestrictions(): WorkspaceRestriction[];
}
export interface WorkspaceRestriction {
/** Display name of the feature being restricted */
label: string;
/** Optional details (e.g., list of blocked items) */
details?: string[];
}
@injectable()
export class WorkspaceTrustService {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(PreferenceService)
protected readonly preferences: PreferenceService;
@inject(StorageService)
protected readonly storage: StorageService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(WorkspaceTrustPreferences)
protected readonly workspaceTrustPref: WorkspaceTrustPreferences;
@inject(PreferenceSchemaService)
protected readonly preferenceSchemaService: PreferenceSchemaService;
@inject(WindowService)
protected readonly windowService: WindowService;
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
@inject(StatusBar)
protected readonly statusBar: StatusBar;
@inject(ContributionProvider) @named(WorkspaceRestrictionContribution)
protected readonly restrictionContributions: ContributionProvider<WorkspaceRestrictionContribution>;
@inject(UntitledWorkspaceService)
protected readonly untitledWorkspaceService: UntitledWorkspaceService;
@inject(EnvVariablesServer)
protected readonly envVariablesServer: EnvVariablesServer;
protected workspaceTrust = new Deferred<boolean>();
protected currentTrust: boolean | undefined;
protected pendingTrustDialog: Deferred<boolean> | undefined;
protected readonly onDidChangeWorkspaceTrustEmitter = new Emitter<boolean>();
readonly onDidChangeWorkspaceTrust: Event<boolean> = this.onDidChangeWorkspaceTrustEmitter.event;
protected readonly toDispose = new DisposableCollection(this.onDidChangeWorkspaceTrustEmitter);
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
await this.workspaceService.ready;
await this.workspaceTrustPref.ready;
await this.preferenceSchemaService.ready;
await this.resolveWorkspaceTrust();
this.toDispose.push(
this.preferences.onPreferenceChanged(change => this.handlePreferenceChange(change))
);
this.toDispose.push(
this.workspaceService.onWorkspaceChanged(() => this.handleWorkspaceChanged())
);
// Show status bar item if starting in restricted mode
const initialTrust = await this.getWorkspaceTrust();
this.updateRestrictedModeIndicator(initialTrust);
// React to trust changes
this.toDispose.push(
this.onDidChangeWorkspaceTrust(trust => {
this.updateRestrictedModeIndicator(trust);
})
);
}
@preDestroy()
protected onStop(): void {
this.toDispose.dispose();
}
getWorkspaceTrust(): Promise<boolean> {
// Return current trust if already resolved, otherwise wait for initial resolution
if (this.currentTrust !== undefined) {
return Promise.resolve(this.currentTrust);
}
return this.workspaceTrust.promise;
}
protected async resolveWorkspaceTrust(givenTrust?: boolean): Promise<void> {
if (!this.isWorkspaceTrustResolved()) {
const trust = givenTrust ?? await this.calculateWorkspaceTrust();
if (trust !== undefined) {
await this.storeWorkspaceTrust(trust);
this.contextKeyService.setContext('isWorkspaceTrusted', trust);
this.currentTrust = trust;
this.workspaceTrust.resolve(trust);
this.onDidChangeWorkspaceTrustEmitter.fire(trust);
if (trust && this.workspaceTrustPref[WORKSPACE_TRUST_ENABLED]) {
await this.addToTrustedFolders();
}
}
}
}
setWorkspaceTrust(trusted: boolean): void {
if (this.currentTrust === trusted) {
return;
}
this.currentTrust = trusted;
this.contextKeyService.setContext('isWorkspaceTrusted', trusted);
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.ONCE) {
this.storeWorkspaceTrust(trusted);
}
this.onDidChangeWorkspaceTrustEmitter.fire(trusted);
}
protected isWorkspaceTrustResolved(): boolean {
return this.workspaceTrust.state !== 'unresolved';
}
protected async calculateWorkspaceTrust(): Promise<boolean | undefined> {
const trustEnabled = this.workspaceTrustPref[WORKSPACE_TRUST_ENABLED];
if (!trustEnabled) {
return true;
}
// Empty workspace - no folders open
if (await this.isEmptyWorkspace()) {
return !!this.workspaceTrustPref[WORKSPACE_TRUST_EMPTY_WINDOW];
}
if (await this.areAllWorkspaceUrisTrusted()) {
return true;
}
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.NEVER) {
return false;
}
// For ONCE mode, check stored trust first
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.ONCE) {
const storedTrust = await this.loadWorkspaceTrust();
if (storedTrust !== undefined) {
return storedTrust;
}
}
// For ALWAYS mode or ONCE mode with no stored decision, show dialog
return this.showTrustPromptDialog();
}
/**
* Check if the workspace is empty (no workspace or folder opened, or
* an untitled workspace with no folders).
* A saved workspace file with 0 folders is NOT empty - it still needs trust
* evaluation because it could have tasks defined.
*/
protected async isEmptyWorkspace(): Promise<boolean> {
const workspace = this.workspaceService.workspace;
if (!workspace) {
return true;
}
const roots = this.workspaceService.tryGetRoots();
// Only consider it empty if it's an untitled workspace with no folders
// Use secure check with configDirUri for trust-related decisions
if (roots.length === 0) {
const configDirUri = new URI(await this.envVariablesServer.getConfigDirUri());
if (this.untitledWorkspaceService.isUntitledWorkspace(workspace.resource, configDirUri)) {
return true;
}
}
return false;
}
/**
* Get the URIs that need to be trusted for the current workspace.
* This includes all workspace folder URIs, plus the workspace file URI
* for saved workspaces (since workspace files can contain tasks/settings).
*/
protected getWorkspaceUris(): URI[] {
const uris = this.workspaceService.tryGetRoots().map(root => root.resource);
const workspace = this.workspaceService.workspace;
// For saved workspaces, include the workspace file itself
if (workspace && this.workspaceService.saved) {
uris.push(workspace.resource);
}
return uris;
}
/**
* Check if all workspace URIs are trusted.
* A workspace is trusted only if ALL of its folders (and the workspace
* file for saved workspaces) are trusted.
*/
protected async areAllWorkspaceUrisTrusted(): Promise<boolean> {
const uris = this.getWorkspaceUris();
if (uris.length === 0) {
return false;
}
return uris.every(uri => this.isUriTrusted(uri));
}
/**
* Check if a URI is trusted. A URI is trusted if it or any of its
* parent folders is in the trusted folders list.
*/
protected isUriTrusted(uri: URI): boolean {
const trustedFolders = this.workspaceTrustPref[WORKSPACE_TRUST_TRUSTED_FOLDERS] || [];
const caseSensitive = !OS.backend.isWindows;
const normalizedUri = uri.normalizePath();
return trustedFolders.some(folder => {
try {
const folderUri = new URI(folder).normalizePath();
// Check if the trusted folder is equal to or a parent of the URI
return folderUri.isEqualOrParent(normalizedUri, caseSensitive);
} catch {
return false; // Invalid URI in preferences
}
});
}
protected async showTrustPromptDialog(): Promise<boolean> {
// If dialog is already open, wait for its result
if (this.pendingTrustDialog) {
return this.pendingTrustDialog.promise;
}
this.pendingTrustDialog = new Deferred<boolean>();
try {
// Show the workspace folders in the dialog
const folderUris = this.workspaceService.tryGetRoots().map(root => root.resource);
const dialog = new WorkspaceTrustDialog(folderUris);
const result = await dialog.open();
const trusted = result === true;
this.pendingTrustDialog.resolve(trusted);
return trusted;
} catch (e) {
this.pendingTrustDialog.resolve(false);
throw e;
} finally {
this.pendingTrustDialog = undefined;
}
}
async addToTrustedFolders(): Promise<void> {
const uris = this.getWorkspaceUris();
if (uris.length === 0) {
return;
}
const currentFolders = this.workspaceTrustPref[WORKSPACE_TRUST_TRUSTED_FOLDERS] || [];
const newFolders = [...currentFolders];
let changed = false;
for (const uri of uris) {
if (!this.isUriTrusted(uri)) {
newFolders.push(uri.toString());
changed = true;
}
}
if (changed) {
await this.preferences.set(
WORKSPACE_TRUST_TRUSTED_FOLDERS,
newFolders,
PreferenceScope.User
);
}
}
async removeFromTrustedFolders(): Promise<void> {
const uris = this.getWorkspaceUris();
if (uris.length === 0) {
return;
}
const currentFolders = this.workspaceTrustPref[WORKSPACE_TRUST_TRUSTED_FOLDERS] || [];
const caseSensitive = !OS.backend.isWindows;
const normalizedUris = uris.map(uri => uri.normalizePath());
const updatedFolders = currentFolders.filter(folder => {
try {
const folderUri = new URI(folder).normalizePath();
// Remove folder if it exactly matches any workspace URI
return !normalizedUris.some(wsUri => wsUri.isEqual(folderUri, caseSensitive));
} catch {
return true; // Keep invalid URIs
}
});
if (updatedFolders.length !== currentFolders.length) {
await this.preferences.set(
WORKSPACE_TRUST_TRUSTED_FOLDERS,
updatedFolders,
PreferenceScope.User
);
}
}
protected async loadWorkspaceTrust(): Promise<boolean | undefined> {
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.ONCE) {
return this.storage.getData<boolean>(STORAGE_TRUSTED);
}
}
protected async storeWorkspaceTrust(trust: boolean): Promise<void> {
if (this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] === WorkspaceTrustPrompt.ONCE) {
return this.storage.setData(STORAGE_TRUSTED, trust);
}
}
protected async handlePreferenceChange(change: PreferenceChange): Promise<void> {
// Handle trustedFolders changes regardless of scope
if (change.preferenceName === WORKSPACE_TRUST_TRUSTED_FOLDERS) {
// For empty windows with emptyWindow setting enabled, trust should remain true
if (await this.isEmptyWorkspace() && this.workspaceTrustPref[WORKSPACE_TRUST_EMPTY_WINDOW]) {
return;
}
const areAllUrisTrusted = await this.areAllWorkspaceUrisTrusted();
if (areAllUrisTrusted !== this.currentTrust) {
this.setWorkspaceTrust(areAllUrisTrusted);
}
return;
}
if (change.scope === PreferenceScope.User) {
if (change.preferenceName === WORKSPACE_TRUST_STARTUP_PROMPT && this.workspaceTrustPref[WORKSPACE_TRUST_STARTUP_PROMPT] !== WorkspaceTrustPrompt.ONCE) {
this.storage.setData(STORAGE_TRUSTED, undefined);
}
if (change.preferenceName === WORKSPACE_TRUST_ENABLED) {
if (!await this.isEmptyWorkspace() && this.isWorkspaceTrustResolved() && await this.confirmRestart()) {
this.windowService.setSafeToShutDown();
this.windowService.reload();
}
this.resolveWorkspaceTrust();
}
// Handle emptyWindow setting change for empty windows
if (change.preferenceName === WORKSPACE_TRUST_EMPTY_WINDOW && await this.isEmptyWorkspace()) {
// For empty windows, directly update trust based on the new setting value
const shouldTrust = !!this.workspaceTrustPref[WORKSPACE_TRUST_EMPTY_WINDOW];
if (this.currentTrust !== shouldTrust) {
this.setWorkspaceTrust(shouldTrust);
}
}
}
}
protected async handleWorkspaceChanged(): Promise<void> {
// Reset trust state for the new workspace
this.workspaceTrust = new Deferred<boolean>();
this.currentTrust = undefined;
// Re-evaluate trust for the new workspace
await this.resolveWorkspaceTrust();
// Update status bar indicator
const trust = await this.getWorkspaceTrust();
this.updateRestrictedModeIndicator(trust);
}
protected async confirmRestart(): Promise<boolean> {
const shouldRestart = await new ConfirmDialog({
title: nls.localizeByDefault('A setting has changed that requires a restart to take effect.'),
msg: nls.localizeByDefault('Press the restart button to restart {0} and enable the setting.', FrontendApplicationConfigProvider.get().applicationName),
ok: nls.localizeByDefault('Restart'),
cancel: Dialog.CANCEL,
}).open();
return shouldRestart === true;
}
protected updateRestrictedModeIndicator(trusted: boolean): void {
if (trusted) {
this.hideRestrictedModeStatusBarItem();
} else {
this.showRestrictedModeStatusBarItem();
}
}
protected showRestrictedModeStatusBarItem(): void {
this.statusBar.setElement(WORKSPACE_TRUST_STATUS_BAR_ID, {
text: '$(shield) ' + nls.localizeByDefault('Restricted Mode'),
alignment: StatusBarAlignment.LEFT,
backgroundColor: 'var(--theia-statusBarItem-prominentBackground)',
color: 'var(--theia-statusBarItem-prominentForeground)',
priority: 5000,
tooltip: this.createRestrictedModeTooltip(),
command: WorkspaceCommands.MANAGE_WORKSPACE_TRUST.id
});
}
protected createRestrictedModeTooltip(): MarkdownString {
const md = new MarkdownStringImpl('', { supportThemeIcons: true });
md.appendMarkdown(`**${nls.localizeByDefault('Restricted Mode')}**\n\n`);
md.appendMarkdown(nls.localize('theia/workspace/restrictedModeDescription',
'Some features are disabled because this workspace is not trusted.'));
md.appendMarkdown('\n\n');
md.appendMarkdown(nls.localize('theia/workspace/restrictedModeNote',
'*Please note: The workspace trust feature is currently under development in Theia; not all features are integrated with workspace trust yet*'));
const restrictions = this.collectRestrictions();
if (restrictions.length > 0) {
md.appendMarkdown('\n\n---\n\n');
for (const restriction of restrictions) {
md.appendMarkdown(`**${restriction.label}**\n\n`);
if (restriction.details && restriction.details.length > 0) {
for (const detail of restriction.details) {
md.appendMarkdown(`- ${detail}\n`);
}
md.appendMarkdown('\n');
}
}
}
md.appendMarkdown('\n\n---\n\n');
md.appendMarkdown(nls.localize('theia/workspace/clickToManageTrust', 'Click to manage trust settings.'));
return md;
}
protected collectRestrictions(): WorkspaceRestriction[] {
const restrictions: WorkspaceRestriction[] = [];
for (const contribution of this.restrictionContributions.getContributions()) {
restrictions.push(...contribution.getRestrictions());
}
return restrictions;
}
protected hideRestrictedModeStatusBarItem(): void {
this.statusBar.removeElement(WORKSPACE_TRUST_STATUS_BAR_ID);
}
/**
* Refreshes the restricted mode status bar item.
* Call this when restriction contributions change.
*/
refreshRestrictedModeIndicator(): void {
if (this.currentTrust === false) {
this.showRestrictedModeStatusBarItem();
}
}
async requestWorkspaceTrust(): Promise<boolean | undefined> {
if (!this.isWorkspaceTrustResolved()) {
const trusted = await this.showTrustPromptDialog();
await this.resolveWorkspaceTrust(trusted);
}
return this.workspaceTrust.promise;
}
}

View File

@@ -0,0 +1,191 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
const disableJSDOM = enableJSDOM();
import { expect } from 'chai';
import * as sinon from 'sinon';
import { Container } from '@theia/core/shared/inversify';
import { Event } from '@theia/core/lib/common/event';
import { ApplicationShell, WidgetManager } from '@theia/core/lib/browser';
import { DefaultUriLabelProviderContribution } from '@theia/core/lib/browser/label-provider';
import { WorkspaceUriLabelProviderContribution } from './workspace-uri-contribution';
import URI from '@theia/core/lib/common/uri';
import { WorkspaceVariableContribution } from './workspace-variable-contribution';
import { WorkspaceService } from './workspace-service';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { MockEnvVariablesServerImpl } from '@theia/core/lib/browser/test/mock-env-variables-server';
import { FileUri } from '@theia/core/lib/node';
import { OS } from '@theia/core/lib/common/os';
import * as temp from 'temp';
after(() => disableJSDOM());
let container: Container;
let labelProvider: WorkspaceUriLabelProviderContribution;
let roots: FileStat[];
beforeEach(() => {
roots = [FileStat.dir('file:///workspace')];
container = new Container();
container.bind(ApplicationShell).toConstantValue({
onDidChangeCurrentWidget: () => undefined,
widgets: []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
container.bind(WidgetManager).toConstantValue({
onDidCreateWidget: Event.None
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const workspaceService = new WorkspaceService();
workspaceService.tryGetRoots = () => roots;
container.bind(WorkspaceService).toConstantValue(workspaceService);
container.bind(WorkspaceVariableContribution).toSelf().inSingletonScope();
container.bind(WorkspaceUriLabelProviderContribution).toSelf().inSingletonScope();
container.bind(FileService).toConstantValue({} as FileService);
container.bind(EnvVariablesServer).toConstantValue(new MockEnvVariablesServerImpl(FileUri.create(temp.track().mkdirSync())));
labelProvider = container.get(WorkspaceUriLabelProviderContribution);
});
afterEach(() => {
roots = undefined!;
labelProvider = undefined!;
container = undefined!;
});
describe('WorkspaceUriLabelProviderContribution class', () => {
const stubs: sinon.SinonStub[] = [];
afterEach(() => {
stubs.forEach(s => s.restore());
stubs.length = 0;
});
describe('canHandle()', () => {
it('should return 0 if the passed in argument is not a FileStat or URI with the "file" scheme', () => {
expect(labelProvider.canHandle(new URI('user-storage:settings.json'))).eq(0);
expect(labelProvider.canHandle({ uri: 'file:///home/settings.json' })).eq(0);
});
it('should return 10 if the passed in argument is a FileStat or URI with the "file" scheme', () => {
expect(labelProvider.canHandle(new URI('file:///home/settings.json'))).eq(10);
expect(labelProvider.canHandle(FileStat.file('file:///home/settings.json'))).eq(10);
});
});
describe('getIcon()', () => {
it('should return folder icon from the FileStat of a folder', async () => {
expect(labelProvider.getIcon(FileStat.dir('file:///home/'))).eq(labelProvider.defaultFolderIcon);
});
it('should return file icon from a non-folder FileStat', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, <any>'getFileIcon').returns(undefined));
expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(labelProvider.defaultFileIcon);
});
it('should return folder icon from a folder FileStat', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, <any>'getFileIcon').returns(undefined));
expect(labelProvider.getIcon(FileStat.dir('file:///home/test'))).eq(labelProvider.defaultFolderIcon);
});
it('should return file icon from a file FileStat', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, <any>'getFileIcon').returns(undefined));
expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(labelProvider.defaultFileIcon);
});
it('should return what getFileIcon() returns from a URI or non-folder FileStat, if getFileIcon() does not return null or undefined', async () => {
const ret = 'TestString';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stubs.push(sinon.stub(DefaultUriLabelProviderContribution.prototype, <any>'getFileIcon').returns(ret));
expect(labelProvider.getIcon(new URI('file:///home/test'))).eq(ret);
expect(labelProvider.getIcon(FileStat.file('file:///home/test'))).eq(ret);
});
it('should return the default folder icon for a URI or file stat that corresponds to a workspace root', () => {
expect(labelProvider.getIcon(new URI('file:///workspace'))).eq(labelProvider.defaultFolderIcon);
expect(labelProvider.getIcon(FileStat.dir('file:///workspace'))).eq(labelProvider.defaultFolderIcon);
});
});
describe('getName()', () => {
it('should return the display name of a file from its URI', () => {
const file = new URI('file:///workspace-2/jacques.doc');
const name = labelProvider.getName(file);
expect(name).eq('jacques.doc');
});
it('should return the display name of a file from its FileStat', () => {
const file: FileStat = FileStat.file('file:///workspace-2/jacques.doc');
const name = labelProvider.getName(file);
expect(name).eq('jacques.doc');
});
});
describe('getLongName()', () => {
it('should return the path of a file relative to the workspace from the file\'s URI if the file is in the workspace', () => {
const file = new URI('file:///workspace/some/very-long/path.js');
const longName = labelProvider.getLongName(file);
expect(longName).eq('some/very-long/path.js');
});
it('should return the path of a file relative to the workspace from the file\'s FileStat if the file is in the workspace', () => {
const file: FileStat = FileStat.file('file:///workspace/some/very-long/path.js');
const longName = labelProvider.getLongName(file);
expect(longName).eq('some/very-long/path.js');
});
it('should return the absolute path of a file from the file\'s URI if the file is not in the workspace', () => {
const file = new URI('file:///tmp/prout.txt');
const longName = labelProvider.getLongName(file);
if (OS.backend.isWindows) {
expect(longName).eq('\\tmp\\prout.txt');
} else {
expect(longName).eq('/tmp/prout.txt');
}
});
it('should return the absolute path of a file from the file\'s FileStat if the file is not in the workspace', () => {
const file: FileStat = FileStat.file('file:///tmp/prout.txt');
const longName = labelProvider.getLongName(file);
if (OS.backend.isWindows) {
expect(longName).eq('\\tmp\\prout.txt');
} else {
expect(longName).eq('/tmp/prout.txt');
}
});
it('should return the path of a file if WorkspaceService returns no roots', () => {
roots = [];
const file = new URI('file:///tmp/prout.txt');
const longName = labelProvider.getLongName(file);
if (OS.backend.isWindows) {
expect(longName).eq('\\tmp\\prout.txt');
} else {
expect(longName).eq('/tmp/prout.txt');
}
});
});
});

View File

@@ -0,0 +1,97 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { DefaultUriLabelProviderContribution, URIIconReference } from '@theia/core/lib/browser/label-provider';
import URI from '@theia/core/lib/common/uri';
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { WorkspaceService } from './workspace-service';
import { WorkspaceVariableContribution } from './workspace-variable-contribution';
@injectable()
export class WorkspaceUriLabelProviderContribution extends DefaultUriLabelProviderContribution {
@inject(WorkspaceVariableContribution) protected readonly workspaceVariable: WorkspaceVariableContribution;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@postConstruct()
override init(): void {
// no-op, backward compatibility
}
override canHandle(element: object): number {
if ((element instanceof URI && element.scheme === 'file' || URIIconReference.is(element) || FileStat.is(element))) {
return 10;
}
return 0;
}
override getIcon(element: URI | URIIconReference | FileStat): string {
return super.getIcon(this.asURIIconReference(element));
}
override getName(element: URI | URIIconReference | FileStat): string | undefined {
return super.getName(this.asURIIconReference(element));
}
/**
* trims the workspace root from a file uri, if it is a child.
*/
override getLongName(element: URI | URIIconReference | FileStat): string | undefined {
const uri = this.getUri(element);
if (uri) {
const formatting = this.findFormatting(uri);
if (formatting) {
return this.formatUri(uri, formatting);
}
}
const relativePath = uri && this.workspaceVariable.getWorkspaceRelativePath(uri);
return relativePath || super.getLongName(this.asURIIconReference(element));
}
override getDetails(element: URI | URIIconReference | FileStat): string | undefined {
const uri = this.getUri(element);
if (!uri) {
return this.getLongName(element);
}
// Parent in order to omit the name - that's what comes out of `getName`, and `getDetails` should supplement, not duplicate.
const relativePath = uri && this.workspaceVariable.getWorkspaceRelativePath(uri.parent);
if (relativePath !== undefined) {
const prefix = this.workspaceService.tryGetRoots().length > 1 ? this.getName(this.workspaceVariable.getWorkspaceRootUri(uri)!) : '';
const separator = prefix && relativePath ? ' • ' : '';
return prefix + separator + relativePath;
}
return this.getLongName(uri.parent);
}
protected asURIIconReference(element: URI | URIIconReference | FileStat): URI | URIIconReference {
if (FileStat.is(element)) {
return URIIconReference.create(element.isDirectory ? 'folder' : 'file', element.resource);
}
const uri = this.getUri(element);
if (uri && this.workspaceVariable.getWorkspaceRootUri(uri)?.isEqual(uri)) {
return URIIconReference.create('folder', uri);
}
return element;
}
protected override getUri(element: URI | URIIconReference | FileStat): URI | undefined {
if (FileStat.is(element)) {
return element.resource;
}
return super.getUri(element);
}
}

View File

@@ -0,0 +1,49 @@
// *****************************************************************************
// Copyright (C) 2022 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider';
import URI from '@theia/core/lib/common/uri';
import { WorkspaceService } from './workspace-service';
import { MaybePromise } from '@theia/core';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
@injectable()
export class WorkspaceUserWorkingDirectoryProvider extends UserWorkingDirectoryProvider {
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(FileService) protected readonly fileService: FileService;
override async getUserWorkingDir(): Promise<URI> {
return await this.getFromSelection()
?? await this.getFromLastOpenResource()
?? await this.getFromWorkspace()
?? this.getFromUserHome();
}
protected getFromWorkspace(): MaybePromise<URI | undefined> {
return this.workspaceService.tryGetRoots()[0]?.resource;
}
protected override async ensureIsDirectory(uri?: URI): Promise<URI | undefined> {
if (uri) {
const asFile = uri.withScheme('file');
const stat = await this.fileService.resolve(asFile)
.catch(() => this.fileService.resolve(asFile.parent))
.catch(() => undefined);
return stat?.isDirectory ? stat.resource : stat?.resource.parent;
}
}
}

View File

@@ -0,0 +1,45 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
// TODO get rid of util files, replace with methods in a responsible class
import URI from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceService } from './workspace-service';
/**
* Collection of workspace utility functions
* @class
*/
@injectable()
export class WorkspaceUtils {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
/**
* Determine if root directory exists
* for a given array of URIs
* @param uris
*/
containsRootDirectory(uris: URI[]): boolean {
// obtain all roots URIs for a given workspace
const rootUris = this.workspaceService.tryGetRoots().map(root => root.resource);
// return true if at least a single URI is a root directory
return rootUris.some(rootUri => uris.some(uri => uri.isEqualOrParent(rootUri)));
}
}

View File

@@ -0,0 +1,223 @@
// *****************************************************************************
// Copyright (C) 2018 Red Hat, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Path } from '@theia/core/lib/common/path';
import { ApplicationShell, NavigatableWidget, WidgetManager } from '@theia/core/lib/browser';
import { VariableContribution, VariableRegistry, Variable } from '@theia/variable-resolver/lib/browser';
import { WorkspaceService } from './workspace-service';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { nls } from '@theia/core';
@injectable()
export class WorkspaceVariableContribution implements VariableContribution {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@inject(FileService)
protected readonly fileService: FileService;
@inject(WidgetManager)
protected readonly widgetManager: WidgetManager;
protected currentWidget: NavigatableWidget | undefined;
@postConstruct()
protected init(): void {
this.shell.onDidChangeCurrentWidget(() => this.updateCurrentWidget());
this.widgetManager.onDidCreateWidget(({ widget }) => {
if (NavigatableWidget.is(widget)) {
widget.onDidChangeVisibility(() => {
if (widget.isVisible) {
this.addRecentlyVisible(widget);
} else {
this.removeRecentlyVisible(widget);
}
this.updateCurrentWidget();
});
widget.onDidDispose(() => {
this.removeRecentlyVisible(widget);
this.updateCurrentWidget();
});
}
});
for (const widget of this.shell.widgets) {
if (NavigatableWidget.is(widget) && widget.isVisible) {
this.addRecentlyVisible(widget);
}
}
this.updateCurrentWidget();
}
protected readonly recentlyVisibleIds: string[] = [];
protected get recentlyVisible(): NavigatableWidget | undefined {
const id = this.recentlyVisibleIds[0];
const widget = id && this.shell.getWidgetById(id) || undefined;
if (NavigatableWidget.is(widget)) {
return widget;
}
return undefined;
}
protected addRecentlyVisible(widget: NavigatableWidget): void {
this.removeRecentlyVisible(widget);
this.recentlyVisibleIds.unshift(widget.id);
}
protected removeRecentlyVisible(widget: NavigatableWidget): void {
const index = this.recentlyVisibleIds.indexOf(widget.id);
if (index !== -1) {
this.recentlyVisibleIds.splice(index, 1);
}
}
protected updateCurrentWidget(): void {
const { currentWidget } = this.shell;
if (NavigatableWidget.is(currentWidget)) {
this.currentWidget = currentWidget;
} else if (!this.currentWidget || !this.currentWidget.isVisible) {
this.currentWidget = this.recentlyVisible;
}
}
registerVariables(variables: VariableRegistry): void {
this.registerWorkspaceRootVariables(variables);
variables.registerVariable({
name: 'file',
description: nls.localize('theia/workspace/variables/file/description', 'The path of the currently opened file'),
resolve: () => {
const uri = this.getResourceUri();
return uri && this.fileService.fsPath(uri);
}
});
variables.registerVariable({
name: 'fileBasename',
description: nls.localize('theia/workspace/variables/fileBasename/description', 'The basename of the currently opened file'),
resolve: () => {
const uri = this.getResourceUri();
return uri && uri.path.base;
}
});
variables.registerVariable({
name: 'fileBasenameNoExtension',
description: nls.localize('theia/workspace/variables/fileBasenameNoExtension/description', "The currently opened file's name without extension"),
resolve: () => {
const uri = this.getResourceUri();
return uri && uri.path.name;
}
});
variables.registerVariable({
name: 'fileDirname',
description: nls.localize('theia/workspace/variables/fileDirname/description', "The name of the currently opened file's directory"),
resolve: () => {
const uri = this.getResourceUri();
return uri && uri.path.dir.toString();
}
});
variables.registerVariable({
name: 'fileExtname',
description: nls.localize('theia/workspace/variables/fileExtname/description', 'The extension of the currently opened file'),
resolve: () => {
const uri = this.getResourceUri();
return uri && uri.path.ext;
}
});
}
protected registerWorkspaceRootVariables(variables: VariableRegistry): void {
const scoped = (variable: Variable): Variable => ({
name: variable.name,
description: variable.description,
resolve: (context, workspaceRootName) => {
const workspaceRoot = workspaceRootName && this.workspaceService.tryGetRoots().find(r => r.resource.path.name === workspaceRootName);
return variable.resolve(workspaceRoot ? workspaceRoot.resource : context);
}
});
variables.registerVariable(scoped({
name: 'workspaceRoot',
description: nls.localize('theia/workspace/variables/workspaceRoot/description', 'The path of the workspace root folder'),
resolve: (context?: URI) => {
const uri = this.getWorkspaceRootUri(context);
return uri && this.fileService.fsPath(uri);
}
}));
variables.registerVariable(scoped({
name: 'workspaceFolder',
description: nls.localize('theia/workspace/variables/workspaceFolder/description', 'The path of the workspace root folder'),
resolve: (context?: URI) => {
const uri = this.getWorkspaceRootUri(context);
return uri && this.fileService.fsPath(uri);
}
}));
variables.registerVariable(scoped({
name: 'workspaceRootFolderName',
description: nls.localize('theia/workspace/variables/workspaceRootFolderName/description', 'The name of the workspace root folder'),
resolve: (context?: URI) => {
const uri = this.getWorkspaceRootUri(context);
return uri && uri.displayName;
}
}));
variables.registerVariable(scoped({
name: 'workspaceFolderBasename',
description: nls.localize('theia/workspace/variables/workspaceFolderBasename/description', 'The name of the workspace root folder'),
resolve: (context?: URI) => {
const uri = this.getWorkspaceRootUri(context);
return uri && uri.displayName;
}
}));
variables.registerVariable(scoped({
name: 'cwd',
description: nls.localize('theia/workspace/variables/cwd/description', "The task runner's current working directory on startup"),
resolve: (context?: URI) => {
const uri = this.getWorkspaceRootUri(context);
return (uri && this.fileService.fsPath(uri)) || '';
}
}));
variables.registerVariable(scoped({
name: 'relativeFile',
description: nls.localize('theia/workspace/variables/relativeFile/description', "The currently opened file's path relative to the workspace root"),
resolve: (context?: URI) => {
const uri = this.getResourceUri();
return uri && this.getWorkspaceRelativePath(uri, context);
}
}));
variables.registerVariable(scoped({
name: 'relativeFileDirname',
description: nls.localize('theia/workspace/variables/relativeFileDirname/description', "The current opened file's dirname relative to ${workspaceFolder}"),
resolve: (context?: URI) => {
const uri = this.getResourceUri();
const relativePath = uri && this.getWorkspaceRelativePath(uri, context);
return relativePath && new Path(relativePath).dir.toString();
}
}));
}
getWorkspaceRootUri(uri: URI | undefined = this.getResourceUri()): URI | undefined {
return this.workspaceService.getWorkspaceRootUri(uri);
}
getResourceUri(): URI | undefined {
return this.currentWidget && this.currentWidget.getResourceUri();
}
getWorkspaceRelativePath(uri: URI, context?: URI): string | undefined {
const workspaceRootUri = this.getWorkspaceRootUri(context || uri);
const path = workspaceRootUri && workspaceRootUri.path.relative(uri.path);
return path && path.toString();
}
}

View File

@@ -0,0 +1,45 @@
// *****************************************************************************
// Copyright (C) 2022 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { WindowTitleUpdater } from '@theia/core/lib/browser/window/window-title-updater';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Widget } from '@theia/core/lib/browser/widgets/widget';
import { WorkspaceService } from './workspace-service';
import { Navigatable } from '@theia/core/lib/browser/navigatable';
@injectable()
export class WorkspaceWindowTitleUpdater extends WindowTitleUpdater {
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
protected override updateTitleWidget(widget?: Widget): void {
super.updateTitleWidget(widget);
let folderName: string | undefined;
let folderPath: string | undefined;
if (Navigatable.is(widget)) {
const folder = this.workspaceService.getWorkspaceRootUri(widget.getResourceUri());
if (folder) {
folderName = this.labelProvider.getName(folder);
folderPath = folder.path.toString();
}
}
this.windowTitleService.update({
folderName,
folderPath
});
}
}

View File

@@ -0,0 +1,20 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './workspace-protocol';
export * from './workspace-file-service';
export * from './untitled-workspace-service';
export * from './workspace-preferences';

View File

@@ -0,0 +1,29 @@
// *****************************************************************************
// Copyright (C) 2017 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import { WorkspaceServer } from '../workspace-protocol';
@injectable()
export class MockWorkspaceServer implements WorkspaceServer {
getRecentWorkspaces(): Promise<string[]> { return Promise.resolve([]); }
getMostRecentlyUsedWorkspace(): Promise<string | undefined> { return Promise.resolve(''); }
setMostRecentlyUsedWorkspace(uri: string): Promise<void> { return Promise.resolve(); }
removeRecentWorkspace(uri: string): Promise<void> { return Promise.resolve(); }
}

View File

@@ -0,0 +1,69 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import URI from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import { MaybePromise } from '@theia/core';
import { WorkspaceFileService } from './workspace-file-service';
@injectable()
export class UntitledWorkspaceService {
@inject(WorkspaceFileService)
protected readonly workspaceFileService: WorkspaceFileService;
/**
* Check if a URI is an untitled workspace.
* @param candidate The URI to check
* @param configDirUri Optional config directory URI. If provided, also verifies
* that the candidate is under the expected workspaces directory.
* This is the secure check and should be used when possible.
*/
isUntitledWorkspace(candidate?: URI, configDirUri?: URI): boolean {
if (!candidate || !this.workspaceFileService.isWorkspaceFile(candidate)) {
return false;
}
if (!candidate.path.base.startsWith('Untitled')) {
return false;
}
// If configDirUri is provided, verify the candidate is in the expected location
if (configDirUri) {
const expectedParentDir = configDirUri.resolve('workspaces');
return expectedParentDir.isEqualOrParent(candidate);
}
// Without configDirUri, fall back to name-only check (less secure)
return true;
}
async getUntitledWorkspaceUri(configDirUri: URI, isAcceptable: (candidate: URI) => MaybePromise<boolean>, warnOnHits?: () => unknown): Promise<URI> {
const parentDir = configDirUri.resolve('workspaces');
const workspaceExtensions = this.workspaceFileService.getWorkspaceFileExtensions();
const defaultFileExtension = workspaceExtensions[this.workspaceFileService.defaultFileTypeIndex];
let uri;
let attempts = 0;
do {
attempts++;
uri = parentDir.resolve(`Untitled-${Math.round(Math.random() * 1000)}.${defaultFileExtension}`);
if (attempts === 10) {
warnOnHits?.();
}
if (attempts === 50) {
throw new Error('Workspace Service: too many attempts to find unused filename.');
}
} while (!(await isAcceptable(uri)));
return uri;
}
}

View File

@@ -0,0 +1,72 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { URI } from '@theia/core';
import { injectable } from '@theia/core/shared/inversify';
import { FileStat } from '@theia/filesystem/lib/common/files';
export interface WorkspaceFileType {
extension: string
name: string
}
/**
* @deprecated Since 1.39.0. Use `WorkspaceFileService#getWorkspaceFileTypes` instead.
*/
export const THEIA_EXT = 'theia-workspace';
/**
* @deprecated Since 1.39.0. Use `WorkspaceFileService#getWorkspaceFileTypes` instead.
*/
export const VSCODE_EXT = 'code-workspace';
@injectable()
export class WorkspaceFileService {
protected _defaultFileTypeIndex = 0;
get defaultFileTypeIndex(): number {
return this._defaultFileTypeIndex;
}
/**
* Check if the file should be considered as a workspace file.
*
* Example: We should not try to read the contents of an .exe file.
*/
isWorkspaceFile(candidate: FileStat | URI): boolean {
const uri = FileStat.is(candidate) ? candidate.resource : candidate;
const extensions = this.getWorkspaceFileExtensions(true);
return extensions.includes(uri.path.ext);
}
getWorkspaceFileTypes(): WorkspaceFileType[] {
return [
{
name: 'Theia',
extension: THEIA_EXT
},
{
name: 'Visual Studio Code',
extension: VSCODE_EXT
}
];
}
getWorkspaceFileExtensions(dot?: boolean): string[] {
return this.getWorkspaceFileTypes().map(type => dot ? `.${type.extension}` : type.extension);
}
}

View File

@@ -0,0 +1,51 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { interfaces } from '@theia/core/shared/inversify';
import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/common/preferences';
import { nls } from '@theia/core/lib/common/nls';
export const workspacePreferenceSchema: PreferenceSchema = {
properties: {
'workspace.preserveWindow': {
description: nls.localize('theia/workspace/preserveWindow', 'Enable opening workspaces in current window.'),
type: 'boolean',
default: false
},
}
};
export interface WorkspaceConfiguration {
'workspace.preserveWindow': boolean,
}
export const WorkspacePreferenceContribution = Symbol('WorkspacePreferenceContribution');
export const WorkspacePreferences = Symbol('WorkspacePreferences');
export type WorkspacePreferences = PreferenceProxy<WorkspaceConfiguration>;
export function createWorkspacePreferences(preferences: PreferenceService, schema: PreferenceSchema = workspacePreferenceSchema): WorkspacePreferences {
return createPreferenceProxy(preferences, schema);
}
export function bindWorkspacePreferences(bind: interfaces.Bind): void {
bind(WorkspacePreferences).toDynamicValue(ctx => {
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
const contribution = ctx.container.get<PreferenceContribution>(WorkspacePreferenceContribution);
return createWorkspacePreferences(preferences, contribution.schema);
}).inSingletonScope();
bind(WorkspacePreferenceContribution).toConstantValue({ schema: workspacePreferenceSchema });
bind(PreferenceContribution).toService(WorkspacePreferenceContribution);
}

View File

@@ -0,0 +1,47 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export const workspacePath = '/services/workspace';
/**
* The JSON-RPC workspace interface.
*/
export const WorkspaceServer = Symbol('WorkspaceServer');
export interface WorkspaceServer {
/**
* Returns with a promise that resolves to the most recently used workspace folder URI as a string.
* Resolves to `undefined` if the workspace folder is not yet set.
*/
getMostRecentlyUsedWorkspace(): Promise<string | undefined>;
/**
* Sets the desired string representation of the URI as the most recently used workspace folder.
*/
setMostRecentlyUsedWorkspace(uri: string): Promise<void>;
/**
* Removes a workspace from the list of recently opened workspaces.
*
* @param uri the workspace uri.
*/
removeRecentWorkspace(uri: string): Promise<void>;
/**
* Returns list of recently opened workspaces as an array.
*/
getRecentWorkspaces(): Promise<string[]>
}

View File

@@ -0,0 +1,85 @@
// *****************************************************************************
// Copyright (C) 2021 EclipseSource and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { createPreferenceProxy, PreferenceProxy, PreferenceScope, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/common/preferences';
import { nls } from '@theia/core/lib/common/nls';
import { interfaces } from '@theia/core/shared/inversify';
export const WORKSPACE_TRUST_ENABLED = 'security.workspace.trust.enabled';
export const WORKSPACE_TRUST_STARTUP_PROMPT = 'security.workspace.trust.startupPrompt';
export const WORKSPACE_TRUST_EMPTY_WINDOW = 'security.workspace.trust.emptyWindow';
export const WORKSPACE_TRUST_TRUSTED_FOLDERS = 'security.workspace.trust.trustedFolders';
export enum WorkspaceTrustPrompt {
ALWAYS = 'always',
ONCE = 'once',
NEVER = 'never'
}
export const workspaceTrustPreferenceSchema: PreferenceSchema = {
scope: PreferenceScope.User,
properties: {
[WORKSPACE_TRUST_ENABLED]: {
description: nls.localize('theia/workspace/trustEnabled', 'Controls whether or not workspace trust is enabled. If disabled, all workspaces are trusted.'),
type: 'boolean',
default: true
},
[WORKSPACE_TRUST_STARTUP_PROMPT]: {
description: nls.localizeByDefault('Controls when the startup prompt to trust a workspace is shown.'),
enum: Object.values(WorkspaceTrustPrompt),
default: WorkspaceTrustPrompt.ALWAYS
},
[WORKSPACE_TRUST_EMPTY_WINDOW]: {
description: nls.localize('theia/workspace/trustEmptyWindow', 'Controls whether or not the empty workspace is trusted by default.'),
type: 'boolean',
default: true
},
[WORKSPACE_TRUST_TRUSTED_FOLDERS]: {
description: nls.localize('theia/workspace/trustTrustedFolders', 'List of folder URIs that are trusted without prompting.'),
type: 'array',
items: {
type: 'string'
},
default: [],
scope: PreferenceScope.User
}
}
};
export interface WorkspaceTrustConfiguration {
[WORKSPACE_TRUST_ENABLED]: boolean,
[WORKSPACE_TRUST_STARTUP_PROMPT]: WorkspaceTrustPrompt;
[WORKSPACE_TRUST_EMPTY_WINDOW]: boolean;
[WORKSPACE_TRUST_TRUSTED_FOLDERS]: string[];
}
export const WorkspaceTrustPreferenceContribution = Symbol('WorkspaceTrustPreferenceContribution');
export const WorkspaceTrustPreferences = Symbol('WorkspaceTrustPreferences');
export type WorkspaceTrustPreferences = PreferenceProxy<WorkspaceTrustConfiguration>;
export function createWorkspaceTrustPreferences(preferences: PreferenceService, schema: PreferenceSchema = workspaceTrustPreferenceSchema): WorkspaceTrustPreferences {
return createPreferenceProxy(preferences, schema);
}
export function bindWorkspaceTrustPreferences(bind: interfaces.Bind): void {
bind(WorkspaceTrustPreferences).toDynamicValue(ctx => {
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
const contribution = ctx.container.get<PreferenceContribution>(WorkspaceTrustPreferenceContribution);
return createWorkspaceTrustPreferences(preferences, contribution.schema);
}).inSingletonScope();
bind(WorkspaceTrustPreferenceContribution).toConstantValue({ schema: workspaceTrustPreferenceSchema });
bind(PreferenceContribution).toService(WorkspaceTrustPreferenceContribution);
}

View File

@@ -0,0 +1,111 @@
// *****************************************************************************
// Copyright (C) 2022 Alexander Flammer and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Container, ContainerModule } from '@theia/core/shared/inversify';
import { MockEnvVariablesServerImpl } from '@theia/core/lib/browser/test/mock-env-variables-server';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import URI from '@theia/core/lib/common/uri';
import { FileUri } from '@theia/core/lib/node';
import { WorkspaceFileService, UntitledWorkspaceService } from '../common';
import { DefaultWorkspaceServer, FileWorkspaceHandlerContribution, WorkspaceCliContribution, WorkspaceHandlerContribution } from './default-workspace-server';
import { expect } from 'chai';
import * as temp from 'temp';
import * as fs from 'fs';
import { ILogger, bindContributionProvider } from '@theia/core';
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
describe('DefaultWorkspaceServer', function (): void {
describe('getRecentWorkspaces()', async () => {
let workspaceServer: DefaultWorkspaceServer;
let tmpConfigDir: URI;
let recentWorkspaceFile: string;
beforeEach(() => {
// create a temporary directory
const tempDirPath = temp.track().mkdirSync();
tmpConfigDir = FileUri.create(fs.realpathSync(tempDirPath));
recentWorkspaceFile = FileUri.fsPath(tmpConfigDir.resolve('recentworkspace.json'));
// create a container with the necessary bindings for the DefaultWorkspaceServer
const container = new Container();
const containerModule = new ContainerModule(bind => {
/* Mock logger binding*/
bind(ILogger).to(MockLogger);
bindContributionProvider(bind, WorkspaceHandlerContribution);
bind(FileWorkspaceHandlerContribution).toSelf().inSingletonScope();
bind(WorkspaceHandlerContribution).toService(FileWorkspaceHandlerContribution);
bind(WorkspaceCliContribution).toSelf().inSingletonScope();
bind(DefaultWorkspaceServer).toSelf().inSingletonScope();
bind(WorkspaceFileService).toSelf().inSingletonScope();
bind(UntitledWorkspaceService).toSelf().inSingletonScope();
bind(EnvVariablesServer).toConstantValue(new MockEnvVariablesServerImpl(tmpConfigDir));
});
container.load(containerModule);
workspaceServer = container.get(DefaultWorkspaceServer);
});
it('should return empty list of workspaces if no recent workspaces file is existing', async function (): Promise<void> {
const recent = await workspaceServer.getRecentWorkspaces();
expect(recent).to.be.empty;
});
it('should not return non-existing workspaces from recent workspaces file', async function (): Promise<void> {
fs.writeFileSync(recentWorkspaceFile, JSON.stringify({
recentRoots: [
tmpConfigDir.resolve('somethingNotExisting').toString(),
tmpConfigDir.resolve('somethingElseNotExisting').toString()
]
}));
const recent = await workspaceServer.getRecentWorkspaces();
expect(recent).to.be.empty;
});
it('should return only existing workspaces from recent workspaces file', async function (): Promise<void> {
fs.writeFileSync(recentWorkspaceFile, JSON.stringify({
recentRoots: [
tmpConfigDir.toString(),
tmpConfigDir.resolve('somethingNotExisting').toString()
]
}));
const recent = await workspaceServer.getRecentWorkspaces();
expect(recent).to.have.members([tmpConfigDir.toString()]);
});
it('should ignore non-string array entries but return remaining existing file paths', async function (): Promise<void> {
// previously caused: 'TypeError: Cannot read property 'fsPath' of undefined', see issue #10250
fs.writeFileSync(recentWorkspaceFile, JSON.stringify({
recentRoots: [
[tmpConfigDir.toString()],
{},
12345678,
undefined,
tmpConfigDir.toString(),
]
}));
const recent = await workspaceServer.getRecentWorkspaces();
expect(recent).to.have.members([tmpConfigDir.toString()]);
});
});
});

View File

@@ -0,0 +1,271 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as path from 'path';
import * as yargs from '@theia/core/shared/yargs';
import * as fs from '@theia/core/shared/fs-extra';
import * as jsoncparser from 'jsonc-parser';
import { injectable, inject, postConstruct, named } from '@theia/core/shared/inversify';
import { FileUri, BackendApplicationContribution } from '@theia/core/lib/node';
import { CliContribution } from '@theia/core/lib/node/cli';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { WorkspaceServer, UntitledWorkspaceService } from '../common';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import URI from '@theia/core/lib/common/uri';
import { ContributionProvider, notEmpty } from '@theia/core';
export const WorkspaceHandlerContribution = Symbol('workspaceHandlerContribution');
export interface WorkspaceHandlerContribution {
canHandle(uri: URI): boolean;
workspaceStillExists(uri: URI): Promise<boolean>;
}
@injectable()
export class WorkspaceCliContribution implements CliContribution {
@inject(EnvVariablesServer) protected readonly envVariablesServer: EnvVariablesServer;
@inject(UntitledWorkspaceService) protected readonly untitledWorkspaceService: UntitledWorkspaceService;
workspaceRoot = new Deferred<string | undefined>();
configure(conf: yargs.Argv): void {
conf.usage('$0 [workspace-directories] [options]');
conf.option('root-dir', {
description: 'DEPRECATED: Sets the workspace directory.',
});
}
async setArguments(args: yargs.Arguments): Promise<void> {
const workspaceArguments = args._.map(probablyAlreadyString => String(probablyAlreadyString));
if (workspaceArguments.length === 0 && args['root-dir']) {
workspaceArguments.push(String(args['root-dir']));
}
if (workspaceArguments.length === 0) {
this.workspaceRoot.resolve(undefined);
} else if (workspaceArguments.length === 1) {
this.workspaceRoot.resolve(this.normalizeWorkspaceArg(workspaceArguments[0]));
} else {
this.workspaceRoot.resolve(this.buildWorkspaceForMultipleArguments(workspaceArguments));
}
}
protected normalizeWorkspaceArg(raw: string): string {
return path.resolve(raw).replace(/\/$/, '');
}
protected async buildWorkspaceForMultipleArguments(workspaceArguments: string[]): Promise<string | undefined> {
try {
const dirs = await Promise.all(workspaceArguments.map(async maybeDir => (await fs.stat(maybeDir).catch(() => undefined))?.isDirectory()));
const folders = workspaceArguments.filter((_, index) => dirs[index]).map(dir => ({ path: this.normalizeWorkspaceArg(dir) }));
if (folders.length < 2) {
return folders[0]?.path;
}
const untitledWorkspaceUri = await this.untitledWorkspaceService.getUntitledWorkspaceUri(
new URI(await this.envVariablesServer.getConfigDirUri()),
async uri => !await fs.pathExists(uri.path.fsPath()),
);
const untitledWorkspacePath = untitledWorkspaceUri.path.fsPath();
await fs.ensureDir(path.dirname(untitledWorkspacePath));
await fs.writeFile(untitledWorkspacePath, JSON.stringify({ folders }, undefined, 4));
return untitledWorkspacePath;
} catch {
return undefined;
}
}
}
@injectable()
export class DefaultWorkspaceServer implements WorkspaceServer, BackendApplicationContribution {
protected root: Deferred<string | undefined> = new Deferred();
/**
* Untitled workspaces that are not among the most recent N workspaces will be deleted on start. Increase this number to keep older files,
* lower it to delete stale untitled workspaces more aggressively.
*/
protected untitledWorkspaceStaleThreshold = 10;
@inject(WorkspaceCliContribution)
protected readonly cliParams: WorkspaceCliContribution;
@inject(EnvVariablesServer)
protected readonly envServer: EnvVariablesServer;
@inject(UntitledWorkspaceService)
protected readonly untitledWorkspaceService: UntitledWorkspaceService;
@inject(ContributionProvider) @named(WorkspaceHandlerContribution)
protected readonly workspaceHandlers: ContributionProvider<WorkspaceHandlerContribution>;
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
const root = await this.getRoot();
this.root.resolve(root);
}
async onStart(): Promise<void> {
await this.removeOldUntitledWorkspaces();
}
protected async getRoot(): Promise<string | undefined> {
let root = await this.getWorkspaceURIFromCli();
if (!root) {
const data = await this.readRecentWorkspacePathsFromUserHome();
if (data && data.recentRoots) {
root = data.recentRoots[0];
}
}
return root;
}
getMostRecentlyUsedWorkspace(): Promise<string | undefined> {
return this.root.promise;
}
async setMostRecentlyUsedWorkspace(rawUri: string): Promise<void> {
const uri = rawUri && new URI(rawUri).toString(); // the empty string is used as a signal from the frontend not to load a workspace.
this.root = new Deferred();
this.root.resolve(uri);
const recentRoots = Array.from(new Set([uri, ...await this.getRecentWorkspaces()]));
this.writeToUserHome({ recentRoots });
}
async removeRecentWorkspace(rawUri: string): Promise<void> {
const uri = rawUri && new URI(rawUri).toString(); // the empty string is used as a signal from the frontend not to load a workspace.
const recentRoots = await this.getRecentWorkspaces();
const index = recentRoots.indexOf(uri);
if (index !== -1) {
recentRoots.splice(index, 1);
this.writeToUserHome({
recentRoots
});
}
}
async getRecentWorkspaces(): Promise<string[]> {
const data = await this.readRecentWorkspacePathsFromUserHome();
if (data && data.recentRoots) {
const allRootUris = await Promise.all(data.recentRoots.map(async element =>
element && await this.workspaceStillExist(element) ? element : undefined));
return allRootUris.filter(notEmpty);
}
return [];
}
protected async workspaceStillExist(workspaceRootUri: string): Promise<boolean> {
const uri = new URI(workspaceRootUri);
for (const handler of this.workspaceHandlers.getContributions()) {
if (handler.canHandle(uri)) {
return handler.workspaceStillExists(uri);
}
}
return false;
}
protected async getWorkspaceURIFromCli(): Promise<string | undefined> {
const arg = await this.cliParams.workspaceRoot.promise;
return arg !== undefined ? FileUri.create(arg).toString() : undefined;
}
/**
* Writes the given uri as the most recently used workspace root to the user's home directory.
* @param uri most recently used uri
*/
protected async writeToUserHome(data: RecentWorkspacePathsData): Promise<void> {
const file = await this.getUserStoragePath();
await this.writeToFile(file, data);
}
protected async writeToFile(fsPath: string, data: object): Promise<void> {
if (!await fs.pathExists(fsPath)) {
await fs.mkdirs(path.resolve(fsPath, '..'));
}
await fs.writeJson(fsPath, data);
}
/**
* Reads the most recently used workspace root from the user's home directory.
*/
protected async readRecentWorkspacePathsFromUserHome(): Promise<RecentWorkspacePathsData | undefined> {
const fsPath = await this.getUserStoragePath();
const data = await this.readJsonFromFile(fsPath);
return RecentWorkspacePathsData.create(data);
}
protected async readJsonFromFile(fsPath: string): Promise<object | undefined> {
if (await fs.pathExists(fsPath)) {
const rawContent = await fs.readFile(fsPath, 'utf-8');
const strippedContent = jsoncparser.stripComments(rawContent);
return jsoncparser.parse(strippedContent);
}
}
protected async getUserStoragePath(): Promise<string> {
const configDirUri = await this.envServer.getConfigDirUri();
return path.resolve(FileUri.fsPath(configDirUri), 'recentworkspace.json');
}
/**
* Removes untitled workspaces that are not among the most recently used workspaces.
* Use the `untitledWorkspaceStaleThreshold` to configure when to delete workspaces.
*/
protected async removeOldUntitledWorkspaces(): Promise<void> {
const recents = (await this.getRecentWorkspaces()).map(FileUri.fsPath);
const olderUntitledWorkspaces = recents
.slice(this.untitledWorkspaceStaleThreshold)
.filter(workspace => this.untitledWorkspaceService.isUntitledWorkspace(FileUri.create(workspace)));
await Promise.all(olderUntitledWorkspaces.map(workspace => fs.promises.unlink(FileUri.fsPath(workspace)).catch(() => { })));
if (olderUntitledWorkspaces.length > 0) {
await this.writeToUserHome({ recentRoots: await this.getRecentWorkspaces() });
}
}
}
@injectable()
export class FileWorkspaceHandlerContribution implements WorkspaceHandlerContribution {
canHandle(uri: URI): boolean {
return uri.scheme === 'file';
}
async workspaceStillExists(uri: URI): Promise<boolean> {
return fs.pathExists(uri.path.fsPath());
}
}
export interface RecentWorkspacePathsData {
recentRoots: string[];
}
export namespace RecentWorkspacePathsData {
/**
* Parses `data` as `RecentWorkspacePathsData` but removes any non-string array entry.
*
* Returns undefined if the given `data` does not contain a `recentRoots` array property.
*/
export function create(data: unknown): RecentWorkspacePathsData | undefined {
if (typeof data !== 'object' || !data || !Array.isArray((data as RecentWorkspacePathsData).recentRoots)) {
return;
}
return {
recentRoots: (data as RecentWorkspacePathsData).recentRoots.filter(root => typeof root === 'string')
};
}
}

View File

@@ -0,0 +1,18 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from './default-workspace-server';
export * from './workspace-backend-module';

View File

@@ -0,0 +1,46 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContainerModule } from '@theia/core/shared/inversify';
import { ConnectionHandler, RpcConnectionHandler, bindContributionProvider } from '@theia/core/lib/common';
import { WorkspaceServer, workspacePath, UntitledWorkspaceService, WorkspaceFileService, bindWorkspacePreferences } from '../common';
import { DefaultWorkspaceServer, FileWorkspaceHandlerContribution, WorkspaceCliContribution, WorkspaceHandlerContribution } from './default-workspace-server';
import { CliContribution } from '@theia/core/lib/node/cli';
import { BackendApplicationContribution } from '@theia/core/lib/node';
import { bindWorkspaceTrustPreferences } from '../common/workspace-trust-preferences';
export default new ContainerModule(bind => {
bind(WorkspaceCliContribution).toSelf().inSingletonScope();
bind(CliContribution).toService(WorkspaceCliContribution);
bind(DefaultWorkspaceServer).toSelf().inSingletonScope();
bind(WorkspaceServer).toService(DefaultWorkspaceServer);
bind(BackendApplicationContribution).toService(WorkspaceServer);
bind(UntitledWorkspaceService).toSelf().inSingletonScope();
bind(WorkspaceFileService).toSelf().inSingletonScope();
bindContributionProvider(bind, WorkspaceHandlerContribution);
bind(FileWorkspaceHandlerContribution).toSelf().inSingletonScope();
bind(WorkspaceHandlerContribution).toService(FileWorkspaceHandlerContribution);
bind(ConnectionHandler).toDynamicValue(ctx =>
new RpcConnectionHandler(workspacePath, () =>
ctx.container.get(WorkspaceServer)
)
).inSingletonScope();
bindWorkspacePreferences(bind);
bindWorkspaceTrustPreferences(bind);
});

View File

@@ -0,0 +1,22 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../core"
},
{
"path": "../filesystem"
},
{
"path": "../variable-resolver"
}
]
}