diff --git a/.gitignore b/.gitignore
index edb4f7b24b58a..9fc528ae0bad1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,8 @@ coverage
errorShots
examples/*/src-gen
examples/*/gen-webpack.config.js
+examples/*/.theia
+examples/*/.vscode
.browser_modules
**/docs/api
package-backup.json
diff --git a/packages/debug/src/browser/preferences/launch-preferences.spec.ts b/examples/api-tests/src/launch-preferences.spec.js
similarity index 72%
rename from packages/debug/src/browser/preferences/launch-preferences.spec.ts
rename to examples/api-tests/src/launch-preferences.spec.js
index 72d37dbccb8dc..14fbc5688544a 100644
--- a/packages/debug/src/browser/preferences/launch-preferences.spec.ts
+++ b/examples/api-tests/src/launch-preferences.spec.js
@@ -14,52 +14,39 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
+// @ts-check
+///
+
/* eslint-disable no-unused-expressions, @typescript-eslint/no-explicit-any */
-import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
-const disableJSDOM = enableJSDOM();
-
-import * as path from 'path';
-import * as fs from 'fs-extra';
-import * as assert from 'assert';
-import { Container } from 'inversify';
-import { FileUri } from '@theia/core/lib/node/file-uri';
-import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
-import { PreferenceService, PreferenceServiceImpl, PreferenceScope } from '@theia/core/lib/browser/preferences/preference-service';
-import { bindPreferenceService, bindMessageService, bindResourceProvider } from '@theia/core/lib/browser/frontend-application-bindings';
-import { bindFileSystem } from '@theia/filesystem/lib/node/filesystem-backend-module';
-import { bindFileResource } from '@theia/filesystem/lib/browser/filesystem-frontend-module';
-import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
-import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
-import { FileSystemWatcher } from '@theia/filesystem/lib/browser/filesystem-watcher';
-import { bindFileSystemPreferences } from '@theia/filesystem/lib/browser/filesystem-preferences';
-import { FileShouldOverwrite } from '@theia/filesystem/lib/common/filesystem';
-import { bindLogger } from '@theia/core/lib/node/logger-backend-module';
-import { bindWorkspacePreferences } from '@theia/workspace/lib/browser';
-import { WindowService } from '@theia/core/lib/browser/window/window-service';
-import { MockWindowService } from '@theia/core/lib/browser/window/test/mock-window-service';
-import { MockWorkspaceServer } from '@theia/workspace/lib/common/test/mock-workspace-server';
-import { WorkspaceServer } from '@theia/workspace/lib/common/workspace-protocol';
-import { bindPreferenceProviders } from '@theia/preferences/lib/browser/preference-bindings';
-import { bindUserStorage } from '@theia/userstorage/lib/browser/user-storage-frontend-module';
-import { FileSystemWatcherServer } from '@theia/filesystem/lib/common/filesystem-watcher-protocol';
-import { MockFilesystemWatcherServer } from '@theia/filesystem/lib/common/test/mock-filesystem-watcher-server';
-import { bindLaunchPreferences } from './launch-preferences';
-
-disableJSDOM();
-
-process.on('unhandledRejection', (reason, promise) => {
- console.error(reason);
- throw reason;
-});
+/**
+ * @typedef {'.vscode' | '.theia' | ['.theia', '.vscode']} ConfigMode
+ */
/**
* Expectations should be tested and aligned against VS Code.
* See https://github.com/akosyakov/vscode-launch/blob/master/src/test/extension.test.ts
*/
-describe('Launch Preferences', () => {
-
- type ConfigMode = '.vscode' | '.theia' | ['.theia', '.vscode'];
+describe('Launch Preferences', function () {
+
+ const { assert } = chai;
+
+ const { PreferenceService, PreferenceScope } = require('@theia/core/lib/browser/preferences/preference-service');
+ const Uri = require('@theia/core/lib/common/uri');
+ const { WorkspaceService } = require('@theia/workspace/lib/browser/workspace-service');
+ const { FileSystem } = require('@theia/filesystem/lib/common/filesystem');
+ const { MonacoTextModelService } = require('@theia/monaco/lib/browser/monaco-text-model-service');
+ const { MonacoWorkspace } = require('@theia/monaco/lib/browser/monaco-workspace');
+
+ /** @type {import('inversify').Container} */
+ const container = window['theia'].container;
+ /** @type {import('@theia/core/lib/browser/preferences/preference-service').PreferenceService} */
+ const preferences = container.get(PreferenceService);
+ const workspaceService = container.get(WorkspaceService);
+ /** @type {import('@theia/filesystem/lib/common/filesystem').FileSystem} */
+ const fileSystem = container.get(FileSystem);
+ const textModelService = container.get(MonacoTextModelService);
+ const workspace = container.get(MonacoWorkspace);
const defaultLaunch = {
'configurations': [],
@@ -329,15 +316,20 @@ describe('Launch Preferences', () => {
}
});
+ /**
+ * @typedef {Object} LaunchAndSettingsSuiteOptions
+ * @property {string} name
+ * @property {any} expectation
+ * @property {any} [launch]
+ * @property {boolean} [only]
+ * @property {ConfigMode} [configMode]
+ */
+ /**
+ * @type {(options: LaunchAndSettingsSuiteOptions) => void}
+ */
function testLaunchAndSettingsSuite({
name, expectation, launch, only, configMode
- }: {
- name: string,
- expectation: any,
- launch?: any,
- only?: boolean,
- configMode?: ConfigMode
- }): void {
+ }) {
testSuite({
name: name + ' Launch Configuration',
launch,
@@ -356,20 +348,24 @@ describe('Launch Preferences', () => {
});
}
- function testSuite(options: {
- name: string,
- expectation: any,
- inspectExpectation?: any,
- launch?: any,
- settings?: any,
- only?: boolean,
- configMode?: ConfigMode
- }): void {
-
+ /**
+ * @typedef {Object} SuiteOptions
+ * @property {string} name
+ * @property {any} expectation
+ * @property {any} [inspectExpectation]
+ * @property {any} [launch]
+ * @property {any} [settings]
+ * @property {boolean} [only]
+ * @property {ConfigMode} [configMode]
+ */
+ /**
+ * @type {(options: SuiteOptions) => void}
+ */
+ function testSuite(options) {
describe(options.name, () => {
if (options.configMode) {
- testConfigSuite(options as any);
+ testConfigSuite(options);
} else {
testConfigSuite({
@@ -394,92 +390,114 @@ describe('Launch Preferences', () => {
}
+ /**
+ * @typedef {Object} ConfigSuiteOptions
+ * @property {any} expectation
+ * @property {any} [inspectExpectation]
+ * @property {any} [launch]
+ * @property {any} [settings]
+ * @property {boolean} [only]
+ * @property {ConfigMode} [configMode]
+ */
+ /**
+ * @type {(options: ConfigSuiteOptions) => void}
+ */
function testConfigSuite({
configMode, expectation, inspectExpectation, settings, launch, only
- }: {
- configMode: ConfigMode
- expectation: any,
- inspectExpectation?: any,
- launch?: any,
- settings?: any,
- only?: boolean
- }): void {
+ }) {
describe(JSON.stringify(configMode, undefined, 2), () => {
-
const configPaths = Array.isArray(configMode) ? configMode : [configMode];
- const rootPath = path.resolve(__dirname, '..', '..', '..', 'launch-preference-test-temp');
- const rootUri = FileUri.create(rootPath).toString();
-
- let preferences: PreferenceService;
-
- const toTearDown = new DisposableCollection();
- beforeEach(async function (): Promise {
- toTearDown.push(Disposable.create(enableJSDOM()));
- FrontendApplicationConfigProvider.set({
- 'applicationName': 'test',
- });
-
- fs.removeSync(rootPath);
- fs.ensureDirSync(rootPath);
- toTearDown.push(Disposable.create(() => fs.removeSync(rootPath)));
-
- if (settings) {
+ const rootUri = new Uri.default(workspaceService.tryGetRoots()[0].uri);
+
+ /** @typedef {monaco.editor.IReference} ConfigModelReference */
+ /** @type {ConfigModelReference[]} */
+ beforeEach(() => {
+ const promises = [];
+ /**
+ * @param {string} name
+ * @param {string} text
+ */
+ const ensureConfigModel = (name, text) => {
for (const configPath of configPaths) {
- const settingsPath = path.resolve(rootPath, configPath, 'settings.json');
- fs.ensureFileSync(settingsPath);
- fs.writeFileSync(settingsPath, JSON.stringify(settings), 'utf-8');
+ promises.push((async () => {
+ try {
+ const reference = await textModelService.createModelReference(rootUri.resolve(configPath + '/' + name + '.json'));
+ try {
+ await workspace.applyBackgroundEdit(reference.object, [{
+ text,
+ range: reference.object.textEditorModel.getFullModelRange(),
+ forceMoveMarkers: false
+ }]);
+ } finally {
+ reference.dispose();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ })());
}
+ };
+ if (settings) {
+ ensureConfigModel('settings', JSON.stringify(settings));
}
if (launch) {
+ ensureConfigModel('launch', JSON.stringify(launch));
+ }
+ return Promise.all(promises);
+ });
+
+ after(() => {
+ const promises = [];
+ /**
+ * @param {string} name
+ */
+ const ensureReleaseModel = name => {
for (const configPath of configPaths) {
- const launchPath = path.resolve(rootPath, configPath, 'launch.json');
- fs.ensureFileSync(launchPath);
- fs.writeFileSync(launchPath, JSON.stringify(launch), 'utf-8');
+ promises.push((async () => {
+ try {
+ const reference = await textModelService.createModelReference(rootUri.resolve(configPath + '/' + name + '.json'));
+ try {
+ const model = reference.object.textEditorModel;
+ if (model.getValue() === '') {
+ return;
+ }
+ await new Promise(resolve => {
+ const listener = model.onDidChangeContent(() => {
+ const value = model.getValue();
+ if (value !== '') {
+ console.error(`'${model.uri.toString()}' should be empty, but was '${value}'`);
+ }
+ listener.dispose();
+ resolve();
+ reference.dispose();
+ });
+ });
+ } finally {
+ reference.dispose();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ })());
}
+ };
+ if (settings) {
+ ensureReleaseModel('settings');
}
-
- const container = new Container();
- const bind = container.bind.bind(container);
- const unbind = container.unbind.bind(container);
- bindLogger(bind);
- bindMessageService(bind);
- bindResourceProvider(bind);
- bindFileResource(bind);
- bindUserStorage(bind);
- bindPreferenceService(bind);
- bindFileSystem(bind);
- bind(FileSystemWatcherServer).toConstantValue(new MockFilesystemWatcherServer());
- bindFileSystemPreferences(bind);
- container.bind(FileShouldOverwrite).toConstantValue(async () => true);
- bind(FileSystemWatcher).toSelf().inSingletonScope();
- bindPreferenceProviders(bind, unbind);
- bindWorkspacePreferences(bind);
- container.bind(WorkspaceService).toSelf().inSingletonScope();
- container.bind(WindowService).toConstantValue(new MockWindowService());
-
- const workspaceServer = new MockWorkspaceServer();
- workspaceServer['getMostRecentlyUsedWorkspace'] = async () => rootUri;
- container.bind(WorkspaceServer).toConstantValue(workspaceServer);
-
- bindLaunchPreferences(bind);
-
- toTearDown.push(container.get(FileSystemWatcher));
-
- const impl = container.get(PreferenceServiceImpl);
- toTearDown.push(impl);
-
- preferences = impl;
- toTearDown.push(Disposable.create(() => preferences = undefined!));
-
- await preferences.ready;
- await container.get(WorkspaceService).roots;
+ if (launch) {
+ ensureReleaseModel('launch');
+ }
+ return Promise.all([
+ ...promises,
+ fileSystem.delete(rootUri.resolve('.theia').toString(), { moveToTrash: false }).catch(() => { }),
+ fileSystem.delete(rootUri.resolve('.vscode').toString(), { moveToTrash: false }).catch(() => { })
+ ]);
});
- afterEach(() => toTearDown.dispose());
-
- const testIt = !!only ? it.only : it;
+ const testItOnly = !!only ? it.only : it;
+ const testIt = testItOnly;
const settingsLaunch = settings ? settings['launch'] : undefined;
@@ -494,7 +512,7 @@ describe('Launch Preferences', () => {
});
testIt('get from rootUri', () => {
- const config = preferences.get('launch', undefined, rootUri);
+ const config = preferences.get('launch', undefined, rootUri.toString());
assert.deepStrictEqual(JSON.parse(JSON.stringify(config)), expectation);
});
@@ -515,7 +533,7 @@ describe('Launch Preferences', () => {
});
testIt('inspect in rootUri', () => {
- const inspect = preferences.inspect('launch', rootUri);
+ const inspect = preferences.inspect('launch', rootUri.toString());
const expected = {
preferenceName: 'launch',
defaultValue: defaultLaunch
@@ -574,7 +592,7 @@ describe('Launch Preferences', () => {
});
testIt('update launch WorkspaceFolder with resource', async () => {
- await preferences.set('launch', validLaunch, PreferenceScope.Folder, rootUri);
+ await preferences.set('launch', validLaunch, PreferenceScope.Folder, rootUri.toString());
const inspect = preferences.inspect('launch');
const actual = inspect && inspect.workspaceValue;
@@ -587,7 +605,7 @@ describe('Launch Preferences', () => {
await preferences.set('launch.configurations', [validConfiguration, validConfiguration2]);
const inspect = preferences.inspect('launch');
- const actual = inspect && inspect.workspaceValue && (inspect.workspaceValue).configurations;
+ const actual = inspect && inspect.workspaceValue && inspect.workspaceValue.configurations;
assert.deepStrictEqual(actual, [validConfiguration, validConfiguration2]);
});
}
diff --git a/packages/filesystem/src/node/file-change-collection.spec.ts b/packages/filesystem/src/node/file-change-collection.spec.ts
index a3d52c3713470..41ece2c9ce9c6 100644
--- a/packages/filesystem/src/node/file-change-collection.spec.ts
+++ b/packages/filesystem/src/node/file-change-collection.spec.ts
@@ -22,79 +22,84 @@ import { FileChangeType } from '../common/filesystem-watcher-protocol';
describe('FileChangeCollection', () => {
assertChanges({
- first: FileChangeType.ADDED,
- second: FileChangeType.ADDED,
+ changes: [FileChangeType.ADDED, FileChangeType.ADDED],
expected: FileChangeType.ADDED
});
assertChanges({
- first: FileChangeType.ADDED,
- second: FileChangeType.UPDATED,
+ changes: [FileChangeType.ADDED, FileChangeType.UPDATED],
expected: FileChangeType.ADDED
});
assertChanges({
- first: FileChangeType.ADDED,
- second: FileChangeType.DELETED,
- expected: undefined
+ changes: [FileChangeType.ADDED, FileChangeType.DELETED],
+ expected: [FileChangeType.ADDED, FileChangeType.DELETED]
});
assertChanges({
- first: FileChangeType.UPDATED,
- second: FileChangeType.ADDED,
+ changes: [FileChangeType.UPDATED, FileChangeType.ADDED],
expected: FileChangeType.UPDATED
});
assertChanges({
- first: FileChangeType.UPDATED,
- second: FileChangeType.UPDATED,
+ changes: [FileChangeType.UPDATED, FileChangeType.UPDATED],
expected: FileChangeType.UPDATED
});
assertChanges({
- first: FileChangeType.UPDATED,
- second: FileChangeType.DELETED,
+ changes: [FileChangeType.UPDATED, FileChangeType.DELETED],
expected: FileChangeType.DELETED
});
assertChanges({
- first: FileChangeType.DELETED,
- second: FileChangeType.ADDED,
+ changes: [FileChangeType.DELETED, FileChangeType.ADDED],
expected: FileChangeType.UPDATED
});
assertChanges({
- first: FileChangeType.DELETED,
- second: FileChangeType.UPDATED,
+ changes: [FileChangeType.DELETED, FileChangeType.UPDATED],
expected: FileChangeType.UPDATED
});
assertChanges({
- first: FileChangeType.DELETED,
- second: FileChangeType.DELETED,
+ changes: [FileChangeType.DELETED, FileChangeType.DELETED],
expected: FileChangeType.DELETED
});
- function assertChanges({ first, second, expected }: {
- first: FileChangeType,
- second: FileChangeType,
- expected: FileChangeType | undefined
+ assertChanges({
+ changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED],
+ expected: [FileChangeType.ADDED, FileChangeType.DELETED]
+ });
+
+ assertChanges({
+ changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED, FileChangeType.ADDED],
+ expected: [FileChangeType.ADDED]
+ });
+
+ assertChanges({
+ changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED, FileChangeType.UPDATED],
+ expected: [FileChangeType.ADDED]
+ });
+
+ assertChanges({
+ changes: [FileChangeType.ADDED, FileChangeType.UPDATED, FileChangeType.DELETED, FileChangeType.DELETED],
+ expected: [FileChangeType.ADDED, FileChangeType.DELETED]
+ });
+
+ function assertChanges({ changes, expected }: {
+ changes: FileChangeType[],
+ expected: FileChangeType[] | FileChangeType
}): void {
- it(`${FileChangeType[first]} + ${FileChangeType[second]} => ${expected !== undefined ? FileChangeType[expected] : 'NONE'}`, () => {
+ const expectedTypes = Array.isArray(expected) ? expected : [expected];
+ const expectation = expectedTypes.map(type => FileChangeType[type]).join(' + ');
+ it(`${changes.map(type => FileChangeType[type]).join(' + ')} => ${expectation}`, () => {
const collection = new FileChangeCollection();
const uri = FileUri.create('/root/foo/bar.txt').toString();
- collection.push({
- uri,
- type: first
- });
- collection.push({
- uri,
- type: second
- });
- assert.deepEqual(expected !== undefined ? [{
- uri,
- type: expected
- }] : [], collection.values());
+ for (const type of changes) {
+ collection.push({ uri, type });
+ }
+ const actual = collection.values().map(({ type }) => FileChangeType[type]).join(' + ');
+ assert.equal(expectation, actual);
});
}
diff --git a/packages/filesystem/src/node/file-change-collection.ts b/packages/filesystem/src/node/file-change-collection.ts
index ab0b9d3744d11..4fe28bd6f6520 100644
--- a/packages/filesystem/src/node/file-change-collection.ts
+++ b/packages/filesystem/src/node/file-change-collection.ts
@@ -22,7 +22,7 @@ import { FileChange, FileChangeType } from '../common/filesystem-watcher-protoco
* Changes are normalized according following rules:
* - ADDED + ADDED => ADDED
* - ADDED + UPDATED => ADDED
- * - ADDED + DELETED => NONE
+ * - ADDED + DELETED => [ADDED, DELETED]
* - UPDATED + ADDED => UPDATED
* - UPDATED + UPDATED => UPDATED
* - UPDATED + DELETED => DELETED
@@ -31,37 +31,48 @@ import { FileChange, FileChangeType } from '../common/filesystem-watcher-protoco
* - DELETED + DELETED => DELETED
*/
export class FileChangeCollection {
- protected readonly changes = new Map();
+ protected readonly changes = new Map();
push(change: FileChange): void {
- const current = this.changes.get(change.uri);
- if (current) {
- if (this.isDeleted(current, change)) {
- this.changes.delete(change.uri);
- } else if (this.isUpdated(current, change)) {
- current.type = FileChangeType.UPDATED;
- } else if (!this.shouldSkip(current, change)) {
- current.type = change.type;
- }
- } else {
- this.changes.set(change.uri, change);
- }
+ const changes = this.changes.get(change.uri) || [];
+ this.normalize(changes, change);
+ this.changes.set(change.uri, changes);
}
- protected isDeleted(current: FileChange, change: FileChange): boolean {
- return current.type === FileChangeType.ADDED && change.type === FileChangeType.DELETED;
- }
+ protected normalize(changes: FileChange[], change: FileChange): void {
+ let currentType;
+ let nextType: FileChangeType | [FileChangeType, FileChangeType] = change.type;
+ do {
+ const current = changes.pop();
+ currentType = current && current.type;
+ nextType = this.reduce(currentType, nextType);
+ } while (!Array.isArray(nextType) && currentType !== undefined && currentType !== nextType);
- protected isUpdated(current: FileChange, change: FileChange): boolean {
- return current.type === FileChangeType.DELETED && change.type === FileChangeType.ADDED;
+ const uri = change.uri;
+ if (Array.isArray(nextType)) {
+ changes.push(...nextType.map(type => ({ uri, type })));
+ } else {
+ changes.push({ uri, type: nextType });
+ }
}
- protected shouldSkip(current: FileChange, change: FileChange): boolean {
- return (current.type === FileChangeType.ADDED && change.type === FileChangeType.UPDATED) ||
- (current.type === FileChangeType.UPDATED && change.type === FileChangeType.ADDED);
+ protected reduce(current: FileChangeType | undefined, change: FileChangeType): FileChangeType | [FileChangeType, FileChangeType] {
+ if (current === undefined) {
+ return change;
+ }
+ if (current === FileChangeType.ADDED) {
+ if (change === FileChangeType.DELETED) {
+ return [FileChangeType.ADDED, FileChangeType.DELETED];
+ }
+ return FileChangeType.ADDED;
+ }
+ if (change === FileChangeType.DELETED) {
+ return FileChangeType.DELETED;
+ }
+ return FileChangeType.UPDATED;
}
values(): FileChange[] {
- return Array.from(this.changes.values());
+ return Array.from(this.changes.values()).reduce((acc, val) => acc.concat(val), []);
}
}
diff --git a/packages/filesystem/src/node/node-filesystem.ts b/packages/filesystem/src/node/node-filesystem.ts
index c7abf9c58ddfe..9970c01ebd773 100644
--- a/packages/filesystem/src/node/node-filesystem.ts
+++ b/packages/filesystem/src/node/node-filesystem.ts
@@ -327,19 +327,11 @@ export class FileSystemNode implements FileSystem {
const filePath = FileUri.fsPath(_uri);
const outputRootPath = paths.join(os.tmpdir(), v4());
try {
- await new Promise((resolve, reject) => {
- fs.rename(filePath, outputRootPath, async error => {
- if (error) {
- reject(error);
- return;
- }
- resolve();
- });
- });
+ await fs.rename(filePath, outputRootPath);
// There is no reason for the promise returned by this function not to resolve
// as soon as the move is complete. Clearing up the temporary files can be
// done in the background.
- fs.remove(FileUri.fsPath(outputRootPath));
+ fs.remove(outputRootPath);
} catch (error) {
return fs.remove(filePath);
}
diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts
index 4f418cd13a6ed..877b895a0d43b 100644
--- a/packages/monaco/src/browser/monaco-editor-model.ts
+++ b/packages/monaco/src/browser/monaco-editor-model.ts
@@ -75,8 +75,8 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument {
this.toDispose.push(this.onDidSaveModelEmitter);
this.toDispose.push(this.onWillSaveModelEmitter);
this.toDispose.push(this.onDirtyChangedEmitter);
- this.resolveModel = resource.readContents(options).then(content => this.initialize(content));
this.defaultEncoding = options && options.encoding ? options.encoding : undefined;
+ this.resolveModel = this.readContents().then(content => this.initialize(content || ''));
}
dispose(): void {
@@ -120,6 +120,21 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument {
}
}
+ /**
+ * Use `valid` to access it.
+ * Use `setValid` to mutate it.
+ */
+ protected _valid = false;
+ /**
+ * Whether it is possible to load content from the underlying resource.
+ */
+ get valid(): boolean {
+ return this._valid;
+ }
+ protected setValid(valid: boolean): void {
+ this._valid = valid;
+ }
+
protected _dirty = false;
get dirty(): boolean {
return this._dirty;
@@ -243,10 +258,13 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument {
return;
}
- const newText = await this.readContents();
- if (newText === undefined || token.isCancellationRequested || this._dirty) {
+ let newText = await this.readContents();
+ if (token.isCancellationRequested || this._dirty) {
return;
}
+ if (newText === undefined) {
+ newText = '';
+ }
const value = this.model.getValue();
if (value === newText) {
@@ -261,9 +279,12 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument {
}
protected async readContents(): Promise {
try {
- return await this.resource.readContents({ encoding: this.getEncoding() });
+ const content = await this.resource.readContents({ encoding: this.getEncoding() });
+ this.setValid(true);
+ return content;
} catch (e) {
if (ResourceError.NotFound.is(e)) {
+ this.setValid(false);
return undefined;
}
throw e;
@@ -377,6 +398,7 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument {
const content = this.model.getValue();
await Resource.save(this.resource, { changes, content, options: { encoding: this.getEncoding(), overwriteEncoding } }, token);
+ this.setValid(true);
if (token.isCancellationRequested) {
return;
}
diff --git a/packages/monaco/src/browser/monaco-workspace.ts b/packages/monaco/src/browser/monaco-workspace.ts
index ef552149c166f..2da1ff9441359 100644
--- a/packages/monaco/src/browser/monaco-workspace.ts
+++ b/packages/monaco/src/browser/monaco-workspace.ts
@@ -32,6 +32,7 @@ import { WillSaveMonacoModelEvent, MonacoEditorModel, MonacoModelContentChangedE
import { MonacoEditor } from './monaco-editor';
import { MonacoConfigurations } from './monaco-configurations';
import { ProblemManager } from '@theia/markers/lib/browser';
+import { MaybePromise } from '@theia/core/lib/common/types';
export interface MonacoDidChangeTextDocumentParams extends lang.DidChangeTextDocumentParams {
readonly textDocument: MonacoEditorModel;
@@ -276,7 +277,12 @@ export class MonacoWorkspace implements lang.Workspace {
this.onDidSaveTextDocumentEmitter.fire(model);
}
+ protected suppressedOpenIfDirty: MonacoEditorModel[] = [];
+
protected openEditorIfDirty(model: MonacoEditorModel): void {
+ if (this.suppressedOpenIfDirty.indexOf(model) !== -1) {
+ return;
+ }
if (model.dirty && MonacoEditor.findByDocument(this.editorManager, model).length === 0) {
// create a new reference to make sure the model is not disposed before it is
// acquired by the editor, thus losing the changes that made it dirty.
@@ -288,6 +294,18 @@ export class MonacoWorkspace implements lang.Workspace {
}
}
+ protected async suppressOpenIfDirty(model: MonacoEditorModel, cb: () => MaybePromise): Promise {
+ this.suppressedOpenIfDirty.push(model);
+ try {
+ await cb();
+ } finally {
+ const i = this.suppressedOpenIfDirty.indexOf(model);
+ if (i !== -1) {
+ this.suppressedOpenIfDirty.splice(i, 1);
+ }
+ }
+ }
+
createFileSystemWatcher(globPattern: string, ignoreCreateEvents?: boolean, ignoreChangeEvents?: boolean, ignoreDeleteEvents?: boolean): lang.FileSystemWatcher {
const disposables = new DisposableCollection();
const onDidCreateEmitter = new lang.Emitter();
@@ -331,6 +349,19 @@ export class MonacoWorkspace implements lang.Workspace {
};
}
+ applyBackgroundEdit(model: MonacoEditorModel, editOperations: monaco.editor.IIdentifiedSingleEditOperation[]): Promise {
+ return this.suppressOpenIfDirty(model, async () => {
+ const editor = MonacoEditor.findByDocument(this.editorManager, model)[0];
+ const cursorState = editor && editor.getControl().getSelections() || [];
+ model.textEditorModel.pushStackElement();
+ model.textEditorModel.pushEditOperations(cursorState, editOperations, () => cursorState);
+ model.textEditorModel.pushStackElement();
+ if (!editor) {
+ await model.save();
+ }
+ });
+ }
+
async applyEdit(changes: lang.WorkspaceEdit, options?: EditorOpenerOptions): Promise {
const workspaceEdit = this.p2m.asWorkspaceEdit(changes);
await this.applyBulkEdit(workspaceEdit, options);
diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts
index 1622b83fc3580..9ed451a7b7341 100644
--- a/packages/plugin-ext/src/common/plugin-api-rpc.ts
+++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts
@@ -811,7 +811,7 @@ export interface TextEditorsExt {
}
export interface SingleEditOperation {
- range: Range;
+ range?: Range;
text?: string;
forceMoveMarkers?: boolean;
}
diff --git a/packages/plugin-ext/src/main/browser/documents-main.ts b/packages/plugin-ext/src/main/browser/documents-main.ts
index cf94a6cbe8aeb..1753f7e4f4921 100644
--- a/packages/plugin-ext/src/main/browser/documents-main.ts
+++ b/packages/plugin-ext/src/main/browser/documents-main.ts
@@ -108,13 +108,24 @@ export class DocumentsMainImpl implements DocumentsMain, Disposable {
onWillSaveModelEvent.waitUntil(new Promise(async (resolve, reject) => {
setTimeout(() => reject(new Error(`Aborted onWillSaveTextDocument-event after ${this.saveTimeout}ms`)), this.saveTimeout);
const edits = await this.proxy.$acceptModelWillSave(onWillSaveModelEvent.model.textEditorModel.uri, onWillSaveModelEvent.reason, this.saveTimeout);
- const transformedEdits = edits.map((edit): monaco.editor.IIdentifiedSingleEditOperation =>
- ({
- range: monaco.Range.lift(edit.range),
- text: edit.text!,
+ const editOperations: monaco.editor.IIdentifiedSingleEditOperation[] = [];
+ for (const edit of edits) {
+ const { range, text } = edit;
+ if (!range && !text) {
+ continue;
+ }
+ if (range && range.startLineNumber === range.endLineNumber && range.startColumn === range.endColumn && !edit.text) {
+ continue;
+ }
+
+ editOperations.push({
+ range: range ? monaco.Range.lift(range) : onWillSaveModelEvent.model.textEditorModel.getFullModelRange(),
+ /* eslint-disable-next-line no-null/no-null */
+ text: text || null,
forceMoveMarkers: edit.forceMoveMarkers
- }));
- resolve(transformedEdits);
+ });
+ }
+ resolve(editOperations);
}));
}));
this.toDispose.push(modelService.onModelDirtyChanged(m => {
diff --git a/packages/plugin-ext/src/main/browser/text-editor-main.ts b/packages/plugin-ext/src/main/browser/text-editor-main.ts
index 07aa30e35f58d..00563293289d7 100644
--- a/packages/plugin-ext/src/main/browser/text-editor-main.ts
+++ b/packages/plugin-ext/src/main/browser/text-editor-main.ts
@@ -234,17 +234,28 @@ export class TextEditorMain implements Disposable {
this.model.setEOL(monaco.editor.EndOfLineSequence.LF);
}
- const transformedEdits = edits.map((edit): monaco.editor.IIdentifiedSingleEditOperation =>
- ({
- range: monaco.Range.lift(edit.range),
- text: edit.text!,
+ const editOperations: monaco.editor.IIdentifiedSingleEditOperation[] = [];
+ for (const edit of edits) {
+ const { range, text } = edit;
+ if (!range && !text) {
+ continue;
+ }
+ if (range && range.startLineNumber === range.endLineNumber && range.startColumn === range.endColumn && !edit.text) {
+ continue;
+ }
+
+ editOperations.push({
+ range: range ? monaco.Range.lift(range) : this.editor.getControl().getModel()!.getFullModelRange(),
+ /* eslint-disable-next-line no-null/no-null */
+ text: text || null,
forceMoveMarkers: edit.forceMoveMarkers
- }));
+ });
+ }
if (opts.undoStopBefore) {
this.editor.getControl().pushUndoStop();
}
- this.editor.getControl().executeEdits('MainThreadTextEditor', transformedEdits);
+ this.editor.getControl().executeEdits('MainThreadTextEditor', editOperations);
if (opts.undoStopAfter) {
this.editor.getControl().pushUndoStop();
}
diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.ts
index 81ef6222cf3ce..05aef35f8a644 100644
--- a/packages/preferences/src/browser/abstract-resource-preference-provider.ts
+++ b/packages/preferences/src/browser/abstract-resource-preference-provider.ts
@@ -15,20 +15,28 @@
********************************************************************************/
/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable no-null/no-null */
import * as jsoncparser from 'jsonc-parser';
import { JSONExt } from '@phosphor/coreutils/lib/json';
import { inject, injectable, postConstruct } from 'inversify';
-import { MessageService, Resource, ResourceProvider, Disposable } from '@theia/core';
+import { ResourceProvider } from '@theia/core/lib/common/resource';
+import { MessageService } from '@theia/core/lib/common/message-service';
+import { Disposable } from '@theia/core/lib/common/disposable';
import { PreferenceProvider, PreferenceSchemaProvider, PreferenceScope, PreferenceProviderDataChange, PreferenceService } from '@theia/core/lib/browser';
import URI from '@theia/core/lib/common/uri';
import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations';
+import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
+import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
+import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
+import { Deferred } from '@theia/core/lib/common/promise-util';
@injectable()
export abstract class AbstractResourcePreferenceProvider extends PreferenceProvider {
protected preferences: { [key: string]: any } = {};
- protected resource: Promise;
+ protected model: MonacoEditorModel | undefined;
+ protected readonly loading = new Deferred();
@inject(PreferenceService) protected readonly preferenceService: PreferenceService;
@inject(ResourceProvider) protected readonly resourceProvider: ResourceProvider;
@@ -38,36 +46,65 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi
@inject(PreferenceConfigurations)
protected readonly configurations: PreferenceConfigurations;
+ @inject(MonacoTextModelService)
+ protected readonly textModelService: MonacoTextModelService;
+
+ @inject(MonacoWorkspace)
+ protected readonly workspace: MonacoWorkspace;
+
@postConstruct()
protected async init(): Promise {
const uri = this.getUri();
- this.resource = this.resourceProvider(uri);
+ this.toDispose.push(Disposable.create(() => this.loading.reject(new Error(`preference provider for '${uri}' was disposed`))));
+
+ // it is blocking till the preference service is initialized,
+ // so first try to load from the underlying resource
+ const deferredReference = this.textModelService.createModelReference(uri);
- // Try to read the initial content of the preferences. The provider
+ // Try to read the initial content of the preferences. The provider
// becomes ready even if we fail reading the preferences, so we don't
// hang the preference service.
- this.readPreferences()
- .then(() => this._ready.resolve())
- .catch(() => this._ready.resolve());
+ try {
+ const resource = await this.resourceProvider(uri);
+ try {
+ const content = await resource.readContents();
+ this.loadPreferences(content);
+ } finally {
+ resource.dispose();
+ }
+ } catch {
+ /* no-op */
+ } finally {
+ this._ready.resolve();
+ }
- const resource = await this.resource;
- this.toDispose.push(resource);
- if (resource.onDidChangeContents) {
- this.toDispose.push(resource.onDidChangeContents(() => this.readPreferences()));
+ const reference = await deferredReference;
+ if (this.toDispose.disposed) {
+ reference.dispose();
+ return;
}
+ this.toDispose.push(reference);
+ this.model = reference.object;
+ this.toDispose.push(Disposable.create(() => this.model = undefined));
+ this.toDispose.push(this.model.onDidChangeContent(() => this.readPreferences()));
this.toDispose.push(Disposable.create(() => this.reset()));
+ this.loading.resolve();
}
protected abstract getUri(): URI;
protected abstract getScope(): PreferenceScope;
+ protected get valid(): boolean {
+ return this.model && this.model.valid || false;
+ }
+
getConfigUri(): URI;
getConfigUri(resourceUri: string | undefined): URI | undefined;
getConfigUri(resourceUri?: string): URI | undefined {
if (!resourceUri) {
return this.getUri();
}
- return this.loaded && this.contains(resourceUri) ? this.getUri() : undefined;
+ return this.valid && this.contains(resourceUri) ? this.getUri() : undefined;
}
contains(resourceUri: string | undefined): boolean {
@@ -83,10 +120,14 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi
}
getPreferences(resourceUri?: string): { [key: string]: any } {
- return this.loaded && this.contains(resourceUri) ? this.preferences : {};
+ return this.valid && this.contains(resourceUri) ? this.preferences : {};
}
async setPreference(key: string, value: any, resourceUri?: string): Promise {
+ await this.loading.promise;
+ if (!this.model) {
+ return false;
+ }
if (!this.contains(resourceUri)) {
return false;
}
@@ -94,53 +135,68 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi
if (!path) {
return false;
}
- const resource = await this.resource;
- if (!resource.saveContents) {
- return false;
- }
- const content = ((await this.readContents()) || '').trim();
- if (!content && value === undefined) {
- return true;
- }
try {
- let newContent = '';
+ const content = this.model.getText().trim();
+ if (!content && value === undefined) {
+ return true;
+ }
+ const textModel = this.model.textEditorModel;
+ const editOperations: monaco.editor.IIdentifiedSingleEditOperation[] = [];
if (path.length || value !== undefined) {
- const formattingOptions = this.getFormattingOptions(resourceUri);
- const edits = jsoncparser.modify(content, path, value, { formattingOptions });
- newContent = jsoncparser.applyEdits(content, edits);
+ const { insertSpaces, tabSize, defaultEOL } = textModel.getOptions();
+ for (const edit of jsoncparser.modify(content, path, value, {
+ formattingOptions: {
+ insertSpaces,
+ tabSize,
+ eol: defaultEOL === monaco.editor.DefaultEndOfLine.LF ? '\n' : '\r\n'
+ }
+ })) {
+ const start = textModel.getPositionAt(edit.offset);
+ const end = textModel.getPositionAt(edit.offset + edit.length);
+ editOperations.push({
+ range: monaco.Range.fromPositions(start, end),
+ text: edit.content || null,
+ forceMoveMarkers: false
+ });
+ }
+ } else {
+ editOperations.push({
+ range: textModel.getFullModelRange(),
+ text: null,
+ forceMoveMarkers: false
+ });
}
- await resource.saveContents(newContent);
+ await this.workspace.applyBackgroundEdit(this.model, editOperations);
+ return true;
} catch (e) {
- const message = `Failed to update the value of ${key}.`;
- this.messageService.error(`${message} Please check if ${resource.uri.toString()} is corrupted.`);
- console.error(`${message} ${e.toString()}`);
+ const message = `Failed to update the value of '${key}' in '${this.getUri()}'.`;
+ this.messageService.error(`${message} Please check if it is corrupted.`);
+ console.error(`${message}`, e);
return false;
}
- await this.readPreferences();
- return true;
}
protected getPath(preferenceName: string): string[] | undefined {
return [preferenceName];
}
- protected loaded = false;
protected async readPreferences(): Promise {
- const newContent = await this.readContents();
- this.loaded = newContent !== undefined;
- const newPrefs = newContent ? this.getParsedContent(newContent) : {};
- this.handlePreferenceChanges(newPrefs);
- }
-
- protected async readContents(): Promise {
+ await this.loading.promise;
+ if (!this.model) {
+ return;
+ }
try {
- const resource = await this.resource;
- return await resource.readContents();
- } catch {
- return undefined;
+ this.loadPreferences(this.model.getText());
+ } catch (e) {
+ console.error(`Failed to load preferences from '${this.getUri()}'.`, e);
}
}
+ protected loadPreferences(content: string): void {
+ const newPrefs = this.getParsedContent(content);
+ this.handlePreferenceChanges(newPrefs);
+ }
+
protected getParsedContent(content: string): { [key: string]: any } {
const jsonData = this.parse(content);
@@ -222,30 +278,5 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi
}
}
- /**
- * Get the formatting options to be used when calling `jsoncparser`.
- * The formatting options are based on the corresponding preference values.
- *
- * The formatting options should attempt to obtain the preference values from JSONC,
- * and if necessary fallback to JSON and the global values.
- * @param uri the preference settings URI.
- *
- * @returns a tuple representing the tab indentation size, and if it is spaces.
- */
- protected getFormattingOptions(uri?: string): jsoncparser.FormattingOptions {
- // Get the global formatting options for both `tabSize` and `insertSpaces`.
- const globalTabSize = this.preferenceService.get('editor.tabSize', 2, uri);
- const globalInsertSpaces = this.preferenceService.get('editor.insertSpaces', true, uri);
-
- // Get the superset JSON formatting options for both `tabSize` and `insertSpaces`.
- const jsonTabSize = this.preferenceService.get('[json].editor.tabSize', globalTabSize, uri);
- const jsonInsertSpaces = this.preferenceService.get('[json].editor.insertSpaces', globalInsertSpaces, uri);
-
- return {
- tabSize: this.preferenceService.get('[jsonc].editor.tabSize', jsonTabSize, uri),
- insertSpaces: this.preferenceService.get('[jsonc].editor.insertSpaces', jsonInsertSpaces, uri),
- eol: ''
- };
- }
-
}
+