diff --git a/extensions/javascript/syntaxes/JavaScript.tmLanguage.json b/extensions/javascript/syntaxes/JavaScript.tmLanguage.json index 31316129b7f75..9ea6c3cec1bba 100644 --- a/extensions/javascript/syntaxes/JavaScript.tmLanguage.json +++ b/extensions/javascript/syntaxes/JavaScript.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/Microsoft/TypeScript-TmLanguage/commit/a4c4dafb226a4c1d037a52434aa1154d9dabad8b", + "version": "https://github.com/Microsoft/TypeScript-TmLanguage/commit/3508c88a4ac6112934e0c34de7942c67682b2321", "name": "JavaScript (with React support)", "scopeName": "source.js", "patterns": [ @@ -2416,7 +2416,7 @@ "patterns": [ { "begin": "(? { test('Should use label as function name', async () => { diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts index b3b91a036f5e0..dc700abdf1b89 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts @@ -287,6 +287,30 @@ suite('workspace-namespace', () => { }); }); + test('events: onDidSaveTextDocument fires even for non dirty file when saved', () => { + return createRandomFile().then(file => { + let disposables: vscode.Disposable[] = []; + + let onDidSaveTextDocument = false; + disposables.push(vscode.workspace.onDidSaveTextDocument(e => { + assert.ok(pathEquals(e.uri.fsPath, file.fsPath)); + onDidSaveTextDocument = true; + })); + + return vscode.workspace.openTextDocument(file).then(doc => { + return vscode.window.showTextDocument(doc).then(() => { + return vscode.commands.executeCommand('workbench.action.files.save').then(() => { + assert.ok(onDidSaveTextDocument); + + disposeAll(disposables); + + return deleteFile(file); + }); + }); + }); + }); + }); + test('openTextDocument, with selection', function () { return createRandomFile('foo\nbar\nbar').then(file => { return vscode.workspace.openTextDocument(file).then(doc => { diff --git a/package.json b/package.json index 0b033e15b0f88..85af8fa34de86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.34.0", + "version": "1.35.0", "distro": "b9fb31b7caa6d9f506aa458342ab71183bc90b52", "author": { "name": "Microsoft Corporation" diff --git a/src/typings/vscode-xterm.d.ts b/src/typings/vscode-xterm.d.ts index e425a37c43f16..569af6027a64c 100644 --- a/src/typings/vscode-xterm.d.ts +++ b/src/typings/vscode-xterm.d.ts @@ -813,5 +813,7 @@ declare module 'vscode-xterm' { * @return Whether a result was found. */ findPrevious(term: string, findOptions: ISearchOptions): boolean; + + addCsiHandler(flag: string, callback: (params: number[], collect: string) => boolean): IDisposable; } } diff --git a/src/vs/base/common/mime.ts b/src/vs/base/common/mime.ts index de19c7c03f8e2..70e70de33a119 100644 --- a/src/vs/base/common/mime.ts +++ b/src/vs/base/common/mime.ts @@ -197,7 +197,11 @@ function guessMimeTypeByFirstline(firstLine: string): string | null { } if (firstLine.length > 0) { - for (const association of registeredAssociations) { + + // We want to prioritize associations based on the order they are registered so that the last registered + // association wins over all other. This is for https://github.com/Microsoft/vscode/issues/20074 + for (let i = registeredAssociations.length - 1; i >= 0; i--) { + const association = registeredAssociations[i]; if (!association.firstline) { continue; } @@ -230,10 +234,11 @@ export function isUnspecific(mime: string[] | string): boolean { * 2. Otherwise, if there are other extensions, suggest the first one. * 3. Otherwise, suggest the prefix. */ -export function suggestFilename(langId: string | null, prefix: string): string { +export function suggestFilename(mode: string | undefined, prefix: string): string { const extensions = registeredAssociations - .filter(assoc => !assoc.userConfigured && assoc.extension && assoc.id === langId) + .filter(assoc => !assoc.userConfigured && assoc.extension && assoc.id === mode) .map(assoc => assoc.extension); + const extensionsWithDotFirst = coalesce(extensions) .filter(assoc => startsWith(assoc, '.')); diff --git a/src/vs/base/test/common/mime.test.ts b/src/vs/base/test/common/mime.test.ts index 75f6d531fe7d4..4cea0feb56533 100644 --- a/src/vs/base/test/common/mime.test.ts +++ b/src/vs/base/test/common/mime.test.ts @@ -6,6 +6,7 @@ import * as assert from 'assert'; import { guessMimeTypes, registerTextMime, suggestFilename } from 'vs/base/common/mime'; suite('Mime', () => { + test('Dynamically Register Text Mime', () => { let guess = guessMimeTypes('foo.monaco'); assert.deepEqual(guess, ['application/unknown']); @@ -56,6 +57,11 @@ suite('Mime', () => { registerTextMime({ id: 'docker', filepattern: 'dockerfile*', mime: 'text/looser' }); guess = guessMimeTypes('dockerfile'); assert.deepEqual(guess, ['text/winner', 'text/plain']); + + registerTextMime({ id: 'azure-looser', mime: 'text/azure-looser', firstline: /azure/ }); + registerTextMime({ id: 'azure-winner', mime: 'text/azure-winner', firstline: /azure/ }); + guess = guessMimeTypes('azure', 'azure'); + assert.deepEqual(guess, ['text/azure-winner', 'text/plain']); }); test('Specificity priority 1', () => { diff --git a/src/vs/code/electron-browser/workbench/workbench.js b/src/vs/code/electron-browser/workbench/workbench.js index dbec9eadbbb44..28e3fefafcc1a 100644 --- a/src/vs/code/electron-browser/workbench/workbench.js +++ b/src/vs/code/electron-browser/workbench/workbench.js @@ -87,8 +87,8 @@ function showPartsSplash(configuration) { const style = document.createElement('style'); style.className = 'initialShellColors'; document.head.appendChild(style); - document.body.className = `monaco-shell ${baseTheme}`; - style.innerHTML = `.monaco-shell { background-color: ${shellBackground}; color: ${shellForeground}; }`; + document.body.className = baseTheme; + style.innerHTML = `body { background-color: ${shellBackground}; color: ${shellForeground}; }`; if (data && data.layoutInfo) { // restore parts if possible (we might not always store layout info) diff --git a/src/vs/code/electron-browser/workbench/workbench.nodeless.html b/src/vs/code/electron-browser/workbench/workbench.nodeless.html index e37abfdf5ae28..de77f56d68ef0 100644 --- a/src/vs/code/electron-browser/workbench/workbench.nodeless.html +++ b/src/vs/code/electron-browser/workbench/workbench.nodeless.html @@ -4,7 +4,7 @@ - + diff --git a/src/vs/code/electron-main/window.ts b/src/vs/code/electron-main/window.ts index fdfb2dcbc043c..915cf30ba61b2 100644 --- a/src/vs/code/electron-main/window.ts +++ b/src/vs/code/electron-main/window.ts @@ -8,7 +8,7 @@ import * as objects from 'vs/base/common/objects'; import * as nls from 'vs/nls'; import { URI } from 'vs/base/common/uri'; import { IStateService } from 'vs/platform/state/common/state'; -import { screen, BrowserWindow, systemPreferences, app, TouchBar, nativeImage } from 'electron'; +import { screen, BrowserWindow, systemPreferences, app, TouchBar, nativeImage, Rectangle, Display } from 'electron'; import { IEnvironmentService, ParsedArgs } from 'vs/platform/environment/common/environment'; import { ILogService } from 'vs/platform/log/common/log'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -740,32 +740,30 @@ export class CodeWindow extends Disposable implements ICodeWindow { // Single Monitor: be strict about x/y positioning if (displays.length === 1) { - const displayBounds = displays[0].bounds; - - // Careful with maximized: in that mode x/y can well be negative! - if (state.mode !== WindowMode.Maximized && displayBounds.width > 0 && displayBounds.height > 0 /* Linux X11 sessions sometimes report wrong display bounds */) { - if (state.x < displayBounds.x) { - state.x = displayBounds.x; // prevent window from falling out of the screen to the left + const displayWorkingArea = this.getWorkingArea(displays[0]); + if (state.mode !== WindowMode.Maximized && displayWorkingArea) { + if (state.x < displayWorkingArea.x) { + state.x = displayWorkingArea.x; // prevent window from falling out of the screen to the left } - if (state.y < displayBounds.y) { - state.y = displayBounds.y; // prevent window from falling out of the screen to the top + if (state.y < displayWorkingArea.y) { + state.y = displayWorkingArea.y; // prevent window from falling out of the screen to the top } - if (state.x > (displayBounds.x + displayBounds.width)) { - state.x = displayBounds.x; // prevent window from falling out of the screen to the right + if (state.x > (displayWorkingArea.x + displayWorkingArea.width)) { + state.x = displayWorkingArea.x; // prevent window from falling out of the screen to the right } - if (state.y > (displayBounds.y + displayBounds.height)) { - state.y = displayBounds.y; // prevent window from falling out of the screen to the bottom + if (state.y > (displayWorkingArea.y + displayWorkingArea.height)) { + state.y = displayWorkingArea.y; // prevent window from falling out of the screen to the bottom } - if (state.width > displayBounds.width) { - state.width = displayBounds.width; // prevent window from exceeding display bounds width + if (state.width > displayWorkingArea.width) { + state.width = displayWorkingArea.width; // prevent window from exceeding display bounds width } - if (state.height > displayBounds.height) { - state.height = displayBounds.height; // prevent window from exceeding display bounds height + if (state.height > displayWorkingArea.height) { + state.height = displayWorkingArea.height; // prevent window from exceeding display bounds height } } @@ -791,12 +789,14 @@ export class CodeWindow extends Disposable implements ICodeWindow { // Multi Monitor (non-fullscreen): be less strict because metrics can be crazy const bounds = { x: state.x, y: state.y, width: state.width, height: state.height }; const display = screen.getDisplayMatching(bounds); + const displayWorkingArea = this.getWorkingArea(display); if ( - display && // we have a display matching the desired bounds - bounds.x < display.bounds.x + display.bounds.width && // prevent window from falling out of the screen to the right - bounds.y < display.bounds.y + display.bounds.height && // prevent window from falling out of the screen to the bottom - bounds.x + bounds.width > display.bounds.x && // prevent window from falling out of the screen to the left - bounds.y + bounds.height > display.bounds.y // prevent window from falling out of the scree nto the top + display && // we have a display matching the desired bounds + displayWorkingArea && // we have valid working area bounds + bounds.x < displayWorkingArea.x + displayWorkingArea.width && // prevent window from falling out of the screen to the right + bounds.y < displayWorkingArea.y + displayWorkingArea.height && // prevent window from falling out of the screen to the bottom + bounds.x + bounds.width > displayWorkingArea.x && // prevent window from falling out of the screen to the left + bounds.y + bounds.height > displayWorkingArea.y // prevent window from falling out of the scree nto the top ) { if (state.mode === WindowMode.Maximized) { const defaults = defaultWindowState(WindowMode.Maximized); // when maximized, make sure we have good values when the user restores the window @@ -812,6 +812,24 @@ export class CodeWindow extends Disposable implements ICodeWindow { return null; } + private getWorkingArea(display: Display): Rectangle | undefined { + + // Prefer the working area of the display to account for taskbars on the + // desktop being positioned somewhere (https://github.com/Microsoft/vscode/issues/50830). + // + // Linux X11 sessions sometimes report wrong display bounds, so we validate + // the reported sizes are positive. + if (display.workArea.width > 0 && display.workArea.height > 0) { + return display.workArea; + } + + if (display.bounds.width > 0 && display.bounds.height > 0) { + return display.bounds; + } + + return undefined; + } + getBounds(): Electron.Rectangle { const pos = this._win.getPosition(); const dimension = this._win.getSize(); diff --git a/src/vs/editor/common/services/getIconClasses.ts b/src/vs/editor/common/services/getIconClasses.ts index ca49de2528ff4..af148c324af47 100644 --- a/src/vs/editor/common/services/getIconClasses.ts +++ b/src/vs/editor/common/services/getIconClasses.ts @@ -19,14 +19,11 @@ export function getIconClasses(modelService: IModelService, modeService: IModeSe // Get the path and name of the resource. For data-URIs, we need to parse specially let name: string | undefined; - let path: string | undefined; if (resource.scheme === Schemas.data) { const metadata = DataUri.parseMetaData(resource); name = metadata.get(DataUri.META_DATA_LABEL); - path = name; } else { name = cssEscape(basenameOrAuthority(resource).toLowerCase()); - path = resource.path.toLowerCase(); } // Folders @@ -47,46 +44,60 @@ export function getIconClasses(modelService: IModelService, modeService: IModeSe classes.push(`ext-file-icon`); // extra segment to increase file-ext score } - // Configured Language - let configuredLangId: string | null = getConfiguredLangId(modelService, modeService, resource); - configuredLangId = configuredLangId || (path ? modeService.getModeIdByFilepathOrFirstLine(path) : null); - if (configuredLangId) { - classes.push(`${cssEscape(configuredLangId)}-lang-file-icon`); + // Detected Mode + const detectedModeId = detectModeId(modelService, modeService, resource); + if (detectedModeId) { + classes.push(`${cssEscape(detectedModeId)}-lang-file-icon`); } } } return classes; } -export function getConfiguredLangId(modelService: IModelService, modeService: IModeService, resource: uri): string | null { - let configuredLangId: string | null = null; - if (resource) { - let modeId: string | null = null; +export function detectModeId(modelService: IModelService, modeService: IModeService, resource: uri): string | null { + if (!resource) { + return null; // we need a resource at least + } - // Data URI: check for encoded metadata - if (resource.scheme === Schemas.data) { - const metadata = DataUri.parseMetaData(resource); - const mime = metadata.get(DataUri.META_DATA_MIME); + let modeId: string | null = null; - if (mime) { - modeId = modeService.getModeId(mime); - } - } + // Data URI: check for encoded metadata + if (resource.scheme === Schemas.data) { + const metadata = DataUri.parseMetaData(resource); + const mime = metadata.get(DataUri.META_DATA_MIME); - // Any other URI: check for model if existing - else { - const model = modelService.getModel(resource); - if (model) { - modeId = model.getLanguageIdentifier().language; - } + if (mime) { + modeId = modeService.getModeId(mime); } + } - if (modeId && modeId !== PLAINTEXT_MODE_ID) { - configuredLangId = modeId; // only take if the mode is specific (aka no just plain text) + // Any other URI: check for model if existing + else { + const model = modelService.getModel(resource); + if (model) { + modeId = model.getModeId(); } } - return configuredLangId; + // only take if the mode is specific (aka no just plain text) + if (modeId && modeId !== PLAINTEXT_MODE_ID) { + return modeId; + } + + // otherwise fallback to path based detection + let path: string | undefined; + if (resource.scheme === Schemas.data) { + const metadata = DataUri.parseMetaData(resource); + path = metadata.get(DataUri.META_DATA_LABEL); + } else { + path = resource.path.toLowerCase(); + } + + if (path) { + return modeService.getModeIdByFilepathOrFirstLine(path); + } + + return null; // finally - we do not know the mode id } export function cssEscape(val: string): string { diff --git a/src/vs/editor/common/services/resolverService.ts b/src/vs/editor/common/services/resolverService.ts index 442813334d595..958469e16f1ab 100644 --- a/src/vs/editor/common/services/resolverService.ts +++ b/src/vs/editor/common/services/resolverService.ts @@ -5,7 +5,7 @@ import { IDisposable, IReference } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; -import { ITextModel } from 'vs/editor/common/model'; +import { ITextModel, ITextSnapshot } from 'vs/editor/common/model'; import { IEditorModel } from 'vs/platform/editor/common/editor'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -46,6 +46,12 @@ export interface ITextEditorModel extends IEditorModel { */ readonly textEditorModel: ITextModel | null; + /** + * Creates a snapshot of the model's contents. + */ + createSnapshot(this: IResolvedTextEditorModel): ITextSnapshot; + createSnapshot(this: ITextEditorModel): ITextSnapshot | null; + isReadonly(): boolean; } diff --git a/src/vs/editor/contrib/codeAction/codeActionTrigger.ts b/src/vs/editor/contrib/codeAction/codeActionTrigger.ts index 15e90604ed6f9..f0fb3b3e6bf48 100644 --- a/src/vs/editor/contrib/codeAction/codeActionTrigger.ts +++ b/src/vs/editor/contrib/codeAction/codeActionTrigger.ts @@ -20,8 +20,12 @@ export class CodeActionKind { public readonly value: string ) { } + public equals(other: CodeActionKind): boolean { + return this.value === other.value; + } + public contains(other: CodeActionKind): boolean { - return this.value === other.value || startsWith(other.value, this.value + CodeActionKind.sep); + return this.equals(other) || startsWith(other.value, this.value + CodeActionKind.sep); } public intersects(other: CodeActionKind): boolean { diff --git a/src/vs/editor/standalone/browser/simpleServices.ts b/src/vs/editor/standalone/browser/simpleServices.ts index b6468cddfc1da..349f140365786 100644 --- a/src/vs/editor/standalone/browser/simpleServices.ts +++ b/src/vs/editor/standalone/browser/simpleServices.ts @@ -19,7 +19,7 @@ import { EditOperation } from 'vs/editor/common/core/editOperation'; import { IPosition, Position as Pos } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import * as editorCommon from 'vs/editor/common/editorCommon'; -import { ITextModel } from 'vs/editor/common/model'; +import { ITextModel, ITextSnapshot } from 'vs/editor/common/model'; import { TextEdit, WorkspaceEdit, isResourceTextEdit } from 'vs/editor/common/modes'; import { IModelService } from 'vs/editor/common/services/modelService'; import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService'; @@ -67,6 +67,10 @@ export class SimpleModel implements IResolvedTextEditorModel { return this.model; } + public createSnapshot(): ITextSnapshot { + return this.model.createSnapshot(); + } + public isReadonly(): boolean { return false; } diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 203bd80327be3..160ea244b45d3 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -63,7 +63,7 @@ export interface IBaseResourceInput { export interface IResourceInput extends IBaseResourceInput { /** - * The resource URL of the resource to open. + * The resource URI of the resource to open. */ resource: URI; @@ -71,6 +71,12 @@ export interface IResourceInput extends IBaseResourceInput { * The encoding of the text input if known. */ readonly encoding?: string; + + /** + * The identifier of the language mode of the text input + * if known to use when displaying the contents. + */ + readonly mode?: string; } export interface IEditorOptions { diff --git a/src/vs/platform/history/electron-main/historyMainService.ts b/src/vs/platform/history/electron-main/historyMainService.ts index 8a9db6d0aca24..4429cc10574ca 100644 --- a/src/vs/platform/history/electron-main/historyMainService.ts +++ b/src/vs/platform/history/electron-main/historyMainService.ts @@ -299,11 +299,11 @@ export class HistoryMainService implements IHistoryMainService { description = nls.localize('folderDesc', "{0} {1}", getBaseLabel(workspace), getPathLabel(dirname(workspace), this.environmentService)); args = `--folder-uri "${workspace.toString()}"`; } else { - description = nls.localize('codeWorkspace', "Code Workspace"); + description = nls.localize('workspaceDesc', "{0} {1}", getBaseLabel(workspace.configPath), getPathLabel(dirname(workspace.configPath), this.environmentService)); args = `--file-uri "${workspace.configPath.toString()}"`; } - return { + return { type: 'task', title, description, diff --git a/src/vs/platform/instantiation/common/instantiation.ts b/src/vs/platform/instantiation/common/instantiation.ts index 608480b1aac84..df147f419c3c3 100644 --- a/src/vs/platform/instantiation/common/instantiation.ts +++ b/src/vs/platform/instantiation/common/instantiation.ts @@ -125,7 +125,7 @@ function storeServiceDependency(id: Function, target: Function, index: number, o /** * A *only* valid way to create a {{ServiceIdentifier}}. */ -export function createDecorator(serviceId: string): { (...args: any[]): void; type: T; } { +export function createDecorator(serviceId: string): ServiceIdentifier { if (_util.serviceIds.has(serviceId)) { return _util.serviceIds.get(serviceId)!; diff --git a/src/vs/workbench/api/browser/mainThreadDocuments.ts b/src/vs/workbench/api/browser/mainThreadDocuments.ts index c18e4fc136389..af99b8a772064 100644 --- a/src/vs/workbench/api/browser/mainThreadDocuments.ts +++ b/src/vs/workbench/api/browser/mainThreadDocuments.ts @@ -228,10 +228,10 @@ export class MainThreadDocuments implements MainThreadDocumentsShape { }); } - private _doCreateUntitled(resource?: URI, modeId?: string, initialValue?: string): Promise { + private _doCreateUntitled(resource?: URI, mode?: string, initialValue?: string): Promise { return this._untitledEditorService.loadOrCreate({ resource, - modeId, + mode, initialValue, useResourcePath: Boolean(resource && resource.path) }).then(model => { diff --git a/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts b/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts index 27ae35cfcd502..76bed28b12ee0 100644 --- a/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts +++ b/src/vs/workbench/api/browser/mainThreadSaveParticipant.ts @@ -266,14 +266,18 @@ class CodeActionOnSaveParticipant implements ISaveParticipant { const codeActionsOnSave = Object.keys(setting) .filter(x => setting[x]).map(x => new CodeActionKind(x)) .sort((a, b) => { - if (a.value === CodeActionKind.SourceFixAll.value) { - return -1; - } - if (b.value === CodeActionKind.SourceFixAll.value) { + if (CodeActionKind.SourceFixAll.contains(a)) { + if (CodeActionKind.SourceFixAll.contains(b)) { + return 0; + } return 1; } + if (CodeActionKind.SourceFixAll.contains(b)) { + return -1; + } return 0; }); + if (!codeActionsOnSave.length) { return undefined; } @@ -289,11 +293,8 @@ class CodeActionOnSaveParticipant implements ISaveParticipant { reject(localize('codeActionsOnSave.didTimeout', "Aborted codeActionsOnSave after {0}ms", timeout)); }, timeout)), this.applyOnSaveActions(model, codeActionsOnSave, tokenSource.token) - ]).then(() => { - tokenSource.cancel(); - }, (e) => { + ]).finally(() => { tokenSource.cancel(); - return Promise.reject(e); }); } diff --git a/src/vs/workbench/api/node/extHostTerminalService.ts b/src/vs/workbench/api/node/extHostTerminalService.ts index 5a010d83f0072..bb9505c65f930 100644 --- a/src/vs/workbench/api/node/extHostTerminalService.ts +++ b/src/vs/workbench/api/node/extHostTerminalService.ts @@ -20,6 +20,7 @@ import { ExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace'; import { IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { ExtHostVariableResolverService } from 'vs/workbench/api/node/extHostDebugService'; import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; +import { getDefaultShell } from 'vs/workbench/contrib/terminal/node/terminal'; const RENDERER_NO_PROCESS_ID = -1; @@ -470,7 +471,7 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { .inspect(key.substr(key.lastIndexOf('.') + 1)); return this._apiInspectConfigToPlain(setting); }; - terminalEnvironment.mergeDefaultShellPathAndArgs(shellLaunchConfig, fetchSetting, isWorkspaceShellAllowed || false); + terminalEnvironment.mergeDefaultShellPathAndArgs(shellLaunchConfig, fetchSetting, isWorkspaceShellAllowed || false, getDefaultShell(platform.platform)); } // Get the initial cwd diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index 5c4a13a5587f0..3751e3bf4b27a 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -240,7 +240,7 @@ export class ResourcesDropHandler { return this.backupFileService.resolveBackupContent(droppedDirtyEditor.backupResource!).then(content => { // Set the contents of to the resource to the target - return this.backupFileService.backupResource(droppedDirtyEditor.resource, content!.create(this.getDefaultEOL()).createSnapshot(true)); + return this.backupFileService.backupResource(droppedDirtyEditor.resource, content.value.create(this.getDefaultEOL()).createSnapshot(true)); }).then(() => false, () => false /* ignore any error */); } diff --git a/src/vs/workbench/browser/labels.ts b/src/vs/workbench/browser/labels.ts index d4cc5b6851f5d..8eaff3f7e373a 100644 --- a/src/vs/workbench/browser/labels.ts +++ b/src/vs/workbench/browser/labels.ts @@ -21,7 +21,7 @@ import { ITextModel } from 'vs/editor/common/model'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { Event, Emitter } from 'vs/base/common/event'; import { ILabelService } from 'vs/platform/label/common/label'; -import { getIconClasses, getConfiguredLangId } from 'vs/editor/common/services/getIconClasses'; +import { getIconClasses, detectModeId } from 'vs/editor/common/services/getIconClasses'; import { Disposable, dispose, IDisposable } from 'vs/base/common/lifecycle'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { withNullAsUndefined } from 'vs/base/common/types'; @@ -121,7 +121,16 @@ export class ResourceLabels extends Disposable { return; // ignore transitions in files from no mode to specific mode because this happens each time a model is created } - this._widgets.forEach(widget => widget.notifyModelModeChanged(e)); + this._widgets.forEach(widget => widget.notifyModelModeChanged(e.model)); + })); + + // notify when model is added + this._register(this.modelService.onModelAdded(model => { + if (!model.uri) { + return; // we need the resource to compare + } + + this._widgets.forEach(widget => widget.notifyModelAdded(model)); })); // notify when file decoration changes @@ -228,7 +237,7 @@ class ResourceLabelWidget extends IconLabel { private label?: IResourceLabelProps; private options?: IResourceLabelOptions; private computedIconClasses?: string[]; - private lastKnownConfiguredLangId?: string; + private lastKnownDetectedModeId?: string; private computedPathLabel?: string; private needsRedraw?: Redraw; @@ -258,13 +267,21 @@ class ResourceLabelWidget extends IconLabel { } } - notifyModelModeChanged(e: { model: ITextModel; oldModeId: string; }): void { + notifyModelModeChanged(model: ITextModel): void { + this.handleModelEvent(model); + } + + notifyModelAdded(model: ITextModel): void { + this.handleModelEvent(model); + } + + private handleModelEvent(model: ITextModel): void { if (!this.label || !this.label.resource) { return; // only update if label exists } - if (e.model.uri.toString() === this.label.resource.toString()) { - if (this.lastKnownConfiguredLangId !== e.model.getLanguageIdentifier().language) { + if (model.uri.toString() === this.label.resource.toString()) { + if (this.lastKnownDetectedModeId !== model.getModeId()) { this.render(true); // update if the language id of the model has changed from our last known state } } @@ -367,7 +384,7 @@ class ResourceLabelWidget extends IconLabel { clear(): void { this.label = undefined; this.options = undefined; - this.lastKnownConfiguredLangId = undefined; + this.lastKnownDetectedModeId = undefined; this.computedIconClasses = undefined; this.computedPathLabel = undefined; @@ -388,10 +405,10 @@ class ResourceLabelWidget extends IconLabel { } if (this.label) { - const configuredLangId = this.label.resource ? withNullAsUndefined(getConfiguredLangId(this.modelService, this.modeService, this.label.resource)) : undefined; - if (this.lastKnownConfiguredLangId !== configuredLangId) { + const detectedModeId = this.label.resource ? withNullAsUndefined(detectModeId(this.modelService, this.modeService, this.label.resource)) : undefined; + if (this.lastKnownDetectedModeId !== detectedModeId) { clearIconCache = true; - this.lastKnownConfiguredLangId = configuredLangId; + this.lastKnownDetectedModeId = detectedModeId; } } @@ -465,7 +482,7 @@ class ResourceLabelWidget extends IconLabel { this.label = undefined; this.options = undefined; - this.lastKnownConfiguredLangId = undefined; + this.lastKnownDetectedModeId = undefined; this.computedIconClasses = undefined; this.computedPathLabel = undefined; } diff --git a/src/vs/workbench/browser/nodeless.simpleservices.ts b/src/vs/workbench/browser/nodeless.simpleservices.ts index 474cd6f53e561..7814e0986ba3a 100644 --- a/src/vs/workbench/browser/nodeless.simpleservices.ts +++ b/src/vs/workbench/browser/nodeless.simpleservices.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from 'vs/base/common/uri'; -import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; -import { ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model'; +import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup'; +import { ITextSnapshot } from 'vs/editor/common/model'; import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel'; import { keys, ResourceMap } from 'vs/base/common/map'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; @@ -86,20 +86,20 @@ export class SimpleBackupFileService implements IBackupFileService { return Promise.resolve(undefined); } - backupResource(resource: URI, content: ITextSnapshot, versionId?: number): Promise { + backupResource(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise { const backupResource = this.toBackupResource(resource); this.backups.set(backupResource.toString(), content); return Promise.resolve(); } - resolveBackupContent(backupResource: URI): Promise { + resolveBackupContent(backupResource: URI): Promise> { const snapshot = this.backups.get(backupResource.toString()); if (snapshot) { - return Promise.resolve(createTextBufferFactoryFromSnapshot(snapshot)); + return Promise.resolve({ value: createTextBufferFactoryFromSnapshot(snapshot) }); } - return Promise.resolve(undefined); + return Promise.reject('Unexpected backup resource to resolve'); } getWorkspaceFileBackups(): Promise { diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 414b807ea7045..a8abe040f01f2 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -104,7 +104,7 @@ Registry.as(EditorExtensions.Editors).registerEditor( interface ISerializedUntitledEditorInput { resource: string; resourceJSON: object; - modeId: string | null; + modeId: string | undefined; encoding: string; } @@ -131,7 +131,7 @@ class UntitledEditorInputFactory implements IEditorInputFactory { const serialized: ISerializedUntitledEditorInput = { resource: resource.toString(), // Keep for backwards compatibility resourceJSON: resource.toJSON(), - modeId: untitledEditorInput.getModeId(), + modeId: untitledEditorInput.getMode(), encoding: untitledEditorInput.getEncoding() }; @@ -142,10 +142,10 @@ class UntitledEditorInputFactory implements IEditorInputFactory { return instantiationService.invokeFunction(accessor => { const deserialized: ISerializedUntitledEditorInput = JSON.parse(serializedEditorInput); const resource = !!deserialized.resourceJSON ? URI.revive(deserialized.resourceJSON) : URI.parse(deserialized.resource); - const language = deserialized.modeId; + const mode = deserialized.modeId; const encoding = deserialized.encoding; - return accessor.get(IEditorService).createInput({ resource, language, encoding, forceUntitled: true }) as UntitledEditorInput; + return accessor.get(IEditorService).createInput({ resource, mode, encoding, forceUntitled: true }) as UntitledEditorInput; }); } } diff --git a/src/vs/workbench/browser/parts/editor/editorStatus.ts b/src/vs/workbench/browser/parts/editor/editorStatus.ts index b6c197e5b66af..5c6df3b90ae16 100644 --- a/src/vs/workbench/browser/parts/editor/editorStatus.ts +++ b/src/vs/workbench/browser/parts/editor/editorStatus.ts @@ -14,11 +14,11 @@ import { IStatusbarItem } from 'vs/workbench/browser/parts/statusbar/statusbar'; import { Action } from 'vs/base/common/actions'; import { Language } from 'vs/base/common/platform'; import { UntitledEditorInput } from 'vs/workbench/common/editor/untitledEditorInput'; -import { IFileEditorInput, EncodingMode, IEncodingSupport, toResource, SideBySideEditorInput, IEditor as IBaseEditor, IEditorInput, SideBySideEditor } from 'vs/workbench/common/editor'; +import { IFileEditorInput, EncodingMode, IEncodingSupport, toResource, SideBySideEditorInput, IEditor as IBaseEditor, IEditorInput, SideBySideEditor, IModeSupport } from 'vs/workbench/common/editor'; import { IDisposable, combinedDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle'; import { IUntitledEditorService } from 'vs/workbench/services/untitled/common/untitledEditorService'; import { IEditorAction } from 'vs/editor/common/editorCommon'; -import { EndOfLineSequence, ITextModel } from 'vs/editor/common/model'; +import { EndOfLineSequence } from 'vs/editor/common/model'; import { IModelLanguageChangedEvent, IModelOptionsChangedEvent } from 'vs/editor/common/model/textModelEvents'; import { TrimTrailingWhitespaceAction } from 'vs/editor/contrib/linesOperations/linesOperations'; import { IndentUsingSpaces, IndentUsingTabs, DetectIndentation, IndentationToSpacesAction, IndentationToTabsAction } from 'vs/editor/contrib/indentation/indentation'; @@ -59,7 +59,15 @@ class SideBySideEditorEncodingSupport implements IEncodingSupport { } setEncoding(encoding: string, mode: EncodingMode): void { - [this.master, this.details].forEach(s => s.setEncoding(encoding, mode)); + [this.master, this.details].forEach(editor => editor.setEncoding(encoding, mode)); + } +} + +class SideBySideEditorModeSupport implements IModeSupport { + constructor(private master: IModeSupport, private details: IModeSupport) { } + + setMode(mode: string): void { + [this.master, this.details].forEach(editor => editor.setMode(mode)); } } @@ -83,7 +91,7 @@ function toEditorWithEncodingSupport(input: IEditorInput): IEncodingSupport | nu } // File or Resource Editor - let encodingSupport = input as IFileEditorInput; + const encodingSupport = input as IFileEditorInput; if (areFunctions(encodingSupport.setEncoding, encodingSupport.getEncoding)) { return encodingSupport; } @@ -92,14 +100,41 @@ function toEditorWithEncodingSupport(input: IEditorInput): IEncodingSupport | nu return null; } +function toEditorWithModeSupport(input: IEditorInput): IModeSupport | null { + + // Untitled Editor + if (input instanceof UntitledEditorInput) { + return input; + } + + // Side by Side (diff) Editor + if (input instanceof SideBySideEditorInput) { + const masterModeSupport = toEditorWithModeSupport(input.master); + const detailsModeSupport = toEditorWithModeSupport(input.details); + + if (masterModeSupport && detailsModeSupport) { + return new SideBySideEditorModeSupport(masterModeSupport, detailsModeSupport); + } + + return masterModeSupport; + } + + // File or Resource Editor + const modeSupport = input as IFileEditorInput; + if (typeof modeSupport.setMode === 'function') { + return modeSupport; + } + + // Unsupported for any other editor + return null; +} + interface IEditorSelectionStatus { selections?: Selection[]; charactersSelected?: number; } class StateChange { - _stateChangeBrand: void; - indentation: boolean = false; selectionStatus: boolean = false; mode: boolean = false; @@ -120,7 +155,7 @@ class StateChange { this.metadata = this.metadata || other.metadata; } - public hasChanges(): boolean { + hasChanges(): boolean { return this.indentation || this.selectionStatus || this.mode @@ -179,42 +214,49 @@ class State { change.selectionStatus = true; } } + if ('indentation' in update) { if (this._indentation !== update.indentation) { this._indentation = update.indentation; change.indentation = true; } } + if ('mode' in update) { if (this._mode !== update.mode) { this._mode = update.mode; change.mode = true; } } + if ('encoding' in update) { if (this._encoding !== update.encoding) { this._encoding = update.encoding; change.encoding = true; } } + if ('EOL' in update) { if (this._EOL !== update.EOL) { this._EOL = update.EOL; change.EOL = true; } } + if ('tabFocusMode' in update) { if (this._tabFocusMode !== update.tabFocusMode) { this._tabFocusMode = update.tabFocusMode; change.tabFocusMode = true; } } + if ('screenReaderMode' in update) { if (this._screenReaderMode !== update.screenReaderMode) { this._screenReaderMode = update.screenReaderMode; change.screenReaderMode = true; } } + if ('metadata' in update) { if (this._metadata !== update.metadata) { this._metadata = update.metadata; @@ -236,7 +278,6 @@ const nlsTabFocusMode = nls.localize('tabFocusModeEnabled', "Tab Moves Focus"); const nlsScreenReaderDetected = nls.localize('screenReaderDetected', "Screen Reader Optimized"); const nlsScreenReaderDetectedTitle = nls.localize('screenReaderDetectedExtra', "If you are not using a Screen Reader, please change the setting `editor.accessibilitySupport` to \"off\"."); - class StatusBarItem { private _showing = true; @@ -248,15 +289,15 @@ class StatusBarItem { this.element.title = title; } - public set textContent(value: string) { + set textContent(value: string) { this.element.textContent = value; } - public set onclick(value: () => void) { + set onclick(value: () => void) { this.element.onclick = value; } - public setVisible(shouldShow: boolean): void { + setVisible(shouldShow: boolean): void { if (shouldShow !== this._showing) { this._showing = shouldShow; this.element.style.display = shouldShow ? '' : 'none'; @@ -264,7 +305,6 @@ class StatusBarItem { } } - export class EditorStatus implements IStatusbarItem { private state: State; private element: HTMLElement; @@ -661,7 +701,7 @@ export class EditorStatus implements IStatusbarItem { this.updateState(update); } - private _promptedScreenReader: boolean = false; + private promptedScreenReader: boolean = false; private onScreenReaderModeChange(editorWidget: ICodeEditor | undefined): void { let screenReaderMode = false; @@ -673,8 +713,8 @@ export class EditorStatus implements IStatusbarItem { const screenReaderConfiguration = this.configurationService.getValue('editor').accessibilitySupport; if (screenReaderConfiguration === 'auto') { // show explanation - if (!this._promptedScreenReader) { - this._promptedScreenReader = true; + if (!this.promptedScreenReader) { + this.promptedScreenReader = true; setTimeout(() => { this.onScreenReaderModeClick(); }, 100); @@ -948,43 +988,28 @@ export class ChangeModeAction extends Action { // Change mode for active editor const activeEditor = this.editorService.activeEditor; - const activeTextEditorWidget = this.editorService.activeTextEditorWidget; - const models: ITextModel[] = []; - if (isCodeEditor(activeTextEditorWidget)) { - const codeEditorModel = activeTextEditorWidget.getModel(); - if (codeEditorModel) { - models.push(codeEditorModel); - } - } else if (isDiffEditor(activeTextEditorWidget)) { - const diffEditorModel = activeTextEditorWidget.getModel(); - if (diffEditorModel) { - if (diffEditorModel.original) { - models.push(diffEditorModel.original); - } - if (diffEditorModel.modified) { - models.push(diffEditorModel.modified); + if (activeEditor) { + const modeSupport = toEditorWithModeSupport(activeEditor); + if (modeSupport) { + + // Find mode + let languageSelection: ILanguageSelection | undefined; + if (pick === autoDetectMode) { + if (textModel) { + const resource = toResource(activeEditor, { supportSideBySide: SideBySideEditor.MASTER }); + if (resource) { + languageSelection = this.modeService.createByFilepathOrFirstLine(resource.fsPath, textModel.getLineContent(1)); + } + } + } else { + languageSelection = this.modeService.createByLanguageName(pick.label); } - } - } - // Find mode - let languageSelection: ILanguageSelection | undefined; - if (pick === autoDetectMode) { - if (textModel) { - const resource = toResource(activeEditor, { supportSideBySide: SideBySideEditor.MASTER }); - if (resource) { - languageSelection = this.modeService.createByFilepathOrFirstLine(resource.fsPath, textModel.getLineContent(1)); + // Change mode + if (typeof languageSelection !== 'undefined') { + modeSupport.setMode(languageSelection.languageIdentifier.language); } } - } else { - languageSelection = this.modeService.createByLanguageName(pick.label); - } - - // Change mode - if (typeof languageSelection !== 'undefined') { - for (const textModel of models) { - this.modelService.setMode(textModel, languageSelection); - } } }); } @@ -1159,6 +1184,7 @@ export class ChangeEncodingAction extends Action { if (!activeControl) { return this.quickInputService.pick([{ label: nls.localize('noEditor', "No text editor active at this time") }]); } + const encodingSupport: IEncodingSupport | null = toEditorWithEncodingSupport(activeControl.input); if (!encodingSupport) { return this.quickInputService.pick([{ label: nls.localize('noFileEditor', "No file active at this time") }]); @@ -1249,10 +1275,12 @@ export class ChangeEncodingAction extends Action { if (!encoding) { return; } + const activeControl = this.editorService.activeControl; if (!activeControl) { return; } + const encodingSupport = toEditorWithEncodingSupport(activeControl.input); if (typeof encoding.id !== 'undefined' && encodingSupport && encodingSupport.getEncoding() !== encoding.id) { encodingSupport.setEncoding(encoding.id, isReopenWithEncoding ? EncodingMode.Decode : EncodingMode.Encode); // Set new encoding diff --git a/src/vs/workbench/browser/parts/editor/resourceViewer.ts b/src/vs/workbench/browser/parts/editor/resourceViewer.ts index a509721f06451..8ecf2131c31e4 100644 --- a/src/vs/workbench/browser/parts/editor/resourceViewer.ts +++ b/src/vs/workbench/browser/parts/editor/resourceViewer.ts @@ -370,7 +370,7 @@ class InlineImageView { dispose: () => combinedDisposable(disposables).dispose() }; - const cacheKey = descriptor.resource.toString(); + const cacheKey = `${descriptor.resource.toString()}:${descriptor.etag}`; let ctrlPressed = false; let altPressed = false; diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index 80915b758e203..37bd1a1eee6c3 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -144,7 +144,7 @@ export interface IEditorControl extends ICompositeControl { } export interface IFileInputFactory { - createFileInput(resource: URI, encoding: string | undefined, instantiationService: IInstantiationService): IFileEditorInput; + createFileInput(resource: URI, encoding: string | undefined, mode: string | undefined, instantiationService: IInstantiationService): IFileEditorInput; isFileInput(obj: any): obj is IFileEditorInput; } @@ -209,7 +209,7 @@ export interface IUntitledResourceInput extends IBaseResourceInput { /** * Optional language of the untitled resource. */ - language?: string; + mode?: string; /** * Optional contents of the untitled resource. @@ -505,19 +505,35 @@ export interface IEncodingSupport { setEncoding(encoding: string, mode: EncodingMode): void; } +export interface IModeSupport { + + /** + * Sets the language mode of the input. + */ + setMode(mode: string): void; +} + /** * This is a tagging interface to declare an editor input being capable of dealing with files. It is only used in the editor registry * to register this kind of input to the platform. */ -export interface IFileEditorInput extends IEditorInput, IEncodingSupport { +export interface IFileEditorInput extends IEditorInput, IEncodingSupport, IModeSupport { + /** + * Gets the resource this editor is about. + */ getResource(): URI; /** - * Sets the preferred encodingt to use for this input. + * Sets the preferred encoding to use for this input. */ setPreferredEncoding(encoding: string): void; + /** + * Sets the preferred language mode to use for this input. + */ + setPreferredMode(mode: string): void; + /** * Forces this file input to open as binary instead of text. */ diff --git a/src/vs/workbench/common/editor/resourceEditorInput.ts b/src/vs/workbench/common/editor/resourceEditorInput.ts index 7fed290b43bef..aad672b42e55e 100644 --- a/src/vs/workbench/common/editor/resourceEditorInput.ts +++ b/src/vs/workbench/common/editor/resourceEditorInput.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { EditorInput, ITextEditorModel } from 'vs/workbench/common/editor'; +import { EditorInput, ITextEditorModel, IModeSupport } from 'vs/workbench/common/editor'; import { URI } from 'vs/base/common/uri'; import { IReference } from 'vs/base/common/lifecycle'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; @@ -13,16 +13,18 @@ import { ResourceEditorModel } from 'vs/workbench/common/editor/resourceEditorMo * A read-only text editor input whos contents are made of the provided resource that points to an existing * code editor model. */ -export class ResourceEditorInput extends EditorInput { +export class ResourceEditorInput extends EditorInput implements IModeSupport { static readonly ID: string = 'workbench.editors.resourceEditorInput'; + private cachedModel: ResourceEditorModel | null; private modelReference: Promise> | null; constructor( private name: string, private description: string | null, private readonly resource: URI, + private preferredMode: string | undefined, @ITextModelService private readonly textModelResolverService: ITextModelService ) { super(); @@ -62,6 +64,18 @@ export class ResourceEditorInput extends EditorInput { } } + setMode(mode: string): void { + this.setPreferredMode(mode); + + if (this.cachedModel) { + this.cachedModel.setMode(mode); + } + } + + setPreferredMode(mode: string): void { + this.preferredMode = mode; + } + resolve(): Promise { if (!this.modelReference) { this.modelReference = this.textModelResolverService.createModelReference(this.resource); @@ -70,6 +84,7 @@ export class ResourceEditorInput extends EditorInput { return this.modelReference.then(ref => { const model = ref.object; + // Ensure the resolved model is of expected type if (!(model instanceof ResourceEditorModel)) { ref.dispose(); this.modelReference = null; @@ -77,6 +92,13 @@ export class ResourceEditorInput extends EditorInput { return Promise.reject(new Error(`Unexpected model for ResourceInput: ${this.resource}`)); } + this.cachedModel = model; + + // Set mode if we have a preferred mode configured + if (this.preferredMode) { + model.setMode(this.preferredMode); + } + return model; }); } @@ -87,7 +109,7 @@ export class ResourceEditorInput extends EditorInput { } if (otherInput instanceof ResourceEditorInput) { - let otherResourceEditorInput = otherInput; + const otherResourceEditorInput = otherInput; // Compare by properties return otherResourceEditorInput.resource.toString() === this.resource.toString(); @@ -102,6 +124,8 @@ export class ResourceEditorInput extends EditorInput { this.modelReference = null; } + this.cachedModel = null; + super.dispose(); } } diff --git a/src/vs/workbench/common/editor/resourceEditorModel.ts b/src/vs/workbench/common/editor/resourceEditorModel.ts index 8177f3464575e..ae5d8ff2e907a 100644 --- a/src/vs/workbench/common/editor/resourceEditorModel.ts +++ b/src/vs/workbench/common/editor/resourceEditorModel.ts @@ -19,12 +19,18 @@ export class ResourceEditorModel extends BaseTextEditorModel { @IModelService modelService: IModelService ) { super(modelService, modeService, resource); - - // TODO@Joao: force this class to dispose the underlying model - this.createdEditorModel = true; } isReadonly(): boolean { return true; } + + dispose(): void { + // TODO@Joao: force this class to dispose the underlying model + if (this.textEditorModelHandle) { + this.modelService.destroyModel(this.textEditorModelHandle); + } + + super.dispose(); + } } \ No newline at end of file diff --git a/src/vs/workbench/common/editor/textEditorModel.ts b/src/vs/workbench/common/editor/textEditorModel.ts index 0d7f807b4d167..a8644fbc39e0b 100644 --- a/src/vs/workbench/common/editor/textEditorModel.ts +++ b/src/vs/workbench/common/editor/textEditorModel.ts @@ -4,21 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { ITextModel, ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model'; -import { EditorModel } from 'vs/workbench/common/editor'; +import { EditorModel, IModeSupport } from 'vs/workbench/common/editor'; import { URI } from 'vs/base/common/uri'; import { ITextEditorModel, IResolvedTextEditorModel } from 'vs/editor/common/services/resolverService'; import { IModeService, ILanguageSelection } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; import { IDisposable } from 'vs/base/common/lifecycle'; +import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; /** * The base text editor model leverages the code editor model. This class is only intended to be subclassed and not instantiated. */ -export abstract class BaseTextEditorModel extends EditorModel implements ITextEditorModel { +export abstract class BaseTextEditorModel extends EditorModel implements ITextEditorModel, IModeSupport { + protected textEditorModelHandle: URI | null; + private createdEditorModel: boolean; - protected createdEditorModel: boolean; - - private textEditorModelHandle: URI | null; private modelDisposeListener: IDisposable | null; constructor( @@ -64,12 +64,25 @@ export abstract class BaseTextEditorModel extends EditorModel implements ITextEd abstract isReadonly(): boolean; + setMode(mode: string): void { + if (!this.isResolved()) { + return; + } + + if (!mode || mode === this.textEditorModel.getModeId()) { + return; + } + + this.modelService.setMode(this.textEditorModel, this.modeService.create(mode)); + } + /** - * Creates the text editor model with the provided value, modeId (can be comma separated for multiple values) and optional resource URL. + * Creates the text editor model with the provided value, optional preferred mode + * (can be comma separated for multiple values) and optional resource URL. */ - protected createTextEditorModel(value: ITextBufferFactory, resource: URI | undefined, modeId?: string): EditorModel { + protected createTextEditorModel(value: ITextBufferFactory, resource: URI | undefined, preferredMode?: string): EditorModel { const firstLineText = this.getFirstLineText(value); - const languageSelection = this.getOrCreateMode(this.modeService, modeId, firstLineText); + const languageSelection = this.getOrCreateMode(resource, this.modeService, preferredMode, firstLineText); return this.doCreateTextEditorModel(value, languageSelection, resource); } @@ -83,8 +96,7 @@ export abstract class BaseTextEditorModel extends EditorModel implements ITextEd // Make sure we clean up when this model gets disposed this.registerModelDisposeListener(model); } else { - this.modelService.updateModel(model, value); - this.modelService.setMode(model, languageSelection); + this.updateTextEditorModel(value, languageSelection.languageIdentifier.language); } this.textEditorModelHandle = model.uri; @@ -110,28 +122,42 @@ export abstract class BaseTextEditorModel extends EditorModel implements ITextEd * * @param firstLineText optional first line of the text buffer to set the mode on. This can be used to guess a mode from content. */ - protected getOrCreateMode(modeService: IModeService, modeId: string | undefined, firstLineText?: string): ILanguageSelection { - return modeService.create(modeId); + protected getOrCreateMode(resource: URI | undefined, modeService: IModeService, preferredMode: string | undefined, firstLineText?: string): ILanguageSelection { + + // lookup mode via resource path if the provided mode is unspecific + if (!preferredMode || preferredMode === PLAINTEXT_MODE_ID) { + return modeService.createByFilepathOrFirstLine(resource ? resource.fsPath : null, firstLineText); + } + + // otherwise take the preferred mode for granted + return modeService.create(preferredMode); } /** * Updates the text editor model with the provided value. If the value is the same as the model has, this is a no-op. */ - protected updateTextEditorModel(newValue: ITextBufferFactory): void { - if (!this.textEditorModel) { + protected updateTextEditorModel(newValue: ITextBufferFactory, preferredMode?: string): void { + if (!this.isResolved()) { return; } + // contents this.modelService.updateModel(this.textEditorModel, newValue); + + // mode (only if specific and changed) + if (preferredMode && preferredMode !== PLAINTEXT_MODE_ID && this.textEditorModel.getModeId() !== preferredMode) { + this.modelService.setMode(this.textEditorModel, this.modeService.create(preferredMode)); + } } + createSnapshot(this: IResolvedTextEditorModel): ITextSnapshot; + createSnapshot(this: ITextEditorModel): ITextSnapshot | null; createSnapshot(): ITextSnapshot | null { - const model = this.textEditorModel; - if (model) { - return model.createSnapshot(true /* Preserve BOM */); + if (!this.textEditorModel) { + return null; } - return null; + return this.textEditorModel.createSnapshot(true /* preserve BOM */); } isResolved(): this is IResolvedTextEditorModel { diff --git a/src/vs/workbench/common/editor/untitledEditorInput.ts b/src/vs/workbench/common/editor/untitledEditorInput.ts index dea43a4c3220e..37bd6eab9afd4 100644 --- a/src/vs/workbench/common/editor/untitledEditorInput.ts +++ b/src/vs/workbench/common/editor/untitledEditorInput.ts @@ -9,7 +9,7 @@ import { memoize } from 'vs/base/common/decorators'; import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; import { basename } from 'vs/base/common/path'; import { basenameOrAuthority, dirname } from 'vs/base/common/resources'; -import { EditorInput, IEncodingSupport, EncodingMode, ConfirmResult, Verbosity } from 'vs/workbench/common/editor'; +import { EditorInput, IEncodingSupport, EncodingMode, ConfirmResult, Verbosity, IModeSupport } from 'vs/workbench/common/editor'; import { UntitledEditorModel } from 'vs/workbench/common/editor/untitledEditorModel'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { Event, Emitter } from 'vs/base/common/event'; @@ -20,12 +20,12 @@ import { IResolvedTextEditorModel } from 'vs/editor/common/services/resolverServ /** * An editor input to be used for untitled text buffers. */ -export class UntitledEditorInput extends EditorInput implements IEncodingSupport { +export class UntitledEditorInput extends EditorInput implements IEncodingSupport, IModeSupport { static readonly ID: string = 'workbench.editors.untitledEditorInput'; - private cachedModel: UntitledEditorModel; - private modelResolve?: Promise; + private cachedModel: UntitledEditorModel | null; + private modelResolve: Promise | null; private readonly _onDidModelChangeContent: Emitter = this._register(new Emitter()); get onDidModelChangeContent(): Event { return this._onDidModelChangeContent.event; } @@ -36,7 +36,7 @@ export class UntitledEditorInput extends EditorInput implements IEncodingSupport constructor( private readonly resource: URI, private readonly _hasAssociatedFilePath: boolean, - private readonly modeId: string, + private preferredMode: string, private readonly initialValue: string, private preferredEncoding: string, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -58,14 +58,6 @@ export class UntitledEditorInput extends EditorInput implements IEncodingSupport return this.resource; } - getModeId(): string | null { - if (this.cachedModel) { - return this.cachedModel.getModeId(); - } - - return this.modeId; - } - getName(): string { return this.hasAssociatedFilePath ? basenameOrAuthority(this.resource) : this.resource.path; } @@ -168,9 +160,9 @@ export class UntitledEditorInput extends EditorInput implements IEncodingSupport suggestFileName(): string { if (!this.hasAssociatedFilePath) { if (this.cachedModel) { - const modeId = this.cachedModel.getModeId(); - if (modeId !== PLAINTEXT_MODE_ID) { // do not suggest when the mode ID is simple plain text - return suggestFilename(modeId, this.getName()); + const mode = this.cachedModel.getMode(); + if (mode !== PLAINTEXT_MODE_ID) { // do not suggest when the mode ID is simple plain text + return suggestFilename(mode, this.getName()); } } } @@ -194,6 +186,22 @@ export class UntitledEditorInput extends EditorInput implements IEncodingSupport } } + setMode(mode: string): void { + this.preferredMode = mode; + + if (this.cachedModel) { + this.cachedModel.setMode(mode); + } + } + + getMode(): string | undefined { + if (this.cachedModel) { + return this.cachedModel.getMode(); + } + + return this.preferredMode; + } + resolve(): Promise { // Join a model resolve if we have had one before @@ -209,7 +217,7 @@ export class UntitledEditorInput extends EditorInput implements IEncodingSupport } private createModel(): UntitledEditorModel { - const model = this._register(this.instantiationService.createInstance(UntitledEditorModel, this.modeId, this.resource, this.hasAssociatedFilePath, this.initialValue, this.preferredEncoding)); + const model = this._register(this.instantiationService.createInstance(UntitledEditorModel, this.preferredMode, this.resource, this.hasAssociatedFilePath, this.initialValue, this.preferredEncoding)); // re-emit some events from the model this._register(model.onDidChangeContent(() => this._onDidModelChangeContent.fire())); @@ -235,7 +243,8 @@ export class UntitledEditorInput extends EditorInput implements IEncodingSupport } dispose(): void { - this.modelResolve = undefined; + this.cachedModel = null; + this.modelResolve = null; super.dispose(); } diff --git a/src/vs/workbench/common/editor/untitledEditorModel.ts b/src/vs/workbench/common/editor/untitledEditorModel.ts index 4f8901e3001b0..a658a8f379387 100644 --- a/src/vs/workbench/common/editor/untitledEditorModel.ts +++ b/src/vs/workbench/common/editor/untitledEditorModel.ts @@ -6,9 +6,8 @@ import { IEncodingSupport } from 'vs/workbench/common/editor'; import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel'; import { URI } from 'vs/base/common/uri'; -import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; import { CONTENT_CHANGE_EVENT_BUFFER_DELAY } from 'vs/platform/files/common/files'; -import { IModeService, ILanguageSelection } from 'vs/editor/common/services/modeService'; +import { IModeService } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; import { Event, Emitter } from 'vs/base/common/event'; import { RunOnceScheduler } from 'vs/base/common/async'; @@ -37,7 +36,7 @@ export class UntitledEditorModel extends BaseTextEditorModel implements IEncodin private configuredEncoding: string; constructor( - private readonly modeId: string, + private readonly preferredMode: string, private readonly resource: URI, private _hasAssociatedFilePath: boolean, private readonly initialValue: string, @@ -58,14 +57,6 @@ export class UntitledEditorModel extends BaseTextEditorModel implements IEncodin return this._hasAssociatedFilePath; } - protected getOrCreateMode(modeService: IModeService, modeId: string, firstLineText?: string): ILanguageSelection { - if (!modeId || modeId === PLAINTEXT_MODE_ID) { - return modeService.createByFilepathOrFirstLine(this.resource.fsPath, firstLineText); // lookup mode via resource path if the provided modeId is unspecific - } - - return super.getOrCreateMode(modeService, modeId, firstLineText); - } - private registerListeners(): void { // Config Changes @@ -88,12 +79,12 @@ export class UntitledEditorModel extends BaseTextEditorModel implements IEncodin return this.versionId; } - getModeId(): string | null { + getMode(): string | undefined { if (this.textEditorModel) { - return this.textEditorModel.getLanguageIdentifier().language; + return this.textEditorModel.getModeId(); } - return this.modeId; + return this.preferredMode; } getEncoding(): string { @@ -134,36 +125,44 @@ export class UntitledEditorModel extends BaseTextEditorModel implements IEncodin this.contentChangeEventScheduler.schedule(); } + backup(): Promise { + if (this.isResolved()) { + return this.backupFileService.backupResource(this.resource, this.createSnapshot(), this.versionId); + } + + return Promise.resolve(); + } + load(): Promise { // Check for backups first - return this.backupFileService.loadBackupResource(this.resource).then((backupResource) => { + return this.backupFileService.loadBackupResource(this.resource).then(backupResource => { if (backupResource) { return this.backupFileService.resolveBackupContent(backupResource); } - return undefined; - }).then(backupTextBufferFactory => { - const hasBackup = !!backupTextBufferFactory; + return Promise.resolve(undefined); + }).then(backup => { + const hasBackup = !!backup; // untitled associated to file path are dirty right away as well as untitled with content - this.setDirty(this._hasAssociatedFilePath || hasBackup); + this.setDirty(this._hasAssociatedFilePath || hasBackup || !!this.initialValue); let untitledContents: ITextBufferFactory; - if (backupTextBufferFactory) { - untitledContents = backupTextBufferFactory; + if (backup) { + untitledContents = backup.value; } else { untitledContents = createTextBufferFactory(this.initialValue || ''); } // Create text editor model if not yet done if (!this.textEditorModel) { - this.createTextEditorModel(untitledContents, this.resource, this.modeId); + this.createTextEditorModel(untitledContents, this.resource, this.preferredMode); } // Otherwise update else { - this.updateTextEditorModel(untitledContents); + this.updateTextEditorModel(untitledContents, this.preferredMode); } // Encoding diff --git a/src/vs/workbench/contrib/backup/common/backupModelTracker.ts b/src/vs/workbench/contrib/backup/common/backupModelTracker.ts index 2b245986c9b03..aa16c7330367c 100644 --- a/src/vs/workbench/contrib/backup/common/backupModelTracker.ts +++ b/src/vs/workbench/contrib/backup/common/backupModelTracker.ts @@ -66,10 +66,7 @@ export class BackupModelTracker extends Disposable implements IWorkbenchContribu if (!this.configuredAutoSaveAfterDelay) { const model = this.textFileService.models.get(event.resource); if (model) { - const snapshot = model.createSnapshot(); - if (snapshot) { - this.backupFileService.backupResource(model.getResource(), snapshot, model.getVersionId()); - } + model.backup(); } } } @@ -77,12 +74,7 @@ export class BackupModelTracker extends Disposable implements IWorkbenchContribu private onUntitledModelChanged(resource: Uri): void { if (this.untitledEditorService.isDirty(resource)) { - this.untitledEditorService.loadOrCreate({ resource }).then(model => { - const snapshot = model.createSnapshot(); - if (snapshot) { - this.backupFileService.backupResource(resource, snapshot, model.getVersionId()); - } - }); + this.untitledEditorService.loadOrCreate({ resource }).then(model => model.backup()); } else { this.discardBackup(resource); } diff --git a/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.css b/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.css index 113a5a1fbb440..2804246129860 100644 --- a/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.css +++ b/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.css @@ -3,43 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monaco-editor .codelens-decoration { - overflow: hidden; - display: inline-block; - text-overflow: ellipsis; -} - -.monaco-editor .codelens-decoration > span, -.monaco-editor .codelens-decoration > a { - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; - white-space: nowrap; - vertical-align: sub; -} - -.monaco-editor .codelens-decoration > a { - text-decoration: none; -} - -.monaco-editor .codelens-decoration > a:hover { - text-decoration: underline; - cursor: pointer; -} - -.monaco-editor .codelens-decoration.invisible-cl { - opacity: 0; -} - -@keyframes fadein { 0% { opacity:0; visibility:visible;} 100% { opacity:1; } } -@-moz-keyframes fadein { 0% { opacity:0; visibility:visible;} 100% { opacity:1; } } -@-o-keyframes fadein { 0% { opacity:0; visibility:visible;} 100% { opacity:1; } } -@-webkit-keyframes fadein { 0% { opacity:0; visibility:visible;} 100% { opacity:1; } } - -.monaco-editor .codelens-decoration.fadein { - -webkit-animation: fadein 0.5s linear; - -moz-animation: fadein 0.5s linear; - -o-animation: fadein 0.5s linear; - animation: fadein 0.5s linear; +.monaco-editor .code-inset { + z-index: 10; } diff --git a/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.ts b/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.ts index 5522eb6a8403d..d5e16dc47a9bd 100644 --- a/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.ts +++ b/src/vs/workbench/contrib/codeinset/electron-browser/codeInsetWidget.ts @@ -155,6 +155,7 @@ export class CodeInsetWidget { } const div = document.createElement('div'); + div.className = 'code-inset'; webview.mountTo(div); webview.onMessage((e: { type: string, payload: any }) => { // The webview contents can use a "size-info" message to report its size. diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index 5a106b857a707..ede015a2dc1ba 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -860,7 +860,7 @@ class ClipboardContentProvider implements ITextModelContentProvider { ) { } provideTextContent(resource: URI): Promise { - const model = this.modelService.createModel(this.clipboardService.readText(), this.modeService.create('text/plain'), resource); + const model = this.modelService.createModel(this.clipboardService.readText(), this.modeService.createByFilepathOrFirstLine(resource.path), resource); return Promise.resolve(model); } diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index e090fa12cc22d..ef84bbdaf897c 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -42,8 +42,8 @@ import { Schemas } from 'vs/base/common/network'; // Viewlet Action export class OpenExplorerViewletAction extends ShowViewletAction { - public static readonly ID = VIEWLET_ID; - public static readonly LABEL = nls.localize('showExplorerViewlet', "Show Explorer"); + static readonly ID = VIEWLET_ID; + static readonly LABEL = nls.localize('showExplorerViewlet', "Show Explorer"); constructor( id: string, @@ -124,8 +124,8 @@ Registry.as(EditorExtensions.Editors).registerEditor( // Register default file input factory Registry.as(EditorInputExtensions.EditorInputFactories).registerFileInputFactory({ - createFileInput: (resource, encoding, instantiationService): IFileEditorInput => { - return instantiationService.createInstance(FileEditorInput, resource, encoding); + createFileInput: (resource, encoding, mode, instantiationService): IFileEditorInput => { + return instantiationService.createInstance(FileEditorInput, resource, encoding, mode); }, isFileInput: (obj): obj is IFileEditorInput => { @@ -137,6 +137,7 @@ interface ISerializedFileInput { resource: string; resourceJSON: object; encoding?: string; + modeId?: string; } // Register Editor Input Factory @@ -144,25 +145,27 @@ class FileEditorInputFactory implements IEditorInputFactory { constructor() { } - public serialize(editorInput: EditorInput): string { + serialize(editorInput: EditorInput): string { const fileEditorInput = editorInput; const resource = fileEditorInput.getResource(); const fileInput: ISerializedFileInput = { resource: resource.toString(), // Keep for backwards compatibility resourceJSON: resource.toJSON(), - encoding: fileEditorInput.getEncoding() + encoding: fileEditorInput.getEncoding(), + modeId: fileEditorInput.getPreferredMode() // only using the preferred user associated mode here if available to not store redundant data }; return JSON.stringify(fileInput); } - public deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): FileEditorInput { + deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): FileEditorInput { return instantiationService.invokeFunction(accessor => { const fileInput: ISerializedFileInput = JSON.parse(serializedEditorInput); const resource = !!fileInput.resourceJSON ? URI.revive(fileInput.resourceJSON) : URI.parse(fileInput.resource); const encoding = fileInput.encoding; + const mode = fileInput.modeId; - return accessor.get(IEditorService).createInput({ resource, encoding, forceFile: true }) as FileEditorInput; + return accessor.get(IEditorService).createInput({ resource, encoding, mode, forceFile: true }) as FileEditorInput; }); } } diff --git a/src/vs/workbench/contrib/files/common/editors/fileEditorInput.ts b/src/vs/workbench/contrib/files/common/editors/fileEditorInput.ts index 2c5d1068a2089..02eab17958bd9 100644 --- a/src/vs/workbench/contrib/files/common/editors/fileEditorInput.ts +++ b/src/vs/workbench/contrib/files/common/editors/fileEditorInput.ts @@ -24,8 +24,11 @@ import { ILabelService } from 'vs/platform/label/common/label'; */ export class FileEditorInput extends EditorInput implements IFileEditorInput { private preferredEncoding: string; + private preferredMode: string; + private forceOpenAsBinary: boolean; private forceOpenAsText: boolean; + private textModelReference: Promise> | null; private name: string; @@ -35,6 +38,7 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput { constructor( private resource: URI, preferredEncoding: string | undefined, + preferredMode: string | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITextFileService private readonly textFileService: ITextFileService, @ITextModelService private readonly textModelResolverService: ITextModelService, @@ -46,6 +50,10 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput { this.setPreferredEncoding(preferredEncoding); } + if (preferredMode) { + this.setPreferredMode(preferredMode); + } + this.registerListeners(); } @@ -89,7 +97,7 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput { } setEncoding(encoding: string, mode: EncodingMode): void { - this.preferredEncoding = encoding; + this.setPreferredEncoding(encoding); const textModel = this.textFileService.models.get(this.resource); if (textModel) { @@ -102,6 +110,24 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput { this.forceOpenAsText = true; // encoding is a good hint to open the file as text } + getPreferredMode(): string | undefined { + return this.preferredMode; + } + + setMode(mode: string): void { + this.setPreferredMode(mode); + + const textModel = this.textFileService.models.get(this.resource); + if (textModel) { + textModel.setMode(mode); + } + } + + setPreferredMode(mode: string): void { + this.preferredMode = mode; + this.forceOpenAsText = true; // mode is a good hint to open the file as text + } + setForceOpenAsText(): void { this.forceOpenAsText = true; this.forceOpenAsBinary = false; @@ -251,6 +277,7 @@ export class FileEditorInput extends EditorInput implements IFileEditorInput { // Resolve as text return this.textFileService.models.loadOrCreate(this.resource, { + mode: this.preferredMode, encoding: this.preferredEncoding, reload: { async: true }, // trigger a reload of the model if it exists already but do not wait to show the model allowBinary: this.forceOpenAsText, diff --git a/src/vs/workbench/contrib/files/test/browser/fileEditorInput.test.ts b/src/vs/workbench/contrib/files/test/browser/fileEditorInput.test.ts index a134f8f6ab2e5..674d1f2a1e406 100644 --- a/src/vs/workbench/contrib/files/test/browser/fileEditorInput.test.ts +++ b/src/vs/workbench/contrib/files/test/browser/fileEditorInput.test.ts @@ -16,6 +16,7 @@ import { FileOperationResult, FileOperationError } from 'vs/platform/files/commo import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel'; import { IModelService } from 'vs/editor/common/services/modelService'; import { timeout } from 'vs/base/common/async'; +import { ModesRegistry, PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; class ServiceAccessor { constructor( @@ -36,10 +37,10 @@ suite('Files - FileEditorInput', () => { accessor = instantiationService.createInstance(ServiceAccessor); }); - test('Basics', function () { - let input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined); - const otherInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, 'foo/bar/otherfile.js'), undefined); - const otherInputSame = instantiationService.createInstance(FileEditorInput, toResource.call(this, 'foo/bar/file.js'), undefined); + test('Basics', async function () { + let input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined, undefined); + const otherInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, 'foo/bar/otherfile.js'), undefined, undefined); + const otherInputSame = instantiationService.createInstance(FileEditorInput, toResource.call(this, 'foo/bar/file.js'), undefined, undefined); assert(input.matches(input)); assert(input.matches(otherInputSame)); @@ -54,52 +55,65 @@ suite('Files - FileEditorInput', () => { assert.strictEqual(toResource.call(this, '/foo/bar/file.js').fsPath, input.getResource().fsPath); assert(input.getResource() instanceof URI); - input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar.html'), undefined); + input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar.html'), undefined, undefined); - const inputToResolve: FileEditorInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined); - const sameOtherInput: FileEditorInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined); + const inputToResolve: FileEditorInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined, undefined); + const sameOtherInput: FileEditorInput = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined, undefined); - return inputToResolve.resolve().then(resolved => { - assert.ok(inputToResolve.isResolved()); + let resolved = await inputToResolve.resolve(); + assert.ok(inputToResolve.isResolved()); - const resolvedModelA = resolved; - return inputToResolve.resolve().then(resolved => { - assert(resolvedModelA === resolved); // OK: Resolved Model cached globally per input + const resolvedModelA = resolved; + resolved = await inputToResolve.resolve(); + assert(resolvedModelA === resolved); // OK: Resolved Model cached globally per input - return sameOtherInput.resolve().then(otherResolved => { - assert(otherResolved === resolvedModelA); // OK: Resolved Model cached globally per input + const otherResolved = await sameOtherInput.resolve(); + assert(otherResolved === resolvedModelA); // OK: Resolved Model cached globally per input + inputToResolve.dispose(); - inputToResolve.dispose(); + resolved = await inputToResolve.resolve(); + assert(resolvedModelA === resolved); // Model is still the same because we had 2 clients + inputToResolve.dispose(); + sameOtherInput.dispose(); + resolvedModelA.dispose(); - return inputToResolve.resolve().then(resolved => { - assert(resolvedModelA === resolved); // Model is still the same because we had 2 clients + resolved = await inputToResolve.resolve(); + assert(resolvedModelA !== resolved); // Different instance, because input got disposed - inputToResolve.dispose(); - sameOtherInput.dispose(); + const stat = (resolved as TextFileEditorModel).getStat(); + resolved = await inputToResolve.resolve(); + await timeout(0); + assert(stat !== (resolved as TextFileEditorModel).getStat()); // Different stat, because resolve always goes to the server for refresh + }); + + test('preferred mode', async function () { + const mode = 'file-input-test'; + ModesRegistry.registerLanguage({ + id: mode, + }); - resolvedModelA.dispose(); + const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined, mode); + assert.equal(input.getPreferredMode(), mode); - return inputToResolve.resolve().then(resolved => { - assert(resolvedModelA !== resolved); // Different instance, because input got disposed + const model = await input.resolve() as TextFileEditorModel; + assert.equal(model.textEditorModel!.getModeId(), mode); - let stat = (resolved as TextFileEditorModel).getStat(); - return inputToResolve.resolve().then(resolved => { - return timeout(0).then(() => { // due to file editor input using `reload: { async: true }` - assert(stat !== (resolved as TextFileEditorModel).getStat()); // Different stat, because resolve always goes to the server for refresh - }); - }); - }); - }); - }); - }); - }); + input.setMode('text'); + assert.equal(input.getPreferredMode(), 'text'); + assert.equal(model.textEditorModel!.getModeId(), PLAINTEXT_MODE_ID); + + const input2 = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/file.js'), undefined, undefined); + input2.setPreferredMode(mode); + + const model2 = await input2.resolve() as TextFileEditorModel; + assert.equal(model2.textEditorModel!.getModeId(), mode); }); test('matches', function () { - const input1 = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined); - const input2 = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined); - const input3 = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/other.js'), undefined); - const input2Upper = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/UPDATEFILE.js'), undefined); + const input1 = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined, undefined); + const input2 = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined, undefined); + const input3 = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/other.js'), undefined, undefined); + const input2Upper = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/UPDATEFILE.js'), undefined, undefined); assert.strictEqual(input1.matches(null), false); assert.strictEqual(input1.matches(input1), true); @@ -109,70 +123,58 @@ suite('Files - FileEditorInput', () => { assert.strictEqual(input1.matches(input2Upper), false); }); - test('getEncoding/setEncoding', function () { - const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined); + test('getEncoding/setEncoding', async function () { + const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined, undefined); input.setEncoding('utf16', EncodingMode.Encode); assert.equal(input.getEncoding(), 'utf16'); - return input.resolve().then((resolved: TextFileEditorModel) => { - assert.equal(input.getEncoding(), resolved.getEncoding()); - - resolved.dispose(); - }); + const resolved = await input.resolve() as TextFileEditorModel; + assert.equal(input.getEncoding(), resolved.getEncoding()); + resolved.dispose(); }); - test('save', function () { - const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined); + test('save', async function () { + const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined, undefined); - return input.resolve().then((resolved: TextFileEditorModel) => { - resolved.textEditorModel!.setValue('changed'); - assert.ok(input.isDirty()); + const resolved = await input.resolve() as TextFileEditorModel; + resolved.textEditorModel!.setValue('changed'); + assert.ok(input.isDirty()); - return input.save().then(() => { - assert.ok(!input.isDirty()); - - resolved.dispose(); - }); - }); + await input.save(); + assert.ok(!input.isDirty()); + resolved.dispose(); }); - test('revert', function () { - const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined); + test('revert', async function () { + const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined, undefined); - return input.resolve().then((resolved: TextFileEditorModel) => { - resolved.textEditorModel!.setValue('changed'); - assert.ok(input.isDirty()); + const resolved = await input.resolve() as TextFileEditorModel; + resolved.textEditorModel!.setValue('changed'); + assert.ok(input.isDirty()); - return input.revert().then(() => { - assert.ok(!input.isDirty()); - - resolved.dispose(); - }); - }); + await input.revert(); + assert.ok(!input.isDirty()); + resolved.dispose(); }); - test('resolve handles binary files', function () { - const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined); + test('resolve handles binary files', async function () { + const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined, undefined); accessor.textFileService.setResolveTextContentErrorOnce(new TextFileOperationError('error', TextFileOperationResult.FILE_IS_BINARY)); - return input.resolve().then(resolved => { - assert.ok(resolved); - - resolved.dispose(); - }); + const resolved = await input.resolve(); + assert.ok(resolved); + resolved.dispose(); }); - test('resolve handles too large files', function () { - const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined); + test('resolve handles too large files', async function () { + const input = instantiationService.createInstance(FileEditorInput, toResource.call(this, '/foo/bar/updatefile.js'), undefined, undefined); accessor.textFileService.setResolveTextContentErrorOnce(new FileOperationError('error', FileOperationResult.FILE_TOO_LARGE)); - return input.resolve().then(resolved => { - assert.ok(resolved); - - resolved.dispose(); - }); + const resolved = await input.resolve(); + assert.ok(resolved); + resolved.dispose(); }); }); diff --git a/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts b/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts index 0b6ef5a245afc..37762b7c66fce 100644 --- a/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts +++ b/src/vs/workbench/contrib/format/browser/formatActionsMultiple.ts @@ -201,19 +201,34 @@ async function showFormatterPick(accessor: ServicesAccessor, model: ITextModel, const overrides = { resource: model.uri, overrideIdentifier: model.getModeId() }; const defaultFormatter = configService.getValue(DefaultFormatter.configName, overrides); + let defaultFormatterPick: IIndexedPick | undefined; + const picks = formatters.map((provider, index) => { - return { + const isDefault = ExtensionIdentifier.equals(provider.extensionId, defaultFormatter); + const pick = { index, label: provider.displayName || '', - description: ExtensionIdentifier.equals(provider.extensionId, defaultFormatter) ? nls.localize('def', "(default)") : undefined, + description: isDefault ? nls.localize('def', "(default)") : undefined, }; + + if (isDefault) { + // autofocus default pick + defaultFormatterPick = pick; + } + + return pick; }); const configurePick: IQuickPickItem = { label: nls.localize('config', "Configure Default Formatter...") }; - const pick = await quickPickService.pick([...picks, { type: 'separator' }, configurePick], { placeHolder: nls.localize('format.placeHolder', "Select a formatter") }); + const pick = await quickPickService.pick([...picks, { type: 'separator' }, configurePick], + { + placeHolder: nls.localize('format.placeHolder', "Select a formatter"), + activeItem: defaultFormatterPick + } + ); if (!pick) { // dismissed return undefined; diff --git a/src/vs/workbench/contrib/output/browser/logViewer.ts b/src/vs/workbench/contrib/output/browser/logViewer.ts index c33c399e893ff..c1bbbd2f7c0cc 100644 --- a/src/vs/workbench/contrib/output/browser/logViewer.ts +++ b/src/vs/workbench/contrib/output/browser/logViewer.ts @@ -28,7 +28,7 @@ export class LogViewerInput extends ResourceEditorInput { constructor(private outputChannelDescriptor: IFileOutputChannelDescriptor, @ITextModelService textModelResolverService: ITextModelService ) { - super(basename(outputChannelDescriptor.file.path), dirname(outputChannelDescriptor.file.path), URI.from({ scheme: LOG_SCHEME, path: outputChannelDescriptor.id }), textModelResolverService); + super(basename(outputChannelDescriptor.file.path), dirname(outputChannelDescriptor.file.path), URI.from({ scheme: LOG_SCHEME, path: outputChannelDescriptor.id }), undefined, textModelResolverService); } public getTypeId(): string { diff --git a/src/vs/workbench/contrib/output/browser/outputServices.ts b/src/vs/workbench/contrib/output/browser/outputServices.ts index cb5a3e6410814..eb8ef512efd53 100644 --- a/src/vs/workbench/contrib/output/browser/outputServices.ts +++ b/src/vs/workbench/contrib/output/browser/outputServices.ts @@ -244,7 +244,7 @@ export class OutputService extends Disposable implements IOutputService, ITextMo private createInput(channel: IOutputChannel): ResourceEditorInput { const resource = URI.from({ scheme: OUTPUT_SCHEME, path: channel.id }); - return this.instantiationService.createInstance(ResourceEditorInput, nls.localize('output', "{0} - Output", channel.label), nls.localize('channel', "Output channel for '{0}'", channel.label), resource); + return this.instantiationService.createInstance(ResourceEditorInput, nls.localize('output', "{0} - Output", channel.label), nls.localize('channel', "Output channel for '{0}'", channel.label), resource, undefined); } private saveState(): void { diff --git a/src/vs/workbench/contrib/performance/electron-browser/perfviewEditor.ts b/src/vs/workbench/contrib/performance/electron-browser/perfviewEditor.ts index 831cad8a43e8c..bd40d800b9448 100644 --- a/src/vs/workbench/contrib/performance/electron-browser/perfviewEditor.ts +++ b/src/vs/workbench/contrib/performance/electron-browser/perfviewEditor.ts @@ -52,6 +52,7 @@ export class PerfviewInput extends ResourceEditorInput { localize('name', "Startup Performance"), null, PerfviewInput.Uri, + undefined, textModelResolverService ); } diff --git a/src/vs/workbench/contrib/search/browser/openFileHandler.ts b/src/vs/workbench/contrib/search/browser/openFileHandler.ts index 8746f2124976f..933e47b2e7370 100644 --- a/src/vs/workbench/contrib/search/browser/openFileHandler.ts +++ b/src/vs/workbench/contrib/search/browser/openFileHandler.ts @@ -203,7 +203,7 @@ export class OpenFileHandler extends QuickOpenHandler { const queryOptions: IFileQueryBuilderOptions = { _reason: 'openFileHandler', extraFileResources: getOutOfWorkspaceEditorResources(this.editorService, this.contextService), - filePattern: query.value, + filePattern: query.original, cacheKey }; diff --git a/src/vs/workbench/contrib/tasks/electron-browser/task.contribution.ts b/src/vs/workbench/contrib/tasks/electron-browser/task.contribution.ts index 688ffaf6fe82e..4a7a6f9a9a73f 100644 --- a/src/vs/workbench/contrib/tasks/electron-browser/task.contribution.ts +++ b/src/vs/workbench/contrib/tasks/electron-browser/task.contribution.ts @@ -1360,6 +1360,7 @@ class TaskService extends Disposable implements ITaskService { this.modelService, this.configurationResolverService, this.telemetryService, this.contextService, this._environmentService, TaskService.OutputChannelId, + this.configurationService, (workspaceFolder: IWorkspaceFolder) => { if (!workspaceFolder) { return undefined; @@ -2012,7 +2013,7 @@ class TaskService extends Disposable implements ITaskService { Severity.Info, nls.localize('TaskService.ignoredFolder', 'The following workspace folders are ignored since they use task version 0.1.0: {0}', this.ignoredWorkspaceFolders.map(f => f.name).join(', ')), [{ - label: nls.localize('TaskService.notAgain', 'Don\'t Show Again'), + label: nls.localize('TaskService.notAgain', "Don't Show Again"), isSecondary: true, run: () => { this.storageService.store(TaskService.IgnoreTask010DonotShowAgain_key, true, StorageScope.WORKSPACE); diff --git a/src/vs/workbench/contrib/tasks/electron-browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/electron-browser/terminalTaskSystem.ts index b0302bd3d2396..2509f820f032c 100644 --- a/src/vs/workbench/contrib/tasks/electron-browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/electron-browser/terminalTaskSystem.ts @@ -44,6 +44,7 @@ import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/ import { Schemas } from 'vs/base/common/network'; import { getWindowsBuildNumber } from 'vs/workbench/contrib/terminal/node/terminal'; import { IPanelService } from 'vs/workbench/services/panel/common/panelService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; interface TerminalData { terminal: ITerminalInstance; @@ -171,7 +172,8 @@ export class TerminalTaskSystem implements ITaskSystem { private contextService: IWorkspaceContextService, private environmentService: IWorkbenchEnvironmentService, private outputChannelId: string, - taskSystemInfoResolver: TaskSystemInfoResovler + private readonly configurationService: IConfigurationService, + taskSystemInfoResolver: TaskSystemInfoResovler, ) { this.activeTasks = Object.create(null); @@ -754,7 +756,7 @@ export class TerminalTaskSystem implements ITaskSystem { let originalCommand = task.command.name; if (isShellCommand) { shellLaunchConfig = { name: terminalName, executable: undefined, args: undefined, waitOnExit }; - this.terminalService.configHelper.mergeDefaultShellPathAndArgs(shellLaunchConfig, platform); + this.terminalService.configHelper.mergeDefaultShellPathAndArgs(shellLaunchConfig, this.terminalService.getDefaultShell(platform), platform); let shellSpecified: boolean = false; let shellOptions: ShellConfiguration | undefined = task.command.options && task.command.options.shell; if (shellOptions) { @@ -877,6 +879,9 @@ export class TerminalTaskSystem implements ITaskSystem { if (options.env) { shellLaunchConfig.env = options.env; } + + // Conpty doesn't do linefeeds in an expected way. Force winpty unless the user has requested otherwise. + shellLaunchConfig.forceWinpty = !this.configurationService.getValue('terminal.integrated.windowsAllowConptyTasks'); return shellLaunchConfig; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index d467ac1b6b3d3..eb7a270b494ba 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -273,6 +273,11 @@ configurationRegistry.registerConfiguration({ description: nls.localize('terminal.integrated.experimentalRefreshOnResume', "An experimental setting that will refresh the terminal renderer when the system is resumed."), type: 'boolean', default: false + }, + 'terminal.integrated.windowsAllowConptyTasks': { + markdownDescription: nls.localize('terminal.integrated.windowsAllowConptyTasks', "Works in conjunction with the `#terminal.integrated.windowsEnableConpty#` setting. Both must be enabled for tasks to use conpty. Defaults to `false`."), + type: 'boolean', + default: false } } }); diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index ff7f6b95f5b6f..07d8a21d41461 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -6,7 +6,7 @@ import { Terminal as XTermTerminal } from 'vscode-xterm'; import { ITerminalInstance, IWindowsShellHelper, ITerminalProcessManager, ITerminalConfigHelper, ITerminalChildProcess, IShellLaunchConfig } from 'vs/workbench/contrib/terminal/common/terminal'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IProcessEnvironment } from 'vs/base/common/platform'; +import { IProcessEnvironment, Platform } from 'vs/base/common/platform'; export const ITerminalInstanceService = createDecorator('terminalInstanceService'); @@ -17,6 +17,7 @@ export interface ITerminalInstanceService { createWindowsShellHelper(shellProcessId: number, instance: ITerminalInstance, xterm: XTermTerminal): IWindowsShellHelper; createTerminalProcessManager(id: number, configHelper: ITerminalConfigHelper): ITerminalProcessManager; createTerminalProcess(shellLaunchConfig: IShellLaunchConfig, cwd: string, cols: number, rows: number, env: IProcessEnvironment, windowsEnableConpty: boolean): ITerminalChildProcess; + getDefaultShell(p: Platform): string; } export interface IBrowserTerminalConfigHelper extends ITerminalConfigHelper { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalConfigHelper.ts b/src/vs/workbench/contrib/terminal/browser/terminalConfigHelper.ts index cd39294d5292d..716e1f872cb79 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalConfigHelper.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalConfigHelper.ts @@ -227,9 +227,9 @@ export class TerminalConfigHelper implements IBrowserTerminalConfigHelper { return !!isWorkspaceShellAllowed; } - public mergeDefaultShellPathAndArgs(shell: IShellLaunchConfig, platformOverride: platform.Platform = platform.platform): void { + public mergeDefaultShellPathAndArgs(shell: IShellLaunchConfig, defaultShell: string, platformOverride: platform.Platform = platform.platform): void { const isWorkspaceShellAllowed = this.checkWorkspaceShellPermissions(platformOverride === platform.Platform.Windows ? platform.OperatingSystem.Windows : (platformOverride === platform.Platform.Mac ? platform.OperatingSystem.Macintosh : platform.OperatingSystem.Linux)); - mergeDefaultShellPathAndArgs(shell, (key) => this._workspaceConfigurationService.inspect(key), isWorkspaceShellAllowed, platformOverride); + mergeDefaultShellPathAndArgs(shell, (key) => this._workspaceConfigurationService.inspect(key), isWorkspaceShellAllowed, defaultShell, platformOverride); } private _toInteger(source: any, minimum: number, maximum: number, fallback: number): number { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 28b1d731cf739..94b419556afcc 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -25,7 +25,7 @@ import { activeContrastBorder, scrollbarSliderActiveBackground, scrollbarSliderB import { ICssStyleCollector, ITheme, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { PANEL_BACKGROUND } from 'vs/workbench/common/theme'; import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/terminalWidgetManager'; -import { IShellLaunchConfig, ITerminalDimensions, ITerminalInstance, ITerminalProcessManager, KEYBINDING_CONTEXT_TERMINAL_TEXT_SELECTED, NEVER_MEASURE_RENDER_TIME_STORAGE_KEY, ProcessState, TERMINAL_PANEL_ID, IWindowsShellHelper } from 'vs/workbench/contrib/terminal/common/terminal'; +import { IShellLaunchConfig, ITerminalDimensions, ITerminalInstance, ITerminalProcessManager, KEYBINDING_CONTEXT_TERMINAL_TEXT_SELECTED, NEVER_MEASURE_RENDER_TIME_STORAGE_KEY, ProcessState, TERMINAL_PANEL_ID, IWindowsShellHelper, SHELL_PATH_INVALID_EXIT_CODE } from 'vs/workbench/contrib/terminal/common/terminal'; import { ansiColorIdentifiers, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_FOREGROUND_COLOR, TERMINAL_SELECTION_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; import { TERMINAL_COMMAND_ID } from 'vs/workbench/contrib/terminal/common/terminalCommands'; import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; @@ -440,6 +440,13 @@ export class TerminalInstance implements ITerminalInstance { } if (this._processManager.os === platform.OperatingSystem.Windows) { this._xterm.winptyCompatInit(); + // Force line data to be sent when the cursor is moved, the main purpose for + // this is because ConPTY will often not do a line feed but instead move the + // cursor, in which case we still want to send the current line's data to tasks. + this._xterm.addCsiHandler('H', () => { + this._onCursorMove(); + return false; + }); } this._linkHandler = this._instantiationService.createInstance(TerminalLinkHandler, this._xterm, platform.platform, this._processManager); }); @@ -970,10 +977,32 @@ export class TerminalInstance implements ITerminalInstance { } this._isExiting = true; - let exitCodeMessage: string; + let exitCodeMessage: string | undefined; + // Create exit code message if (exitCode) { - exitCodeMessage = nls.localize('terminal.integrated.exitedWithCode', 'The terminal process terminated with exit code: {0}', exitCode); + if (exitCode === SHELL_PATH_INVALID_EXIT_CODE) { + exitCodeMessage = nls.localize('terminal.integrated.exitedWithInvalidPath', 'The terminal shell path does not exist: {0}', this._shellLaunchConfig.executable); + } else if (this._processManager && this._processManager.processState === ProcessState.KILLED_DURING_LAUNCH) { + let args = ''; + if (typeof this._shellLaunchConfig.args === 'string') { + args = ` ${this._shellLaunchConfig.args}`; + } else if (this._shellLaunchConfig.args && this._shellLaunchConfig.args.length) { + args = ' ' + this._shellLaunchConfig.args.map(a => { + if (typeof a === 'string' && a.indexOf(' ') !== -1) { + return `'${a}'`; + } + return a; + }).join(' '); + } + if (this._shellLaunchConfig.executable) { + exitCodeMessage = nls.localize('terminal.integrated.launchFailed', 'The terminal process command \'{0}{1}\' failed to launch (exit code: {2})', this._shellLaunchConfig.executable, args, exitCode); + } else { + exitCodeMessage = nls.localize('terminal.integrated.launchFailedExtHost', 'The terminal process failed to launch (exit code: {0})', exitCode); + } + } else { + exitCodeMessage = nls.localize('terminal.integrated.exitedWithCode', 'The terminal process terminated with exit code: {0}', exitCode); + } } this._logService.debug(`Terminal process exit (id: ${this.id})${this._processManager ? ' state ' + this._processManager.processState : ''}`); @@ -981,8 +1010,8 @@ export class TerminalInstance implements ITerminalInstance { // Only trigger wait on exit when the exit was *not* triggered by the // user (via the `workbench.action.terminal.kill` command). if (this._shellLaunchConfig.waitOnExit && (!this._processManager || this._processManager.processState !== ProcessState.KILLED_BY_USER)) { - if (exitCode) { - this._xterm.writeln(exitCodeMessage!); + if (exitCodeMessage) { + this._xterm.writeln(exitCodeMessage); } if (typeof this._shellLaunchConfig.waitOnExit === 'string') { let message = this._shellLaunchConfig.waitOnExit; @@ -997,29 +1026,14 @@ export class TerminalInstance implements ITerminalInstance { } } else { this.dispose(); - if (exitCode) { + if (exitCodeMessage) { if (this._processManager && this._processManager.processState === ProcessState.KILLED_DURING_LAUNCH) { - let args = ''; - if (typeof this._shellLaunchConfig.args === 'string') { - args = this._shellLaunchConfig.args; - } else if (this._shellLaunchConfig.args && this._shellLaunchConfig.args.length) { - args = ' ' + this._shellLaunchConfig.args.map(a => { - if (typeof a === 'string' && a.indexOf(' ') !== -1) { - return `'${a}'`; - } - return a; - }).join(' '); - } - if (this._shellLaunchConfig.executable) { - this._notificationService.error(nls.localize('terminal.integrated.launchFailed', 'The terminal process command \'{0}{1}\' failed to launch (exit code: {2})', this._shellLaunchConfig.executable, args, exitCode)); - } else { - this._notificationService.error(nls.localize('terminal.integrated.launchFailedExtHost', 'The terminal process failed to launch (exit code: {0})', exitCode)); - } + this._notificationService.error(exitCodeMessage); } else { if (this._configHelper.config.showExitAlert) { - this._notificationService.error(exitCodeMessage!); + this._notificationService.error(exitCodeMessage); } else { - console.warn(exitCodeMessage!); + console.warn(exitCodeMessage); } } } @@ -1108,6 +1122,11 @@ export class TerminalInstance implements ITerminalInstance { } } + private _onCursorMove(): void { + const buffer = (this._xterm._core.buffer); + this._sendLineData(buffer, buffer.ybase + buffer.y); + } + private _sendLineData(buffer: any, lineIndex: number): void { let lineData = buffer.translateBufferLineToString(lineIndex, true); while (lineIndex >= 0 && buffer.lines.get(lineIndex--).isWrapped) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 7bcf1281bdd19..04447e63c4a78 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -169,7 +169,7 @@ export class TerminalProcessManager implements ITerminalProcessManager { private _launchProcess(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number): ITerminalChildProcess { if (!shellLaunchConfig.executable) { - this._configHelper.mergeDefaultShellPathAndArgs(shellLaunchConfig); + this._configHelper.mergeDefaultShellPathAndArgs(shellLaunchConfig, this._terminalInstanceService.getDefaultShell(platform.platform)); } const activeWorkspaceRootUri = this._historyService.getLastActiveWorkspaceRoot(Schemas.file); @@ -182,7 +182,8 @@ export class TerminalProcessManager implements ITerminalProcessManager { const env = terminalEnvironment.createTerminalEnvironment(shellLaunchConfig, lastActiveWorkspace, envFromConfigValue, this._configurationResolverService, isWorkspaceShellAllowed, this._productService.version, this._configHelper.config.setLocaleVariables); this._logService.debug(`Terminal process launching`, shellLaunchConfig, initialCwd, cols, rows, env); - return this._terminalInstanceService.createTerminalProcess(shellLaunchConfig, initialCwd, cols, rows, env, this._configHelper.config.windowsEnableConpty); + const useConpty = (shellLaunchConfig.forceWinpty !== true) && this._configHelper.config.windowsEnableConpty; + return this._terminalInstanceService.createTerminalProcess(shellLaunchConfig, initialCwd, cols, rows, env, useConpty); } public setDimensions(cols: number, rows: number): void { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index f280a830240d7..094dcdb7e4539 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -44,7 +44,7 @@ export abstract class TerminalService extends CommonTerminalService implements I super(contextKeyService, panelService, lifecycleService, storageService, notificationService, dialogService, extensionService, fileService, remoteAgentService); } - protected abstract _getDefaultShell(p: platform.Platform): string; + public abstract getDefaultShell(p: platform.Platform): string; public createInstance(terminalFocusContextKey: IContextKey, configHelper: ITerminalConfigHelper, container: HTMLElement | undefined, shellLaunchConfig: IShellLaunchConfig, doCreateProcess: boolean): ITerminalInstance { const instance = this._instantiationService.createInstance(TerminalInstance, terminalFocusContextKey, configHelper, container, shellLaunchConfig); @@ -101,7 +101,7 @@ export abstract class TerminalService extends CommonTerminalService implements I } // Never suggest if the setting is non-default already (ie. they set the setting manually) - if (this.configHelper.config.shell.windows !== this._getDefaultShell(platform.Platform.Windows)) { + if (this.configHelper.config.shell.windows !== this.getDefaultShell(platform.Platform.Windows)) { this._storageService.store(NEVER_SUGGEST_SELECT_WINDOWS_SHELL_STORAGE_KEY, true, StorageScope.GLOBAL); return; } diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index a1932f3934544..4cc7a9629127a 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -58,6 +58,7 @@ export const TERMINAL_CONFIG_SECTION = 'terminal.integrated'; export const DEFAULT_LETTER_SPACING = 0; export const MINIMUM_LETTER_SPACING = -5; export const DEFAULT_LINE_HEIGHT = 1; +export const SHELL_PATH_INVALID_EXIT_CODE = -1; export type FontWeight = 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; @@ -112,7 +113,7 @@ export interface ITerminalConfigHelper { /** * Merges the default shell path and args into the provided launch configuration */ - mergeDefaultShellPathAndArgs(shell: IShellLaunchConfig, platformOverride?: platform.Platform): void; + mergeDefaultShellPathAndArgs(shell: IShellLaunchConfig, defaultShell: string, platformOverride?: platform.Platform): void; /** Sets whether a workspace shell configuration is allowed or not */ setWorkspaceShellAllowed(isAllowed: boolean): void; checkWorkspaceShellPermissions(osOverride?: platform.OperatingSystem): boolean; @@ -192,6 +193,12 @@ export interface IShellLaunchConfig { * provided as nothing will be inherited from the process or any configuration. */ strictEnv?: boolean; + + /** + * Moving forward, conpty will be the default. However, there are cases where conpty is not ready + * to be the default. This property will force winpty to be used, even when conpty would normally be used. + */ + forceWinpty?: boolean; } export interface ITerminalService { @@ -253,6 +260,7 @@ export interface ITerminalService { findPrevious(): void; setContainers(panelContainer: HTMLElement, terminalContainer: HTMLElement): void; + getDefaultShell(p: platform.Platform): string; selectDefaultWindowsShell(): Promise; setWorkspaceShellAllowed(isAllowed: boolean): void; @@ -688,7 +696,6 @@ export const enum ProcessState { KILLED_BY_PROCESS } - export interface ITerminalProcessExtHostProxy extends IDisposable { readonly terminalId: number; diff --git a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts index ced4d525a88c2..1597d17998f40 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts @@ -164,15 +164,14 @@ export function mergeDefaultShellPathAndArgs( shell: IShellLaunchConfig, fetchSetting: (key: string) => { user: string | string[] | undefined, value: string | string[] | undefined, default: string | string[] | undefined }, isWorkspaceShellAllowed: boolean, + defaultShell: string, platformOverride: platform.Platform = platform.platform ): void { const platformKey = platformOverride === platform.Platform.Windows ? 'windows' : platformOverride === platform.Platform.Mac ? 'osx' : 'linux'; const shellConfigValue = fetchSetting(`terminal.integrated.shell.${platformKey}`); - // const shellConfigValue = this._workspaceConfigurationService.inspect(`terminal.integrated.shell.${platformKey}`); const shellArgsConfigValue = fetchSetting(`terminal.integrated.shellArgs.${platformKey}`); - // const shellArgsConfigValue = this._workspaceConfigurationService.inspect(`terminal.integrated.shellArgs.${platformKey}`); - shell.executable = (isWorkspaceShellAllowed ? shellConfigValue.value : shellConfigValue.user) || shellConfigValue.default; + shell.executable = (isWorkspaceShellAllowed ? shellConfigValue.value : shellConfigValue.user) || (shellConfigValue.default || defaultShell); shell.args = (isWorkspaceShellAllowed ? shellArgsConfigValue.value : shellArgsConfigValue.user) || shellArgsConfigValue.default; // Change Sysnative to System32 if the OS is Windows but NOT WoW64. It's diff --git a/src/vs/workbench/contrib/terminal/common/terminalService.ts b/src/vs/workbench/contrib/terminal/common/terminalService.ts index 2fc857e432bbf..01b3254479f38 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalService.ts @@ -17,7 +17,7 @@ import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IFileService } from 'vs/platform/files/common/files'; import { escapeNonWindowsPath } from 'vs/workbench/contrib/terminal/common/terminalEnvironment'; -import { isWindows } from 'vs/base/common/platform'; +import { isWindows, Platform } from 'vs/base/common/platform'; import { basename } from 'vs/base/common/path'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { timeout } from 'vs/base/common/async'; @@ -106,6 +106,7 @@ export abstract class TerminalService implements ITerminalService { public abstract createTerminal(shell?: IShellLaunchConfig, wasNewTerminalAction?: boolean): ITerminalInstance; public abstract createInstance(terminalFocusContextKey: IContextKey, configHelper: ITerminalConfigHelper, container: HTMLElement, shellLaunchConfig: IShellLaunchConfig, doCreateProcess: boolean): ITerminalInstance; + public abstract getDefaultShell(platform: Platform): string; public abstract selectDefaultWindowsShell(): Promise; public abstract setContainers(panelContainer: HTMLElement, terminalContainer: HTMLElement): void; @@ -426,6 +427,9 @@ export abstract class TerminalService implements ITerminalService { return Promise.resolve(null); } const current = potentialPaths.shift(); + if (current! === '') { + return this._validateShellPaths(label, potentialPaths); + } return this._fileService.exists(URI.file(current!)).then(exists => { if (!exists) { return this._validateShellPaths(label, potentialPaths); diff --git a/src/vs/workbench/contrib/terminal/electron-browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/electron-browser/terminal.contribution.ts index a55b93caa40c7..616bf9eba8a21 100644 --- a/src/vs/workbench/contrib/terminal/electron-browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/electron-browser/terminal.contribution.ts @@ -22,19 +22,19 @@ configurationRegistry.registerConfiguration({ type: 'object', properties: { 'terminal.integrated.shell.linux': { - markdownDescription: nls.localize('terminal.integrated.shell.linux', "The path of the shell that the terminal uses on Linux. [Read more about configuring the shell](https://code.visualstudio.com/docs/editor/integrated-terminal#_configuration)."), - type: 'string', - default: getDefaultShell(platform.Platform.Linux) + markdownDescription: nls.localize('terminal.integrated.shell.linux', "The path of the shell that the terminal uses on Linux (default: {0}). [Read more about configuring the shell](https://code.visualstudio.com/docs/editor/integrated-terminal#_configuration).", getDefaultShell(platform.Platform.Linux)), + type: ['string', 'null'], + default: null }, 'terminal.integrated.shell.osx': { - markdownDescription: nls.localize('terminal.integrated.shell.osx', "The path of the shell that the terminal uses on macOS. [Read more about configuring the shell](https://code.visualstudio.com/docs/editor/integrated-terminal#_configuration)."), - type: 'string', - default: getDefaultShell(platform.Platform.Mac) + markdownDescription: nls.localize('terminal.integrated.shell.osx', "The path of the shell that the terminal uses on macOS (default: {0}). [Read more about configuring the shell](https://code.visualstudio.com/docs/editor/integrated-terminal#_configuration).", getDefaultShell(platform.Platform.Mac)), + type: ['string', 'null'], + default: null }, 'terminal.integrated.shell.windows': { - markdownDescription: nls.localize('terminal.integrated.shell.windows', "The path of the shell that the terminal uses on Windows. [Read more about configuring the shell](https://code.visualstudio.com/docs/editor/integrated-terminal#_configuration)."), - type: 'string', - default: getDefaultShell(platform.Platform.Windows) + markdownDescription: nls.localize('terminal.integrated.shell.windows', "The path of the shell that the terminal uses on Windows (default: {0}). [Read more about configuring the shell](https://code.visualstudio.com/docs/editor/integrated-terminal#_configuration).", getDefaultShell(platform.Platform.Windows)), + type: ['string', 'null'], + default: null } } }); diff --git a/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts b/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts index 9fedff9cb212e..b3c85613d8718 100644 --- a/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts +++ b/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts @@ -10,9 +10,10 @@ import { ITerminalInstance, IWindowsShellHelper, ITerminalConfigHelper, ITermina import { WindowsShellHelper } from 'vs/workbench/contrib/terminal/node/windowsShellHelper'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { TerminalProcessManager } from 'vs/workbench/contrib/terminal/browser/terminalProcessManager'; -import { IProcessEnvironment } from 'vs/base/common/platform'; +import { IProcessEnvironment, Platform } from 'vs/base/common/platform'; import { TerminalProcess } from 'vs/workbench/contrib/terminal/node/terminalProcess'; import * as typeAheadAddon from 'vs/workbench/contrib/terminal/browser/terminalTypeAheadAddon'; +import { getDefaultShell } from 'vs/workbench/contrib/terminal/node/terminal'; let Terminal: typeof XTermTerminal; @@ -56,4 +57,8 @@ export class TerminalInstanceService implements ITerminalInstanceService { public createTerminalProcess(shellLaunchConfig: IShellLaunchConfig, cwd: string, cols: number, rows: number, env: IProcessEnvironment, windowsEnableConpty: boolean): ITerminalChildProcess { return new TerminalProcess(shellLaunchConfig, cwd, cols, rows, env, windowsEnableConpty); } + + public getDefaultShell(p: Platform): string { + return getDefaultShell(p); + } } \ No newline at end of file diff --git a/src/vs/workbench/contrib/terminal/electron-browser/terminalService.ts b/src/vs/workbench/contrib/terminal/electron-browser/terminalService.ts index d7e3026986edd..6ead83f5b676e 100644 --- a/src/vs/workbench/contrib/terminal/electron-browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/electron-browser/terminalService.ts @@ -98,7 +98,7 @@ export class TerminalService extends BrowserTerminalService implements ITerminal }); } - protected _getDefaultShell(p: platform.Platform): string { + public getDefaultShell(p: platform.Platform): string { return getDefaultShell(p); } @@ -117,7 +117,28 @@ export class TerminalService extends BrowserTerminalService implements ITerminal }); } - private _detectWindowsShells(): Promise { + /** + * Get the executable file path of shell from registry. + * @param shellName The shell name to get the executable file path + * @returns `[]` or `[ 'path' ]` + */ + private async _getShellPathFromRegistry(shellName: string): Promise { + const Registry = await import('vscode-windows-registry'); + + try { + const shellPath = Registry.GetStringRegKey('HKEY_LOCAL_MACHINE', `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${shellName}.exe`, ''); + + if (shellPath === undefined) { + return []; + } + + return [shellPath]; + } catch (error) { + return []; + } + } + + private async _detectWindowsShells(): Promise { // Determine the correct System32 path. We want to point to Sysnative // when the 32-bit version of VS Code is running on a 64-bit machine. // The reason for this is because PowerShell's important PSReadline @@ -134,6 +155,7 @@ export class TerminalService extends BrowserTerminalService implements ITerminal const expectedLocations = { 'Command Prompt': [`${system32Path}\\cmd.exe`], PowerShell: [`${system32Path}\\WindowsPowerShell\\v1.0\\powershell.exe`], + 'PowerShell Core': await this._getShellPathFromRegistry('pwsh'), 'WSL Bash': [`${system32Path}\\${useWSLexe ? 'wsl.exe' : 'bash.exe'}`], 'Git Bash': [ `${process.env['ProgramW6432']}\\Git\\bin\\bash.exe`, diff --git a/src/vs/workbench/contrib/terminal/node/terminalProcess.ts b/src/vs/workbench/contrib/terminal/node/terminalProcess.ts index c45954b11455c..eb25041699879 100644 --- a/src/vs/workbench/contrib/terminal/node/terminalProcess.ts +++ b/src/vs/workbench/contrib/terminal/node/terminalProcess.ts @@ -17,7 +17,7 @@ import { exec } from 'child_process'; export class TerminalProcess implements ITerminalChildProcess, IDisposable { private _exitCode: number; private _closeTimeout: any; - private _ptyProcess: pty.IPty; + private _ptyProcess: pty.IPty | undefined; private _currentTitle: string = ''; private _processStartupComplete: Promise; private _isDisposed: boolean = false; @@ -69,37 +69,40 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { experimentalUseConpty: useConpty }; - try { - this._ptyProcess = pty.spawn(shellLaunchConfig.executable!, shellLaunchConfig.args || [], options); - this._processStartupComplete = new Promise(c => { - this.onProcessIdReady((pid) => { - c(); - }); - }); - } catch (error) { - // The only time this is expected to happen is when the file specified to launch with does not exist. - this._exitCode = 2; - this._queueProcessExit(); - this._processStartupComplete = Promise.resolve(undefined); - return; - } - this._ptyProcess.on('data', (data) => { + // TODO: Need to verify whether executable is on $PATH, otherwise things like cmd.exe will break + // fs.stat(shellLaunchConfig.executable!, (err) => { + // if (err && err.code === 'ENOENT') { + // this._exitCode = SHELL_PATH_INVALID_EXIT_CODE; + // this._queueProcessExit(); + // this._processStartupComplete = Promise.resolve(undefined); + // return; + // } + this.setupPtyProcess(shellLaunchConfig, options); + // }); + } + + private setupPtyProcess(shellLaunchConfig: IShellLaunchConfig, options: pty.IPtyForkOptions): void { + const ptyProcess = pty.spawn(shellLaunchConfig.executable!, shellLaunchConfig.args || [], options); + this._ptyProcess = ptyProcess; + this._processStartupComplete = new Promise(c => { + this.onProcessIdReady(() => c()); + }); + ptyProcess.on('data', (data) => { this._onProcessData.fire(data); if (this._closeTimeout) { clearTimeout(this._closeTimeout); this._queueProcessExit(); } }); - this._ptyProcess.on('exit', (code) => { + ptyProcess.on('exit', (code) => { this._exitCode = code; this._queueProcessExit(); }); - + this._setupTitlePolling(ptyProcess); // TODO: We should no longer need to delay this since pty.spawn is sync setTimeout(() => { - this._sendProcessId(); + this._sendProcessId(ptyProcess); }, 500); - this._setupTitlePolling(); } public dispose(): void { @@ -114,15 +117,15 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { this._onProcessTitleChanged.dispose(); } - private _setupTitlePolling() { + private _setupTitlePolling(ptyProcess: pty.IPty) { // Send initial timeout async to give event listeners a chance to init setTimeout(() => { - this._sendProcessTitle(); + this._sendProcessTitle(ptyProcess); }, 0); // Setup polling this._titleInterval = setInterval(() => { - if (this._currentTitle !== this._ptyProcess.process) { - this._sendProcessTitle(); + if (this._currentTitle !== ptyProcess.process) { + this._sendProcessTitle(ptyProcess); } }, 200); } @@ -146,7 +149,9 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { // Attempt to kill the pty, it may have already been killed at this // point but we want to make sure try { - this._ptyProcess.kill(); + if (this._ptyProcess) { + this._ptyProcess.kill(); + } } catch (ex) { // Swallow, the pty has already been killed } @@ -155,15 +160,15 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { }); } - private _sendProcessId() { - this._onProcessIdReady.fire(this._ptyProcess.pid); + private _sendProcessId(ptyProcess: pty.IPty) { + this._onProcessIdReady.fire(ptyProcess.pid); } - private _sendProcessTitle(): void { + private _sendProcessTitle(ptyProcess: pty.IPty): void { if (this._isDisposed) { return; } - this._currentTitle = this._ptyProcess.process; + this._currentTitle = ptyProcess.process; this._onProcessTitleChanged.fire(this._currentTitle); } @@ -176,7 +181,7 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { } public input(data: string): void { - if (this._isDisposed) { + if (this._isDisposed || !this._ptyProcess) { return; } this._ptyProcess.write(data); @@ -188,7 +193,9 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { } // Ensure that cols and rows are always >= 1, this prevents a native // exception in winpty. - this._ptyProcess.resize(Math.max(cols, 1), Math.max(rows, 1)); + if (this._ptyProcess) { + this._ptyProcess.resize(Math.max(cols, 1), Math.max(rows, 1)); + } } public getInitialCwd(): Promise { @@ -198,6 +205,10 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { public getCwd(): Promise { if (platform.isMacintosh) { return new Promise(resolve => { + if (!this._ptyProcess) { + resolve(this._initialCwd); + return; + } exec('lsof -p ' + this._ptyProcess.pid + ' | grep cwd', (error, stdout, stderr) => { if (stdout !== '') { resolve(stdout.substring(stdout.indexOf('/'), stdout.length - 1)); @@ -208,6 +219,10 @@ export class TerminalProcess implements ITerminalChildProcess, IDisposable { if (platform.isLinux) { return new Promise(resolve => { + if (!this._ptyProcess) { + resolve(this._initialCwd); + return; + } fs.readlink('/proc/' + this._ptyProcess.pid + '/cwd', (err, linkedstr) => { if (err) { resolve(this._initialCwd); diff --git a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts index 756bfc7733a5f..7c65d7b895608 100644 --- a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts +++ b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts @@ -236,7 +236,7 @@ class GenerateColorThemeAction extends Action { }, null, '\t'); contents = contents.replace(/\"__/g, '//"'); - return this.editorService.openEditor({ contents, language: 'jsonc' }); + return this.editorService.openEditor({ contents, mode: 'jsonc' }); } } diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js index 133b3d28edd0d..2afb9ea3125b1 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/main.js +++ b/src/vs/workbench/contrib/webview/browser/pre/main.js @@ -153,7 +153,7 @@ module.exports = function createWebviewManager(host) { scrollTarget.scrollIntoView(); } } else { - host.postMessage('did-click-link', node.href); + host.postMessage('did-click-link', node.href.baseVal || node.href); } event.preventDefault(); break; diff --git a/src/vs/workbench/electron-browser/main.contribution.ts b/src/vs/workbench/electron-browser/main.contribution.ts index de7017c8edd35..0fbffedbc3653 100644 --- a/src/vs/workbench/electron-browser/main.contribution.ts +++ b/src/vs/workbench/electron-browser/main.contribution.ts @@ -36,12 +36,12 @@ import { LogStorageAction } from 'vs/platform/storage/node/storageService'; if (isMacintosh) { registry.registerWorkbenchAction(new SyncActionDescriptor(OpenFileFolderAction, OpenFileFolderAction.ID, OpenFileFolderAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }), 'File: Open...', fileCategory); - registry.registerWorkbenchAction(new SyncActionDescriptor(OpenLocalFileFolderAction, OpenLocalFileFolderAction.ID, OpenLocalFileFolderAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }, RemoteFileDialogContext), 'File: Open Local...', fileCategory, RemoteFileDialogContext); + registry.registerWorkbenchAction(new SyncActionDescriptor(OpenLocalFileFolderAction, OpenLocalFileFolderAction.ID, OpenLocalFileFolderAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }, RemoteFileDialogContext), 'File: Open Local...', fileCategory); } else { registry.registerWorkbenchAction(new SyncActionDescriptor(OpenFileAction, OpenFileAction.ID, OpenFileAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }), 'File: Open File...', fileCategory); registry.registerWorkbenchAction(new SyncActionDescriptor(OpenFolderAction, OpenFolderAction.ID, OpenFolderAction.LABEL, { primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyMod.CtrlCmd | KeyCode.KEY_O) }), 'File: Open Folder...', fileCategory); - registry.registerWorkbenchAction(new SyncActionDescriptor(OpenLocalFileAction, OpenLocalFileAction.ID, OpenLocalFileAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }, RemoteFileDialogContext), 'File: Open Local File...', fileCategory, RemoteFileDialogContext); - registry.registerWorkbenchAction(new SyncActionDescriptor(OpenLocalFolderAction, OpenLocalFolderAction.ID, OpenLocalFolderAction.LABEL, { primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyMod.CtrlCmd | KeyCode.KEY_O) }, RemoteFileDialogContext), 'File: Open Local Folder...', fileCategory, RemoteFileDialogContext); + registry.registerWorkbenchAction(new SyncActionDescriptor(OpenLocalFileAction, OpenLocalFileAction.ID, OpenLocalFileAction.LABEL, { primary: KeyMod.CtrlCmd | KeyCode.KEY_O }, RemoteFileDialogContext), 'File: Open Local File...', fileCategory); + registry.registerWorkbenchAction(new SyncActionDescriptor(OpenLocalFolderAction, OpenLocalFolderAction.ID, OpenLocalFolderAction.LABEL, { primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyMod.CtrlCmd | KeyCode.KEY_O) }, RemoteFileDialogContext), 'File: Open Local Folder...', fileCategory); } registry.registerWorkbenchAction(new SyncActionDescriptor(QuickOpenRecentAction, QuickOpenRecentAction.ID, QuickOpenRecentAction.LABEL), 'File: Quick Open Recent...', fileCategory); diff --git a/src/vs/workbench/electron-browser/window.ts b/src/vs/workbench/electron-browser/window.ts index 7687183710ff4..6141ad0cfee4e 100644 --- a/src/vs/workbench/electron-browser/window.ts +++ b/src/vs/workbench/electron-browser/window.ts @@ -43,6 +43,8 @@ import { IAccessibilityService, AccessibilitySupport } from 'vs/platform/accessi import { WorkbenchState, IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { coalesce } from 'vs/base/common/arrays'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { isEqual } from 'vs/base/common/resources'; const TextInputActions: IAction[] = [ new Action('undo', nls.localize('undo', "Undo"), undefined, true, () => Promise.resolve(document.execCommand('undo'))), @@ -88,7 +90,8 @@ export class ElectronWindow extends Disposable { @IIntegrityService private readonly integrityService: IIntegrityService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - @IWorkspaceContextService private readonly contextService: IWorkspaceContextService + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, + @ITextFileService private readonly textFileService: ITextFileService ) { super(); @@ -228,11 +231,10 @@ export class ElectronWindow extends Disposable { // Listen to editor closing (if we run with --wait) const filesToWait = this.environmentService.configuration.filesToWait; if (filesToWait) { - const resourcesToWaitFor = coalesce(filesToWait.paths.map(p => p.fileUri)); const waitMarkerFile = filesToWait.waitMarkerFileUri; - const listenerDispose = this.editorService.onDidCloseEditor(() => this.onEditorClosed(listenerDispose, resourcesToWaitFor, waitMarkerFile)); + const resourcesToWaitFor = coalesce(filesToWait.paths.map(p => p.fileUri)); - this._register(listenerDispose); + this._register(this.trackClosedWaitFiles(waitMarkerFile, resourcesToWaitFor)); } } @@ -257,17 +259,6 @@ export class ElectronWindow extends Disposable { } } - private onEditorClosed(listenerDispose: IDisposable, resourcesToWaitFor: URI[], waitMarkerFile: URI): void { - - // In wait mode, listen to changes to the editors and wait until the files - // are closed that the user wants to wait for. When this happens we delete - // the wait marker file to signal to the outside that editing is done. - if (resourcesToWaitFor.every(resource => !this.editorService.isOpen({ resource }))) { - listenerDispose.dispose(); - this.fileService.del(waitMarkerFile); - } - } - private onContextMenu(e: MouseEvent): void { if (e.target instanceof HTMLElement) { const target = e.target; @@ -488,15 +479,50 @@ export class ElectronWindow extends Disposable { // In wait mode, listen to changes to the editors and wait until the files // are closed that the user wants to wait for. When this happens we delete // the wait marker file to signal to the outside that editing is done. - const resourcesToWaitFor = request.filesToWait.paths.map(p => URI.revive(p.fileUri)); const waitMarkerFile = URI.revive(request.filesToWait.waitMarkerFileUri); - const unbind = this.editorService.onDidCloseEditor(() => { - if (resourcesToWaitFor.every(resource => !this.editorService.isOpen({ resource }))) { - unbind.dispose(); - this.fileService.del(waitMarkerFile); + const resourcesToWaitFor = coalesce(request.filesToWait.paths.map(p => URI.revive(p.fileUri))); + this.trackClosedWaitFiles(waitMarkerFile, resourcesToWaitFor); + } + } + + private trackClosedWaitFiles(waitMarkerFile: URI, resourcesToWaitFor: URI[]): IDisposable { + const listener = this.editorService.onDidCloseEditor(async () => { + // In wait mode, listen to changes to the editors and wait until the files + // are closed that the user wants to wait for. When this happens we delete + // the wait marker file to signal to the outside that editing is done. + if (resourcesToWaitFor.every(resource => !this.editorService.isOpen({ resource }))) { + // If auto save is configured with the default delay (1s) it is possible + // to close the editor while the save still continues in the background. As such + // we have to also check if the files to wait for are dirty and if so wait + // for them to get saved before deleting the wait marker file. + const dirtyFilesToWait = this.textFileService.getDirty(resourcesToWaitFor); + if (dirtyFilesToWait.length > 0) { + await Promise.all(dirtyFilesToWait.map(async dirtyFileToWait => await this.joinResourceSaved(dirtyFileToWait))); + } + + listener.dispose(); + await this.fileService.del(waitMarkerFile); + } + }); + + return listener; + } + + private joinResourceSaved(resource: URI): Promise { + return new Promise(resolve => { + if (!this.textFileService.isDirty(resource)) { + return resolve(); // return early if resource is not dirty + } + + // Otherwise resolve promise when resource is saved + const listener = this.textFileService.models.onModelSaved(e => { + if (isEqual(resource, e.resource)) { + listener.dispose(); + + resolve(); } }); - } + }); } private openResources(resources: Array, diffMode: boolean): void { diff --git a/src/vs/workbench/services/backup/common/backup.ts b/src/vs/workbench/services/backup/common/backup.ts index b1f30ade0a370..9d1b0208434bd 100644 --- a/src/vs/workbench/services/backup/common/backup.ts +++ b/src/vs/workbench/services/backup/common/backup.ts @@ -9,6 +9,11 @@ import { ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model'; export const IBackupFileService = createDecorator('backupFileService'); +export interface IResolvedBackup { + value: ITextBufferFactory; + meta?: T; +} + /** * A service that handles any I/O and state associated with the backup system. */ @@ -42,8 +47,10 @@ export interface IBackupFileService { * @param resource The resource to back up. * @param content The content of the resource as snapshot. * @param versionId The version id of the resource to backup. + * @param meta The (optional) meta data of the resource to backup. This information + * can be restored later when loading the backup again. */ - backupResource(resource: URI, content: ITextSnapshot, versionId?: number): Promise; + backupResource(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise; /** * Gets a list of file backups for the current workspace. @@ -55,10 +62,10 @@ export interface IBackupFileService { /** * Resolves the backup for the given resource. * - * @param value The contents from a backup resource as stream. - * @return The backup file's backed up content as text buffer factory. + * @param resource The resource to get the backup for. + * @return The backup file's backed up content and metadata if available. */ - resolveBackupContent(backup: URI): Promise; + resolveBackupContent(resource: URI): Promise>; /** * Discards the backup associated with a resource if it exists.. diff --git a/src/vs/workbench/services/backup/node/backupFileService.ts b/src/vs/workbench/services/backup/node/backupFileService.ts index 21d8187ef26a4..850fe4b103a14 100644 --- a/src/vs/workbench/services/backup/node/backupFileService.ts +++ b/src/vs/workbench/services/backup/node/backupFileService.ts @@ -3,94 +3,112 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'vs/base/common/path'; -import * as crypto from 'crypto'; -import * as pfs from 'vs/base/node/pfs'; -import { URI as Uri } from 'vs/base/common/uri'; +import { join } from 'vs/base/common/path'; +import { joinPath } from 'vs/base/common/resources'; +import { createHash } from 'crypto'; +import { URI } from 'vs/base/common/uri'; +import { coalesce } from 'vs/base/common/arrays'; +import { equals, deepClone } from 'vs/base/common/objects'; import { ResourceQueue } from 'vs/base/common/async'; -import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; +import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup'; import { IFileService } from 'vs/platform/files/common/files'; import { readToMatchingString } from 'vs/base/node/stream'; -import { ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model'; +import { ITextSnapshot } from 'vs/editor/common/model'; import { createTextBufferFactoryFromStream, createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel'; -import { keys } from 'vs/base/common/map'; +import { keys, ResourceMap } from 'vs/base/common/map'; import { Schemas } from 'vs/base/common/network'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { VSBuffer } from 'vs/base/common/buffer'; import { TextSnapshotReadable } from 'vs/workbench/services/textfile/common/textfiles'; +import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation'; export interface IBackupFilesModel { - resolve(backupRoot: string): Promise; + resolve(backupRoot: URI): Promise; - add(resource: Uri, versionId?: number): void; - has(resource: Uri, versionId?: number): boolean; - get(): Uri[]; - remove(resource: Uri): void; + add(resource: URI, versionId?: number, meta?: object): void; + has(resource: URI, versionId?: number, meta?: object): boolean; + get(): URI[]; + remove(resource: URI): void; count(): number; clear(): void; } -export class BackupFilesModel implements IBackupFilesModel { - private cache: { [resource: string]: number /* version ID */ } = Object.create(null); - - resolve(backupRoot: string): Promise { - return pfs.readDirsInDir(backupRoot).then(backupSchemas => { - - // For all supported schemas - return Promise.all(backupSchemas.map(backupSchema => { +interface IBackupCacheEntry { + versionId?: number; + meta?: object; +} - // Read backup directory for backups - const backupSchemaPath = path.join(backupRoot, backupSchema); - return pfs.readdir(backupSchemaPath).then(backupHashes => { +export class BackupFilesModel implements IBackupFilesModel { + private cache: ResourceMap = new ResourceMap(); + + constructor(private fileService: IFileService) { } + + async resolve(backupRoot: URI): Promise { + try { + const backupRootStat = await this.fileService.resolve(backupRoot); + if (backupRootStat.children) { + await Promise.all(backupRootStat.children + .filter(child => child.isDirectory) + .map(async backupSchema => { + + // Read backup directory for backups + const backupSchemaStat = await this.fileService.resolve(backupSchema.resource); + + // Remember known backups in our caches + if (backupSchemaStat.children) { + backupSchemaStat.children.forEach(backupHash => this.add(backupHash.resource)); + } + })); + } + } catch (error) { + // ignore any errors + } - // Remember known backups in our caches - backupHashes.forEach(backupHash => { - const backupResource = Uri.file(path.join(backupSchemaPath, backupHash)); - this.add(backupResource); - }); - }); - })); - }).then(() => this, error => this); + return this; } - add(resource: Uri, versionId = 0): void { - this.cache[resource.toString()] = versionId; + add(resource: URI, versionId = 0, meta?: object): void { + this.cache.set(resource, { versionId, meta: deepClone(meta) }); // make sure to not store original meta in our cache... } count(): number { - return Object.keys(this.cache).length; + return this.cache.size; } - has(resource: Uri, versionId?: number): boolean { - const cachedVersionId = this.cache[resource.toString()]; - if (typeof cachedVersionId !== 'number') { + has(resource: URI, versionId?: number, meta?: object): boolean { + const entry = this.cache.get(resource); + if (!entry) { return false; // unknown resource } - if (typeof versionId === 'number') { - return versionId === cachedVersionId; // if we are asked with a specific version ID, make sure to test for it + if (typeof versionId === 'number' && versionId !== entry.versionId) { + return false; // different versionId + } + + if (meta && !equals(meta, entry.meta)) { + return false; // different metadata } return true; } - get(): Uri[] { - return Object.keys(this.cache).map(k => Uri.parse(k)); + get(): URI[] { + return this.cache.keys(); } - remove(resource: Uri): void { - delete this.cache[resource.toString()]; + remove(resource: URI): void { + this.cache.delete(resource); } clear(): void { - this.cache = Object.create(null); + this.cache.clear(); } } export class BackupFileService implements IBackupFileService { - _serviceBrand: any; + _serviceBrand: ServiceIdentifier; private impl: IBackupFileService; @@ -116,15 +134,15 @@ export class BackupFileService implements IBackupFileService { return this.impl.hasBackups(); } - loadBackupResource(resource: Uri): Promise { + loadBackupResource(resource: URI): Promise { return this.impl.loadBackupResource(resource); } - backupResource(resource: Uri, content: ITextSnapshot, versionId?: number): Promise { - return this.impl.backupResource(resource, content, versionId); + backupResource(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise { + return this.impl.backupResource(resource, content, versionId, meta); } - discardResourceBackup(resource: Uri): Promise { + discardResourceBackup(resource: URI): Promise { return this.impl.discardResourceBackup(resource); } @@ -132,26 +150,28 @@ export class BackupFileService implements IBackupFileService { return this.impl.discardAllWorkspaceBackups(); } - getWorkspaceFileBackups(): Promise { + getWorkspaceFileBackups(): Promise { return this.impl.getWorkspaceFileBackups(); } - resolveBackupContent(backup: Uri): Promise { + resolveBackupContent(backup: URI): Promise> { return this.impl.resolveBackupContent(backup); } - toBackupResource(resource: Uri): Uri { + toBackupResource(resource: URI): URI { return this.impl.toBackupResource(resource); } } class BackupFileServiceImpl implements IBackupFileService { - private static readonly META_MARKER = '\n'; + private static readonly PREAMBLE_END_MARKER = '\n'; + private static readonly PREAMBLE_META_SEPARATOR = ' '; // using a character that is know to be escaped in a URI as separator + private static readonly PREAMBLE_MAX_LENGTH = 10000; _serviceBrand: any; - private backupWorkspacePath: string; + private backupWorkspacePath: URI; private isShuttingDown: boolean; private ready: Promise; @@ -168,115 +188,165 @@ class BackupFileServiceImpl implements IBackupFileService { } initialize(backupWorkspacePath: string): void { - this.backupWorkspacePath = backupWorkspacePath; + this.backupWorkspacePath = URI.file(backupWorkspacePath); this.ready = this.init(); } private init(): Promise { - const model = new BackupFilesModel(); + const model = new BackupFilesModel(this.fileService); return model.resolve(this.backupWorkspacePath); } - hasBackups(): Promise { - return this.ready.then(model => { - return model.count() > 0; - }); + async hasBackups(): Promise { + const model = await this.ready; + + return model.count() > 0; } - loadBackupResource(resource: Uri): Promise { - return this.ready.then(model => { + async loadBackupResource(resource: URI): Promise { + const model = await this.ready; - // Return directly if we have a known backup with that resource - const backupResource = this.toBackupResource(resource); - if (model.has(backupResource)) { - return backupResource; - } + // Return directly if we have a known backup with that resource + const backupResource = this.toBackupResource(resource); + if (model.has(backupResource)) { + return backupResource; + } - return undefined; - }); + return undefined; } - backupResource(resource: Uri, content: ITextSnapshot, versionId?: number): Promise { + async backupResource(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise { if (this.isShuttingDown) { - return Promise.resolve(); + return; + } + + const model = await this.ready; + + const backupResource = this.toBackupResource(resource); + if (model.has(backupResource, versionId, meta)) { + return; // return early if backup version id matches requested one } - return this.ready.then(model => { - const backupResource = this.toBackupResource(resource); - if (model.has(backupResource, versionId)) { - return undefined; // return early if backup version id matches requested one + return this.ioOperationQueues.queueFor(backupResource).queue(async () => { + let preamble: string | undefined = undefined; + + // With Metadata: URI + META-START + Meta + END + if (meta) { + const preambleWithMeta = `${resource.toString()}${BackupFileServiceImpl.PREAMBLE_META_SEPARATOR}${JSON.stringify(meta)}${BackupFileServiceImpl.PREAMBLE_END_MARKER}`; + if (preambleWithMeta.length < BackupFileServiceImpl.PREAMBLE_MAX_LENGTH) { + preamble = preambleWithMeta; + } + } + + // Without Metadata: URI + END + if (!preamble) { + preamble = `${resource.toString()}${BackupFileServiceImpl.PREAMBLE_END_MARKER}`; } - return this.ioOperationQueues.queueFor(backupResource).queue(() => { - const preamble = `${resource.toString()}${BackupFileServiceImpl.META_MARKER}`; + // Update content with value + await this.fileService.writeFile(backupResource, new TextSnapshotReadable(content, preamble)); - // Update content with value - return this.fileService.writeFile(backupResource, new TextSnapshotReadable(content, preamble)).then(() => model.add(backupResource, versionId)); - }); + // Update model + model.add(backupResource, versionId, meta); }); } - discardResourceBackup(resource: Uri): Promise { - return this.ready.then(model => { - const backupResource = this.toBackupResource(resource); + async discardResourceBackup(resource: URI): Promise { + const model = await this.ready; + const backupResource = this.toBackupResource(resource); + + return this.ioOperationQueues.queueFor(backupResource).queue(async () => { + await this.fileService.del(backupResource, { recursive: true }); - return this.ioOperationQueues.queueFor(backupResource).queue(() => { - return pfs.rimraf(backupResource.fsPath, pfs.RimRafMode.MOVE).then(() => model.remove(backupResource)); - }); + model.remove(backupResource); }); } - discardAllWorkspaceBackups(): Promise { + async discardAllWorkspaceBackups(): Promise { this.isShuttingDown = true; - return this.ready.then(model => { - return pfs.rimraf(this.backupWorkspacePath, pfs.RimRafMode.MOVE).then(() => model.clear()); - }); + const model = await this.ready; + + await this.fileService.del(this.backupWorkspacePath, { recursive: true }); + + model.clear(); } - getWorkspaceFileBackups(): Promise { - return this.ready.then(model => { - const readPromises: Promise[] = []; + async getWorkspaceFileBackups(): Promise { + const model = await this.ready; - model.get().forEach(fileBackup => { - readPromises.push( - readToMatchingString(fileBackup.fsPath, BackupFileServiceImpl.META_MARKER, 2000, 10000).then(Uri.parse) - ); - }); + const backups = await Promise.all(model.get().map(async fileBackup => { + const backupPreamble = await readToMatchingString(fileBackup.fsPath, BackupFileServiceImpl.PREAMBLE_END_MARKER, BackupFileServiceImpl.PREAMBLE_MAX_LENGTH / 5, BackupFileServiceImpl.PREAMBLE_MAX_LENGTH); + if (!backupPreamble) { + return undefined; + } - return Promise.all(readPromises); - }); + // Preamble with metadata: URI + META-START + Meta + END + const metaStartIndex = backupPreamble.indexOf(BackupFileServiceImpl.PREAMBLE_META_SEPARATOR); + if (metaStartIndex > 0) { + return URI.parse(backupPreamble.substring(0, metaStartIndex)); + } + + // Preamble without metadata: URI + END + else { + return URI.parse(backupPreamble); + } + })); + + return coalesce(backups); } - resolveBackupContent(backup: Uri): Promise { - return this.fileService.readFileStream(backup).then(content => { + async resolveBackupContent(backup: URI): Promise> { + + // Metadata extraction + let metaRaw = ''; + let metaEndFound = false; - // Add a filter method to filter out everything until the meta marker - let metaFound = false; - const metaPreambleFilter = (chunk: VSBuffer) => { - const chunkString = chunk.toString(); + // Add a filter method to filter out everything until the meta end marker + const metaPreambleFilter = (chunk: VSBuffer) => { + const chunkString = chunk.toString(); - if (!metaFound && chunk) { - const metaIndex = chunkString.indexOf(BackupFileServiceImpl.META_MARKER); - if (metaIndex === -1) { - return VSBuffer.fromString(''); // meta not yet found, return empty string - } + if (!metaEndFound) { + const metaEndIndex = chunkString.indexOf(BackupFileServiceImpl.PREAMBLE_END_MARKER); + if (metaEndIndex === -1) { + metaRaw += chunkString; - metaFound = true; - return VSBuffer.fromString(chunkString.substr(metaIndex + 1)); // meta found, return everything after + return VSBuffer.fromString(''); // meta not yet found, return empty string } - return chunk; - }; + metaEndFound = true; + metaRaw += chunkString.substring(0, metaEndIndex); // ensure to get last chunk from metadata - return createTextBufferFactoryFromStream(content.value, metaPreambleFilter); - }); + return VSBuffer.fromString(chunkString.substr(metaEndIndex + 1)); // meta found, return everything after + } + + return chunk; + }; + + // Read backup into factory + const content = await this.fileService.readFileStream(backup); + const factory = await createTextBufferFactoryFromStream(content.value, metaPreambleFilter); + + // Trigger read for meta data extraction from the filter above + factory.getFirstLineText(1); + + let meta: T | undefined; + const metaStartIndex = metaRaw.indexOf(BackupFileServiceImpl.PREAMBLE_META_SEPARATOR); + if (metaStartIndex !== -1) { + try { + meta = JSON.parse(metaRaw.substr(metaStartIndex + 1)); + } catch (error) { + // ignore JSON parse errors + } + } + + return { value: factory, meta }; } - toBackupResource(resource: Uri): Uri { - return Uri.file(path.join(this.backupWorkspacePath, resource.scheme, hashPath(resource))); + toBackupResource(resource: URI): URI { + return joinPath(this.backupWorkspacePath, resource.scheme, hashPath(resource)); } } @@ -290,7 +360,7 @@ export class InMemoryBackupFileService implements IBackupFileService { return Promise.resolve(this.backups.size > 0); } - loadBackupResource(resource: Uri): Promise { + loadBackupResource(resource: URI): Promise { const backupResource = this.toBackupResource(resource); if (this.backups.has(backupResource.toString())) { return Promise.resolve(backupResource); @@ -299,27 +369,27 @@ export class InMemoryBackupFileService implements IBackupFileService { return Promise.resolve(undefined); } - backupResource(resource: Uri, content: ITextSnapshot, versionId?: number): Promise { + backupResource(resource: URI, content: ITextSnapshot, versionId?: number, meta?: T): Promise { const backupResource = this.toBackupResource(resource); this.backups.set(backupResource.toString(), content); return Promise.resolve(); } - resolveBackupContent(backupResource: Uri): Promise { + resolveBackupContent(backupResource: URI): Promise> { const snapshot = this.backups.get(backupResource.toString()); if (snapshot) { - return Promise.resolve(createTextBufferFactoryFromSnapshot(snapshot)); + return Promise.resolve({ value: createTextBufferFactoryFromSnapshot(snapshot) }); } - return Promise.resolve(undefined); + return Promise.reject('Unexpected backup resource to resolve'); } - getWorkspaceFileBackups(): Promise { - return Promise.resolve(keys(this.backups).map(key => Uri.parse(key))); + getWorkspaceFileBackups(): Promise { + return Promise.resolve(keys(this.backups).map(key => URI.parse(key))); } - discardResourceBackup(resource: Uri): Promise { + discardResourceBackup(resource: URI): Promise { this.backups.delete(this.toBackupResource(resource).toString()); return Promise.resolve(); @@ -331,17 +401,17 @@ export class InMemoryBackupFileService implements IBackupFileService { return Promise.resolve(); } - toBackupResource(resource: Uri): Uri { - return Uri.file(path.join(resource.scheme, hashPath(resource))); + toBackupResource(resource: URI): URI { + return URI.file(join(resource.scheme, hashPath(resource))); } } /* * Exported only for testing */ -export function hashPath(resource: Uri): string { +export function hashPath(resource: URI): string { const str = resource.scheme === Schemas.file || resource.scheme === Schemas.untitled ? resource.fsPath : resource.toString(); - return crypto.createHash('md5').update(str).digest('hex'); + return createHash('md5').update(str).digest('hex'); } registerSingleton(IBackupFileService, BackupFileService); \ No newline at end of file diff --git a/src/vs/workbench/services/backup/test/electron-browser/backupFileService.test.ts b/src/vs/workbench/services/backup/test/electron-browser/backupFileService.test.ts deleted file mode 100644 index fff74b2b70172..0000000000000 --- a/src/vs/workbench/services/backup/test/electron-browser/backupFileService.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; -import * as platform from 'vs/base/common/platform'; -import * as crypto from 'crypto'; -import * as os from 'os'; -import * as fs from 'fs'; -import * as path from 'vs/base/common/path'; -import * as pfs from 'vs/base/node/pfs'; -import { URI as Uri } from 'vs/base/common/uri'; -import { BackupFileService, BackupFilesModel, hashPath } from 'vs/workbench/services/backup/node/backupFileService'; -import { TextModel, createTextBufferFactory } from 'vs/editor/common/model/textModel'; -import { getRandomTestPath } from 'vs/base/test/node/testUtils'; -import { DefaultEndOfLine } from 'vs/editor/common/model'; -import { Schemas } from 'vs/base/common/network'; -import { IWindowConfiguration } from 'vs/platform/windows/common/windows'; -import { FileService } from 'vs/workbench/services/files/common/fileService'; -import { NullLogService } from 'vs/platform/log/common/log'; -import { DiskFileSystemProvider } from 'vs/workbench/services/files/node/diskFileSystemProvider'; -import { WorkbenchEnvironmentService } from 'vs/workbench/services/environment/node/environmentService'; -import { parseArgs } from 'vs/platform/environment/node/argv'; -import { snapshotToString } from 'vs/workbench/services/textfile/common/textfiles'; - -const parentDir = getRandomTestPath(os.tmpdir(), 'vsctests', 'backupfileservice'); -const backupHome = path.join(parentDir, 'Backups'); -const workspacesJsonPath = path.join(backupHome, 'workspaces.json'); - -const workspaceResource = Uri.file(platform.isWindows ? 'c:\\workspace' : '/workspace'); -const workspaceBackupPath = path.join(backupHome, hashPath(workspaceResource)); -const fooFile = Uri.file(platform.isWindows ? 'c:\\Foo' : '/Foo'); -const barFile = Uri.file(platform.isWindows ? 'c:\\Bar' : '/Bar'); -const untitledFile = Uri.from({ scheme: Schemas.untitled, path: 'Untitled-1' }); -const fooBackupPath = path.join(workspaceBackupPath, 'file', hashPath(fooFile)); -const barBackupPath = path.join(workspaceBackupPath, 'file', hashPath(barFile)); -const untitledBackupPath = path.join(workspaceBackupPath, 'untitled', hashPath(untitledFile)); - -class TestBackupEnvironmentService extends WorkbenchEnvironmentService { - - private config: IWindowConfiguration; - - constructor(workspaceBackupPath: string) { - super(parseArgs(process.argv) as IWindowConfiguration, process.execPath); - - this.config = Object.create(null); - this.config.backupPath = workspaceBackupPath; - } - - get configuration(): IWindowConfiguration { - return this.config; - } -} - -class TestBackupFileService extends BackupFileService { - constructor(workspace: Uri, backupHome: string, workspacesJsonPath: string) { - const fileService = new FileService(new NullLogService()); - fileService.registerProvider(Schemas.file, new DiskFileSystemProvider(new NullLogService())); - const environmentService = new TestBackupEnvironmentService(workspaceBackupPath); - - super(environmentService, fileService); - } - - public toBackupResource(resource: Uri): Uri { - return super.toBackupResource(resource); - } -} - -suite('BackupFileService', () => { - let service: TestBackupFileService; - - setup(() => { - service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); - - // Delete any existing backups completely and then re-create it. - return pfs.rimraf(backupHome, pfs.RimRafMode.MOVE).then(() => { - return pfs.mkdirp(backupHome).then(() => { - return pfs.writeFile(workspacesJsonPath, ''); - }); - }); - }); - - teardown(() => { - return pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); - }); - - suite('hashPath', () => { - test('should correctly hash the path for untitled scheme URIs', () => { - const uri = Uri.from({ - scheme: 'untitled', - path: 'Untitled-1' - }); - const actual = hashPath(uri); - // If these hashes change people will lose their backed up files! - assert.equal(actual, '13264068d108c6901b3592ea654fcd57'); - assert.equal(actual, crypto.createHash('md5').update(uri.fsPath).digest('hex')); - }); - - test('should correctly hash the path for file scheme URIs', () => { - const uri = Uri.file('/foo'); - const actual = hashPath(uri); - // If these hashes change people will lose their backed up files! - if (platform.isWindows) { - assert.equal(actual, 'dec1a583f52468a020bd120c3f01d812'); - } else { - assert.equal(actual, '1effb2475fcfba4f9e8b8a1dbc8f3caf'); - } - assert.equal(actual, crypto.createHash('md5').update(uri.fsPath).digest('hex')); - }); - }); - - suite('getBackupResource', () => { - test('should get the correct backup path for text files', () => { - // Format should be: /// - const backupResource = fooFile; - const workspaceHash = hashPath(workspaceResource); - const filePathHash = hashPath(backupResource); - const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'file', filePathHash)).fsPath; - assert.equal(service.toBackupResource(backupResource).fsPath, expectedPath); - }); - - test('should get the correct backup path for untitled files', () => { - // Format should be: /// - const backupResource = Uri.from({ scheme: Schemas.untitled, path: 'Untitled-1' }); - const workspaceHash = hashPath(workspaceResource); - const filePathHash = hashPath(backupResource); - const expectedPath = Uri.file(path.join(backupHome, workspaceHash, 'untitled', filePathHash)).fsPath; - assert.equal(service.toBackupResource(backupResource).fsPath, expectedPath); - }); - }); - - suite('loadBackupResource', () => { - test('should return whether a backup resource exists', () => { - return pfs.mkdirp(path.dirname(fooBackupPath)).then(() => { - fs.writeFileSync(fooBackupPath, 'foo'); - service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); - return service.loadBackupResource(fooFile).then(resource => { - assert.ok(resource); - assert.equal(path.basename(resource!.fsPath), path.basename(fooBackupPath)); - return service.hasBackups().then(hasBackups => { - assert.ok(hasBackups); - }); - }); - }); - }); - }); - - suite('backupResource', () => { - test('text file', function () { - return service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); - assert.equal(fs.existsSync(fooBackupPath), true); - assert.equal(fs.readFileSync(fooBackupPath), `${fooFile.toString()}\ntest`); - }); - }); - - test('untitled file', function () { - return service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); - assert.equal(fs.existsSync(untitledBackupPath), true); - assert.equal(fs.readFileSync(untitledBackupPath), `${untitledFile.toString()}\ntest`); - }); - }); - - test('text file (ITextSnapshot)', function () { - const model = TextModel.createFromString('test'); - - return service.backupResource(fooFile, model.createSnapshot()).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); - assert.equal(fs.existsSync(fooBackupPath), true); - assert.equal(fs.readFileSync(fooBackupPath), `${fooFile.toString()}\ntest`); - model.dispose(); - }); - }); - - test('untitled file (ITextSnapshot)', function () { - const model = TextModel.createFromString('test'); - - return service.backupResource(untitledFile, model.createSnapshot()).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); - assert.equal(fs.existsSync(untitledBackupPath), true); - assert.equal(fs.readFileSync(untitledBackupPath), `${untitledFile.toString()}\ntest`); - model.dispose(); - }); - }); - - test('text file (large file, ITextSnapshot)', function () { - const largeString = (new Array(10 * 1024)).join('Large String\n'); - const model = TextModel.createFromString(largeString); - - return service.backupResource(fooFile, model.createSnapshot()).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); - assert.equal(fs.existsSync(fooBackupPath), true); - assert.equal(fs.readFileSync(fooBackupPath), `${fooFile.toString()}\n${largeString}`); - model.dispose(); - }); - }); - - test('untitled file (large file, ITextSnapshot)', function () { - const largeString = (new Array(10 * 1024)).join('Large String\n'); - const model = TextModel.createFromString(largeString); - - return service.backupResource(untitledFile, model.createSnapshot()).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); - assert.equal(fs.existsSync(untitledBackupPath), true); - assert.equal(fs.readFileSync(untitledBackupPath), `${untitledFile.toString()}\n${largeString}`); - model.dispose(); - }); - }); - }); - - suite('discardResourceBackup', () => { - test('text file', function () { - return service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); - return service.discardResourceBackup(fooFile).then(() => { - assert.equal(fs.existsSync(fooBackupPath), false); - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 0); - }); - }); - }); - - test('untitled file', function () { - return service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); - return service.discardResourceBackup(untitledFile).then(() => { - assert.equal(fs.existsSync(untitledBackupPath), false); - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 0); - }); - }); - }); - }); - - suite('discardAllWorkspaceBackups', () => { - test('text file', function () { - return service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); - return service.backupResource(barFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 2); - return service.discardAllWorkspaceBackups().then(() => { - assert.equal(fs.existsSync(fooBackupPath), false); - assert.equal(fs.existsSync(barBackupPath), false); - assert.equal(fs.existsSync(path.join(workspaceBackupPath, 'file')), false); - }); - }); - }); - }); - - test('untitled file', function () { - return service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); - return service.discardAllWorkspaceBackups().then(() => { - assert.equal(fs.existsSync(untitledBackupPath), false); - assert.equal(fs.existsSync(path.join(workspaceBackupPath, 'untitled')), false); - }); - }); - }); - - test('should disable further backups', function () { - return service.discardAllWorkspaceBackups().then(() => { - return service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - assert.equal(fs.existsSync(workspaceBackupPath), false); - }); - }); - }); - }); - - suite('getWorkspaceFileBackups', () => { - test('("file") - text file', () => { - return service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - return service.getWorkspaceFileBackups().then(textFiles => { - assert.deepEqual(textFiles.map(f => f.fsPath), [fooFile.fsPath]); - return service.backupResource(barFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - return service.getWorkspaceFileBackups().then(textFiles => { - assert.deepEqual(textFiles.map(f => f.fsPath), [fooFile.fsPath, barFile.fsPath]); - }); - }); - }); - }); - }); - - test('("file") - untitled file', () => { - return service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - return service.getWorkspaceFileBackups().then(textFiles => { - assert.deepEqual(textFiles.map(f => f.fsPath), [untitledFile.fsPath]); - }); - }); - }); - - test('("untitled") - untitled file', () => { - return service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - return service.getWorkspaceFileBackups().then(textFiles => { - assert.deepEqual(textFiles.map(f => f.fsPath), ['Untitled-1']); - }); - }); - }); - }); - - test('resolveBackupContent', () => { - test('should restore the original contents (untitled file)', () => { - const contents = 'test\nand more stuff'; - service.backupResource(untitledFile, createTextBufferFactory(contents).create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - service.resolveBackupContent(service.toBackupResource(untitledFile)).then(factory => { - assert.equal(contents, snapshotToString(factory!.create(platform.isWindows ? DefaultEndOfLine.CRLF : DefaultEndOfLine.LF).createSnapshot(true))); - }); - }); - }); - - test('should restore the original contents (text file)', () => { - const contents = [ - 'Lorem ipsum ', - 'dolor öäü sit amet ', - 'consectetur ', - 'adipiscing ßß elit', - ].join(''); - - service.backupResource(fooFile, createTextBufferFactory(contents).create(DefaultEndOfLine.LF).createSnapshot(false)).then(() => { - service.resolveBackupContent(service.toBackupResource(untitledFile)).then(factory => { - assert.equal(contents, snapshotToString(factory!.create(platform.isWindows ? DefaultEndOfLine.CRLF : DefaultEndOfLine.LF).createSnapshot(true))); - }); - }); - }); - }); -}); - -suite('BackupFilesModel', () => { - test('simple', () => { - const model = new BackupFilesModel(); - - const resource1 = Uri.file('test.html'); - - assert.equal(model.has(resource1), false); - - model.add(resource1); - - assert.equal(model.has(resource1), true); - assert.equal(model.has(resource1, 0), true); - assert.equal(model.has(resource1, 1), false); - - model.remove(resource1); - - assert.equal(model.has(resource1), false); - - model.add(resource1); - - assert.equal(model.has(resource1), true); - assert.equal(model.has(resource1, 0), true); - assert.equal(model.has(resource1, 1), false); - - model.clear(); - - assert.equal(model.has(resource1), false); - - model.add(resource1, 1); - - assert.equal(model.has(resource1), true); - assert.equal(model.has(resource1, 0), false); - assert.equal(model.has(resource1, 1), true); - - const resource2 = Uri.file('test1.html'); - const resource3 = Uri.file('test2.html'); - const resource4 = Uri.file('test3.html'); - - model.add(resource2); - model.add(resource3); - model.add(resource4); - - assert.equal(model.has(resource1), true); - assert.equal(model.has(resource2), true); - assert.equal(model.has(resource3), true); - assert.equal(model.has(resource4), true); - }); - - test('resolve', () => { - return pfs.mkdirp(path.dirname(fooBackupPath)).then(() => { - fs.writeFileSync(fooBackupPath, 'foo'); - - const model = new BackupFilesModel(); - - return model.resolve(workspaceBackupPath).then(model => { - assert.equal(model.has(Uri.file(fooBackupPath)), true); - }); - }); - }); - - test('get', () => { - const model = new BackupFilesModel(); - - assert.deepEqual(model.get(), []); - - const file1 = Uri.file('/root/file/foo.html'); - const file2 = Uri.file('/root/file/bar.html'); - const untitled = Uri.file('/root/untitled/bar.html'); - - model.add(file1); - model.add(file2); - model.add(untitled); - - assert.deepEqual(model.get().map(f => f.fsPath), [file1.fsPath, file2.fsPath, untitled.fsPath]); - }); -}); diff --git a/src/vs/workbench/services/backup/test/node/backupFileService.test.ts b/src/vs/workbench/services/backup/test/node/backupFileService.test.ts new file mode 100644 index 0000000000000..52a5bc95d1ce1 --- /dev/null +++ b/src/vs/workbench/services/backup/test/node/backupFileService.test.ts @@ -0,0 +1,579 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as platform from 'vs/base/common/platform'; +import * as crypto from 'crypto'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'vs/base/common/path'; +import * as pfs from 'vs/base/node/pfs'; +import { URI } from 'vs/base/common/uri'; +import { BackupFileService, BackupFilesModel, hashPath } from 'vs/workbench/services/backup/node/backupFileService'; +import { TextModel, createTextBufferFactory } from 'vs/editor/common/model/textModel'; +import { getRandomTestPath } from 'vs/base/test/node/testUtils'; +import { DefaultEndOfLine } from 'vs/editor/common/model'; +import { Schemas } from 'vs/base/common/network'; +import { IWindowConfiguration } from 'vs/platform/windows/common/windows'; +import { FileService } from 'vs/workbench/services/files/common/fileService'; +import { NullLogService } from 'vs/platform/log/common/log'; +import { DiskFileSystemProvider } from 'vs/workbench/services/files/node/diskFileSystemProvider'; +import { WorkbenchEnvironmentService } from 'vs/workbench/services/environment/node/environmentService'; +import { parseArgs } from 'vs/platform/environment/node/argv'; +import { snapshotToString } from 'vs/workbench/services/textfile/common/textfiles'; +import { IFileService } from 'vs/platform/files/common/files'; + +const parentDir = getRandomTestPath(os.tmpdir(), 'vsctests', 'backupfileservice'); +const backupHome = path.join(parentDir, 'Backups'); +const workspacesJsonPath = path.join(backupHome, 'workspaces.json'); + +const workspaceResource = URI.file(platform.isWindows ? 'c:\\workspace' : '/workspace'); +const workspaceBackupPath = path.join(backupHome, hashPath(workspaceResource)); +const fooFile = URI.file(platform.isWindows ? 'c:\\Foo' : '/Foo'); +const customFile = URI.parse('customScheme://some/path'); +const customFileWithFragment = URI.parse('customScheme2://some/path#fragment'); +const barFile = URI.file(platform.isWindows ? 'c:\\Bar' : '/Bar'); +const fooBarFile = URI.file(platform.isWindows ? 'c:\\Foo Bar' : '/Foo Bar'); +const untitledFile = URI.from({ scheme: Schemas.untitled, path: 'Untitled-1' }); +const fooBackupPath = path.join(workspaceBackupPath, 'file', hashPath(fooFile)); +const barBackupPath = path.join(workspaceBackupPath, 'file', hashPath(barFile)); +const untitledBackupPath = path.join(workspaceBackupPath, 'untitled', hashPath(untitledFile)); + +class TestBackupEnvironmentService extends WorkbenchEnvironmentService { + + private config: IWindowConfiguration; + + constructor(workspaceBackupPath: string) { + super(parseArgs(process.argv) as IWindowConfiguration, process.execPath); + + this.config = Object.create(null); + this.config.backupPath = workspaceBackupPath; + } + + get configuration(): IWindowConfiguration { + return this.config; + } +} + +class TestBackupFileService extends BackupFileService { + + readonly fileService: IFileService; + + constructor(workspace: URI, backupHome: string, workspacesJsonPath: string) { + const fileService = new FileService(new NullLogService()); + fileService.registerProvider(Schemas.file, new DiskFileSystemProvider(new NullLogService())); + const environmentService = new TestBackupEnvironmentService(workspaceBackupPath); + + super(environmentService, fileService); + + this.fileService = fileService; + } + + toBackupResource(resource: URI): URI { + return super.toBackupResource(resource); + } +} + +suite('BackupFileService', () => { + let service: TestBackupFileService; + + setup(async () => { + service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); + + // Delete any existing backups completely and then re-create it. + await pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); + await pfs.mkdirp(backupHome); + + return pfs.writeFile(workspacesJsonPath, ''); + }); + + teardown(() => { + return pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); + }); + + suite('hashPath', () => { + test('should correctly hash the path for untitled scheme URIs', () => { + const uri = URI.from({ + scheme: 'untitled', + path: 'Untitled-1' + }); + const actual = hashPath(uri); + // If these hashes change people will lose their backed up files! + assert.equal(actual, '13264068d108c6901b3592ea654fcd57'); + assert.equal(actual, crypto.createHash('md5').update(uri.fsPath).digest('hex')); + }); + + test('should correctly hash the path for file scheme URIs', () => { + const uri = URI.file('/foo'); + const actual = hashPath(uri); + // If these hashes change people will lose their backed up files! + if (platform.isWindows) { + assert.equal(actual, 'dec1a583f52468a020bd120c3f01d812'); + } else { + assert.equal(actual, '1effb2475fcfba4f9e8b8a1dbc8f3caf'); + } + assert.equal(actual, crypto.createHash('md5').update(uri.fsPath).digest('hex')); + }); + }); + + suite('getBackupResource', () => { + test('should get the correct backup path for text files', () => { + // Format should be: /// + const backupResource = fooFile; + const workspaceHash = hashPath(workspaceResource); + const filePathHash = hashPath(backupResource); + const expectedPath = URI.file(path.join(backupHome, workspaceHash, 'file', filePathHash)).fsPath; + assert.equal(service.toBackupResource(backupResource).fsPath, expectedPath); + }); + + test('should get the correct backup path for untitled files', () => { + // Format should be: /// + const backupResource = URI.from({ scheme: Schemas.untitled, path: 'Untitled-1' }); + const workspaceHash = hashPath(workspaceResource); + const filePathHash = hashPath(backupResource); + const expectedPath = URI.file(path.join(backupHome, workspaceHash, 'untitled', filePathHash)).fsPath; + assert.equal(service.toBackupResource(backupResource).fsPath, expectedPath); + }); + }); + + suite('loadBackupResource', () => { + test('should return whether a backup resource exists', async () => { + await pfs.mkdirp(path.dirname(fooBackupPath)); + fs.writeFileSync(fooBackupPath, 'foo'); + service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); + const resource = await service.loadBackupResource(fooFile); + assert.ok(resource); + assert.equal(path.basename(resource!.fsPath), path.basename(fooBackupPath)); + const hasBackups = await service.hasBackups(); + assert.ok(hasBackups); + }); + }); + + suite('backupResource', () => { + test('text file', async () => { + await service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); + assert.equal(fs.existsSync(fooBackupPath), true); + assert.equal(fs.readFileSync(fooBackupPath), `${fooFile.toString()}\ntest`); + }); + + test('text file (with meta)', async () => { + await service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false), undefined, { etag: '678', orphaned: true }); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); + assert.equal(fs.existsSync(fooBackupPath), true); + assert.equal(fs.readFileSync(fooBackupPath).toString(), `${fooFile.toString()} {"etag":"678","orphaned":true}\ntest`); + }); + + test('untitled file', async () => { + await service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); + assert.equal(fs.existsSync(untitledBackupPath), true); + assert.equal(fs.readFileSync(untitledBackupPath), `${untitledFile.toString()}\ntest`); + }); + + test('text file (ITextSnapshot)', async () => { + const model = TextModel.createFromString('test'); + + await service.backupResource(fooFile, model.createSnapshot()); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); + assert.equal(fs.existsSync(fooBackupPath), true); + assert.equal(fs.readFileSync(fooBackupPath), `${fooFile.toString()}\ntest`); + model.dispose(); + }); + + test('untitled file (ITextSnapshot)', async () => { + const model = TextModel.createFromString('test'); + + await service.backupResource(untitledFile, model.createSnapshot()); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); + assert.equal(fs.existsSync(untitledBackupPath), true); + assert.equal(fs.readFileSync(untitledBackupPath), `${untitledFile.toString()}\ntest`); + model.dispose(); + }); + + test('text file (large file, ITextSnapshot)', async () => { + const largeString = (new Array(10 * 1024)).join('Large String\n'); + const model = TextModel.createFromString(largeString); + + await service.backupResource(fooFile, model.createSnapshot()); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); + assert.equal(fs.existsSync(fooBackupPath), true); + assert.equal(fs.readFileSync(fooBackupPath), `${fooFile.toString()}\n${largeString}`); + model.dispose(); + }); + + test('untitled file (large file, ITextSnapshot)', async () => { + const largeString = (new Array(10 * 1024)).join('Large String\n'); + const model = TextModel.createFromString(largeString); + + await service.backupResource(untitledFile, model.createSnapshot()); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); + assert.equal(fs.existsSync(untitledBackupPath), true); + assert.equal(fs.readFileSync(untitledBackupPath), `${untitledFile.toString()}\n${largeString}`); + model.dispose(); + }); + }); + + suite('discardResourceBackup', () => { + test('text file', async () => { + await service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); + await service.discardResourceBackup(fooFile); + assert.equal(fs.existsSync(fooBackupPath), false); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 0); + }); + + test('untitled file', async () => { + await service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); + await service.discardResourceBackup(untitledFile); + assert.equal(fs.existsSync(untitledBackupPath), false); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 0); + }); + }); + + suite('discardAllWorkspaceBackups', () => { + test('text file', async () => { + await service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 1); + await service.backupResource(barFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'file')).length, 2); + await service.discardAllWorkspaceBackups(); + assert.equal(fs.existsSync(fooBackupPath), false); + assert.equal(fs.existsSync(barBackupPath), false); + assert.equal(fs.existsSync(path.join(workspaceBackupPath, 'file')), false); + }); + + test('untitled file', async () => { + await service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.readdirSync(path.join(workspaceBackupPath, 'untitled')).length, 1); + await service.discardAllWorkspaceBackups(); + assert.equal(fs.existsSync(untitledBackupPath), false); + assert.equal(fs.existsSync(path.join(workspaceBackupPath, 'untitled')), false); + }); + + test('should disable further backups', async () => { + await service.discardAllWorkspaceBackups(); + await service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + assert.equal(fs.existsSync(workspaceBackupPath), false); + }); + }); + + suite('getWorkspaceFileBackups', () => { + test('("file") - text file', async () => { + await service.backupResource(fooFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + const textFiles = await service.getWorkspaceFileBackups(); + assert.deepEqual(textFiles.map(f => f.fsPath), [fooFile.fsPath]); + await service.backupResource(barFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + const textFiles_1 = await service.getWorkspaceFileBackups(); + assert.deepEqual(textFiles_1.map(f => f.fsPath), [fooFile.fsPath, barFile.fsPath]); + }); + + test('("file") - untitled file', async () => { + await service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + const textFiles = await service.getWorkspaceFileBackups(); + assert.deepEqual(textFiles.map(f => f.fsPath), [untitledFile.fsPath]); + }); + + test('("untitled") - untitled file', async () => { + await service.backupResource(untitledFile, createTextBufferFactory('test').create(DefaultEndOfLine.LF).createSnapshot(false)); + const textFiles = await service.getWorkspaceFileBackups(); + assert.deepEqual(textFiles.map(f => f.fsPath), ['Untitled-1']); + }); + }); + + suite('resolveBackupContent', () => { + + interface IBackupTestMetaData { + mtime?: number; + size?: number; + etag?: string; + orphaned?: boolean; + } + + test('should restore the original contents (untitled file)', async () => { + const contents = 'test\nand more stuff'; + + await testResolveBackup(untitledFile, contents); + }); + + test('should restore the original contents (untitled file with metadata)', async () => { + const contents = 'test\nand more stuff'; + + const meta = { + etag: 'the Etag', + size: 666, + mtime: Date.now(), + orphaned: true + }; + + await testResolveBackup(untitledFile, contents, meta); + }); + + test('should restore the original contents (text file)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'consectetur ', + 'adipiscing ßß elit' + ].join(''); + + await testResolveBackup(fooFile, contents); + }); + + test('should restore the original contents (text file - custom scheme)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'consectetur ', + 'adipiscing ßß elit' + ].join(''); + + await testResolveBackup(customFile, contents); + }); + + test('should restore the original contents (text file with metadata)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'adipiscing ßß elit', + 'consectetur ' + ].join(''); + + const meta = { + etag: 'theEtag', + size: 888, + mtime: Date.now(), + orphaned: false + }; + + await testResolveBackup(fooFile, contents, meta); + }); + + test('should restore the original contents (text file with metadata changed once)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'adipiscing ßß elit', + 'consectetur ' + ].join(''); + + const meta = { + etag: 'theEtag', + size: 888, + mtime: Date.now(), + orphaned: false + }; + + await testResolveBackup(fooFile, contents, meta); + + // Change meta and test again + meta.size = 999; + await testResolveBackup(fooFile, contents, meta); + }); + + test('should restore the original contents (text file with broken metadata)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'adipiscing ßß elit', + 'consectetur ' + ].join(''); + + const meta = { + etag: 'theEtag', + size: 888, + mtime: Date.now(), + orphaned: false + }; + + await service.backupResource(fooFile, createTextBufferFactory(contents).create(DefaultEndOfLine.LF).createSnapshot(false), 1, meta); + + assert.ok(await service.loadBackupResource(fooFile)); + + const fileContents = fs.readFileSync(fooBackupPath).toString(); + assert.equal(fileContents.indexOf(fooFile.toString()), 0); + + const metaIndex = fileContents.indexOf('{'); + const newFileContents = fileContents.substring(0, metaIndex) + '{{' + fileContents.substr(metaIndex); + fs.writeFileSync(fooBackupPath, newFileContents); + + const backup = await service.resolveBackupContent(service.toBackupResource(fooFile)); + assert.equal(contents, snapshotToString(backup.value.create(platform.isWindows ? DefaultEndOfLine.CRLF : DefaultEndOfLine.LF).createSnapshot(true))); + assert.ok(!backup.meta); + }); + + test('should restore the original contents (text file with metadata and fragment URI)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'adipiscing ßß elit', + 'consectetur ' + ].join(''); + + const meta = { + etag: 'theEtag', + size: 888, + mtime: Date.now(), + orphaned: false + }; + + await testResolveBackup(customFileWithFragment, contents, meta); + }); + + test('should restore the original contents (text file with space in name with metadata)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'adipiscing ßß elit', + 'consectetur ' + ].join(''); + + const meta = { + etag: 'theEtag', + size: 888, + mtime: Date.now(), + orphaned: false + }; + + await testResolveBackup(fooBarFile, contents, meta); + }); + + test('should restore the original contents (text file with too large metadata to persist)', async () => { + const contents = [ + 'Lorem ipsum ', + 'dolor öäü sit amet ', + 'adipiscing ßß elit', + 'consectetur ' + ].join(''); + + const meta = { + etag: (new Array(100 * 1024)).join('Large String'), + size: 888, + mtime: Date.now(), + orphaned: false + }; + + await testResolveBackup(fooBarFile, contents, meta, null); + }); + + async function testResolveBackup(resource: URI, contents: string, meta?: IBackupTestMetaData, expectedMeta?: IBackupTestMetaData | null) { + if (typeof expectedMeta === 'undefined') { + expectedMeta = meta; + } + + await service.backupResource(resource, createTextBufferFactory(contents).create(DefaultEndOfLine.LF).createSnapshot(false), 1, meta); + + assert.ok(await service.loadBackupResource(resource)); + + const backup = await service.resolveBackupContent(service.toBackupResource(resource)); + assert.equal(contents, snapshotToString(backup.value.create(platform.isWindows ? DefaultEndOfLine.CRLF : DefaultEndOfLine.LF).createSnapshot(true))); + + if (expectedMeta) { + assert.equal(backup.meta!.etag, expectedMeta.etag); + assert.equal(backup.meta!.size, expectedMeta.size); + assert.equal(backup.meta!.mtime, expectedMeta.mtime); + assert.equal(backup.meta!.orphaned, expectedMeta.orphaned); + } else { + assert.ok(!backup.meta); + } + } + }); +}); + +suite('BackupFilesModel', () => { + + let service: TestBackupFileService; + + setup(async () => { + service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); + + // Delete any existing backups completely and then re-create it. + await pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); + await pfs.mkdirp(backupHome); + + return pfs.writeFile(workspacesJsonPath, ''); + }); + + teardown(() => { + return pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); + }); + + test('simple', () => { + const model = new BackupFilesModel(service.fileService); + + const resource1 = URI.file('test.html'); + + assert.equal(model.has(resource1), false); + + model.add(resource1); + + assert.equal(model.has(resource1), true); + assert.equal(model.has(resource1, 0), true); + assert.equal(model.has(resource1, 1), false); + assert.equal(model.has(resource1, 1, { foo: 'bar' }), false); + + model.remove(resource1); + + assert.equal(model.has(resource1), false); + + model.add(resource1); + + assert.equal(model.has(resource1), true); + assert.equal(model.has(resource1, 0), true); + assert.equal(model.has(resource1, 1), false); + + model.clear(); + + assert.equal(model.has(resource1), false); + + model.add(resource1, 1); + + assert.equal(model.has(resource1), true); + assert.equal(model.has(resource1, 0), false); + assert.equal(model.has(resource1, 1), true); + + const resource2 = URI.file('test1.html'); + const resource3 = URI.file('test2.html'); + const resource4 = URI.file('test3.html'); + + model.add(resource2); + model.add(resource3); + model.add(resource4, undefined, { foo: 'bar' }); + + assert.equal(model.has(resource1), true); + assert.equal(model.has(resource2), true); + assert.equal(model.has(resource3), true); + + assert.equal(model.has(resource4), true); + assert.equal(model.has(resource4, undefined, { foo: 'bar' }), true); + assert.equal(model.has(resource4, undefined, { bar: 'foo' }), false); + }); + + test('resolve', async () => { + await pfs.mkdirp(path.dirname(fooBackupPath)); + fs.writeFileSync(fooBackupPath, 'foo'); + const model = new BackupFilesModel(service.fileService); + + const resolvedModel = await model.resolve(URI.file(workspaceBackupPath)); + assert.equal(resolvedModel.has(URI.file(fooBackupPath)), true); + }); + + test('get', () => { + const model = new BackupFilesModel(service.fileService); + + assert.deepEqual(model.get(), []); + + const file1 = URI.file('/root/file/foo.html'); + const file2 = URI.file('/root/file/bar.html'); + const untitled = URI.file('/root/untitled/bar.html'); + + model.add(file1); + model.add(file2); + model.add(untitled); + + assert.deepEqual(model.get().map(f => f.fsPath), [file1.fsPath, file2.fsPath, untitled.fsPath]); + }); +}); diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 6969fe7595680..9b93b9b397510 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -528,7 +528,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { // Untitled file support const untitledInput = input; if (untitledInput.forceUntitled || !untitledInput.resource || (untitledInput.resource && untitledInput.resource.scheme === Schemas.untitled)) { - return this.untitledEditorService.createOrGet(untitledInput.resource, untitledInput.language, untitledInput.contents, untitledInput.encoding); + return this.untitledEditorService.createOrGet(untitledInput.resource, untitledInput.mode, untitledInput.contents, untitledInput.encoding); } // Resource Editor Support @@ -539,13 +539,13 @@ export class EditorService extends Disposable implements EditorServiceImpl { label = basename(resourceInput.resource); // derive the label from the path (but not for data URIs) } - return this.createOrGet(resourceInput.resource, this.instantiationService, label, resourceInput.description, resourceInput.encoding, resourceInput.forceFile) as EditorInput; + return this.createOrGet(resourceInput.resource, this.instantiationService, label, resourceInput.description, resourceInput.encoding, resourceInput.mode, resourceInput.forceFile) as EditorInput; } throw new Error('Unknown input type'); } - private createOrGet(resource: URI, instantiationService: IInstantiationService, label: string | undefined, description: string | undefined, encoding: string | undefined, forceFile: boolean | undefined): ICachedEditorInput { + private createOrGet(resource: URI, instantiationService: IInstantiationService, label: string | undefined, description: string | undefined, encoding: string | undefined, mode: string | undefined, forceFile: boolean | undefined): ICachedEditorInput { if (EditorService.CACHE.has(resource)) { const input = EditorService.CACHE.get(resource)!; if (input instanceof ResourceEditorInput) { @@ -556,10 +556,18 @@ export class EditorService extends Disposable implements EditorServiceImpl { if (description) { input.setDescription(description); } + + if (mode) { + input.setPreferredMode(mode); + } } else if (!(input instanceof DataUriEditorInput)) { if (encoding) { input.setPreferredEncoding(encoding); } + + if (mode) { + input.setPreferredMode(mode); + } } return input; @@ -569,7 +577,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { // File if (forceFile /* fix for https://github.com/Microsoft/vscode/issues/48275 */ || this.fileService.canHandleResource(resource)) { - input = this.fileInputFactory.createFileInput(resource, encoding, instantiationService); + input = this.fileInputFactory.createFileInput(resource, encoding, mode, instantiationService); } // Data URI @@ -579,13 +587,12 @@ export class EditorService extends Disposable implements EditorServiceImpl { // Resource else { - input = instantiationService.createInstance(ResourceEditorInput, label, description, resource); + input = instantiationService.createInstance(ResourceEditorInput, label, description, resource, mode); } + // Add to cache and remove when input gets disposed EditorService.CACHE.set(resource, input); - Event.once(input.onDispose)(() => { - EditorService.CACHE.delete(resource); - }); + Event.once(input.onDispose)(() => EditorService.CACHE.delete(resource)); return input; } diff --git a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts index 0fe83803a0c0c..287202578400b 100644 --- a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts @@ -24,10 +24,10 @@ export class TestEditorControl extends BaseEditor { constructor(@ITelemetryService telemetryService: ITelemetryService) { super('MyFileEditorForEditorGroupService', NullTelemetryService, new TestThemeService(), new TestStorageService()); } - setInput(input: EditorInput, options: EditorOptions, token: CancellationToken): Promise { + async setInput(input: EditorInput, options: EditorOptions, token: CancellationToken): Promise { super.setInput(input, options, token); - return input.resolve().then(() => undefined); + await input.resolve(); } getId(): string { return 'MyFileEditorForEditorGroupService'; } @@ -45,11 +45,13 @@ export class TestEditorInput extends EditorInput implements IFileEditorInput { setEncoding(encoding: string) { } getEncoding(): string { return null!; } setPreferredEncoding(encoding: string) { } + setMode(mode: string) { } + setPreferredMode(mode: string) { } getResource(): URI { return this.resource; } setForceOpenAsBinary(): void { } } -suite('Editor groups service', () => { +suite('EditorGroupsService', () => { function registerTestEditorInput(): void { @@ -291,7 +293,7 @@ suite('Editor groups service', () => { part.dispose(); }); - test('copy/merge groups', function () { + test('copy/merge groups', async () => { const part = createPart(); let groupAddedCounter = 0; @@ -312,40 +314,32 @@ suite('Editor groups service', () => { const input = new TestEditorInput(URI.file('foo/bar')); - return rootGroup.openEditor(input, EditorOptions.create({ pinned: true })).then(() => { - const rightGroup = part.addGroup(rootGroup, GroupDirection.RIGHT, { activate: true }); - const downGroup = part.copyGroup(rootGroup, rightGroup, GroupDirection.DOWN); - - assert.equal(groupAddedCounter, 2); - assert.equal(downGroup.count, 1); - assert.ok(downGroup.activeEditor instanceof TestEditorInput); - - part.mergeGroup(rootGroup, rightGroup, { mode: MergeGroupMode.COPY_EDITORS }); - assert.equal(rightGroup.count, 1); - assert.ok(rightGroup.activeEditor instanceof TestEditorInput); - - part.mergeGroup(rootGroup, rightGroup, { mode: MergeGroupMode.MOVE_EDITORS }); - assert.equal(rootGroup.count, 0); - - part.mergeGroup(rootGroup, downGroup); - assert.equal(groupRemovedCounter, 1); - assert.equal(rootGroupDisposed, true); - - groupAddedListener.dispose(); - groupRemovedListener.dispose(); - disposeListener.dispose(); - - part.dispose(); - }); + await rootGroup.openEditor(input, EditorOptions.create({ pinned: true })); + const rightGroup = part.addGroup(rootGroup, GroupDirection.RIGHT, { activate: true }); + const downGroup = part.copyGroup(rootGroup, rightGroup, GroupDirection.DOWN); + assert.equal(groupAddedCounter, 2); + assert.equal(downGroup.count, 1); + assert.ok(downGroup.activeEditor instanceof TestEditorInput); + part.mergeGroup(rootGroup, rightGroup, { mode: MergeGroupMode.COPY_EDITORS }); + assert.equal(rightGroup.count, 1); + assert.ok(rightGroup.activeEditor instanceof TestEditorInput); + part.mergeGroup(rootGroup, rightGroup, { mode: MergeGroupMode.MOVE_EDITORS }); + assert.equal(rootGroup.count, 0); + part.mergeGroup(rootGroup, downGroup); + assert.equal(groupRemovedCounter, 1); + assert.equal(rootGroupDisposed, true); + groupAddedListener.dispose(); + groupRemovedListener.dispose(); + disposeListener.dispose(); + part.dispose(); }); - test('whenRestored', () => { + test('whenRestored', async () => { const part = createPart(); - return part.whenRestored.then(() => { - assert.ok(true); - part.dispose(); - }); + await part.whenRestored; + assert.ok(true); + part.dispose(); }); test('options', () => { @@ -467,7 +461,7 @@ suite('Editor groups service', () => { part.dispose(); }); - test('openEditors / closeEditors', function () { + test('openEditors / closeEditors', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -475,20 +469,17 @@ suite('Editor groups service', () => { const input = new TestEditorInput(URI.file('foo/bar')); const inputInactive = new TestEditorInput(URI.file('foo/bar/inactive')); - return group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]).then(() => { - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input); - assert.equal(group.getEditor(1), inputInactive); - - return group.closeEditors([input, inputInactive]).then(() => { - assert.equal(group.isEmpty(), true); + await group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input); + assert.equal(group.getEditor(1), inputInactive); - part.dispose(); - }); - }); + await group.closeEditors([input, inputInactive]); + assert.equal(group.isEmpty(), true); + part.dispose(); }); - test('closeEditors (except one)', function () { + test('closeEditors (except one)', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -497,22 +488,19 @@ suite('Editor groups service', () => { const input2 = new TestEditorInput(URI.file('foo/bar2')); const input3 = new TestEditorInput(URI.file('foo/bar3')); - return group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]).then(() => { - assert.equal(group.count, 3); - assert.equal(group.getEditor(0), input1); - assert.equal(group.getEditor(1), input2); - assert.equal(group.getEditor(2), input3); + await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + assert.equal(group.count, 3); + assert.equal(group.getEditor(0), input1); + assert.equal(group.getEditor(1), input2); + assert.equal(group.getEditor(2), input3); - return group.closeEditors({ except: input2 }).then(() => { - assert.equal(group.count, 1); - assert.equal(group.getEditor(0), input2); - - part.dispose(); - }); - }); + await group.closeEditors({ except: input2 }); + assert.equal(group.count, 1); + assert.equal(group.getEditor(0), input2); + part.dispose(); }); - test('closeEditors (saved only)', function () { + test('closeEditors (saved only)', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -521,21 +509,18 @@ suite('Editor groups service', () => { const input2 = new TestEditorInput(URI.file('foo/bar2')); const input3 = new TestEditorInput(URI.file('foo/bar3')); - return group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]).then(() => { - assert.equal(group.count, 3); - assert.equal(group.getEditor(0), input1); - assert.equal(group.getEditor(1), input2); - assert.equal(group.getEditor(2), input3); - - return group.closeEditors({ savedOnly: true }).then(() => { - assert.equal(group.count, 0); + await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + assert.equal(group.count, 3); + assert.equal(group.getEditor(0), input1); + assert.equal(group.getEditor(1), input2); + assert.equal(group.getEditor(2), input3); - part.dispose(); - }); - }); + await group.closeEditors({ savedOnly: true }); + assert.equal(group.count, 0); + part.dispose(); }); - test('closeEditors (direction: right)', function () { + test('closeEditors (direction: right)', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -544,23 +529,20 @@ suite('Editor groups service', () => { const input2 = new TestEditorInput(URI.file('foo/bar2')); const input3 = new TestEditorInput(URI.file('foo/bar3')); - return group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]).then(() => { - assert.equal(group.count, 3); - assert.equal(group.getEditor(0), input1); - assert.equal(group.getEditor(1), input2); - assert.equal(group.getEditor(2), input3); - - return group.closeEditors({ direction: CloseDirection.RIGHT, except: input2 }).then(() => { - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input1); - assert.equal(group.getEditor(1), input2); + await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + assert.equal(group.count, 3); + assert.equal(group.getEditor(0), input1); + assert.equal(group.getEditor(1), input2); + assert.equal(group.getEditor(2), input3); - part.dispose(); - }); - }); + await group.closeEditors({ direction: CloseDirection.RIGHT, except: input2 }); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input1); + assert.equal(group.getEditor(1), input2); + part.dispose(); }); - test('closeEditors (direction: left)', function () { + test('closeEditors (direction: left)', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -569,23 +551,20 @@ suite('Editor groups service', () => { const input2 = new TestEditorInput(URI.file('foo/bar2')); const input3 = new TestEditorInput(URI.file('foo/bar3')); - return group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]).then(() => { - assert.equal(group.count, 3); - assert.equal(group.getEditor(0), input1); - assert.equal(group.getEditor(1), input2); - assert.equal(group.getEditor(2), input3); + await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + assert.equal(group.count, 3); + assert.equal(group.getEditor(0), input1); + assert.equal(group.getEditor(1), input2); + assert.equal(group.getEditor(2), input3); - return group.closeEditors({ direction: CloseDirection.LEFT, except: input2 }).then(() => { - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input2); - assert.equal(group.getEditor(1), input3); - - part.dispose(); - }); - }); + await group.closeEditors({ direction: CloseDirection.LEFT, except: input2 }); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input2); + assert.equal(group.getEditor(1), input3); + part.dispose(); }); - test('closeAllEditors', () => { + test('closeAllEditors', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -593,20 +572,17 @@ suite('Editor groups service', () => { const input = new TestEditorInput(URI.file('foo/bar')); const inputInactive = new TestEditorInput(URI.file('foo/bar/inactive')); - return group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]).then(() => { - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input); - assert.equal(group.getEditor(1), inputInactive); - - return group.closeAllEditors().then(() => { - assert.equal(group.isEmpty(), true); + await group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input); + assert.equal(group.getEditor(1), inputInactive); - part.dispose(); - }); - }); + await group.closeAllEditors(); + assert.equal(group.isEmpty(), true); + part.dispose(); }); - test('moveEditor (same group)', function () { + test('moveEditor (same group)', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -622,22 +598,19 @@ suite('Editor groups service', () => { } }); - return group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]).then(() => { - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input); - assert.equal(group.getEditor(1), inputInactive); - - group.moveEditor(inputInactive, group, { index: 0 }); - assert.equal(editorMoveCounter, 1); - assert.equal(group.getEditor(0), inputInactive); - assert.equal(group.getEditor(1), input); - - editorGroupChangeListener.dispose(); - part.dispose(); - }); + await group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input); + assert.equal(group.getEditor(1), inputInactive); + group.moveEditor(inputInactive, group, { index: 0 }); + assert.equal(editorMoveCounter, 1); + assert.equal(group.getEditor(0), inputInactive); + assert.equal(group.getEditor(1), input); + editorGroupChangeListener.dispose(); + part.dispose(); }); - test('moveEditor (across groups)', function () { + test('moveEditor (across groups)', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -647,23 +620,19 @@ suite('Editor groups service', () => { const input = new TestEditorInput(URI.file('foo/bar')); const inputInactive = new TestEditorInput(URI.file('foo/bar/inactive')); - return group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]).then(() => { - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input); - assert.equal(group.getEditor(1), inputInactive); - - group.moveEditor(inputInactive, rightGroup, { index: 0 }); - assert.equal(group.count, 1); - assert.equal(group.getEditor(0), input); - - assert.equal(rightGroup.count, 1); - assert.equal(rightGroup.getEditor(0), inputInactive); - - part.dispose(); - }); + await group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input); + assert.equal(group.getEditor(1), inputInactive); + group.moveEditor(inputInactive, rightGroup, { index: 0 }); + assert.equal(group.count, 1); + assert.equal(group.getEditor(0), input); + assert.equal(rightGroup.count, 1); + assert.equal(rightGroup.getEditor(0), inputInactive); + part.dispose(); }); - test('copyEditor (across groups)', function () { + test('copyEditor (across groups)', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -673,24 +642,20 @@ suite('Editor groups service', () => { const input = new TestEditorInput(URI.file('foo/bar')); const inputInactive = new TestEditorInput(URI.file('foo/bar/inactive')); - return group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]).then(() => { - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input); - assert.equal(group.getEditor(1), inputInactive); - - group.copyEditor(inputInactive, rightGroup, { index: 0 }); - assert.equal(group.count, 2); - assert.equal(group.getEditor(0), input); - assert.equal(group.getEditor(1), inputInactive); - - assert.equal(rightGroup.count, 1); - assert.equal(rightGroup.getEditor(0), inputInactive); - - part.dispose(); - }); + await group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input); + assert.equal(group.getEditor(1), inputInactive); + group.copyEditor(inputInactive, rightGroup, { index: 0 }); + assert.equal(group.count, 2); + assert.equal(group.getEditor(0), input); + assert.equal(group.getEditor(1), inputInactive); + assert.equal(rightGroup.count, 1); + assert.equal(rightGroup.getEditor(0), inputInactive); + part.dispose(); }); - test('replaceEditors', () => { + test('replaceEditors', async () => { const part = createPart(); const group = part.activeGroup; assert.equal(group.isEmpty(), true); @@ -698,17 +663,14 @@ suite('Editor groups service', () => { const input = new TestEditorInput(URI.file('foo/bar')); const inputInactive = new TestEditorInput(URI.file('foo/bar/inactive')); - return group.openEditor(input).then(() => { - assert.equal(group.count, 1); - assert.equal(group.getEditor(0), input); - - return group.replaceEditors([{ editor: input, replacement: inputInactive }]).then(() => { - assert.equal(group.count, 1); - assert.equal(group.getEditor(0), inputInactive); + await group.openEditor(input); + assert.equal(group.count, 1); + assert.equal(group.getEditor(0), input); - part.dispose(); - }); - }); + await group.replaceEditors([{ editor: input, replacement: inputInactive }]); + assert.equal(group.count, 1); + assert.equal(group.getEditor(0), inputInactive); + part.dispose(); }); test('find neighbour group (left/right)', function () { diff --git a/src/vs/workbench/services/editor/test/browser/editorService.test.ts b/src/vs/workbench/services/editor/test/browser/editorService.test.ts index a670ff43ae3ef..658a4a84e773a 100644 --- a/src/vs/workbench/services/editor/test/browser/editorService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorService.test.ts @@ -29,15 +29,17 @@ import { timeout } from 'vs/base/common/async'; import { toResource } from 'vs/base/test/common/utils'; import { IFileService } from 'vs/platform/files/common/files'; import { Disposable } from 'vs/base/common/lifecycle'; +import { ModesRegistry } from 'vs/editor/common/modes/modesRegistry'; +import { UntitledEditorModel } from 'vs/workbench/common/editor/untitledEditorModel'; export class TestEditorControl extends BaseEditor { constructor(@ITelemetryService telemetryService: ITelemetryService) { super('MyTestEditorForEditorService', NullTelemetryService, new TestThemeService(), new TestStorageService()); } - setInput(input: EditorInput, options: EditorOptions, token: CancellationToken): Promise { + async setInput(input: EditorInput, options: EditorOptions, token: CancellationToken): Promise { super.setInput(input, options, token); - return input.resolve().then(() => undefined); + await input.resolve(); } getId(): string { return 'MyTestEditorForEditorService'; } @@ -56,6 +58,8 @@ export class TestEditorInput extends EditorInput implements IFileEditorInput { setEncoding(encoding: string) { } getEncoding(): string { return null!; } setPreferredEncoding(encoding: string) { } + setMode(mode: string) { } + setPreferredMode(mode: string) { } getResource(): URI { return this.resource; } setForceOpenAsBinary(): void { } setFailToOpen(): void { @@ -75,7 +79,7 @@ class FileServiceProvider extends Disposable { } } -suite('Editor service', () => { +suite('EditorService', () => { function registerTestEditorInput(): void { Registry.as(Extensions.Editors).registerEditor(new EditorDescriptor(TestEditorControl, 'MyTestEditorForEditorService', 'My Test Editor For Next Editor Service'), new SyncDescriptor(TestEditorInput)); @@ -83,7 +87,7 @@ suite('Editor service', () => { registerTestEditorInput(); - test('basics', function () { + test('basics', async () => { const partInstantiator = workbenchInstantiationService(); const part = partInstantiator.createInstance(EditorPart); @@ -112,51 +116,49 @@ suite('Editor service', () => { didCloseEditorListenerCounter++; }); - return part.whenRestored.then(() => { - - // Open input - return service.openEditor(input, { pinned: true }).then(editor => { - assert.ok(editor instanceof TestEditorControl); - assert.equal(editor, service.activeControl); - assert.equal(input, service.activeEditor); - assert.equal(service.visibleControls.length, 1); - assert.equal(service.visibleControls[0], editor); - assert.ok(!service.activeTextEditorWidget); - assert.equal(service.visibleTextEditorWidgets.length, 0); - assert.equal(service.isOpen(input), true); - assert.equal(service.getOpened({ resource: input.getResource() }), input); - assert.equal(service.isOpen(input, part.activeGroup), true); - assert.equal(activeEditorChangeEventCounter, 1); - assert.equal(visibleEditorChangeEventCounter, 1); - - // Close input - return editor!.group!.closeEditor(input).then(() => { - assert.equal(didCloseEditorListenerCounter, 1); - assert.equal(activeEditorChangeEventCounter, 2); - assert.equal(visibleEditorChangeEventCounter, 2); - assert.ok(input.gotDisposed); - - // Open again 2 inputs - return service.openEditor(input, { pinned: true }).then(editor => { - return service.openEditor(otherInput, { pinned: true }).then(editor => { - assert.equal(service.visibleControls.length, 1); - assert.equal(service.isOpen(input), true); - assert.equal(service.isOpen(otherInput), true); - - assert.equal(activeEditorChangeEventCounter, 4); - assert.equal(visibleEditorChangeEventCounter, 4); - - activeEditorChangeListener.dispose(); - visibleEditorChangeListener.dispose(); - didCloseEditorListener.dispose(); - }); - }); - }); - }); - }); + await part.whenRestored; + + // Open input + let editor = await service.openEditor(input, { pinned: true }); + + assert.ok(editor instanceof TestEditorControl); + assert.equal(editor, service.activeControl); + assert.equal(input, service.activeEditor); + assert.equal(service.visibleControls.length, 1); + assert.equal(service.visibleControls[0], editor); + assert.ok(!service.activeTextEditorWidget); + assert.equal(service.visibleTextEditorWidgets.length, 0); + assert.equal(service.isOpen(input), true); + assert.equal(service.getOpened({ resource: input.getResource() }), input); + assert.equal(service.isOpen(input, part.activeGroup), true); + assert.equal(activeEditorChangeEventCounter, 1); + assert.equal(visibleEditorChangeEventCounter, 1); + + // Close input + await editor!.group!.closeEditor(input); + + assert.equal(didCloseEditorListenerCounter, 1); + assert.equal(activeEditorChangeEventCounter, 2); + assert.equal(visibleEditorChangeEventCounter, 2); + assert.ok(input.gotDisposed); + + // Open again 2 inputs + await service.openEditor(input, { pinned: true }); + editor = await service.openEditor(otherInput, { pinned: true }); + + assert.equal(service.visibleControls.length, 1); + assert.equal(service.isOpen(input), true); + assert.equal(service.isOpen(otherInput), true); + + assert.equal(activeEditorChangeEventCounter, 4); + assert.equal(visibleEditorChangeEventCounter, 4); + + activeEditorChangeListener.dispose(); + visibleEditorChangeListener.dispose(); + didCloseEditorListener.dispose(); }); - test('openEditors() / replaceEditors()', function () { + test('openEditors() / replaceEditors()', async () => { const partInstantiator = workbenchInstantiationService(); const part = partInstantiator.createInstance(EditorPart); @@ -171,18 +173,16 @@ suite('Editor service', () => { const otherInput = testInstantiationService.createInstance(TestEditorInput, URI.parse('my://resource2-openEditors')); const replaceInput = testInstantiationService.createInstance(TestEditorInput, URI.parse('my://resource3-openEditors')); - return part.whenRestored.then(() => { + await part.whenRestored; - // Open editors - return service.openEditors([{ editor: input }, { editor: otherInput }]).then(() => { - assert.equal(part.activeGroup.count, 2); + // Open editors + await service.openEditors([{ editor: input }, { editor: otherInput }]); + assert.equal(part.activeGroup.count, 2); - return service.replaceEditors([{ editor: input, replacement: replaceInput }], part.activeGroup).then(() => { - assert.equal(part.activeGroup.count, 2); - assert.equal(part.activeGroup.getIndexOfEditor(replaceInput), 0); - }); - }); - }); + // Replace editors + await service.replaceEditors([{ editor: input, replacement: replaceInput }], part.activeGroup); + assert.equal(part.activeGroup.count, 2); + assert.equal(part.activeGroup.getIndexOfEditor(replaceInput), 0); }); test('caching', function () { @@ -234,10 +234,15 @@ suite('Editor service', () => { assert.ok(!input1AgainAndAgain!.isDisposed()); }); - test('createInput', function () { + test('createInput', async function () { const instantiationService = workbenchInstantiationService(); const service: EditorService = instantiationService.createInstance(EditorService); + const mode = 'create-input-test'; + ModesRegistry.registerLanguage({ + id: mode, + }); + // Untyped Input (file) let input = service.createInput({ resource: toResource.call(this, '/index.html'), options: { selection: { startLineNumber: 1, startColumn: 1 } } }); assert(input instanceof FileEditorInput); @@ -250,6 +255,18 @@ suite('Editor service', () => { contentInput = input; assert.equal(contentInput.getPreferredEncoding(), 'utf16le'); + // Untyped Input (file, mode) + input = service.createInput({ resource: toResource.call(this, '/index.html'), mode }); + assert(input instanceof FileEditorInput); + contentInput = input; + assert.equal(contentInput.getPreferredMode(), mode); + + // Untyped Input (file, different mode) + input = service.createInput({ resource: toResource.call(this, '/index.html'), mode: 'text' }); + assert(input instanceof FileEditorInput); + contentInput = input; + assert.equal(contentInput.getPreferredMode(), 'text'); + // Untyped Input (untitled) input = service.createInput({ options: { selection: { startLineNumber: 1, startColumn: 1 } } }); assert(input instanceof UntitledEditorInput); @@ -257,6 +274,14 @@ suite('Editor service', () => { // Untyped Input (untitled with contents) input = service.createInput({ contents: 'Hello Untitled', options: { selection: { startLineNumber: 1, startColumn: 1 } } }); assert(input instanceof UntitledEditorInput); + let model = await input.resolve() as UntitledEditorModel; + assert.equal(model.textEditorModel!.getValue(), 'Hello Untitled'); + + // Untyped Input (untitled with mode) + input = service.createInput({ mode, options: { selection: { startLineNumber: 1, startColumn: 1 } } }); + assert(input instanceof UntitledEditorInput); + model = await input.resolve() as UntitledEditorModel; + assert.equal(model.getMode(), mode); // Untyped Input (untitled with file path) input = service.createInput({ resource: URI.file('/some/path.txt'), forceUntitled: true, options: { selection: { startLineNumber: 1, startColumn: 1 } } }); @@ -276,6 +301,10 @@ suite('Editor service', () => { assert.ok((input as UntitledEditorInput).hasAssociatedFilePath); provider.dispose(); + + // Untyped Input (resource) + input = service.createInput({ resource: URI.parse('custom:resource') }); + assert(input instanceof ResourceEditorInput); }); test('delegate', function (done) { @@ -298,7 +327,7 @@ suite('Editor service', () => { const ed = instantiationService.createInstance(MyEditor, 'my.editor'); - const inp = instantiationService.createInstance(ResourceEditorInput, 'name', 'description', URI.parse('my://resource-delegate')); + const inp = instantiationService.createInstance(ResourceEditorInput, 'name', 'description', URI.parse('my://resource-delegate'), undefined); const delegate = instantiationService.createInstance(DelegatingEditorService); delegate.setEditorOpenHandler((group: IEditorGroup, input: IEditorInput, options?: EditorOptions) => { assert.strictEqual(input, inp); @@ -311,7 +340,7 @@ suite('Editor service', () => { delegate.openEditor(inp); }); - test('close editor does not dispose when editor opened in other group', function () { + test('close editor does not dispose when editor opened in other group', async () => { const partInstantiator = workbenchInstantiationService(); const part = partInstantiator.createInstance(EditorPart); @@ -327,30 +356,26 @@ suite('Editor service', () => { const rootGroup = part.activeGroup; const rightGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); - return part.whenRestored.then(() => { - - // Open input - return service.openEditor(input, { pinned: true }).then(editor => { - return service.openEditor(input, { pinned: true }, rightGroup).then(editor => { - const editors = service.editors; - assert.equal(editors.length, 2); - assert.equal(editors[0], input); - assert.equal(editors[1], input); - - // Close input - return rootGroup.closeEditor(input).then(() => { - assert.equal(input.isDisposed(), false); - - return rightGroup.closeEditor(input).then(() => { - assert.equal(input.isDisposed(), true); - }); - }); - }); - }); - }); + await part.whenRestored; + + // Open input + await service.openEditor(input, { pinned: true }); + await service.openEditor(input, { pinned: true }, rightGroup); + + const editors = service.editors; + assert.equal(editors.length, 2); + assert.equal(editors[0], input); + assert.equal(editors[1], input); + + // Close input + await rootGroup.closeEditor(input); + assert.equal(input.isDisposed(), false); + + await rightGroup.closeEditor(input); + assert.equal(input.isDisposed(), true); }); - test('open to the side', function () { + test('open to the side', async () => { const partInstantiator = workbenchInstantiationService(); const part = partInstantiator.createInstance(EditorPart); @@ -366,22 +391,20 @@ suite('Editor service', () => { const rootGroup = part.activeGroup; - return part.whenRestored.then(() => { - return service.openEditor(input1, { pinned: true }, rootGroup).then(editor => { - return service.openEditor(input1, { pinned: true, preserveFocus: true }, SIDE_GROUP).then(editor => { - assert.equal(part.activeGroup, rootGroup); - assert.equal(part.count, 2); - assert.equal(editor!.group, part.groups[1]); - - // Open to the side uses existing neighbour group if any - return service.openEditor(input2, { pinned: true, preserveFocus: true }, SIDE_GROUP).then(editor => { - assert.equal(part.activeGroup, rootGroup); - assert.equal(part.count, 2); - assert.equal(editor!.group, part.groups[1]); - }); - }); - }); - }); + await part.whenRestored; + + await service.openEditor(input1, { pinned: true }, rootGroup); + let editor = await service.openEditor(input1, { pinned: true, preserveFocus: true }, SIDE_GROUP); + + assert.equal(part.activeGroup, rootGroup); + assert.equal(part.count, 2); + assert.equal(editor!.group, part.groups[1]); + + // Open to the side uses existing neighbour group if any + editor = await service.openEditor(input2, { pinned: true, preserveFocus: true }, SIDE_GROUP); + assert.equal(part.activeGroup, rootGroup); + assert.equal(part.count, 2); + assert.equal(editor!.group, part.groups[1]); }); test('active editor change / visible editor change events', async function () { diff --git a/src/vs/workbench/services/preferences/common/preferencesEditorInput.ts b/src/vs/workbench/services/preferences/common/preferencesEditorInput.ts index 002a49d799c56..ee813e14fe500 100644 --- a/src/vs/workbench/services/preferences/common/preferencesEditorInput.ts +++ b/src/vs/workbench/services/preferences/common/preferencesEditorInput.ts @@ -31,7 +31,7 @@ export class DefaultPreferencesEditorInput extends ResourceEditorInput { constructor(defaultSettingsResource: URI, @ITextModelService textModelResolverService: ITextModelService ) { - super(nls.localize('settingsEditorName', "Default Settings"), '', defaultSettingsResource, textModelResolverService); + super(nls.localize('settingsEditorName', "Default Settings"), '', defaultSettingsResource, undefined, textModelResolverService); } getTypeId(): string { diff --git a/src/vs/workbench/services/search/node/fileSearch.ts b/src/vs/workbench/services/search/node/fileSearch.ts index 612bd746dfbbb..a5c31f63549b5 100644 --- a/src/vs/workbench/services/search/node/fileSearch.ts +++ b/src/vs/workbench/services/search/node/fileSearch.ts @@ -22,6 +22,7 @@ import { URI } from 'vs/base/common/uri'; import { readdir } from 'vs/base/node/pfs'; import { IFileQuery, IFolderQuery, IProgressMessage, ISearchEngineStats, IRawFileMatch, ISearchEngine, ISearchEngineSuccess } from 'vs/workbench/services/search/common/search'; import { spawnRipgrepCmd } from './ripgrepFileSearch'; +import { prepareQuery } from 'vs/base/parts/quickopen/common/quickOpenScorer'; interface IDirectoryEntry { base: string; @@ -76,7 +77,7 @@ export class FileWalker { this.errors = []; if (this.filePattern) { - this.normalizedFilePatternLowercase = strings.stripWildcards(this.filePattern).toLowerCase(); + this.normalizedFilePatternLowercase = prepareQuery(this.filePattern).value; } this.globalExcludePattern = config.excludePattern && glob.parse(config.excludePattern); diff --git a/src/vs/workbench/services/search/node/rawSearchService.ts b/src/vs/workbench/services/search/node/rawSearchService.ts index 1d5ed2ca55e66..021aa7bb235f1 100644 --- a/src/vs/workbench/services/search/node/rawSearchService.ts +++ b/src/vs/workbench/services/search/node/rawSearchService.ts @@ -312,7 +312,7 @@ export class SearchService implements IRawSearchService { // Pattern match on results const results: IRawFileMatch[] = []; - const normalizedSearchValueLowercase = strings.stripWildcards(searchValue).toLowerCase(); + const normalizedSearchValueLowercase = prepareQuery(searchValue).value; for (const entry of cachedEntries) { // Check if this entry is a match for the search value diff --git a/src/vs/workbench/services/search/test/node/search.test.ts b/src/vs/workbench/services/search/test/node/search.test.ts index 34d9c2bad1c72..03fc9cde8601e 100644 --- a/src/vs/workbench/services/search/test/node/search.test.ts +++ b/src/vs/workbench/services/search/test/node/search.test.ts @@ -290,25 +290,25 @@ suite('FileSearchEngine', () => { }); }); - test('Files: NPE (CamelCase)', function (done: () => void) { - this.timeout(testTimeout); - const engine = new FileSearchEngine({ - type: QueryType.File, - folderQueries: ROOT_FOLDER_QUERY, - filePattern: 'NullPE' - }); - - let count = 0; - engine.search((result) => { - if (result) { - count++; - } - }, () => { }, (error) => { - assert.ok(!error); - assert.equal(count, 1); - done(); - }); - }); + // test('Files: NPE (CamelCase)', function (done: () => void) { + // this.timeout(testTimeout); + // const engine = new FileSearchEngine({ + // type: QueryType.File, + // folderQueries: ROOT_FOLDER_QUERY, + // filePattern: 'NullPE' + // }); + + // let count = 0; + // engine.search((result) => { + // if (result) { + // count++; + // } + // }, () => { }, (error) => { + // assert.ok(!error); + // assert.equal(count, 1); + // done(); + // }); + // }); test('Files: *.*', function (done: () => void) { this.timeout(testTimeout); diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index ecb2e2bab4a18..199e8c65bb692 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -9,7 +9,7 @@ import { Event, Emitter } from 'vs/base/common/event'; import { guessMimeTypes } from 'vs/base/common/mime'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { URI } from 'vs/base/common/uri'; -import { isUndefinedOrNull, withUndefinedAsNull } from 'vs/base/common/types'; +import { isUndefinedOrNull } from 'vs/base/common/types'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { ITextFileService, IAutoSaveConfiguration, ModelState, ITextFileEditorModel, ISaveOptions, ISaveErrorHandler, ISaveParticipant, StateChange, SaveReason, ITextFileStreamContent, ILoadOptions, LoadReason, IResolvedTextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles'; @@ -18,13 +18,12 @@ import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel' import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; import { IFileService, FileOperationError, FileOperationResult, CONTENT_CHANGE_EVENT_BUFFER_DELAY, FileChangesEvent, FileChangeType, IFileStatWithMetadata, ETAG_DISABLED } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IModeService, ILanguageSelection } from 'vs/editor/common/services/modeService'; +import { IModeService } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { RunOnceScheduler, timeout } from 'vs/base/common/async'; import { ITextBufferFactory } from 'vs/editor/common/model'; import { hash } from 'vs/base/common/hash'; -import { createTextBufferFactory } from 'vs/editor/common/model/textModel'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { isLinux } from 'vs/base/common/platform'; import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; @@ -33,6 +32,13 @@ import { isEqual, isEqualOrParent, extname, basename } from 'vs/base/common/reso import { onUnexpectedError } from 'vs/base/common/errors'; import { Schemas } from 'vs/base/common/network'; +export interface IBackupMetaData { + mtime: number; + size: number; + etag: string; + orphaned: boolean; +} + /** * The text file editor model listens to changes to its underlying code editor model and saves these changes through the file service back to the disk. */ @@ -57,15 +63,15 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil private resource: URI; - private contentEncoding: string; // encoding as reported from disk - private preferredEncoding: string; // encoding as chosen by the user + private contentEncoding: string; // encoding as reported from disk + private preferredEncoding: string; // encoding as chosen by the user + + private preferredMode: string; // mode as chosen by the user private versionId: number; private bufferSavedVersionId: number; private blockModelContentChange: boolean; - private createTextEditorModelPromise: Promise | null; - private lastResolvedDiskStat: IFileStatWithMetadata; private autoSaveAfterMillies?: number; @@ -88,6 +94,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil constructor( resource: URI, preferredEncoding: string, + preferredMode: string, @INotificationService private readonly notificationService: INotificationService, @IModeService modeService: IModeService, @IModelService modelService: IModelService, @@ -104,6 +111,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.resource = resource; this.preferredEncoding = preferredEncoding; + this.preferredMode = preferredMode; this.inOrphanMode = false; this.dirty = false; this.versionId = 0; @@ -199,18 +207,40 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil } private onFilesAssociationChange(): void { - if (!this.textEditorModel) { + if (!this.isResolved()) { return; } const firstLineText = this.getFirstLineText(this.textEditorModel); - const languageSelection = this.getOrCreateMode(this.modeService, undefined, firstLineText); + const languageSelection = this.getOrCreateMode(this.resource, this.modeService, this.preferredMode, firstLineText); this.modelService.setMode(this.textEditorModel, languageSelection); } - getVersionId(): number { - return this.versionId; + setMode(mode: string): void { + super.setMode(mode); + + this.preferredMode = mode; + } + + async backup(target = this.resource): Promise { + if (this.isResolved()) { + + // Only fill in model metadata if resource matches + let meta: IBackupMetaData | undefined = undefined; + if (isEqual(target, this.resource) && this.lastResolvedDiskStat) { + meta = { + mtime: this.lastResolvedDiskStat.mtime, + size: this.lastResolvedDiskStat.size, + etag: this.lastResolvedDiskStat.etag, + orphaned: this.inOrphanMode + }; + } + + return this.backupFileService.backupResource(target, this.createSnapshot(), this.versionId, meta); + } + + return Promise.resolve(); } async revert(soft?: boolean): Promise { @@ -245,7 +275,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil } } - load(options?: ILoadOptions): Promise { + async load(options?: ILoadOptions): Promise { this.logService.trace('load() - enter', this.resource); // It is very important to not reload the model when the model is dirty. @@ -254,44 +284,57 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil if (this.dirty || this.saveSequentializer.hasPendingSave()) { this.logService.trace('load() - exit - without loading because model is dirty or being saved', this.resource); - return Promise.resolve(this); + return this; } // Only for new models we support to load from backup - if (!this.textEditorModel && !this.createTextEditorModelPromise) { - return this.loadFromBackup(options); + if (!this.isResolved()) { + const backup = await this.backupFileService.loadBackupResource(this.resource); + + if (this.isResolved()) { + return this; // Make sure meanwhile someone else did not suceed in loading + } + + if (backup) { + try { + return await this.loadFromBackup(backup, options); + } catch (error) { + // ignore error and continue to load as file below + } + } } // Otherwise load from file resource return this.loadFromFile(options); } - private async loadFromBackup(options?: ILoadOptions): Promise { - const backup = await this.backupFileService.loadBackupResource(this.resource); + private async loadFromBackup(backup: URI, options?: ILoadOptions): Promise { - // Make sure meanwhile someone else did not suceed or start loading - if (this.createTextEditorModelPromise || this.textEditorModel) { - return this.createTextEditorModelPromise || this; - } + // Resolve actual backup contents + const resolvedBackup = await this.backupFileService.resolveBackupContent(backup); - // If we have a backup, continue loading with it - if (!!backup) { - const content: ITextFileStreamContent = { - resource: this.resource, - name: basename(this.resource), - mtime: Date.now(), - size: 0, - etag: ETAG_DISABLED, // always allow to save content restored from a backup (see https://github.com/Microsoft/vscode/issues/72343) - value: createTextBufferFactory(''), // will be filled later from backup - encoding: this.textFileService.encoding.getPreferredWriteEncoding(this.resource, this.preferredEncoding).encoding, - isReadonly: false - }; + if (this.isResolved()) { + return this; // Make sure meanwhile someone else did not suceed in loading + } - return this.loadWithContent(content, options, backup); + // Load with backup + this.loadFromContent({ + resource: this.resource, + name: basename(this.resource), + mtime: resolvedBackup.meta ? resolvedBackup.meta.mtime : Date.now(), + size: resolvedBackup.meta ? resolvedBackup.meta.size : 0, + etag: resolvedBackup.meta ? resolvedBackup.meta.etag : ETAG_DISABLED, // etag disabled if unknown! + value: resolvedBackup.value, + encoding: this.textFileService.encoding.getPreferredWriteEncoding(this.resource, this.preferredEncoding).encoding, + isReadonly: false + }, options, true /* from backup */); + + // Restore orphaned flag based on state + if (resolvedBackup.meta && resolvedBackup.meta.orphaned) { + this.setOrphaned(true); } - // Otherwise load from file - return this.loadFromFile(options); + return this; } private async loadFromFile(options?: ILoadOptions): Promise { @@ -321,12 +364,11 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // Clear orphaned state when loading was successful this.setOrphaned(false); - // Guard against the model having changed in the meantime - if (currentVersionId === this.versionId) { - return this.loadWithContent(content, options); + if (currentVersionId !== this.versionId) { + return this; // Make sure meanwhile someone else did not suceed loading } - return this; + return this.loadFromContent(content, options); } catch (error) { const result = error.fileOperationResult; @@ -356,33 +398,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil } } - private async loadWithContent(content: ITextFileStreamContent, options?: ILoadOptions, backup?: URI): Promise { - const model = await this.doLoadWithContent(content, backup); - - // Telemetry: We log the fileGet telemetry event after the model has been loaded to ensure a good mimetype - const settingsType = this.getTypeIfSettings(); - if (settingsType) { - /* __GDPR__ - "settingsRead" : { - "settingsType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetryService.publicLog('settingsRead', { settingsType }); // Do not log read to user settings.json and .vscode folder as a fileGet event as it ruins our JSON usage data - } else { - /* __GDPR__ - "fileGet" : { - "${include}": [ - "${FileTelemetryData}" - ] - } - */ - this.telemetryService.publicLog('fileGet', this.getTelemetryData(options && options.reason ? options.reason : LoadReason.OTHER)); - } - - return model; - } - - private doLoadWithContent(content: ITextFileStreamContent, backup?: URI): Promise { + private loadFromContent(content: ITextFileStreamContent, options?: ILoadOptions, fromBackup?: boolean): TextFileEditorModel { this.logService.trace('load() - resolved content', this.resource); // Update our resolved disk stat model @@ -409,21 +425,61 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil } // Update Existing Model - if (this.textEditorModel) { + if (this.isResolved()) { this.doUpdateTextModel(content.value); + } + + // Create New Model + else { + this.doCreateTextModel(content.resource, content.value, !!fromBackup); + } - return Promise.resolve(this); + // Telemetry: We log the fileGet telemetry event after the model has been loaded to ensure a good mimetype + const settingsType = this.getTypeIfSettings(); + if (settingsType) { + /* __GDPR__ + "settingsRead" : { + "settingsType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetryService.publicLog('settingsRead', { settingsType }); // Do not log read to user settings.json and .vscode folder as a fileGet event as it ruins our JSON usage data + } else { + /* __GDPR__ + "fileGet" : { + "${include}": [ + "${FileTelemetryData}" + ] + } + */ + this.telemetryService.publicLog('fileGet', this.getTelemetryData(options && options.reason ? options.reason : LoadReason.OTHER)); } - // Join an existing request to create the editor model to avoid race conditions - else if (this.createTextEditorModelPromise) { - this.logService.trace('load() - join existing text editor model promise', this.resource); + return this; + } + + private doCreateTextModel(resource: URI, value: ITextBufferFactory, fromBackup: boolean): void { + this.logService.trace('load() - created text editor model', this.resource); + + // Create model + this.createTextEditorModel(value, resource, this.preferredMode); - return this.createTextEditorModelPromise; + // We restored a backup so we have to set the model as being dirty + // We also want to trigger auto save if it is enabled to simulate the exact same behaviour + // you would get if manually making the model dirty (fixes https://github.com/Microsoft/vscode/issues/16977) + if (fromBackup) { + this.makeDirty(); + if (this.autoSaveAfterMilliesEnabled) { + this.doAutoSave(this.versionId); + } } - // Create New Model - return this.doCreateTextModel(content.resource, content.value, backup); + // Ensure we are not tracking a stale state + else { + this.setDirty(false); + } + + // Model Listeners + this.installModelListeners(); } private doUpdateTextModel(value: ITextBufferFactory): void { @@ -435,7 +491,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // Update model value in a block that ignores model content change events this.blockModelContentChange = true; try { - this.updateTextEditorModel(value); + this.updateTextEditorModel(value, this.preferredMode); } finally { this.blockModelContentChange = false; } @@ -444,44 +500,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.updateSavedVersionId(); } - private doCreateTextModel(resource: URI, value: ITextBufferFactory, backup: URI | undefined): Promise { - this.logService.trace('load() - created text editor model', this.resource); - - this.createTextEditorModelPromise = this.doLoadBackup(backup).then(backupContent => { - this.createTextEditorModelPromise = null; - - // Create model - const hasBackupContent = !!backupContent; - this.createTextEditorModel(backupContent ? backupContent : value, resource); - - // We restored a backup so we have to set the model as being dirty - // We also want to trigger auto save if it is enabled to simulate the exact same behaviour - // you would get if manually making the model dirty (fixes https://github.com/Microsoft/vscode/issues/16977) - if (hasBackupContent) { - this.makeDirty(); - if (this.autoSaveAfterMilliesEnabled) { - this.doAutoSave(this.versionId); - } - } - - // Ensure we are not tracking a stale state - else { - this.setDirty(false); - } - - // Model Listeners - this.installModelListeners(); - - return this; - }, error => { - this.createTextEditorModelPromise = null; - - return Promise.reject(error); - }); - - return this.createTextEditorModelPromise; - } - private installModelListeners(): void { // See https://github.com/Microsoft/vscode/issues/30189 @@ -489,27 +507,11 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // where `value` was captured in the content change listener closure scope. // Content Change - if (this.textEditorModel) { + if (this.isResolved()) { this._register(this.textEditorModel.onDidChangeContent(() => this.onModelContentChanged())); } } - private async doLoadBackup(backup: URI | undefined): Promise { - if (!backup) { - return null; - } - - try { - return withUndefinedAsNull(await this.backupFileService.resolveBackupContent(backup)); - } catch (error) { - return null; // ignore errors - } - } - - protected getOrCreateMode(modeService: IModeService, preferredModeIds: string | undefined, firstLineText?: string): ILanguageSelection { - return modeService.createByFilepathOrFirstLine(this.resource.fsPath, firstLineText); - } - private onModelContentChanged(): void { this.logService.trace(`onModelContentChanged() - enter`, this.resource); @@ -526,7 +528,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // In this case we clear the dirty flag and emit a SAVED event to indicate this state. // Note: we currently only do this check when auto-save is turned off because there you see // a dirty indicator that you want to get rid of when undoing to the saved version. - if (!this.autoSaveAfterMilliesEnabled && this.textEditorModel && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) { + if (!this.autoSaveAfterMilliesEnabled && this.isResolved() && this.textEditorModel.getAlternativeVersionId() === this.bufferSavedVersionId) { this.logService.trace('onModelContentChanged() - model content changed back to last saved version', this.resource); // Clear flags @@ -657,7 +659,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // Push all edit operations to the undo stack so that the user has a chance to // Ctrl+Z back to the saved version. We only do this when auto-save is turned off - if (!this.autoSaveAfterMilliesEnabled && this.textEditorModel) { + if (!this.autoSaveAfterMilliesEnabled && this.isResolved()) { this.textEditorModel.pushStackElement(); } @@ -687,7 +689,12 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // saving contents to disk that are stale (see https://github.com/Microsoft/vscode/issues/50942). // To fix this issue, we will not store the contents to disk when we got disposed. if (this.disposed) { - return undefined; + return; + } + + // We require a resolved model from this point on, since we are about to write data to disk. + if (!this.isResolved()) { + return; } // Under certain conditions we do a short-cut of flushing contents to disk when we can assume that @@ -713,11 +720,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // Save to Disk // mark the save operation as currently pending with the versionId (it might have changed from a save participant triggering) this.logService.trace(`doSave(${versionId}) - before write()`, this.resource); - const snapshot = this.createSnapshot(); - if (!snapshot) { - throw new Error('Invalid snapshot'); - } - return this.saveSequentializer.setPending(newVersionId, this.textFileService.write(this.lastResolvedDiskStat.resource, snapshot, { + return this.saveSequentializer.setPending(newVersionId, this.textFileService.write(this.lastResolvedDiskStat.resource, this.createSnapshot(), { overwriteReadonly: options.overwriteReadonly, overwriteEncoding: options.overwriteEncoding, mtime: this.lastResolvedDiskStat.mtime, @@ -850,12 +853,11 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil } private doTouch(versionId: number): Promise { - const snapshot = this.createSnapshot(); - if (!snapshot) { - throw new Error('invalid snapshot'); + if (!this.isResolved()) { + return Promise.resolve(); } - return this.saveSequentializer.setPending(versionId, this.textFileService.write(this.lastResolvedDiskStat.resource, snapshot, { + return this.saveSequentializer.setPending(versionId, this.textFileService.write(this.lastResolvedDiskStat.resource, this.createSnapshot(), { mtime: this.lastResolvedDiskStat.mtime, encoding: this.getEncoding(), etag: this.lastResolvedDiskStat.etag @@ -863,6 +865,10 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // Updated resolved stat with updated stat since touching it might have changed mtime this.updateLastResolvedDiskStat(stat); + + // Emit File Saved Event + this._onDidStateChange.fire(StateChange.SAVED); + }, error => onUnexpectedError(error) /* just log any error but do not notify the user since the file was not dirty */)); } @@ -896,7 +902,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // in order to find out if the model changed back to a saved version (e.g. // when undoing long enough to reach to a version that is saved and then to // clear the dirty flag) - if (this.textEditorModel) { + if (this.isResolved()) { this.bufferSavedVersionId = this.textEditorModel.getAlternativeVersionId(); } } @@ -935,10 +941,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil return this.lastSaveAttemptTime; } - getETag(): string | null { - return this.lastResolvedDiskStat ? this.lastResolvedDiskStat.etag || null : null; - } - hasState(state: ModelState): boolean { switch (state) { case ModelState.CONFLICT: @@ -1020,8 +1022,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil return true; } - isResolved(): boolean { - return !isUndefinedOrNull(this.lastResolvedDiskStat); + isResolved(): this is IResolvedTextFileEditorModel { + return !!this.textEditorModel; } isReadonly(): boolean { @@ -1046,8 +1048,6 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.inOrphanMode = false; this.inErrorMode = false; - this.createTextEditorModelPromise = null; - this.cancelPendingAutoSave(); super.dispose(); diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts index 1baca93526c51..af67898ef698b 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts @@ -153,7 +153,7 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE // Model does not exist else { - const newModel = model = this.instantiationService.createInstance(TextFileEditorModel, resource, options ? options.encoding : undefined); + const newModel = model = this.instantiationService.createInstance(TextFileEditorModel, resource, options ? options.encoding : undefined, options ? options.mode : undefined); modelPromise = model.load(options); // Install state change listener @@ -204,6 +204,11 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE // Remove from pending loads this.mapResourceToPendingModelLoaders.delete(resource); + // Apply mode if provided + if (options && options.mode) { + resolvedModel.setMode(options.mode); + } + return resolvedModel; } catch (error) { diff --git a/src/vs/workbench/services/textfile/common/textFileService.ts b/src/vs/workbench/services/textfile/common/textFileService.ts index 1c513e1bca5ec..f7884a3e7b2f8 100644 --- a/src/vs/workbench/services/textfile/common/textFileService.ts +++ b/src/vs/workbench/services/textfile/common/textFileService.ts @@ -40,6 +40,7 @@ import { trim } from 'vs/base/common/strings'; import { VSBuffer } from 'vs/base/common/buffer'; import { ITextSnapshot } from 'vs/editor/common/model'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/resourceConfiguration'; +import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; /** * The workbench file service implementation implements the raw file service spec and adds additional methods on top. @@ -238,59 +239,44 @@ export abstract class TextFileService extends Disposable implements ITextFileSer private async doBackupAll(dirtyFileModels: ITextFileEditorModel[], untitledResources: URI[]): Promise { // Handle file resources first - await Promise.all(dirtyFileModels.map(async model => { - const snapshot = model.createSnapshot(); - if (snapshot) { - await this.backupFileService.backupResource(model.getResource(), snapshot, model.getVersionId()); - } - })); + await Promise.all(dirtyFileModels.map(model => model.backup())); // Handle untitled resources - const untitledModelPromises = untitledResources + await Promise.all(untitledResources .filter(untitled => this.untitledEditorService.exists(untitled)) - .map(untitled => this.untitledEditorService.loadOrCreate({ resource: untitled })); - - const untitledModels = await Promise.all(untitledModelPromises); - - await Promise.all(untitledModels.map(async model => { - const snapshot = model.createSnapshot(); - if (snapshot) { - await this.backupFileService.backupResource(model.getResource(), snapshot, model.getVersionId()); - } - })); + .map(async untitled => (await this.untitledEditorService.loadOrCreate({ resource: untitled })).backup())); } - private confirmBeforeShutdown(): boolean | Promise { - return this.confirmSave().then(confirm => { + private async confirmBeforeShutdown(): Promise { + const confirm = await this.confirmSave(); - // Save - if (confirm === ConfirmResult.SAVE) { - return this.saveAll(true /* includeUntitled */, { skipSaveParticipants: true }).then(result => { - if (result.results.some(r => !r.success)) { - return true; // veto if some saves failed - } + // Save + if (confirm === ConfirmResult.SAVE) { + const result = await this.saveAll(true /* includeUntitled */, { skipSaveParticipants: true }); - return this.noVeto({ cleanUpBackups: true }); - }); + if (result.results.some(r => !r.success)) { + return true; // veto if some saves failed } - // Don't Save - else if (confirm === ConfirmResult.DONT_SAVE) { + return this.noVeto({ cleanUpBackups: true }); + } + + // Don't Save + else if (confirm === ConfirmResult.DONT_SAVE) { - // Make sure to revert untitled so that they do not restore - // see https://github.com/Microsoft/vscode/issues/29572 - this.untitledEditorService.revertAll(); + // Make sure to revert untitled so that they do not restore + // see https://github.com/Microsoft/vscode/issues/29572 + this.untitledEditorService.revertAll(); - return this.noVeto({ cleanUpBackups: true }); - } + return this.noVeto({ cleanUpBackups: true }); + } - // Cancel - else if (confirm === ConfirmResult.CANCEL) { - return true; // veto - } + // Cancel + else if (confirm === ConfirmResult.CANCEL) { + return true; // veto + } - return false; - }); + return false; } private noVeto(options: { cleanUpBackups: boolean }): boolean | Promise { @@ -503,10 +489,7 @@ export abstract class TextFileService extends Disposable implements ITextFileSer dirtyTargetModelUris.push(targetModelResource); // Backup dirty source model to the target resource it will become later - const snapshot = sourceModel.createSnapshot(); - if (snapshot) { - await this.backupFileService.backupResource(targetModelResource, snapshot, sourceModel.getVersionId()); - } + await sourceModel.backup(targetModelResource); })); } @@ -872,17 +855,12 @@ export abstract class TextFileService extends Disposable implements ITextFileSer // take over encoding, mode and model value from source model targetModel.updatePreferredEncoding(sourceModel.getEncoding()); - if (targetModel.textEditorModel) { - const snapshot = sourceModel.createSnapshot(); - if (snapshot) { - this.modelService.updateModel(targetModel.textEditorModel, createTextBufferFactoryFromSnapshot(snapshot)); - } + if (sourceModel.isResolved() && targetModel.isResolved()) { + this.modelService.updateModel(targetModel.textEditorModel, createTextBufferFactoryFromSnapshot(sourceModel.createSnapshot())); - if (sourceModel.textEditorModel) { - const language = sourceModel.textEditorModel.getLanguageIdentifier(); - if (language.id > 1) { - targetModel.textEditorModel.setMode(language); // only use if more specific than plain/text - } + const mode = sourceModel.textEditorModel.getLanguageIdentifier(); + if (mode.language !== PLAINTEXT_MODE_ID) { + targetModel.textEditorModel.setMode(mode); // only use if more specific than plain/text } } diff --git a/src/vs/workbench/services/textfile/common/textfiles.ts b/src/vs/workbench/services/textfile/common/textfiles.ts index 422ea39499b6a..f3db50a0396d0 100644 --- a/src/vs/workbench/services/textfile/common/textfiles.ts +++ b/src/vs/workbench/services/textfile/common/textfiles.ts @@ -6,7 +6,7 @@ import { URI } from 'vs/base/common/uri'; import { Event } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; -import { IEncodingSupport, ConfirmResult, IRevertOptions } from 'vs/workbench/common/editor'; +import { IEncodingSupport, ConfirmResult, IRevertOptions, IModeSupport } from 'vs/workbench/common/editor'; import { IBaseStatWithMetadata, IFileStatWithMetadata, IReadFileOptions, IWriteFileOptions, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; import { createDecorator, ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation'; import { ITextEditorModel } from 'vs/editor/common/services/resolverService'; @@ -367,6 +367,11 @@ export interface IModelLoadOrCreateOptions { */ reason?: LoadReason; + /** + * The language mode to use for the model text content. + */ + mode?: string; + /** * The encoding to use when resolving the model text content. */ @@ -443,19 +448,15 @@ export interface ILoadOptions { reason?: LoadReason; } -export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport { +export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport, IModeSupport { readonly onDidContentChange: Event; readonly onDidStateChange: Event; - getVersionId(): number; - getResource(): URI; hasState(state: ModelState): boolean; - getETag(): string | null; - updatePreferredEncoding(encoding: string): void; save(options?: ISaveOptions): Promise; @@ -464,16 +465,17 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport revert(soft?: boolean): Promise; - createSnapshot(): ITextSnapshot | null; + backup(target?: URI): Promise; isDirty(): boolean; - isResolved(): boolean; + isResolved(): this is IResolvedTextFileEditorModel; isDisposed(): boolean; } export interface IResolvedTextFileEditorModel extends ITextFileEditorModel { + readonly textEditorModel: ITextModel; createSnapshot(): ITextSnapshot; diff --git a/src/vs/workbench/services/textfile/node/textResourcePropertiesService.ts b/src/vs/workbench/services/textfile/node/textResourcePropertiesService.ts index e9a71315beac1..ce097d6fdf562 100644 --- a/src/vs/workbench/services/textfile/node/textResourcePropertiesService.ts +++ b/src/vs/workbench/services/textfile/node/textResourcePropertiesService.ts @@ -17,7 +17,7 @@ import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiatio export class TextResourcePropertiesService implements ITextResourcePropertiesService { - _serviceBrand: ServiceIdentifier; + _serviceBrand: ServiceIdentifier; private remoteEnvironment: IRemoteAgentEnvironment | null = null; diff --git a/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts b/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts index ac6f037067292..e22cbe034dbb2 100644 --- a/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts +++ b/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts @@ -14,6 +14,7 @@ import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/commo import { FileOperationResult, FileOperationError, IFileService } from 'vs/platform/files/common/files'; import { IModelService } from 'vs/editor/common/services/modelService'; import { timeout } from 'vs/base/common/async'; +import { ModesRegistry } from 'vs/editor/common/modes/modesRegistry'; class ServiceAccessor { constructor(@ITextFileService public textFileService: TestTextFileService, @IModelService public modelService: IModelService, @IFileService public fileService: TestFileService) { @@ -44,25 +45,53 @@ suite('Files - TextFileEditorModel', () => { accessor.fileService.setContent(content); }); - test('Save', async function () { - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + test('save', async function () { + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); await model.load(); model.textEditorModel!.setValue('bar'); assert.ok(getLastModifiedTime(model) <= Date.now()); + let savedEvent = false; + model.onDidStateChange(e => { + if (e === StateChange.SAVED) { + savedEvent = true; + } + }); + await model.save(); assert.ok(model.getLastSaveAttemptTime() <= Date.now()); assert.ok(!model.isDirty()); + assert.ok(savedEvent); + + model.dispose(); + assert.ok(!accessor.modelService.getModel(model.getResource())); + }); + + test('save - touching also emits saved event', async function () { + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); + + await model.load(); + + let savedEvent = false; + model.onDidStateChange(e => { + if (e === StateChange.SAVED) { + savedEvent = true; + } + }); + + await model.save({ force: true }); + + assert.ok(savedEvent); model.dispose(); assert.ok(!accessor.modelService.getModel(model.getResource())); }); test('setEncoding - encode', function () { - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); model.setEncoding('utf8', EncodingMode.Encode); // no-op assert.equal(getLastModifiedTime(model), -1); @@ -75,7 +104,7 @@ suite('Files - TextFileEditorModel', () => { }); test('setEncoding - decode', async function () { - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); model.setEncoding('utf16', EncodingMode.Decode); @@ -84,8 +113,24 @@ suite('Files - TextFileEditorModel', () => { model.dispose(); }); + test('create with mode', async function () { + const mode = 'text-file-model-test'; + ModesRegistry.registerLanguage({ + id: mode, + }); + + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', mode); + + await model.load(); + + assert.equal(model.textEditorModel!.getModeId(), mode); + + model.dispose(); + assert.ok(!accessor.modelService.getModel(model.getResource())); + }); + test('disposes when underlying model is destroyed', async function () { - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); await model.load(); @@ -94,7 +139,7 @@ suite('Files - TextFileEditorModel', () => { }); test('Load does not trigger save', async function () { - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index.txt'), 'utf8'); + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index.txt'), 'utf8', undefined); assert.ok(model.hasState(ModelState.SAVED)); model.onDidStateChange(e => { @@ -108,7 +153,7 @@ suite('Files - TextFileEditorModel', () => { }); test('Load returns dirty model as long as model is dirty', async function () { - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); await model.load(); model.textEditorModel!.setValue('foo'); @@ -123,7 +168,7 @@ suite('Files - TextFileEditorModel', () => { test('Revert', async function () { let eventCounter = 0; - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); model.onDidStateChange(e => { if (e === StateChange.REVERTED) { @@ -145,7 +190,7 @@ suite('Files - TextFileEditorModel', () => { test('Revert (soft)', async function () { let eventCounter = 0; - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); model.onDidStateChange(e => { if (e === StateChange.REVERTED) { @@ -165,7 +210,7 @@ suite('Files - TextFileEditorModel', () => { }); test('Load and undo turns model dirty', async function () { - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); await model.load(); accessor.fileService.setContent('Hello Change'); @@ -175,7 +220,7 @@ suite('Files - TextFileEditorModel', () => { }); test('File not modified error is handled gracefully', async function () { - let model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + let model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); await model.load(); @@ -190,7 +235,7 @@ suite('Files - TextFileEditorModel', () => { }); test('Load error is handled gracefully if model already exists', async function () { - let model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + let model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); await model.load(); accessor.textFileService.setResolveTextContentErrorOnce(new FileOperationError('error', FileOperationResult.FILE_NOT_FOUND)); @@ -236,7 +281,7 @@ suite('Files - TextFileEditorModel', () => { test('Save Participant', async function () { let eventCounter = 0; - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); model.onDidStateChange(e => { if (e === StateChange.SAVED) { @@ -266,7 +311,7 @@ suite('Files - TextFileEditorModel', () => { test('Save Participant, async participant', async function () { - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); TextFileEditorModel.setSaveParticipant({ participate: (model) => { @@ -284,7 +329,7 @@ suite('Files - TextFileEditorModel', () => { }); test('Save Participant, bad participant', async function () { - const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8'); + const model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); TextFileEditorModel.setSaveParticipant({ participate: (model) => { diff --git a/src/vs/workbench/services/textfile/test/textFileEditorModelManager.test.ts b/src/vs/workbench/services/textfile/test/textFileEditorModelManager.test.ts index 15be3b8533f0b..1b5f490f490e3 100644 --- a/src/vs/workbench/services/textfile/test/textFileEditorModelManager.test.ts +++ b/src/vs/workbench/services/textfile/test/textFileEditorModelManager.test.ts @@ -13,6 +13,7 @@ import { IFileService, FileChangesEvent, FileChangeType } from 'vs/platform/file import { IModelService } from 'vs/editor/common/services/modelService'; import { timeout } from 'vs/base/common/async'; import { toResource } from 'vs/base/test/common/utils'; +import { ModesRegistry, PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; export class TestTextFileEditorModelManager extends TextFileEditorModelManager { @@ -42,9 +43,9 @@ suite('Files - TextFileEditorModelManager', () => { test('add, remove, clear, get, getAll', function () { const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager); - const model1: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random1.txt'), 'utf8'); - const model2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random2.txt'), 'utf8'); - const model3: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random3.txt'), 'utf8'); + const model1: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random1.txt'), 'utf8', undefined); + const model2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random2.txt'), 'utf8', undefined); + const model3: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random3.txt'), 'utf8', undefined); manager.add(URI.file('/test.html'), model1); manager.add(URI.file('/some/other.html'), model2); @@ -117,9 +118,9 @@ suite('Files - TextFileEditorModelManager', () => { test('removed from cache when model disposed', function () { const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager); - const model1: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random1.txt'), 'utf8'); - const model2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random2.txt'), 'utf8'); - const model3: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random3.txt'), 'utf8'); + const model1: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random1.txt'), 'utf8', undefined); + const model2: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random2.txt'), 'utf8', undefined); + const model3: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/random3.txt'), 'utf8', undefined); manager.add(URI.file('/test.html'), model1); manager.add(URI.file('/some/other.html'), model2); @@ -290,4 +291,24 @@ suite('Files - TextFileEditorModelManager', () => { assert.ok(model.isDisposed()); manager.dispose(); }); + + test('mode', async function () { + const mode = 'text-file-model-manager-test'; + ModesRegistry.registerLanguage({ + id: mode, + }); + + const manager: TestTextFileEditorModelManager = instantiationService.createInstance(TestTextFileEditorModelManager); + + const resource = toResource.call(this, '/path/index_something.txt'); + + let model = await manager.loadOrCreate(resource, { mode }); + assert.equal(model.textEditorModel!.getModeId(), mode); + + model = await manager.loadOrCreate(resource, { mode: 'text' }); + assert.equal(model.textEditorModel!.getModeId(), PLAINTEXT_MODE_ID); + + manager.disposeModel((model as TextFileEditorModel)); + manager.dispose(); + }); }); \ No newline at end of file diff --git a/src/vs/workbench/services/textfile/test/textFileService.test.ts b/src/vs/workbench/services/textfile/test/textFileService.test.ts index 72049db9f118a..ce5dd5dd7727e 100644 --- a/src/vs/workbench/services/textfile/test/textFileService.test.ts +++ b/src/vs/workbench/services/textfile/test/textFileService.test.ts @@ -65,8 +65,8 @@ suite('Files - TextFileService', () => { accessor.untitledEditorService.revertAll(); }); - test('confirm onWillShutdown - no veto', function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + test('confirm onWillShutdown - no veto', async function () { + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const event = new BeforeShutdownEventImpl(); @@ -76,14 +76,12 @@ suite('Files - TextFileService', () => { if (typeof veto === 'boolean') { assert.ok(!veto); } else { - veto.then(veto => { - assert.ok(!veto); - }); + assert.ok(!(await veto)); } }); test('confirm onWillShutdown - veto if user cancels', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -99,7 +97,7 @@ suite('Files - TextFileService', () => { }); test('confirm onWillShutdown - no veto and backups cleaned up if user does not want to save (hot.exit: off)', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -125,7 +123,7 @@ suite('Files - TextFileService', () => { }); test('confirm onWillShutdown - save (hot.exit: off)', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -144,7 +142,7 @@ suite('Files - TextFileService', () => { }); test('isDirty/getDirty - files and untitled', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -171,7 +169,7 @@ suite('Files - TextFileService', () => { }); test('save - file', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -187,11 +185,11 @@ suite('Files - TextFileService', () => { test('save - UNC path', async function () { const untitledUncUri = URI.from({ scheme: 'untitled', authority: 'server', path: '/share/path/file.txt' }); - model = instantiationService.createInstance(TextFileEditorModel, untitledUncUri, 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, untitledUncUri, 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const mockedFileUri = untitledUncUri.with({ scheme: Schemas.file }); - const mockedEditorInput = instantiationService.createInstance(TextFileEditorModel, mockedFileUri, 'utf8'); + const mockedEditorInput = instantiationService.createInstance(TextFileEditorModel, mockedFileUri, 'utf8', undefined); const loadOrCreateStub = sinon.stub(accessor.textFileService.models, 'loadOrCreate', () => Promise.resolve(mockedEditorInput)); sinon.stub(accessor.untitledEditorService, 'exists', () => true); @@ -211,7 +209,7 @@ suite('Files - TextFileService', () => { }); test('saveAll - file', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -228,7 +226,7 @@ suite('Files - TextFileService', () => { }); test('saveAs - file', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -244,7 +242,7 @@ suite('Files - TextFileService', () => { }); test('revert - file', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -260,7 +258,7 @@ suite('Files - TextFileService', () => { }); test('delete - dirty file', async function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; @@ -274,8 +272,8 @@ suite('Files - TextFileService', () => { }); test('move - dirty file', async function () { - let sourceModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); - let targetModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file_target.txt'), 'utf8'); + let sourceModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); + let targetModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file_target.txt'), 'utf8', undefined); (accessor.textFileService.models).add(sourceModel.getResource(), sourceModel); (accessor.textFileService.models).add(targetModel.getResource(), targetModel); @@ -395,7 +393,7 @@ suite('Files - TextFileService', () => { }); async function hotExitTest(this: any, setting: string, shutdownReason: ShutdownReason, multipleWindows: boolean, workspace: true, shouldVeto: boolean): Promise { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8'); + model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file.txt'), 'utf8', undefined); (accessor.textFileService.models).add(model.getResource(), model); const service = accessor.textFileService; diff --git a/src/vs/workbench/services/textmodelResolver/test/textModelResolverService.test.ts b/src/vs/workbench/services/textmodelResolver/test/textModelResolverService.test.ts index 10e4b34c2fb7c..b924c55963df8 100644 --- a/src/vs/workbench/services/textmodelResolver/test/textModelResolverService.test.ts +++ b/src/vs/workbench/services/textmodelResolver/test/textModelResolverService.test.ts @@ -53,7 +53,7 @@ suite('Workbench - TextModelResolverService', () => { accessor.untitledEditorService.revertAll(); }); - test('resolve resource', function () { + test('resolve resource', async () => { const dispose = accessor.textModelResolverService.registerTextModelContentProvider('test', { provideTextContent: function (resource: URI): Promise { if (resource.scheme === 'test') { @@ -67,67 +67,60 @@ suite('Workbench - TextModelResolverService', () => { }); let resource = URI.from({ scheme: 'test', authority: null!, path: 'thePath' }); - let input: ResourceEditorInput = instantiationService.createInstance(ResourceEditorInput, 'The Name', 'The Description', resource); - - return input.resolve().then(async model => { - assert.ok(model); - assert.equal(snapshotToString((model as ResourceEditorModel).createSnapshot()!), 'Hello Test'); - - let disposed = false; - let disposedPromise = new Promise(resolve => { - Event.once(model.onDispose)(() => { - disposed = true; - resolve(); - }); + let input: ResourceEditorInput = instantiationService.createInstance(ResourceEditorInput, 'The Name', 'The Description', resource, undefined); + + const model = await input.resolve(); + assert.ok(model); + assert.equal(snapshotToString(((model as ResourceEditorModel).createSnapshot()!)), 'Hello Test'); + let disposed = false; + let disposedPromise = new Promise(resolve => { + Event.once(model.onDispose)(() => { + disposed = true; + resolve(); }); - input.dispose(); - await disposedPromise; - assert.equal(disposed, true); - - dispose.dispose(); }); + input.dispose(); + + await disposedPromise; + assert.equal(disposed, true); + dispose.dispose(); }); - test('resolve file', function () { - model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file_resolver.txt'), 'utf8'); - (accessor.textFileService.models).add(model.getResource(), model); + test('resolve file', async function () { + const textModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/file_resolver.txt'), 'utf8', undefined); + (accessor.textFileService.models).add(textModel.getResource(), textModel); - return model.load().then(() => { - return accessor.textModelResolverService.createModelReference(model.getResource()).then(ref => { - const model = ref.object; - const editorModel = model.textEditorModel; + await textModel.load(); - assert.ok(editorModel); - assert.equal(editorModel.getValue(), 'Hello Html'); + const ref = await accessor.textModelResolverService.createModelReference(textModel.getResource()); - let disposed = false; - Event.once(model.onDispose)(() => { - disposed = true; - }); + const model = ref.object; + const editorModel = model.textEditorModel; - ref.dispose(); - return timeout(0).then(() => { // due to the reference resolving the model first which is async - assert.equal(disposed, true); - }); - }); + assert.ok(editorModel); + assert.equal(editorModel.getValue(), 'Hello Html'); + + let disposed = false; + Event.once(model.onDispose)(() => { + disposed = true; }); + + ref.dispose(); + await timeout(0); // due to the reference resolving the model first which is async + assert.equal(disposed, true); }); - test('resolve untitled', function () { + test('resolve untitled', async () => { const service = accessor.untitledEditorService; const input = service.createOrGet(); - return input.resolve().then(() => { - return accessor.textModelResolverService.createModelReference(input.getResource()).then(ref => { - const model = ref.object; - const editorModel = model.textEditorModel; - - assert.ok(editorModel); - ref.dispose(); - - input.dispose(); - }); - }); + await input.resolve(); + const ref = await accessor.textModelResolverService.createModelReference(input.getResource()); + const model = ref.object; + const editorModel = model.textEditorModel; + assert.ok(editorModel); + ref.dispose(); + input.dispose(); }); test('even loading documents should be refcounted', async () => { @@ -135,12 +128,12 @@ suite('Workbench - TextModelResolverService', () => { let waitForIt = new Promise(c => resolveModel = c); const disposable = accessor.textModelResolverService.registerTextModelContentProvider('test', { - provideTextContent: (resource: URI): Promise => { - return waitForIt.then(_ => { - let modelContent = 'Hello Test'; - let languageSelection = accessor.modeService.create('json'); - return accessor.modelService.createModel(modelContent, languageSelection, resource); - }); + provideTextContent: async (resource: URI): Promise => { + await waitForIt; + + let modelContent = 'Hello Test'; + let languageSelection = accessor.modeService.create('json'); + return accessor.modelService.createModel(modelContent, languageSelection, resource); } }); diff --git a/src/vs/workbench/services/untitled/common/untitledEditorService.ts b/src/vs/workbench/services/untitled/common/untitledEditorService.ts index 6b343944f7c63..35b571ec52be6 100644 --- a/src/vs/workbench/services/untitled/common/untitledEditorService.ts +++ b/src/vs/workbench/services/untitled/common/untitledEditorService.ts @@ -21,7 +21,7 @@ export const IUntitledEditorService = createDecorator('u export interface IModelLoadOrCreateOptions { resource?: URI; - modeId?: string; + mode?: string; initialValue?: string; encoding?: string; useResourcePath?: boolean; @@ -29,7 +29,7 @@ export interface IModelLoadOrCreateOptions { export interface IUntitledEditorService { - _serviceBrand: any; + _serviceBrand: ServiceIdentifier; /** * Events for when untitled editors content changes (e.g. any keystroke). @@ -78,7 +78,7 @@ export interface IUntitledEditorService { * It is valid to pass in a file resource. In that case the path will be used as identifier. * The use case is to be able to create a new file with a specific path with VSCode. */ - createOrGet(resource?: URI, modeId?: string, initialValue?: string, encoding?: string): UntitledEditorInput; + createOrGet(resource?: URI, mode?: string, initialValue?: string, encoding?: string): UntitledEditorInput; /** * Creates a new untitled model with the optional resource URI or returns an existing one @@ -184,10 +184,10 @@ export class UntitledEditorService extends Disposable implements IUntitledEditor } loadOrCreate(options: IModelLoadOrCreateOptions = Object.create(null)): Promise { - return this.createOrGet(options.resource, options.modeId, options.initialValue, options.encoding, options.useResourcePath).resolve(); + return this.createOrGet(options.resource, options.mode, options.initialValue, options.encoding, options.useResourcePath).resolve(); } - createOrGet(resource?: URI, modeId?: string, initialValue?: string, encoding?: string, hasAssociatedFilePath: boolean = false): UntitledEditorInput { + createOrGet(resource?: URI, mode?: string, initialValue?: string, encoding?: string, hasAssociatedFilePath: boolean = false): UntitledEditorInput { if (resource) { // Massage resource if it comes with known file based resource @@ -207,44 +207,47 @@ export class UntitledEditorService extends Disposable implements IUntitledEditor } // Create new otherwise - return this.doCreate(resource, hasAssociatedFilePath, modeId, initialValue, encoding); + return this.doCreate(resource, hasAssociatedFilePath, mode, initialValue, encoding); } - private doCreate(resource?: URI, hasAssociatedFilePath?: boolean, modeId?: string, initialValue?: string, encoding?: string): UntitledEditorInput { - if (!resource) { + private doCreate(resource?: URI, hasAssociatedFilePath?: boolean, mode?: string, initialValue?: string, encoding?: string): UntitledEditorInput { + let untitledResource: URI; + if (resource) { + untitledResource = resource; + } else { // Create new taking a resource URI that is not already taken let counter = this.mapResourceToInput.size + 1; do { - resource = URI.from({ scheme: Schemas.untitled, path: `Untitled-${counter}` }); + untitledResource = URI.from({ scheme: Schemas.untitled, path: `Untitled-${counter}` }); counter++; - } while (this.mapResourceToInput.has(resource)); + } while (this.mapResourceToInput.has(untitledResource)); } // Look up default language from settings if any - if (!modeId && !hasAssociatedFilePath) { + if (!mode && !hasAssociatedFilePath) { const configuration = this.configurationService.getValue(); if (configuration.files && configuration.files.defaultLanguage) { - modeId = configuration.files.defaultLanguage; + mode = configuration.files.defaultLanguage; } } - const input = this.instantiationService.createInstance(UntitledEditorInput, resource, hasAssociatedFilePath, modeId, initialValue, encoding); + const input = this.instantiationService.createInstance(UntitledEditorInput, untitledResource, hasAssociatedFilePath, mode, initialValue, encoding); const contentListener = input.onDidModelChangeContent(() => { - this._onDidChangeContent.fire(resource!); + this._onDidChangeContent.fire(untitledResource); }); const dirtyListener = input.onDidChangeDirty(() => { - this._onDidChangeDirty.fire(resource!); + this._onDidChangeDirty.fire(untitledResource); }); const encodingListener = input.onDidModelChangeEncoding(() => { - this._onDidChangeEncoding.fire(resource!); + this._onDidChangeEncoding.fire(untitledResource); }); const disposeListener = input.onDispose(() => { - this._onDidDisposeModel.fire(resource!); + this._onDidDisposeModel.fire(untitledResource); }); // Remove from cache on dispose @@ -259,7 +262,7 @@ export class UntitledEditorService extends Disposable implements IUntitledEditor }); // Add to cache - this.mapResourceToInput.set(resource, input); + this.mapResourceToInput.set(untitledResource, input); return input; } diff --git a/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts b/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts index 4f7400dbc916c..c1ab20f25737e 100644 --- a/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts @@ -86,7 +86,7 @@ class MyResourceInput extends ResourceEditorInput { } suite('Workbench base editor', () => { - test('BaseEditor API', function () { + test('BaseEditor API', async () => { let e = new MyEditor(NullTelemetryService); let input = new MyOtherInput(); let options = new EditorOptions(); @@ -94,25 +94,24 @@ suite('Workbench base editor', () => { assert(!e.isVisible()); assert(!e.input); assert(!e.options); - return e.setInput(input, options, CancellationToken.None).then(() => { - assert.strictEqual(input, e.input); - assert.strictEqual(options, e.options); - - const group = new TestEditorGroup(1); - e.setVisible(true, group); - assert(e.isVisible()); - assert.equal(e.group, group); - input.onDispose(() => { - assert(false); - }); - e.dispose(); - e.clearInput(); - e.setVisible(false, group); - assert(!e.isVisible()); - assert(!e.input); - assert(!e.options); - assert(!e.getControl()); + + await e.setInput(input, options, CancellationToken.None); + assert.strictEqual(input, e.input); + assert.strictEqual(options, e.options); + const group = new TestEditorGroup(1); + e.setVisible(true, group); + assert(e.isVisible()); + assert.equal(e.group, group); + input.onDispose(() => { + assert(false); }); + e.dispose(); + e.clearInput(); + e.setVisible(false, group); + assert(!e.isVisible()); + assert(!e.input); + assert(!e.options); + assert(!e.getControl()); }); test('EditorDescriptor', () => { @@ -154,10 +153,10 @@ suite('Workbench base editor', () => { let inst = new TestInstantiationService(); - const editor = EditorRegistry.getEditor(inst.createInstance(MyResourceInput, 'fake', '', URI.file('/fake')))!.instantiate(inst); + const editor = EditorRegistry.getEditor(inst.createInstance(MyResourceInput, 'fake', '', URI.file('/fake'), undefined))!.instantiate(inst); assert.strictEqual(editor.getId(), 'myEditor'); - const otherEditor = EditorRegistry.getEditor(inst.createInstance(ResourceEditorInput, 'fake', '', URI.file('/fake')))!.instantiate(inst); + const otherEditor = EditorRegistry.getEditor(inst.createInstance(ResourceEditorInput, 'fake', '', URI.file('/fake'), undefined))!.instantiate(inst); assert.strictEqual(otherEditor.getId(), 'myOtherEditor'); (EditorRegistry).setEditors(oldEditors); @@ -173,7 +172,7 @@ suite('Workbench base editor', () => { let inst = new TestInstantiationService(); - const editor = EditorRegistry.getEditor(inst.createInstance(MyResourceInput, 'fake', '', URI.file('/fake')))!.instantiate(inst); + const editor = EditorRegistry.getEditor(inst.createInstance(MyResourceInput, 'fake', '', URI.file('/fake'), undefined))!.instantiate(inst); assert.strictEqual('myOtherEditor', editor.getId()); (EditorRegistry).setEditors(oldEditors); diff --git a/src/vs/workbench/test/common/editor/editorDiffModel.test.ts b/src/vs/workbench/test/common/editor/editorDiffModel.test.ts index 3023add68cab8..662bd56d2242d 100644 --- a/src/vs/workbench/test/common/editor/editorDiffModel.test.ts +++ b/src/vs/workbench/test/common/editor/editorDiffModel.test.ts @@ -35,7 +35,7 @@ suite('Workbench editor model', () => { accessor = instantiationService.createInstance(ServiceAccessor); }); - test('TextDiffEditorModel', () => { + test('TextDiffEditorModel', async () => { const dispose = accessor.textModelResolverService.registerTextModelContentProvider('test', { provideTextContent: function (resource: URI): Promise { if (resource.scheme === 'test') { @@ -48,27 +48,26 @@ suite('Workbench editor model', () => { } }); - let input = instantiationService.createInstance(ResourceEditorInput, 'name', 'description', URI.from({ scheme: 'test', authority: null!, path: 'thePath' })); - let otherInput = instantiationService.createInstance(ResourceEditorInput, 'name2', 'description', URI.from({ scheme: 'test', authority: null!, path: 'thePath' })); + let input = instantiationService.createInstance(ResourceEditorInput, 'name', 'description', URI.from({ scheme: 'test', authority: null!, path: 'thePath' }), undefined); + let otherInput = instantiationService.createInstance(ResourceEditorInput, 'name2', 'description', URI.from({ scheme: 'test', authority: null!, path: 'thePath' }), undefined); let diffInput = new DiffEditorInput('name', 'description', input, otherInput); - return diffInput.resolve().then((model: any) => { - assert(model); - assert(model instanceof TextDiffEditorModel); + let model = await diffInput.resolve() as TextDiffEditorModel; - let diffEditorModel = model.textDiffEditorModel; - assert(diffEditorModel.original); - assert(diffEditorModel.modified); + assert(model); + assert(model instanceof TextDiffEditorModel); - return diffInput.resolve().then((model: any) => { - assert(model.isResolved()); + let diffEditorModel = model.textDiffEditorModel!; + assert(diffEditorModel.original); + assert(diffEditorModel.modified); - assert(diffEditorModel !== model.textDiffEditorModel); - diffInput.dispose(); - assert(!model.textDiffEditorModel); + model = await diffInput.resolve() as TextDiffEditorModel; + assert(model.isResolved()); - dispose.dispose(); - }); - }); + assert(diffEditorModel !== model.textDiffEditorModel); + diffInput.dispose(); + assert(!model.textDiffEditorModel); + + dispose.dispose(); }); }); diff --git a/src/vs/workbench/test/common/editor/editorGroups.test.ts b/src/vs/workbench/test/common/editor/editorGroups.test.ts index fde39c097bee4..18d862a91ebc8 100644 --- a/src/vs/workbench/test/common/editor/editorGroups.test.ts +++ b/src/vs/workbench/test/common/editor/editorGroups.test.ts @@ -111,27 +111,17 @@ class TestFileEditorInput extends EditorInput implements IFileEditorInput { } getTypeId() { return 'testFileEditorInputForGroups'; } resolve(): Promise { return Promise.resolve(null!); } + setEncoding(encoding: string) { } + getEncoding(): string { return null!; } + setPreferredEncoding(encoding: string) { } + getResource(): URI { return this.resource; } + setForceOpenAsBinary(): void { } + setMode(mode: string) { } + setPreferredMode(mode: string) { } matches(other: TestFileEditorInput): boolean { return other && this.id === other.id && other instanceof TestFileEditorInput; } - - setEncoding(encoding: string) { - } - - getEncoding(): string { - return null!; - } - - setPreferredEncoding(encoding: string) { - } - - getResource(): URI { - return this.resource; - } - - setForceOpenAsBinary(): void { - } } function input(id = String(index++), nonSerializable?: boolean, resource?: URI): EditorInput { diff --git a/src/vs/workbench/test/common/editor/editorModel.test.ts b/src/vs/workbench/test/common/editor/editorModel.test.ts index 513d2783fa1f5..663f926850135 100644 --- a/src/vs/workbench/test/common/editor/editorModel.test.ts +++ b/src/vs/workbench/test/common/editor/editorModel.test.ts @@ -21,8 +21,8 @@ import { TestTextResourcePropertiesService } from 'vs/workbench/test/workbenchTe class MyEditorModel extends EditorModel { } class MyTextEditorModel extends BaseTextEditorModel { - public createTextEditorModel(value: ITextBufferFactory, resource?: URI, modeId?: string) { - return super.createTextEditorModel(value, resource, modeId); + public createTextEditorModel(value: ITextBufferFactory, resource?: URI, preferredMode?: string) { + return super.createTextEditorModel(value, resource, preferredMode); } isReadonly(): boolean { @@ -40,7 +40,7 @@ suite('Workbench editor model', () => { modeService = instantiationService.stub(IModeService, ModeServiceImpl); }); - test('EditorModel', () => { + test('EditorModel', async () => { let counter = 0; let m = new MyEditorModel(); @@ -50,25 +50,23 @@ suite('Workbench editor model', () => { counter++; }); - return m.load().then(model => { - assert(model === m); - assert.strictEqual(m.isResolved(), true); - m.dispose(); - assert.equal(counter, 1); - }); + const model = await m.load(); + assert(model === m); + assert.strictEqual(m.isResolved(), true); + m.dispose(); + assert.equal(counter, 1); }); - test('BaseTextEditorModel', () => { + test('BaseTextEditorModel', async () => { let modelService = stubModelService(instantiationService); let m = new MyTextEditorModel(modelService, modeService); - return m.load().then((model: MyTextEditorModel) => { - assert(model === m); - model.createTextEditorModel(createTextBufferFactory('foo'), null!, 'text/plain'); - assert.strictEqual(m.isResolved(), true); - }).then(() => { - m.dispose(); - }); + const model = await m.load() as MyTextEditorModel; + + assert(model === m); + model.createTextEditorModel(createTextBufferFactory('foo'), null!, 'text/plain'); + assert.strictEqual(m.isResolved(), true); + m.dispose(); }); function stubModelService(instantiationService: TestInstantiationService): IModelService { diff --git a/src/vs/workbench/test/common/editor/resourceEditorInput.test.ts b/src/vs/workbench/test/common/editor/resourceEditorInput.test.ts index 954b83d938431..d1e788e80f49c 100644 --- a/src/vs/workbench/test/common/editor/resourceEditorInput.test.ts +++ b/src/vs/workbench/test/common/editor/resourceEditorInput.test.ts @@ -12,17 +12,16 @@ import { workbenchInstantiationService } from 'vs/workbench/test/workbenchTestSe import { IModelService } from 'vs/editor/common/services/modelService'; import { IModeService } from 'vs/editor/common/services/modeService'; import { snapshotToString } from 'vs/workbench/services/textfile/common/textfiles'; +import { ModesRegistry, PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; class ServiceAccessor { constructor( @IModelService public modelService: IModelService, @IModeService public modeService: IModeService - ) { - } + ) { } } suite('Workbench resource editor input', () => { - let instantiationService: IInstantiationService; let accessor: ServiceAccessor; @@ -31,14 +30,33 @@ suite('Workbench resource editor input', () => { accessor = instantiationService.createInstance(ServiceAccessor); }); - test('simple', () => { - let resource = URI.from({ scheme: 'inmemory', authority: null!, path: 'thePath' }); + test('basics', async () => { + const resource = URI.from({ scheme: 'inmemory', authority: null!, path: 'thePath' }); accessor.modelService.createModel('function test() {}', accessor.modeService.create('text'), resource); - let input: ResourceEditorInput = instantiationService.createInstance(ResourceEditorInput, 'The Name', 'The Description', resource); - return input.resolve().then(model => { - assert.ok(model); - assert.equal(snapshotToString((model as ResourceEditorModel).createSnapshot()!), 'function test() {}'); + const input: ResourceEditorInput = instantiationService.createInstance(ResourceEditorInput, 'The Name', 'The Description', resource, undefined); + + const model = await input.resolve(); + + assert.ok(model); + assert.equal(snapshotToString(((model as ResourceEditorModel).createSnapshot()!)), 'function test() {}'); + }); + + test('custom mode', async () => { + ModesRegistry.registerLanguage({ + id: 'resource-input-test', }); + + const resource = URI.from({ scheme: 'inmemory', authority: null!, path: 'thePath' }); + accessor.modelService.createModel('function test() {}', accessor.modeService.create('text'), resource); + + const input: ResourceEditorInput = instantiationService.createInstance(ResourceEditorInput, 'The Name', 'The Description', resource, 'resource-input-test'); + + const model = await input.resolve(); + assert.ok(model); + assert.equal(model.textEditorModel.getModeId(), 'resource-input-test'); + + input.setMode('text'); + assert.equal(model.textEditorModel.getModeId(), PLAINTEXT_MODE_ID); }); }); \ No newline at end of file diff --git a/src/vs/workbench/test/common/editor/untitledEditor.test.ts b/src/vs/workbench/test/common/editor/untitledEditor.test.ts index 3dc06265b44bb..001ad7a6a2bc3 100644 --- a/src/vs/workbench/test/common/editor/untitledEditor.test.ts +++ b/src/vs/workbench/test/common/editor/untitledEditor.test.ts @@ -16,6 +16,7 @@ import { ModeServiceImpl } from 'vs/editor/common/services/modeServiceImpl'; import { UntitledEditorInput } from 'vs/workbench/common/editor/untitledEditorInput'; import { timeout } from 'vs/base/common/async'; import { snapshotToString } from 'vs/workbench/services/textfile/common/textfiles'; +import { ModesRegistry, PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; export class TestUntitledEditorService extends UntitledEditorService { get(resource: URI) { return super.get(resource); } @@ -45,7 +46,7 @@ suite('Workbench untitled editors', () => { accessor.untitledEditorService.dispose(); }); - test('Untitled Editor Service', function (done) { + test('Untitled Editor Service', async (done) => { const service = accessor.untitledEditorService; assert.equal(service.getAll().length, 0); @@ -68,36 +69,35 @@ suite('Workbench untitled editors', () => { assert.equal(service.getAll().length, 1); // dirty - input2.resolve().then(model => { - assert.ok(!service.isDirty(input2.getResource())); + const model = await input2.resolve(); - const listener = service.onDidChangeDirty(resource => { - listener.dispose(); + assert.ok(!service.isDirty(input2.getResource())); - assert.equal(resource.toString(), input2.getResource().toString()); + const listener = service.onDidChangeDirty(resource => { + listener.dispose(); - assert.ok(service.isDirty(input2.getResource())); - assert.equal(service.getDirty()[0].toString(), input2.getResource().toString()); - assert.equal(service.getDirty([input2.getResource()])[0].toString(), input2.getResource().toString()); - assert.equal(service.getDirty([input1.getResource()]).length, 0); + assert.equal(resource.toString(), input2.getResource().toString()); - service.revertAll(); - assert.equal(service.getAll().length, 0); - assert.ok(!input2.isDirty()); - assert.ok(!model.isDirty()); + assert.ok(service.isDirty(input2.getResource())); + assert.equal(service.getDirty()[0].toString(), input2.getResource().toString()); + assert.equal(service.getDirty([input2.getResource()])[0].toString(), input2.getResource().toString()); + assert.equal(service.getDirty([input1.getResource()]).length, 0); - input2.dispose(); + service.revertAll(); + assert.equal(service.getAll().length, 0); + assert.ok(!input2.isDirty()); + assert.ok(!model.isDirty()); - assert.ok(!service.exists(input2.getResource())); + input2.dispose(); - done(); - }); + assert.ok(!service.exists(input2.getResource())); + done(); + }); - model.textEditorModel.setValue('foo bar'); - }, err => done(err)); + model.textEditorModel.setValue('foo bar'); }); - test('Untitled with associated resource', function () { + test('Untitled with associated resource', () => { const service = accessor.untitledEditorService; const file = URI.file(join('C:\\', '/foo/file.txt')); const untitled = service.createOrGet(file); @@ -107,53 +107,49 @@ suite('Workbench untitled editors', () => { untitled.dispose(); }); - test('Untitled no longer dirty when content gets empty', function () { + test('Untitled no longer dirty when content gets empty', async () => { const service = accessor.untitledEditorService; const input = service.createOrGet(); // dirty - return input.resolve().then(model => { - model.textEditorModel.setValue('foo bar'); - assert.ok(model.isDirty()); - - model.textEditorModel.setValue(''); - assert.ok(!model.isDirty()); - - input.dispose(); - }); + const model = await input.resolve(); + model.textEditorModel.setValue('foo bar'); + assert.ok(model.isDirty()); + model.textEditorModel.setValue(''); + assert.ok(!model.isDirty()); + input.dispose(); }); - test('Untitled via loadOrCreate', function () { + test('Untitled via loadOrCreate', async () => { const service = accessor.untitledEditorService; - service.loadOrCreate().then(model1 => { - model1.textEditorModel!.setValue('foo bar'); - assert.ok(model1.isDirty()); - - model1.textEditorModel!.setValue(''); - assert.ok(!model1.isDirty()); - - return service.loadOrCreate({ initialValue: 'Hello World' }).then(model2 => { - assert.equal(snapshotToString(model2.createSnapshot()!), 'Hello World'); - - const input = service.createOrGet(); - - return service.loadOrCreate({ resource: input.getResource() }).then(model3 => { - assert.equal(model3.getResource().toString(), input.getResource().toString()); - - const file = URI.file(join('C:\\', '/foo/file44.txt')); - return service.loadOrCreate({ resource: file }).then(model4 => { - assert.ok(service.hasAssociatedFilePath(model4.getResource())); - assert.ok(model4.isDirty()); - - model1.dispose(); - model2.dispose(); - model3.dispose(); - model4.dispose(); - input.dispose(); - }); - }); - }); - }); + + const model1 = await service.loadOrCreate(); + + model1.textEditorModel!.setValue('foo bar'); + assert.ok(model1.isDirty()); + + model1.textEditorModel!.setValue(''); + assert.ok(!model1.isDirty()); + + const model2 = await service.loadOrCreate({ initialValue: 'Hello World' }); + assert.equal(snapshotToString(model2.createSnapshot()!), 'Hello World'); + + const input = service.createOrGet(); + + const model3 = await service.loadOrCreate({ resource: input.getResource() }); + + assert.equal(model3.getResource().toString(), input.getResource().toString()); + + const file = URI.file(join('C:\\', '/foo/file44.txt')); + const model4 = await service.loadOrCreate({ resource: file }); + assert.ok(service.hasAssociatedFilePath(model4.getResource())); + assert.ok(model4.isDirty()); + + model1.dispose(); + model2.dispose(); + model3.dispose(); + model4.dispose(); + input.dispose(); }); test('Untitled suggest name', function () { @@ -163,24 +159,31 @@ suite('Workbench untitled editors', () => { assert.ok(service.suggestFileName(input.getResource())); }); - test('Untitled with associated path remains dirty when content gets empty', function () { + test('Untitled with associated path remains dirty when content gets empty', async () => { const service = accessor.untitledEditorService; const file = URI.file(join('C:\\', '/foo/file.txt')); const input = service.createOrGet(file); // dirty - return input.resolve().then(model => { - model.textEditorModel.setValue('foo bar'); - assert.ok(model.isDirty()); + const model = await input.resolve(); + model.textEditorModel.setValue('foo bar'); + assert.ok(model.isDirty()); + model.textEditorModel.setValue(''); + assert.ok(model.isDirty()); + input.dispose(); + }); - model.textEditorModel.setValue(''); - assert.ok(model.isDirty()); + test('Untitled with initial content is dirty', async () => { + const service = accessor.untitledEditorService; + const input = service.createOrGet(undefined, undefined, 'Hello World'); - input.dispose(); - }); + // dirty + const model = await input.resolve(); + assert.ok(model.isDirty()); + input.dispose(); }); - test('Untitled created with files.defaultLanguage setting', function () { + test('Untitled created with files.defaultLanguage setting', () => { const defaultLanguage = 'javascript'; const config = accessor.testConfigurationService; config.setUserConfiguration('files', { 'defaultLanguage': defaultLanguage }); @@ -188,30 +191,52 @@ suite('Workbench untitled editors', () => { const service = accessor.untitledEditorService; const input = service.createOrGet(); - assert.equal(input.getModeId(), defaultLanguage); + assert.equal(input.getMode(), defaultLanguage); config.setUserConfiguration('files', { 'defaultLanguage': undefined }); input.dispose(); }); - test('Untitled created with modeId overrides files.defaultLanguage setting', function () { - const modeId = 'typescript'; + test('Untitled created with mode overrides files.defaultLanguage setting', () => { + const mode = 'typescript'; const defaultLanguage = 'javascript'; const config = accessor.testConfigurationService; config.setUserConfiguration('files', { 'defaultLanguage': defaultLanguage }); const service = accessor.untitledEditorService; - const input = service.createOrGet(null!, modeId); + const input = service.createOrGet(null!, mode); - assert.equal(input.getModeId(), modeId); + assert.equal(input.getMode(), mode); config.setUserConfiguration('files', { 'defaultLanguage': undefined }); input.dispose(); }); - test('encoding change event', function () { + test('Untitled can change mode afterwards', async () => { + const mode = 'untitled-input-test'; + + ModesRegistry.registerLanguage({ + id: mode, + }); + + const service = accessor.untitledEditorService; + const input = service.createOrGet(null!, mode); + + assert.equal(input.getMode(), mode); + + const model = await input.resolve(); + assert.equal(model.getMode(), mode); + + input.setMode('text'); + + assert.equal(input.getMode(), PLAINTEXT_MODE_ID); + + input.dispose(); + }); + + test('encoding change event', async () => { const service = accessor.untitledEditorService; const input = service.createOrGet(); @@ -223,16 +248,13 @@ suite('Workbench untitled editors', () => { }); // dirty - return input.resolve().then(model => { - model.setEncoding('utf16'); - - assert.equal(counter, 1); - - input.dispose(); - }); + const model = await input.resolve(); + model.setEncoding('utf16'); + assert.equal(counter, 1); + input.dispose(); }); - test('onDidChangeContent event', () => { + test('onDidChangeContent event', async () => { const service = accessor.untitledEditorService; const input = service.createOrGet(); @@ -245,39 +267,32 @@ suite('Workbench untitled editors', () => { assert.equal(r.toString(), input.getResource().toString()); }); - return input.resolve().then(model => { - model.textEditorModel.setValue('foo'); - assert.equal(counter, 0, 'Dirty model should not trigger event immediately'); + const model = await input.resolve(); + model.textEditorModel.setValue('foo'); + assert.equal(counter, 0, 'Dirty model should not trigger event immediately'); - return timeout(3).then(() => { - assert.equal(counter, 1, 'Dirty model should trigger event'); + await timeout(3); + assert.equal(counter, 1, 'Dirty model should trigger event'); + model.textEditorModel.setValue('bar'); - model.textEditorModel.setValue('bar'); - return timeout(3).then(() => { - assert.equal(counter, 2, 'Content change when dirty should trigger event'); + await timeout(3); + assert.equal(counter, 2, 'Content change when dirty should trigger event'); + model.textEditorModel.setValue(''); - model.textEditorModel.setValue(''); - return timeout(3).then(() => { - assert.equal(counter, 3, 'Manual revert should trigger event'); + await timeout(3); + assert.equal(counter, 3, 'Manual revert should trigger event'); + model.textEditorModel.setValue('foo'); - model.textEditorModel.setValue('foo'); - return timeout(3).then(() => { - assert.equal(counter, 4, 'Dirty model should trigger event'); + await timeout(3); + assert.equal(counter, 4, 'Dirty model should trigger event'); + model.revert(); - model.revert(); - return timeout(3).then(() => { - assert.equal(counter, 5, 'Revert should trigger event'); - - input.dispose(); - }); - }); - }); - }); - }); - }); + await timeout(3); + assert.equal(counter, 5, 'Revert should trigger event'); + input.dispose(); }); - test('onDidDisposeModel event', () => { + test('onDidDisposeModel event', async () => { const service = accessor.untitledEditorService; const input = service.createOrGet(); @@ -288,10 +303,9 @@ suite('Workbench untitled editors', () => { assert.equal(r.toString(), input.getResource().toString()); }); - return input.resolve().then(model => { - assert.equal(counter, 0); - input.dispose(); - assert.equal(counter, 1); - }); + await input.resolve(); + assert.equal(counter, 0); + input.dispose(); + assert.equal(counter, 1); }); }); \ No newline at end of file diff --git a/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts b/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts index 54cced7af7024..502d3fae8e908 100644 --- a/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts +++ b/src/vs/workbench/test/electron-browser/api/mainThreadSaveParticipant.test.ts @@ -37,7 +37,7 @@ suite('MainThreadSaveParticipant', function () { }); test('insert final new line', async function () { - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/final_new_line.txt'), 'utf8') as IResolvedTextFileEditorModel; + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel; await model.load(); const configService = new TestConfigurationService(); @@ -70,7 +70,7 @@ suite('MainThreadSaveParticipant', function () { }); test('trim final new lines', async function () { - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8') as IResolvedTextFileEditorModel; + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel; await model.load(); const configService = new TestConfigurationService(); @@ -105,7 +105,7 @@ suite('MainThreadSaveParticipant', function () { }); test('trim final new lines bug#39750', async function () { - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8') as IResolvedTextFileEditorModel; + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel; await model.load(); const configService = new TestConfigurationService(); @@ -132,7 +132,7 @@ suite('MainThreadSaveParticipant', function () { }); test('trim final new lines bug#46075', async function () { - const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8') as IResolvedTextFileEditorModel; + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel; await model.load(); const configService = new TestConfigurationService(); diff --git a/src/vs/workbench/test/workbenchTestServices.ts b/src/vs/workbench/test/workbenchTestServices.ts index 1e00736484cfd..b917b7ac10af0 100644 --- a/src/vs/workbench/test/workbenchTestServices.ts +++ b/src/vs/workbench/test/workbenchTestServices.ts @@ -15,7 +15,7 @@ import { ConfirmResult, IEditorInputWithOptions, CloseDirection, IEditorIdentifi import { IEditorOpeningEvent, EditorServiceImpl, IEditorGroupView } from 'vs/workbench/browser/parts/editor/editor'; import { Event, Emitter } from 'vs/base/common/event'; import Severity from 'vs/base/common/severity'; -import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; +import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IWorkbenchLayoutService, Parts, Position as PartPosition } from 'vs/workbench/services/layout/browser/layoutService'; import { TextModelResolverService } from 'vs/workbench/services/textmodelResolver/common/textModelResolverService'; @@ -85,7 +85,7 @@ import { VSBuffer, VSBufferReadable } from 'vs/base/common/buffer'; import { BrowserTextFileService } from 'vs/workbench/services/textfile/browser/textFileService'; export function createFileInput(instantiationService: IInstantiationService, resource: URI): FileEditorInput { - return instantiationService.createInstance(FileEditorInput, resource, undefined); + return instantiationService.createInstance(FileEditorInput, resource, undefined, undefined); } export const TestEnvironmentService = new WorkbenchEnvironmentService(parseArgs(process.argv) as IWindowConfiguration, process.execPath); @@ -1093,7 +1093,7 @@ export class TestBackupFileService implements IBackupFileService { throw new Error('not implemented'); } - public backupResource(_resource: URI, _content: ITextSnapshot): Promise { + public backupResource(_resource: URI, _content: ITextSnapshot, versionId?: number, meta?: T): Promise { return Promise.resolve(); } @@ -1108,7 +1108,7 @@ export class TestBackupFileService implements IBackupFileService { return textBuffer.getValueInRange(range, EndOfLinePreference.TextDefined); } - public resolveBackupContent(_backup: URI): Promise { + public resolveBackupContent(_backup: URI): Promise> { throw new Error('not implemented'); }