deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/workspace/.eslintrc.js
Normal file
10
packages/workspace/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
31
packages/workspace/README.md
Normal file
31
packages/workspace/README.md
Normal 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>
|
||||
56
packages/workspace/package.json
Normal file
56
packages/workspace/package.json
Normal 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"
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
57
packages/workspace/src/browser/canonical-uri-service.ts
Normal file
57
packages/workspace/src/browser/canonical-uri-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
56
packages/workspace/src/browser/diff-service.ts
Normal file
56
packages/workspace/src/browser/diff-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/workspace/src/browser/index.ts
Normal file
23
packages/workspace/src/browser/index.ts
Normal 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';
|
||||
23
packages/workspace/src/browser/metadata-storage/index.ts
Normal file
23
packages/workspace/src/browser/metadata-storage/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
98
packages/workspace/src/browser/quick-open-workspace.ts
Normal file
98
packages/workspace/src/browser/quick-open-workspace.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
75
packages/workspace/src/browser/style/index.css
Normal file
75
packages/workspace/src/browser/style/index.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
153
packages/workspace/src/browser/workspace-commands.spec.ts
Normal file
153
packages/workspace/src/browser/workspace-commands.spec.ts
Normal 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('');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
564
packages/workspace/src/browser/workspace-commands.ts
Normal file
564
packages/workspace/src/browser/workspace-commands.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
56
packages/workspace/src/browser/workspace-compare-handler.ts
Normal file
56
packages/workspace/src/browser/workspace-compare-handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
212
packages/workspace/src/browser/workspace-delete-handler.ts
Normal file
212
packages/workspace/src/browser/workspace-delete-handler.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
130
packages/workspace/src/browser/workspace-frontend-module.ts
Normal file
130
packages/workspace/src/browser/workspace-frontend-module.ts
Normal 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();
|
||||
});
|
||||
61
packages/workspace/src/browser/workspace-input-dialog.ts
Normal file
61
packages/workspace/src/browser/workspace-input-dialog.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
150
packages/workspace/src/browser/workspace-schema-updater.ts
Normal file
150
packages/workspace/src/browser/workspace-schema-updater.ts
Normal 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,
|
||||
};
|
||||
830
packages/workspace/src/browser/workspace-service.ts
Normal file
830
packages/workspace/src/browser/workspace-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
packages/workspace/src/browser/workspace-storage-service.ts
Normal file
67
packages/workspace/src/browser/workspace-storage-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
90
packages/workspace/src/browser/workspace-trust-dialog.tsx
Normal file
90
packages/workspace/src/browser/workspace-trust-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
462
packages/workspace/src/browser/workspace-trust-service.spec.ts
Normal file
462
packages/workspace/src/browser/workspace-trust-service.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
519
packages/workspace/src/browser/workspace-trust-service.ts
Normal file
519
packages/workspace/src/browser/workspace-trust-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
97
packages/workspace/src/browser/workspace-uri-contribution.ts
Normal file
97
packages/workspace/src/browser/workspace-uri-contribution.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
packages/workspace/src/browser/workspace-utils.ts
Normal file
45
packages/workspace/src/browser/workspace-utils.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
20
packages/workspace/src/common/index.ts
Normal file
20
packages/workspace/src/common/index.ts
Normal 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';
|
||||
29
packages/workspace/src/common/test/mock-workspace-server.ts
Normal file
29
packages/workspace/src/common/test/mock-workspace-server.ts
Normal 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(); }
|
||||
}
|
||||
69
packages/workspace/src/common/untitled-workspace-service.ts
Normal file
69
packages/workspace/src/common/untitled-workspace-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
packages/workspace/src/common/workspace-file-service.ts
Normal file
72
packages/workspace/src/common/workspace-file-service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
51
packages/workspace/src/common/workspace-preferences.ts
Normal file
51
packages/workspace/src/common/workspace-preferences.ts
Normal 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);
|
||||
}
|
||||
47
packages/workspace/src/common/workspace-protocol.ts
Normal file
47
packages/workspace/src/common/workspace-protocol.ts
Normal 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[]>
|
||||
}
|
||||
85
packages/workspace/src/common/workspace-trust-preferences.ts
Normal file
85
packages/workspace/src/common/workspace-trust-preferences.ts
Normal 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);
|
||||
}
|
||||
111
packages/workspace/src/node/default-workspace-server.spec.ts
Normal file
111
packages/workspace/src/node/default-workspace-server.spec.ts
Normal 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()]);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
packages/workspace/src/node/default-workspace-server.ts
Normal file
271
packages/workspace/src/node/default-workspace-server.ts
Normal 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')
|
||||
};
|
||||
}
|
||||
}
|
||||
18
packages/workspace/src/node/index.ts
Normal file
18
packages/workspace/src/node/index.ts
Normal 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';
|
||||
46
packages/workspace/src/node/workspace-backend-module.ts
Normal file
46
packages/workspace/src/node/workspace-backend-module.ts
Normal 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);
|
||||
});
|
||||
22
packages/workspace/tsconfig.json
Normal file
22
packages/workspace/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user