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: '' - }; - } - } +