diff --git a/.yarnrc b/.yarnrc index c45abdbacad..98712ee2b43 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,3 +1,3 @@ disturl "https://atom.io/download/electron" -target "4.2.7" +target "4.2.9" runtime "electron" diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index b4c8db80ad7..189cf4f560e 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -365,12 +365,15 @@ function packageTask(platform, arch, sourceFolderName, destinationFolderName, op .pipe(electron(_.extend({}, config, { platform, arch, ffmpegChromium: true }))) .pipe(filter(['**', '!LICENSE', '!LICENSES.chromium.html', '!version'], { dot: true })); - result = es.merge(result, gulp.src('resources/completions/bash/code', { base: '.' }) - .pipe(replace('@@APPNAME@@', product.applicationName)) - .pipe(rename(function (f) { f.basename = product.applicationName; }))); - result = es.merge(result, gulp.src('resources/completions/zsh/_code', { base: '.' }) - .pipe(replace('@@APPNAME@@', product.applicationName)) - .pipe(rename(function (f) { f.basename = '_' + product.applicationName; }))); + if (platform === 'linux') { + result = es.merge(result, gulp.src('resources/completions/bash/code', { base: '.' }) + .pipe(replace('@@APPNAME@@', product.applicationName)) + .pipe(rename(function (f) { f.basename = product.applicationName; }))); + + result = es.merge(result, gulp.src('resources/completions/zsh/_code', { base: '.' }) + .pipe(replace('@@APPNAME@@', product.applicationName)) + .pipe(rename(function (f) { f.basename = '_' + product.applicationName; }))); + } if (platform === 'win32') { result = es.merge(result, gulp.src('resources/win32/bin/code.js', { base: 'resources/win32', allowEmpty: true })); diff --git a/cgmanifest.json b/cgmanifest.json index 638303702ae..5ba2e166267 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -60,12 +60,12 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "36ea114ac0616e469e75ae94e6d53af48925e036" + "commitHash": "3d4d6454007f14fa9a5f0e1fa49206fb91b676cc" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "4.2.7" + "version": "4.2.9" }, { "component": { diff --git a/extensions/json/package.json b/extensions/json/package.json index 1606f6a3d64..e23e86b14a6 100644 --- a/extensions/json/package.json +++ b/extensions/json/package.json @@ -50,7 +50,8 @@ ".babelrc", ".jsonc", ".eslintrc", - ".eslintrc.json" + ".eslintrc.json", + "tslint.json" ], "configuration": "./language-configuration.json" } @@ -74,4 +75,4 @@ } ] } -} \ No newline at end of file +} 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 3617b55f41c..738e1406834 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { createRandomFile, deleteFile, closeAllEditors, pathEquals, rndName, disposeAll, testFs } from '../utils'; +import { createRandomFile, deleteFile, closeAllEditors, pathEquals, rndName, disposeAll, testFs, delay } from '../utils'; import { join, posix, basename } from 'path'; import * as fs from 'fs'; @@ -587,13 +587,15 @@ suite('workspace-namespace', () => { }, cancellation.token); }); - test('applyEdit', () => { + test('applyEdit', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.parse('untitled:' + join(vscode.workspace.rootPath || '', './new2.txt'))); - return vscode.workspace.openTextDocument(vscode.Uri.parse('untitled:' + join(vscode.workspace.rootPath || '', './new2.txt'))).then(doc => { - let edit = new vscode.WorkspaceEdit(); - edit.insert(doc.uri, new vscode.Position(0, 0), new Array(1000).join('Hello World')); - return vscode.workspace.applyEdit(edit); - }); + let edit = new vscode.WorkspaceEdit(); + edit.insert(doc.uri, new vscode.Position(0, 0), new Array(1000).join('Hello World')); + + let success = await vscode.workspace.applyEdit(edit); + assert.equal(success, true); + assert.equal(doc.isDirty, true); }); test('applyEdit should fail when editing deleted resource', async () => { @@ -630,19 +632,31 @@ suite('workspace-namespace', () => { }); test('applyEdit "edit A -> rename A to B -> edit B"', async () => { + await testEditRenameEdit(oldUri => oldUri.with({ path: oldUri.path + 'NEW' })); + }); + + test('applyEdit "edit A -> rename A to B (different case)" -> edit B', async () => { + await testEditRenameEdit(oldUri => oldUri.with({ path: oldUri.path.toUpperCase() })); + }); + + test('applyEdit "edit A -> rename A to B (same case)" -> edit B', async () => { + await testEditRenameEdit(oldUri => oldUri); + }); + + async function testEditRenameEdit(newUriCreator: (oldUri: vscode.Uri) => vscode.Uri): Promise { const oldUri = await createRandomFile(); - const newUri = oldUri.with({ path: oldUri.path + 'NEW' }); + const newUri = newUriCreator(oldUri); const edit = new vscode.WorkspaceEdit(); edit.insert(oldUri, new vscode.Position(0, 0), 'BEFORE'); edit.renameFile(oldUri, newUri); edit.insert(newUri, new vscode.Position(0, 0), 'AFTER'); - let success = await vscode.workspace.applyEdit(edit); - assert.equal(success, true); + assert.ok(await vscode.workspace.applyEdit(edit)); let doc = await vscode.workspace.openTextDocument(newUri); assert.equal(doc.getText(), 'AFTERBEFORE'); - }); + assert.equal(doc.isDirty, true); + } function nameWithUnderscore(uri: vscode.Uri) { return uri.with({ path: posix.join(posix.dirname(uri.path), `_${posix.basename(uri.path)}`) }); @@ -807,7 +821,7 @@ suite('workspace-namespace', () => { assert.ok(await vscode.workspace.applyEdit(we)); }); - test('The api workspace.applyEdit drops the TextEdit if there is a RenameFile later #77735', async function () { + test('WorkspaceEdit: insert & rename multiple', async function () { let [f1, f2, f3] = await Promise.all([createRandomFile(), createRandomFile(), createRandomFile()]); @@ -831,4 +845,56 @@ suite('workspace-namespace', () => { assert.ok(true); } }); + + test('workspace.applyEdit drops the TextEdit if there is a RenameFile later #77735 (with opened editor)', async function () { + await test77735(true); + }); + + test('workspace.applyEdit drops the TextEdit if there is a RenameFile later #77735 (without opened editor)', async function () { + await test77735(false); + }); + + async function test77735(withOpenedEditor: boolean): Promise { + const docUriOriginal = await createRandomFile(); + const docUriMoved = docUriOriginal.with({ path: `${docUriOriginal.path}.moved` }); + + if (withOpenedEditor) { + const document = await vscode.workspace.openTextDocument(docUriOriginal); + await vscode.window.showTextDocument(document); + } else { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + } + + for (let i = 0; i < 4; i++) { + let we = new vscode.WorkspaceEdit(); + let oldUri: vscode.Uri; + let newUri: vscode.Uri; + let expected: string; + + if (i % 2 === 0) { + oldUri = docUriOriginal; + newUri = docUriMoved; + we.insert(oldUri, new vscode.Position(0, 0), 'Hello'); + expected = 'Hello'; + } else { + oldUri = docUriMoved; + newUri = docUriOriginal; + we.delete(oldUri, new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 5))); + expected = ''; + } + + we.renameFile(oldUri, newUri); + assert.ok(await vscode.workspace.applyEdit(we)); + + const document = await vscode.workspace.openTextDocument(newUri); + assert.equal(document.isDirty, true); + + await document.save(); + assert.equal(document.isDirty, false); + + assert.equal(document.getText(), expected); + + await delay(10); + } + } }); diff --git a/extensions/vscode-api-tests/src/utils.ts b/extensions/vscode-api-tests/src/utils.ts index 38ede848840..969a7cd0051 100644 --- a/extensions/vscode-api-tests/src/utils.ts +++ b/extensions/vscode-api-tests/src/utils.ts @@ -67,3 +67,7 @@ export function conditionalTest(name: string, testCallback: (done: MochaDone) => function isTestTypeActive(): boolean { return !!vscode.extensions.getExtension('vscode-resolver-test'); } + +export function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/package.json b/package.json index 98d73a82799..ec74a2f6bcb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.38.0", - "distro": "fffc03749d2060757cd83580d67a15ded244c2af", + "distro": "b9b380a45ae5d292a9203b1a0ca458796d78abcb", "author": { "name": "Microsoft Corporation" }, @@ -51,7 +51,7 @@ "vscode-ripgrep": "^1.5.6", "vscode-sqlite3": "4.0.8", "vscode-textmate": "^4.2.2", - "xterm": "3.15.0-beta93", + "xterm": "3.15.0-beta94", "xterm-addon-search": "0.2.0-beta3", "xterm-addon-web-links": "0.1.0-beta10", "yauzl": "^2.9.2", diff --git a/remote/package.json b/remote/package.json index 82324d430aa..a057fcb87cd 100644 --- a/remote/package.json +++ b/remote/package.json @@ -20,7 +20,7 @@ "vscode-proxy-agent": "0.4.0", "vscode-ripgrep": "^1.5.5", "vscode-textmate": "^4.2.2", - "xterm": "3.15.0-beta93", + "xterm": "3.15.0-beta94", "xterm-addon-search": "0.2.0-beta3", "xterm-addon-web-links": "0.1.0-beta10", "yauzl": "^2.9.2", diff --git a/remote/yarn.lock b/remote/yarn.lock index 0467f0810c3..4c1a99d899c 100644 --- a/remote/yarn.lock +++ b/remote/yarn.lock @@ -1159,10 +1159,10 @@ xterm-addon-web-links@0.1.0-beta10: resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.1.0-beta10.tgz#610fa9773a2a5ccd41c1c83ba0e2dd2c9eb66a23" integrity sha512-xfpjy0V6bB4BR44qIgZQPoCMVakxb65gMscPkHpO//QxvUxKzabV3dxOsIbeZRFkUGsWTFlvz2OoaBLoNtv5gg== -xterm@3.15.0-beta93: - version "3.15.0-beta93" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.15.0-beta93.tgz#ba1d5e4588f07be9bb36c70082a0e034f9bad565" - integrity sha512-MgzlwBOOwa/xYmWnLiTmqVOk3v/YRxzlPej940zpcp/chXW+ErsSPW6sehy68wedO9TWbR3oBUe8agfLH0uOuA== +xterm@3.15.0-beta94: + version "3.15.0-beta94" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.15.0-beta94.tgz#a2c48db73252021adc9d33d75f1f91c859b81b5f" + integrity sha512-JScndNQV90vicwBDsZiF2BAxMdruzXvVaN8TY6jFqMPC+YjXTXFDBFUij8iCONnGcTZBfNjbrVng+zLheAKphg== yauzl@^2.9.2: version "2.10.0" diff --git a/src/typings/electron.d.ts b/src/typings/electron.d.ts index 5be0ac0e3fe..cc80376a765 100644 --- a/src/typings/electron.d.ts +++ b/src/typings/electron.d.ts @@ -1,4 +1,4 @@ -// Type definitions for Electron 4.2.7 +// Type definitions for Electron 4.2.9 // Project: http://electronjs.org/ // Definitions by: The Electron Team // Definitions: https://github.com/electron/electron-typescript-definitions diff --git a/src/vs/base/browser/ui/grid/gridview.ts b/src/vs/base/browser/ui/grid/gridview.ts index ae8c4ed810a..497678cee41 100644 --- a/src/vs/base/browser/ui/grid/gridview.ts +++ b/src/vs/base/browser/ui/grid/gridview.ts @@ -348,7 +348,6 @@ class BranchNode implements ISplitView, IDisposable { } this.splitview.setViewVisible(index, visible); - this._onDidChange.fire(undefined); } getChildCachedVisibleSize(index: number): number | undefined { diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index c35ddf1e1dd..4f5394f773d 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -57,6 +57,7 @@ export interface IListViewOptions { readonly mouseSupport?: boolean; readonly horizontalScrolling?: boolean; readonly ariaProvider?: IAriaProvider; + readonly additionalScrollHeight?: number; } const DefaultOptions = { @@ -175,6 +176,7 @@ export class ListView implements ISpliceable, IDisposable { private setRowLineHeight: boolean; private supportDynamicHeights: boolean; private horizontalScrolling: boolean; + private additionalScrollHeight: number; private ariaProvider: IAriaProvider; private scrollWidth: number | undefined; private canUseTranslate3d: boolean | undefined = undefined; @@ -228,6 +230,8 @@ export class ListView implements ISpliceable, IDisposable { this.horizontalScrolling = getOrDefault(options, o => o.horizontalScrolling, DefaultOptions.horizontalScrolling); DOM.toggleClass(this.domNode, 'horizontal-scrolling', this.horizontalScrolling); + this.additionalScrollHeight = typeof options.additionalScrollHeight === 'undefined' ? 0 : options.additionalScrollHeight; + this.ariaProvider = options.ariaProvider || { getSetSize: (e, i, length) => length, getPosInSet: (_, index) => index + 1 }; this.rowsContainer = document.createElement('div'); @@ -688,7 +692,7 @@ export class ListView implements ISpliceable, IDisposable { } get scrollHeight(): number { - return this._scrollHeight + (this.horizontalScrolling ? 10 : 0); + return this._scrollHeight + (this.horizontalScrolling ? 10 : 0) + this.additionalScrollHeight; } // Events diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index c2b60a5dc92..49d35fd57a8 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -20,22 +20,8 @@ import { Event, Emitter } from 'vs/base/common/event'; import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview'; import { isLinux, isMacintosh } from 'vs/base/common/platform'; -function createMenuMnemonicRegExp() { - try { - return new RegExp('\\(&([^\\s&])\\)|(? { + if (!this.element) { + return; + } + + this._register(addDisposableListener(this.element, EventType.MOUSE_UP, e => { + EventHelper.stop(e, true); + this.onClick(e); + })); + }, 50); + + this._register(this.runOnceToEnableMouseUp); } render(container: HTMLElement): void { @@ -425,10 +426,8 @@ class BaseMenuActionViewItem extends BaseActionViewItem { append(this.item, $('span.keybinding')).textContent = this.options.keybinding; } - this._register(addDisposableListener(this.element, EventType.MOUSE_UP, e => { - EventHelper.stop(e, true); - this.onClick(e); - })); + // Adds mouse up listener to actually run the action + this.runOnceToEnableMouseUp.schedule(); this.updateClass(); this.updateLabel(); @@ -467,9 +466,23 @@ class BaseMenuActionViewItem extends BaseActionViewItem { const matches = MENU_MNEMONIC_REGEX.exec(label); if (matches) { - label = strings.escape(label).replace(MENU_ESCAPED_MNEMONIC_REGEX, ''); + label = strings.escape(label); + + // This is global, reset it + MENU_ESCAPED_MNEMONIC_REGEX.lastIndex = 0; + let escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(label); + + // We can't use negative lookbehind so if we match our negative and skip + while (escMatch && escMatch[1]) { + escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(label); + } + + if (escMatch) { + label = `${label.substr(0, escMatch.index)}${label.substr(escMatch.index + escMatch[0].length)}`; + } + label = label.replace(/&&/g, '&'); - this.item.setAttribute('aria-keyshortcuts', (!!matches[1] ? matches[1] : matches[2]).toLocaleLowerCase()); + this.item.setAttribute('aria-keyshortcuts', (!!matches[1] ? matches[1] : matches[3]).toLocaleLowerCase()); } else { label = label.replace(/&&/g, '&'); } @@ -802,7 +815,7 @@ export function cleanMnemonic(label: string): string { return label; } - const mnemonicInText = matches[0].charAt(0) === '&'; + const mnemonicInText = !matches[1]; - return label.replace(regex, mnemonicInText ? '$2' : '').trim(); + return label.replace(regex, mnemonicInText ? '$2$3' : '').trim(); } diff --git a/src/vs/base/browser/ui/menu/menubar.ts b/src/vs/base/browser/ui/menu/menubar.ts index 1ffcb47b9a1..c6644247b2b 100644 --- a/src/vs/base/browser/ui/menu/menubar.ts +++ b/src/vs/base/browser/ui/menu/menubar.ts @@ -209,7 +209,7 @@ export class MenuBar extends Disposable { // Register mnemonics if (mnemonicMatches) { - let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[2]; + let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[3]; this.registerMnemonic(this.menuCache.length, mnemonic); } @@ -472,15 +472,34 @@ export class MenuBar extends Disposable { const cleanMenuLabel = cleanMnemonic(label); // Update the button label to reflect mnemonics - titleElement.innerHTML = this.options.enableMnemonics ? - strings.escape(label).replace(MENU_ESCAPED_MNEMONIC_REGEX, '').replace(/&&/g, '&') : - cleanMenuLabel.replace(/&&/g, '&'); + + if (this.options.enableMnemonics) { + let innerHtml = strings.escape(label); + + // This is global so reset it + MENU_ESCAPED_MNEMONIC_REGEX.lastIndex = 0; + let escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(innerHtml); + + // We can't use negative lookbehind so we match our negative and skip + while (escMatch && escMatch[1]) { + escMatch = MENU_ESCAPED_MNEMONIC_REGEX.exec(innerHtml); + } + + if (escMatch) { + innerHtml = `${innerHtml.substr(0, escMatch.index)}${innerHtml.substr(escMatch.index + escMatch[0].length)}`; + } + + innerHtml = innerHtml.replace(/&&/g, '&'); + titleElement.innerHTML = innerHtml; + } else { + titleElement.innerHTML = cleanMenuLabel.replace(/&&/g, '&'); + } let mnemonicMatches = MENU_MNEMONIC_REGEX.exec(label); // Register mnemonics if (mnemonicMatches) { - let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[2]; + let mnemonic = !!mnemonicMatches[1] ? mnemonicMatches[1] : mnemonicMatches[3]; if (this.options.enableMnemonics) { buttonElement.setAttribute('aria-keyshortcuts', 'Alt+' + mnemonic.toLocaleLowerCase()); @@ -1012,4 +1031,4 @@ class ModifierKeyEmitter extends Emitter { super.dispose(); this._subscriptions.dispose(); } -} \ No newline at end of file +} diff --git a/src/vs/base/browser/ui/splitview/splitview.css b/src/vs/base/browser/ui/splitview/splitview.css index c0f3634d499..6fb8f1c61d0 100644 --- a/src/vs/base/browser/ui/splitview/splitview.css +++ b/src/vs/base/browser/ui/splitview/splitview.css @@ -39,7 +39,6 @@ white-space: initial; flex: none; position: relative; - overflow: hidden; } .monaco-split-view2 > .split-view-container > .split-view-view:not(.visible) { @@ -73,4 +72,4 @@ .monaco-split-view2.separator-border.vertical > .split-view-container > .split-view-view:not(:first-child)::before { height: 1px; width: 100%; -} +} \ No newline at end of file diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts index e6eaeae6bdc..4baf9ff4023 100644 --- a/src/vs/base/browser/ui/splitview/splitview.ts +++ b/src/vs/base/browser/ui/splitview/splitview.ts @@ -125,10 +125,7 @@ abstract class ViewItem { dom.addClass(container, 'visible'); } - layout(): void { - this.container.scrollTop = 0; - this.container.scrollLeft = 0; - } + abstract layout(): void; layoutView(orientation: Orientation): void { this.view.layout(this.size, orientation); @@ -143,7 +140,6 @@ abstract class ViewItem { class VerticalViewItem extends ViewItem { layout(): void { - super.layout(); this.container.style.height = `${this.size}px`; this.layoutView(Orientation.VERTICAL); } @@ -152,7 +148,6 @@ class VerticalViewItem extends ViewItem { class HorizontalViewItem extends ViewItem { layout(): void { - super.layout(); this.container.style.width = `${this.size}px`; this.layoutView(Orientation.HORIZONTAL); } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 05f6841fd72..ee5fda5a4ab 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -897,6 +897,7 @@ export interface IAbstractTreeOptions extends IAbstractTr readonly autoExpandSingleChildren?: boolean; readonly keyboardNavigationEventFilter?: IKeyboardNavigationEventFilter; readonly expandOnlyOnTwistieClick?: boolean | ((e: T) => boolean); + readonly additionalScrollHeight?: number; } function dfs(node: ITreeNode, fn: (node: ITreeNode) => void): void { diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 385cd3556de..b26b80d2c49 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -238,7 +238,8 @@ function asObjectTreeOptions(options?: IAsyncDataTreeOpt e => (options.expandOnlyOnTwistieClick as ((e: T) => boolean))(e.element as T) ) ), - ariaProvider: undefined + ariaProvider: undefined, + additionalScrollHeight: options.additionalScrollHeight }; } diff --git a/src/vs/base/node/pfs.ts b/src/vs/base/node/pfs.ts index 1af9f028c70..14dca3d5f49 100644 --- a/src/vs/base/node/pfs.ts +++ b/src/vs/base/node/pfs.ts @@ -138,20 +138,6 @@ export async function readdir(path: string): Promise { return handleDirectoryChildren(await promisify(fs.readdir)(path)); } -export async function readdirWithFileTypes(path: string): Promise { - const children = await promisify(fs.readdir)(path, { withFileTypes: true }); - - // Mac: uses NFD unicode form on disk, but we want NFC - // See also https://github.com/nodejs/node/issues/2165 - if (platform.isMacintosh) { - for (const child of children) { - child.name = normalizeNFC(child.name); - } - } - - return children; -} - export function readdirSync(path: string): string[] { return handleDirectoryChildren(fs.readdirSync(path)); } diff --git a/src/vs/base/test/node/pfs/pfs.test.ts b/src/vs/base/test/node/pfs/pfs.test.ts index 060913bbe72..4f09ad2b56d 100644 --- a/src/vs/base/test/node/pfs/pfs.test.ts +++ b/src/vs/base/test/node/pfs/pfs.test.ts @@ -16,7 +16,6 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { isWindows, isLinux } from 'vs/base/common/platform'; import { canNormalize } from 'vs/base/common/normalization'; import { VSBuffer } from 'vs/base/common/buffer'; -import { join } from 'path'; const chunkSize = 64 * 1024; const readError = 'Error while reading'; @@ -387,31 +386,6 @@ suite('PFS', () => { } }); - test('readdirWithFileTypes', async () => { - if (canNormalize && typeof process.versions['electron'] !== 'undefined' /* needs electron */) { - const id = uuid.generateUuid(); - const parentDir = path.join(os.tmpdir(), 'vsctests', id); - const testDir = join(parentDir, 'pfs', id); - - const newDir = path.join(testDir, 'öäü'); - await pfs.mkdirp(newDir, 493); - - await pfs.writeFile(join(testDir, 'somefile.txt'), 'contents'); - - assert.ok(fs.existsSync(newDir)); - - const children = await pfs.readdirWithFileTypes(testDir); - - assert.equal(children.some(n => n.name === 'öäü'), true); // Mac always converts to NFD, so - assert.equal(children.some(n => n.isDirectory()), true); - - assert.equal(children.some(n => n.name === 'somefile.txt'), true); - assert.equal(children.some(n => n.isFile()), true); - - await pfs.rimraf(parentDir); - } - }); - test('writeFile (string)', async () => { const smallData = 'Hello World'; const bigData = (new Array(100 * 1024)).join('Large String\n'); diff --git a/src/vs/code/electron-main/window.ts b/src/vs/code/electron-main/window.ts index 517417b9867..c6d2f29b1ba 100644 --- a/src/vs/code/electron-main/window.ts +++ b/src/vs/code/electron-main/window.ts @@ -360,17 +360,6 @@ export class CodeWindow extends Disposable implements ICodeWindow { this.pendingLoadConfig = undefined; } - - // To prevent flashing, we set the window visible after the page has finished to load but before Code is loaded - if (this._win && !this._win.isVisible()) { - if (this.windowState.mode === WindowMode.Maximized) { - this._win.maximize(); - } - - if (!this._win.isVisible()) { // maximize also makes visible - this._win.show(); - } - } }); // Window Focus diff --git a/src/vs/editor/contrib/snippet/snippetParser.ts b/src/vs/editor/contrib/snippet/snippetParser.ts index c4b568d38fd..61e0fd2a802 100644 --- a/src/vs/editor/contrib/snippet/snippetParser.ts +++ b/src/vs/editor/contrib/snippet/snippetParser.ts @@ -667,16 +667,24 @@ export class SnippetParser { if (this._token.type === TokenType.EOF) { return false; } - let start = this._token; - while (this._token.type !== type) { + let res = ''; + let pos = this._token.pos; + let prevToken = { type: TokenType.EOF, pos: 0, len: 0 }; + + while (this._token.type !== type || prevToken.type === TokenType.Backslash) { + if (this._token.type === type) { + res += this._scanner.value.substring(pos, prevToken.pos); + pos = this._token.pos; + } + prevToken = this._token; this._token = this._scanner.next(); if (this._token.type === TokenType.EOF) { return false; } } - let value = this._scanner.value.substring(start.pos, this._token.pos); + res += this._scanner.value.substring(pos, this._token.pos); this._token = this._scanner.next(); - return value; + return res; } private _parse(marker: Marker): boolean { diff --git a/src/vs/editor/contrib/snippet/test/snippetParser.test.ts b/src/vs/editor/contrib/snippet/test/snippetParser.test.ts index 165be28f1c7..f999da850bc 100644 --- a/src/vs/editor/contrib/snippet/test/snippetParser.test.ts +++ b/src/vs/editor/contrib/snippet/test/snippetParser.test.ts @@ -754,4 +754,17 @@ suite('SnippetParser', () => { let snippet = new SnippetParser().parse('namespace ${TM_DIRECTORY/[\\/]/\\\\/g};'); assertMarker(snippet, Text, Variable, Text); }); + + test('Snippet cannot escape closing bracket inside conditional insertion variable replacement #78883', function () { + + let snippet = new SnippetParser().parse('${TM_DIRECTORY/(.+)/${1:+import { hello \\} from world}/}'); + let variable = snippet.children[0]; + assert.equal(snippet.children.length, 1); + assert.ok(variable instanceof Variable); + assert.ok(variable.transform); + assert.equal(variable.transform!.children.length, 1); + assert.ok(variable.transform!.children[0] instanceof FormatString); + assert.equal((variable.transform!.children[0]).ifValue, 'import { hello } from world'); + assert.equal((variable.transform!.children[0]).elseValue, undefined); + }); }); diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 3a53fd21524..b13a82f4447 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { createDecorator, ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation'; import { URI } from 'vs/base/common/uri'; export interface ParsedArgs { @@ -98,7 +98,8 @@ export interface IExtensionHostDebugParams extends IDebugParams { export const BACKUPS = 'Backups'; export interface IEnvironmentService { - _serviceBrand: any; + + _serviceBrand: ServiceIdentifier; args: ParsedArgs; diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts index 9373b22383b..c5c4e66272c 100644 --- a/src/vs/platform/environment/node/environmentService.ts +++ b/src/vs/platform/environment/node/environmentService.ts @@ -16,6 +16,7 @@ import { toLocalISOString } from 'vs/base/common/date'; import { isWindows, isLinux } from 'vs/base/common/platform'; import { getPathFromAmdModule } from 'vs/base/common/amd'; import { URI } from 'vs/base/common/uri'; +import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation'; // Read this before there's any chance it is overwritten // Related to https://github.com/Microsoft/vscode/issues/30624 @@ -76,7 +77,7 @@ function getCLIPath(execPath: string, appRoot: string, isBuilt: boolean): string export class EnvironmentService implements IEnvironmentService { - _serviceBrand: any; + _serviceBrand!: ServiceIdentifier; get args(): ParsedArgs { return this._args; } diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index 0ab6883d353..12d5847389d 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { mkdir, open, close, read, write, fdatasync, Dirent, Stats } from 'fs'; +import { mkdir, open, close, read, write, fdatasync } from 'fs'; import { promisify } from 'util'; import { IDisposable, Disposable, toDisposable, dispose, combinedDisposable } from 'vs/base/common/lifecycle'; import { IFileSystemProvider, FileSystemProviderCapabilities, IFileChange, IWatchOptions, IStat, FileType, FileDeleteOptions, FileOverwriteOptions, FileWriteOptions, FileOpenOptions, FileSystemProviderErrorCode, createFileSystemProviderError, FileSystemProviderError } from 'vs/platform/files/common/files'; import { URI } from 'vs/base/common/uri'; import { Event, Emitter } from 'vs/base/common/event'; import { isLinux, isWindows } from 'vs/base/common/platform'; -import { statLink, unlink, move, copy, readFile, truncate, rimraf, RimRafMode, exists, readdirWithFileTypes } from 'vs/base/node/pfs'; +import { statLink, readdir, unlink, move, copy, readFile, truncate, rimraf, RimRafMode, exists } from 'vs/base/node/pfs'; import { normalize, basename, dirname } from 'vs/base/common/path'; import { joinPath } from 'vs/base/common/resources'; import { isEqual } from 'vs/base/common/extpath'; @@ -62,8 +62,15 @@ export class DiskFileSystemProvider extends Disposable implements IFileSystemPro try { const { stat, isSymbolicLink } = await statLink(this.toFilePath(resource)); // cannot use fs.stat() here to support links properly + let type: number; + if (isSymbolicLink) { + type = FileType.SymbolicLink | (stat.isDirectory() ? FileType.Directory : FileType.File); + } else { + type = stat.isFile() ? FileType.File : stat.isDirectory() ? FileType.Directory : FileType.Unknown; + } + return { - type: this.toType(stat, isSymbolicLink), + type, ctime: stat.ctime.getTime(), mtime: stat.mtime.getTime(), size: stat.size @@ -75,19 +82,13 @@ export class DiskFileSystemProvider extends Disposable implements IFileSystemPro async readdir(resource: URI): Promise<[string, FileType][]> { try { - const children = await readdirWithFileTypes(this.toFilePath(resource)); + const children = await readdir(this.toFilePath(resource)); const result: [string, FileType][] = []; await Promise.all(children.map(async child => { try { - let type: FileType; - if (child.isSymbolicLink()) { - type = (await this.stat(joinPath(resource, child.name))).type; // always resolve target the link points to if any - } else { - type = this.toType(child); - } - - result.push([child.name, type]); + const stat = await this.stat(joinPath(resource, child)); + result.push([child, stat.type]); } catch (error) { this.logService.trace(error); // ignore errors for individual entries that can arise from permission denied } @@ -99,14 +100,6 @@ export class DiskFileSystemProvider extends Disposable implements IFileSystemPro } } - private toType(entry: Stats | Dirent, isSymbolicLink = entry.isSymbolicLink()): FileType { - if (isSymbolicLink) { - return FileType.SymbolicLink | (entry.isDirectory() ? FileType.Directory : FileType.File); - } - - return entry.isFile() ? FileType.File : entry.isDirectory() ? FileType.Directory : FileType.Unknown; - } - //#endregion //#region File Reading/Writing diff --git a/src/vs/platform/list/browser/listService.ts b/src/vs/platform/list/browser/listService.ts index 0ef822bae84..6c5ac488c5f 100644 --- a/src/vs/platform/list/browser/listService.ts +++ b/src/vs/platform/list/browser/listService.ts @@ -890,6 +890,7 @@ function workbenchTreeDataPreamble request(e.shell, e.args)); + this._proxy.$requestDefaultShellAndArgs(request.useAutomationShell).then(e => request.callback(e.shell, e.args)); } } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 4272e94a833..e8fec875029 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -255,7 +255,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostClipboard; }, get shell() { - return extHostTerminalService.getDefaultShell(configProvider); + return extHostTerminalService.getDefaultShell(false, configProvider); }, openExternal(uri: URI) { return extHostWindow.openUri(uri, { allowTunneling: !!initData.remote.isRemote }); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ea7865ddb0d..dc50dfafa7c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1166,7 +1166,7 @@ export interface ExtHostTerminalServiceShape { $acceptProcessRequestLatency(id: number): number; $acceptWorkspacePermissionsChanged(isAllowed: boolean): void; $requestAvailableShells(): Promise; - $requestDefaultShellAndArgs(): Promise; + $requestDefaultShellAndArgs(useAutomationShell: boolean): Promise; } export interface ExtHostSCMShape { diff --git a/src/vs/workbench/api/common/extHostTerminalService.ts b/src/vs/workbench/api/common/extHostTerminalService.ts index 7c6907caa26..fcd283b560c 100644 --- a/src/vs/workbench/api/common/extHostTerminalService.ts +++ b/src/vs/workbench/api/common/extHostTerminalService.ts @@ -26,7 +26,7 @@ export interface IExtHostTerminalService extends ExtHostTerminalServiceShape { createTerminalFromOptions(options: vscode.TerminalOptions): vscode.Terminal; createExtensionTerminal(options: vscode.ExtensionTerminalOptions): vscode.Terminal; attachPtyToTerminal(id: number, pty: vscode.Pseudoterminal): void; - getDefaultShell(configProvider: ExtHostConfigProvider): string; + getDefaultShell(useAutomationShell: boolean, configProvider: ExtHostConfigProvider): string; } export const IExtHostTerminalService = createDecorator('IExtHostTerminalService'); diff --git a/src/vs/workbench/api/common/extHostTreeViews.ts b/src/vs/workbench/api/common/extHostTreeViews.ts index 6b5d8d02c58..f8d6dbb5906 100644 --- a/src/vs/workbench/api/common/extHostTreeViews.ts +++ b/src/vs/workbench/api/common/extHostTreeViews.ts @@ -177,6 +177,7 @@ class ExtHostTreeView extends Disposable { private _onDidChangeData: Emitter> = this._register(new Emitter>()); private refreshPromise: Promise = Promise.resolve(); + private refreshQueue: Promise = Promise.resolve(); constructor(private viewId: string, options: vscode.TreeViewOptions, private proxy: MainThreadTreeViewsShape, private commands: CommandsConverter, private logService: ILogService, private extension: IExtensionDescription) { super(); @@ -206,9 +207,11 @@ class ExtHostTreeView extends Disposable { return result; }, 200)(({ message, elements }) => { if (elements.length) { - const _promiseCallback = promiseCallback; - refreshingPromise = null; - this.refresh(elements).then(() => _promiseCallback()); + this.refreshQueue = this.refreshQueue.then(() => { + const _promiseCallback = promiseCallback; + refreshingPromise = null; + return this.refresh(elements).then(() => _promiseCallback()); + }); } if (message) { this.proxy.$setMessage(this.viewId, this._message); diff --git a/src/vs/workbench/api/node/extHostDebugService.ts b/src/vs/workbench/api/node/extHostDebugService.ts index f18bd5bfe91..87adb02d748 100644 --- a/src/vs/workbench/api/node/extHostDebugService.ts +++ b/src/vs/workbench/api/node/extHostDebugService.ts @@ -349,7 +349,7 @@ export class ExtHostDebugService implements IExtHostDebugService, ExtHostDebugSe }).then(async needNewTerminal => { const configProvider = await this._configurationService.getConfigProvider(); - const shell = this._terminalService.getDefaultShell(configProvider); + const shell = this._terminalService.getDefaultShell(true, configProvider); if (needNewTerminal || !this._integratedTerminalInstance) { const options: vscode.TerminalOptions = { diff --git a/src/vs/workbench/api/node/extHostTerminalService.ts b/src/vs/workbench/api/node/extHostTerminalService.ts index 02de2e82ed6..99a7cc8309c 100644 --- a/src/vs/workbench/api/node/extHostTerminalService.ts +++ b/src/vs/workbench/api/node/extHostTerminalService.ts @@ -274,7 +274,7 @@ export class ExtHostTerminalService implements IExtHostTerminalService, ExtHostT this._setupExtHostProcessListeners(id, p); } - public getDefaultShell(configProvider: ExtHostConfigProvider): string { + public getDefaultShell(useAutomationShell: boolean, configProvider: ExtHostConfigProvider): string { const fetchSetting = (key: string) => { const setting = configProvider .getConfiguration(key.substr(0, key.lastIndexOf('.'))) @@ -289,11 +289,12 @@ export class ExtHostTerminalService implements IExtHostTerminalService, ExtHostT process.env.windir, this._lastActiveWorkspace, this._variableResolver, - this._logService + this._logService, + useAutomationShell ); } - private _getDefaultShellArgs(configProvider: ExtHostConfigProvider): string[] | string { + private _getDefaultShellArgs(useAutomationShell: boolean, configProvider: ExtHostConfigProvider): string[] | string { const fetchSetting = (key: string) => { const setting = configProvider .getConfiguration(key.substr(0, key.lastIndexOf('.'))) @@ -301,7 +302,7 @@ export class ExtHostTerminalService implements IExtHostTerminalService, ExtHostT return this._apiInspectConfigToPlain(setting); }; - return terminalEnvironment.getDefaultShellArgs(fetchSetting, this._isWorkspaceShellAllowed, this._lastActiveWorkspace, this._variableResolver, this._logService); + return terminalEnvironment.getDefaultShellArgs(fetchSetting, this._isWorkspaceShellAllowed, useAutomationShell, this._lastActiveWorkspace, this._variableResolver, this._logService); } public async $acceptActiveTerminalChanged(id: number | null): Promise { @@ -461,8 +462,8 @@ export class ExtHostTerminalService implements IExtHostTerminalService, ExtHostT const platformKey = platform.isWindows ? 'windows' : (platform.isMacintosh ? 'osx' : 'linux'); const configProvider = await this._extHostConfiguration.getConfigProvider(); if (!shellLaunchConfig.executable) { - shellLaunchConfig.executable = this.getDefaultShell(configProvider); - shellLaunchConfig.args = this._getDefaultShellArgs(configProvider); + shellLaunchConfig.executable = this.getDefaultShell(false, configProvider); + shellLaunchConfig.args = this._getDefaultShellArgs(false, configProvider); } else { if (this._variableResolver) { shellLaunchConfig.executable = this._variableResolver.resolve(this._lastActiveWorkspace, shellLaunchConfig.executable); @@ -603,11 +604,11 @@ export class ExtHostTerminalService implements IExtHostTerminalService, ExtHostT return detectAvailableShells(); } - public async $requestDefaultShellAndArgs(): Promise { + public async $requestDefaultShellAndArgs(useAutomationShell: boolean): Promise { const configProvider = await this._extHostConfiguration.getConfigProvider(); return Promise.resolve({ - shell: this.getDefaultShell(configProvider), - args: this._getDefaultShellArgs(configProvider) + shell: this.getDefaultShell(useAutomationShell, configProvider), + args: this._getDefaultShellArgs(useAutomationShell, configProvider) }); } diff --git a/src/vs/workbench/browser/actions/listCommands.ts b/src/vs/workbench/browser/actions/listCommands.ts index a811aed2a82..7de343ab1d3 100644 --- a/src/vs/workbench/browser/actions/listCommands.ts +++ b/src/vs/workbench/browser/actions/listCommands.ts @@ -320,6 +320,38 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ } }); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'list.focusParent', + weight: KeybindingWeight.WorkbenchContrib, + when: WorkbenchListFocusContextKey, + handler: (accessor) => { + const focused = accessor.get(IListService).lastFocusedList; + + if (!focused || focused instanceof List || focused instanceof PagedList) { + return; + } + + if (focused instanceof ObjectTree || focused instanceof DataTree || focused instanceof AsyncDataTree) { + const tree = focused; + const focusedElements = tree.getFocus(); + if (focusedElements.length === 0) { + return; + } + const focus = focusedElements[0]; + const parent = tree.getParentElement(focus); + if (parent) { + const fakeKeyboardEvent = new KeyboardEvent('keydown'); + tree.setFocus([parent], fakeKeyboardEvent); + tree.reveal(parent); + } + } else { + const tree = focused; + tree.focusParent({ origin: 'keyboard' }); + } + } +}); + KeybindingsRegistry.registerCommandAndKeybindingRule({ id: 'list.expand', weight: KeybindingWeight.WorkbenchContrib, diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 4dc5c4a28ad..8bb14cc38a3 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -938,14 +938,14 @@ export class EditorPart extends Part implements IEditorGroupsService, IEditorGro } private updateContainer(): void { - toggleClass(this.container, 'empty', this.isEmpty()); + toggleClass(this.container, 'empty', this.isEmpty); } private notifyGroupIndexChange(): void { this.getGroups(GroupsOrder.GRID_APPEARANCE).forEach((group, index) => group.notifyIndexChanged(index)); } - private isEmpty(): boolean { + private get isEmpty(): boolean { return this.groupViews.size === 1 && this._activeGroup.isEmpty; } diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index abb2f710aa5..7c36ea51726 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -237,7 +237,7 @@ import { isMacintosh, isWindows, isLinux, isWeb } from 'vs/base/common/platform' 'workbench.useExperimentalGridLayout': { 'type': 'boolean', 'description': nls.localize('workbench.useExperimentalGridLayout', "Enables the grid layout for the workbench. This setting may enable additional layout options for workbench components."), - 'default': true, + 'default': false, 'scope': ConfigurationScope.APPLICATION } } diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index cb9ac033e43..8a2c0fe652f 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -15,7 +15,7 @@ import { toErrorMessage } from 'vs/base/common/errorMessage'; import * as strings from 'vs/base/common/strings'; import { Action } from 'vs/base/common/actions'; import { dispose, IDisposable } from 'vs/base/common/lifecycle'; -import { VIEWLET_ID, IExplorerService } from 'vs/workbench/contrib/files/common/files'; +import { VIEWLET_ID, IExplorerService, IFilesConfiguration } from 'vs/workbench/contrib/files/common/files'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { IFileService, AutoSaveConfiguration } from 'vs/platform/files/common/files'; import { toResource, SideBySideEditor } from 'vs/workbench/common/editor'; @@ -328,7 +328,7 @@ function containsBothDirectoryAndFile(distinctElements: ExplorerItem[]): boolean } -export function findValidPasteFileTarget(targetFolder: ExplorerItem, fileToPaste: { resource: URI, isDirectory?: boolean, allowOverwrite: boolean }): URI { +export function findValidPasteFileTarget(targetFolder: ExplorerItem, fileToPaste: { resource: URI, isDirectory?: boolean, allowOverwrite: boolean }, incrementalNaming: 'simple' | 'smart'): URI { let name = resources.basenameOrAuthority(fileToPaste.resource); let candidate = resources.joinPath(targetFolder.resource, name); @@ -337,37 +337,104 @@ export function findValidPasteFileTarget(targetFolder: ExplorerItem, fileToPaste break; } - name = incrementFileName(name, !!fileToPaste.isDirectory); + name = incrementFileName(name, !!fileToPaste.isDirectory, incrementalNaming); candidate = resources.joinPath(targetFolder.resource, name); } return candidate; } -export function incrementFileName(name: string, isFolder: boolean): string { - let namePrefix = name; - let extSuffix = ''; - if (!isFolder) { - extSuffix = extname(name); - namePrefix = basename(name, extSuffix); +export function incrementFileName(name: string, isFolder: boolean, incrementalNaming: 'simple' | 'smart'): string { + if (incrementalNaming === 'simple') { + let namePrefix = name; + let extSuffix = ''; + if (!isFolder) { + extSuffix = extname(name); + namePrefix = basename(name, extSuffix); + } + + // name copy 5(.txt) => name copy 6(.txt) + // name copy(.txt) => name copy 2(.txt) + const suffixRegex = /^(.+ copy)( \d+)?$/; + if (suffixRegex.test(namePrefix)) { + return namePrefix.replace(suffixRegex, (match, g1?, g2?) => { + let number = (g2 ? parseInt(g2) : 1); + return number === 0 + ? `${g1}` + : (number < Constants.MAX_SAFE_SMALL_INTEGER + ? `${g1} ${number + 1}` + : `${g1}${g2} copy`); + }) + extSuffix; + } + + // name(.txt) => name copy(.txt) + return `${namePrefix} copy${extSuffix}`; } - // name copy 5(.txt) => name copy 6(.txt) - // name copy(.txt) => name copy 2(.txt) - const suffixRegex = /^(.+ copy)( \d+)?$/; - if (suffixRegex.test(namePrefix)) { - return namePrefix.replace(suffixRegex, (match, g1?, g2?) => { - let number = (g2 ? parseInt(g2) : 1); - return number === 0 - ? `${g1}` - : (number < Constants.MAX_SAFE_SMALL_INTEGER - ? `${g1} ${number + 1}` - : `${g1}${g2} copy`); - }) + extSuffix; + const separators = '[\\.\\-_]'; + const maxNumber = Constants.MAX_SAFE_SMALL_INTEGER; + + // file.1.txt=>file.2.txt + let suffixFileRegex = RegExp('(.*' + separators + ')(\\d+)(\\..*)$'); + if (!isFolder && name.match(suffixFileRegex)) { + return name.replace(suffixFileRegex, (match, g1?, g2?, g3?) => { + let number = parseInt(g2); + return number < maxNumber + ? g1 + strings.pad(number + 1, g2.length) + g3 + : strings.format('{0}{1}.1{2}', g1, g2, g3); + }); } - // name(.txt) => name copy(.txt) - return `${namePrefix} copy${extSuffix}`; + // 1.file.txt=>2.file.txt + let prefixFileRegex = RegExp('(\\d+)(' + separators + '.*)(\\..*)$'); + if (!isFolder && name.match(prefixFileRegex)) { + return name.replace(prefixFileRegex, (match, g1?, g2?, g3?) => { + let number = parseInt(g1); + return number < maxNumber + ? strings.pad(number + 1, g1.length) + g2 + g3 + : strings.format('{0}{1}.1{2}', g1, g2, g3); + }); + } + + // 1.txt=>2.txt + let prefixFileNoNameRegex = RegExp('(\\d+)(\\..*)$'); + if (!isFolder && name.match(prefixFileNoNameRegex)) { + return name.replace(prefixFileNoNameRegex, (match, g1?, g2?) => { + let number = parseInt(g1); + return number < maxNumber + ? strings.pad(number + 1, g1.length) + g2 + : strings.format('{0}.1{1}', g1, g2); + }); + } + + // file.txt=>file.1.txt + const lastIndexOfDot = name.lastIndexOf('.'); + if (!isFolder && lastIndexOfDot >= 0) { + return strings.format('{0}.1{1}', name.substr(0, lastIndexOfDot), name.substr(lastIndexOfDot)); + } + + // folder.1=>folder.2 + if (isFolder && name.match(/(\d+)$/)) { + return name.replace(/(\d+)$/, (match: string, ...groups: any[]) => { + let number = parseInt(groups[0]); + return number < maxNumber + ? strings.pad(number + 1, groups[0].length) + : strings.format('{0}.1', groups[0]); + }); + } + + // 1.folder=>2.folder + if (isFolder && name.match(/^(\d+)/)) { + return name.replace(/^(\d+)(.*)$/, (match: string, ...groups: any[]) => { + let number = parseInt(groups[0]); + return number < maxNumber + ? strings.pad(number + 1, groups[0].length) + groups[1] + : strings.format('{0}{1}.1', groups[0], groups[1]); + }); + } + + // file/folder=>file.1/folder.1 + return strings.format('{0}.1', name); } // Global Compare with @@ -1006,6 +1073,7 @@ export const pasteFileHandler = async (accessor: ServicesAccessor) => { const textFileService = accessor.get(ITextFileService); const notificationService = accessor.get(INotificationService); const editorService = accessor.get(IEditorService); + const configurationService = accessor.get(IConfigurationService); if (listService.lastFocusedList) { const explorerContext = getContext(listService.lastFocusedList); @@ -1030,7 +1098,8 @@ export const pasteFileHandler = async (accessor: ServicesAccessor) => { target = element.isDirectory ? element : element.parent!; } - const targetFile = findValidPasteFileTarget(target, { resource: fileToPaste, isDirectory: fileToPasteStat.isDirectory, allowOverwrite: pasteShouldMove }); + const incrementalNaming = configurationService.getValue().explorer.incrementalNaming; + const targetFile = findValidPasteFileTarget(target, { resource: fileToPaste, isDirectory: fileToPasteStat.isDirectory, allowOverwrite: pasteShouldMove }, incrementalNaming); // Move/Copy File if (pasteShouldMove) { diff --git a/src/vs/workbench/contrib/files/browser/fileCommands.ts b/src/vs/workbench/contrib/files/browser/fileCommands.ts index 38835200d54..384d6735830 100644 --- a/src/vs/workbench/contrib/files/browser/fileCommands.ts +++ b/src/vs/workbench/contrib/files/browser/fileCommands.ts @@ -37,7 +37,6 @@ import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { ILabelService } from 'vs/platform/label/common/label'; -import { onUnexpectedError } from 'vs/base/common/errors'; import { basename, toLocalResource, joinPath } from 'vs/base/common/resources'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; @@ -106,7 +105,7 @@ export const newWindowCommand = (accessor: ServicesAccessor, options?: INewWindo windowsService.openNewWindow(options); }; -function save( +async function save( resource: URI | null, isSaveAs: boolean, options: ISaveOptions | undefined, @@ -117,99 +116,110 @@ function save( editorGroupService: IEditorGroupsService, environmentService: IWorkbenchEnvironmentService ): Promise { - - function ensureForcedSave(options?: ISaveOptions): ISaveOptions { - if (!options) { - options = { force: true }; - } else { - options.force = true; - } - - return options; + if (!resource || (!fileService.canHandleResource(resource) && resource.scheme !== Schemas.untitled)) { + return; // save is not supported } - if (resource && (fileService.canHandleResource(resource) || resource.scheme === Schemas.untitled)) { - - // Save As (or Save untitled with associated path) - if (isSaveAs || resource.scheme === Schemas.untitled) { - let encodingOfSource: string | undefined; - if (resource.scheme === Schemas.untitled) { - encodingOfSource = untitledEditorService.getEncoding(resource); - } else if (fileService.canHandleResource(resource)) { - const textModel = textFileService.models.get(resource); - encodingOfSource = textModel && textModel.getEncoding(); // text model can be null e.g. if this is a binary file! - } - - let viewStateOfSource: IEditorViewState | null; - const activeTextEditorWidget = getCodeEditor(editorService.activeTextEditorWidget); - if (activeTextEditorWidget) { - const activeResource = toResource(editorService.activeEditor, { supportSideBySide: SideBySideEditor.MASTER }); - if (activeResource && (fileService.canHandleResource(activeResource) || resource.scheme === Schemas.untitled) && activeResource.toString() === resource.toString()) { - viewStateOfSource = activeTextEditorWidget.saveViewState(); - } - } - - // Special case: an untitled file with associated path gets saved directly unless "saveAs" is true - let savePromise: Promise; - if (!isSaveAs && resource.scheme === Schemas.untitled && untitledEditorService.hasAssociatedFilePath(resource)) { - savePromise = textFileService.save(resource, options).then(result => { - if (result) { - return toLocalResource(resource, environmentService.configuration.remoteAuthority); - } - - return undefined; - }); - } - - // Otherwise, really "Save As..." - else { - - // Force a change to the file to trigger external watchers if any - // fixes https://github.com/Microsoft/vscode/issues/59655 - options = ensureForcedSave(options); - - savePromise = textFileService.saveAs(resource, undefined, options); - } - - return savePromise.then(target => { - if (!target || target.toString() === resource.toString()) { - return false; // save canceled or same resource used - } - - const replacement: IResourceInput = { - resource: target, - encoding: encodingOfSource, - options: { - pinned: true, - viewState: viewStateOfSource || undefined - } - }; - - return Promise.all(editorGroupService.groups.map(g => - editorService.replaceEditors([{ - editor: { resource }, - replacement - }], g))).then(() => true); - }); - } - - // Pin the active editor if we are saving it - const activeControl = editorService.activeControl; - const activeEditorResource = activeControl && activeControl.input && activeControl.input.getResource(); - if (activeControl && activeEditorResource && activeEditorResource.toString() === resource.toString()) { - activeControl.group.pinEditor(activeControl.input); - } - - // Just save (force a change to the file to trigger external watchers if any) - options = ensureForcedSave(options); - - return textFileService.save(resource, options); + // Save As (or Save untitled with associated path) + if (isSaveAs || resource.scheme === Schemas.untitled) { + return doSaveAs(resource, isSaveAs, options, editorService, fileService, untitledEditorService, textFileService, editorGroupService, environmentService); } - return Promise.resolve(false); + // Save + return doSave(resource, options, editorService, textFileService); } -function saveAll(saveAllArguments: any, editorService: IEditorService, untitledEditorService: IUntitledEditorService, +async function doSaveAs( + resource: URI, + isSaveAs: boolean, + options: ISaveOptions | undefined, + editorService: IEditorService, + fileService: IFileService, + untitledEditorService: IUntitledEditorService, + textFileService: ITextFileService, + editorGroupService: IEditorGroupsService, + environmentService: IWorkbenchEnvironmentService +): Promise { + let viewStateOfSource: IEditorViewState | null = null; + const activeTextEditorWidget = getCodeEditor(editorService.activeTextEditorWidget); + if (activeTextEditorWidget) { + const activeResource = toResource(editorService.activeEditor, { supportSideBySide: SideBySideEditor.MASTER }); + if (activeResource && (fileService.canHandleResource(activeResource) || resource.scheme === Schemas.untitled) && activeResource.toString() === resource.toString()) { + viewStateOfSource = activeTextEditorWidget.saveViewState(); + } + } + + // Special case: an untitled file with associated path gets saved directly unless "saveAs" is true + let target: URI | undefined; + if (!isSaveAs && resource.scheme === Schemas.untitled && untitledEditorService.hasAssociatedFilePath(resource)) { + const result = await textFileService.save(resource, options); + if (result) { + target = toLocalResource(resource, environmentService.configuration.remoteAuthority); + } + } + + // Otherwise, really "Save As..." + else { + + // Force a change to the file to trigger external watchers if any + // fixes https://github.com/Microsoft/vscode/issues/59655 + options = ensureForcedSave(options); + + target = await textFileService.saveAs(resource, undefined, options); + } + + if (!target || target.toString() === resource.toString()) { + return false; // save canceled or same resource used + } + + const replacement: IResourceInput = { + resource: target, + options: { + pinned: true, + viewState: viewStateOfSource || undefined + } + }; + + await Promise.all(editorGroupService.groups.map(group => + editorService.replaceEditors([{ + editor: { resource }, + replacement + }], group))); + + return true; +} + +async function doSave( + resource: URI, + options: ISaveOptions | undefined, + editorService: IEditorService, + textFileService: ITextFileService +): Promise { + + // Pin the active editor if we are saving it + const activeControl = editorService.activeControl; + const activeEditorResource = activeControl && activeControl.input && activeControl.input.getResource(); + if (activeControl && activeEditorResource && activeEditorResource.toString() === resource.toString()) { + activeControl.group.pinEditor(activeControl.input); + } + + // Just save (force a change to the file to trigger external watchers if any) + options = ensureForcedSave(options); + + return textFileService.save(resource, options); +} + +function ensureForcedSave(options?: ISaveOptions): ISaveOptions { + if (!options) { + options = { force: true }; + } else { + options.force = true; + } + + return options; +} + +async function saveAll(saveAllArguments: any, editorService: IEditorService, untitledEditorService: IUntitledEditorService, textFileService: ITextFileService, editorGroupService: IEditorGroupsService): Promise { // Store some properties per untitled file to restore later after save is completed @@ -239,17 +249,18 @@ function saveAll(saveAllArguments: any, editorService: IEditorService, untitledE }); // Save all - return textFileService.saveAll(saveAllArguments).then(result => { - groupIdToUntitledResourceInput.forEach((inputs, groupId) => { - // Update untitled resources to the saved ones, so we open the proper files - inputs.forEach(i => { - const targetResult = result.results.filter(r => r.success && r.source.toString() === i.resource.toString()).pop(); - if (targetResult && targetResult.target) { - i.resource = targetResult.target; - } - }); - editorService.openEditors(inputs, groupId); + const result = await textFileService.saveAll(saveAllArguments); + + // Update untitled resources to the saved ones, so we open the proper files + groupIdToUntitledResourceInput.forEach((inputs, groupId) => { + inputs.forEach(i => { + const targetResult = result.results.filter(r => r.success && r.source.toString() === i.resource.toString()).pop(); + if (targetResult && targetResult.target) { + i.resource = targetResult.target; + } }); + + editorService.openEditors(inputs, groupId); }); } @@ -257,7 +268,7 @@ function saveAll(saveAllArguments: any, editorService: IEditorService, untitledE CommandsRegistry.registerCommand({ id: REVERT_FILE_COMMAND_ID, - handler: (accessor, resource: URI | object) => { + handler: async (accessor, resource: URI | object) => { const editorService = accessor.get(IEditorService); const textFileService = accessor.get(ITextFileService); const notificationService = accessor.get(INotificationService); @@ -265,12 +276,12 @@ CommandsRegistry.registerCommand({ .filter(resource => resource.scheme !== Schemas.untitled); if (resources.length) { - return textFileService.revertAll(resources, { force: true }).then(undefined, error => { + try { + await textFileService.revertAll(resources, { force: true }); + } catch (error) { notificationService.error(nls.localize('genericRevertError', "Failed to revert '{0}': {1}", resources.map(r => basename(r)).join(', '), toErrorMessage(error, false))); - }); + } } - - return Promise.resolve(true); } }); @@ -281,7 +292,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ mac: { primary: KeyMod.WinCtrl | KeyCode.Enter }, - id: OPEN_TO_SIDE_COMMAND_ID, handler: (accessor, resource: URI | object) => { + id: OPEN_TO_SIDE_COMMAND_ID, handler: async (accessor, resource: URI | object) => { const editorService = accessor.get(IEditorService); const listService = accessor.get(IListService); const fileService = accessor.get(IFileService); @@ -289,16 +300,13 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ // Set side input if (resources.length) { - return fileService.resolveAll(resources.map(resource => ({ resource }))).then(resolved => { - const editors = resolved.filter(r => r.stat && r.success && !r.stat.isDirectory).map(r => ({ - resource: r.stat!.resource - })); + const resolved = await fileService.resolveAll(resources.map(resource => ({ resource }))); + const editors = resolved.filter(r => r.stat && r.success && !r.stat.isDirectory).map(r => ({ + resource: r.stat!.resource + })); - return editorService.openEditors(editors, SIDE_GROUP); - }); + await editorService.openEditors(editors, SIDE_GROUP); } - - return Promise.resolve(true); } }); @@ -393,7 +401,7 @@ CommandsRegistry.registerCommand({ editorService.openEditor({ leftResource: globalResourceToCompare, rightResource - }).then(undefined, onUnexpectedError); + }); } } }); @@ -492,27 +500,28 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ CommandsRegistry.registerCommand({ id: REVEAL_IN_EXPLORER_COMMAND_ID, - handler: (accessor, resource: URI | object) => { + handler: async (accessor, resource: URI | object) => { const viewletService = accessor.get(IViewletService); const contextService = accessor.get(IWorkspaceContextService); const explorerService = accessor.get(IExplorerService); const uri = getResourceForCommand(resource, accessor.get(IListService), accessor.get(IEditorService)); - viewletService.openViewlet(VIEWLET_ID, false).then((viewlet: ExplorerViewlet) => { - if (uri && contextService.isInsideWorkspace(uri)) { - const explorerView = viewlet.getExplorerView(); - if (explorerView) { - explorerView.setExpanded(true); - explorerService.select(uri, true).then(() => explorerView.focus(), onUnexpectedError); - } - } else { - const openEditorsView = viewlet.getOpenEditorsView(); - if (openEditorsView) { - openEditorsView.setExpanded(true); - openEditorsView.focus(); - } + const viewlet = await viewletService.openViewlet(VIEWLET_ID, false) as ExplorerViewlet; + + if (uri && contextService.isInsideWorkspace(uri)) { + const explorerView = viewlet.getExplorerView(); + if (explorerView) { + explorerView.setExpanded(true); + await explorerService.select(uri, true); + explorerView.focus(); } - }); + } else { + const openEditorsView = viewlet.getOpenEditorsView(); + if (openEditorsView) { + openEditorsView.setExpanded(true); + openEditorsView.focus(); + } + } } }); diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index b219a608bd2..19fcd5b0acb 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -426,6 +426,15 @@ configurationRegistry.registerConfiguration({ description: nls.localize('explorer.decorations.badges', "Controls whether file decorations should use badges."), default: true }, + 'explorer.incrementalNaming': { + enum: ['simple', 'smart'], + enumDescriptions: [ + nls.localize('simple', "Appends the word \"copy\" at the end of the duplicated name potentially followed by a number"), + nls.localize('smart', "Adds a number at the end of the duplicated name. If some number is already part of the name, tries to increase that number") + ], + description: nls.localize('explorer.incrementalNaming', "Controls what naming strategy to use when a giving a new name to a duplicated explorer item on paste."), + default: 'simple' + } } }); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index f5a52355075..c5bd34e7ac6 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -301,7 +301,8 @@ export class ExplorerView extends ViewletPanel { filter: this.filter, sorter: this.instantiationService.createInstance(FileSorter), dnd: this.instantiationService.createInstance(FileDragAndDrop), - autoExpandSingleChildren: true + autoExpandSingleChildren: true, + additionalScrollHeight: ExplorerDelegate.ITEM_HEIGHT }); this._register(this.tree); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index bffa827671f..75aae73d144 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -49,7 +49,7 @@ import { FuzzyScore, createMatches } from 'vs/base/common/filters'; export class ExplorerDelegate implements IListVirtualDelegate { - private static readonly ITEM_HEIGHT = 22; + static readonly ITEM_HEIGHT = 22; getHeight(element: ExplorerItem): number { return ExplorerDelegate.ITEM_HEIGHT; @@ -808,8 +808,8 @@ export class FileDragAndDrop implements ITreeDragAndDrop { private doHandleExplorerDrop(source: ExplorerItem, target: ExplorerItem, isCopy: boolean): Promise { // Reuse duplicate action if user copies if (isCopy) { - - return this.fileService.copy(source.resource, findValidPasteFileTarget(target, { resource: source.resource, isDirectory: source.isDirectory, allowOverwrite: false })).then(stat => { + const incrementalNaming = this.configurationService.getValue().explorer.incrementalNaming; + return this.fileService.copy(source.resource, findValidPasteFileTarget(target, { resource: source.resource, isDirectory: source.isDirectory, allowOverwrite: false }, incrementalNaming)).then(stat => { if (!stat.isDirectory) { return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }).then(() => undefined); } diff --git a/src/vs/workbench/contrib/files/common/files.ts b/src/vs/workbench/contrib/files/common/files.ts index e60fda6cf31..342088011f5 100644 --- a/src/vs/workbench/contrib/files/common/files.ts +++ b/src/vs/workbench/contrib/files/common/files.ts @@ -114,6 +114,7 @@ export interface IFilesConfiguration extends IFilesConfiguration, IWorkbenchEdit colors: boolean; badges: boolean; }; + incrementalNaming: 'simple' | 'smart'; }; editor: IEditorOptions; } diff --git a/src/vs/workbench/contrib/files/test/electron-browser/fileActions.test.ts b/src/vs/workbench/contrib/files/test/electron-browser/fileActions.test.ts index d52b99bb2d9..bf0642cde5c 100644 --- a/src/vs/workbench/contrib/files/test/electron-browser/fileActions.test.ts +++ b/src/vs/workbench/contrib/files/test/electron-browser/fileActions.test.ts @@ -6,132 +6,292 @@ import * as assert from 'assert'; import { incrementFileName } from 'vs/workbench/contrib/files/browser/fileActions'; -suite('Files - Increment file name', () => { +suite('Files - Increment file name simple', () => { test('Increment file name without any version', function () { const name = 'test.js'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'test copy.js'); }); test('Increment file name with suffix version', function () { const name = 'test copy.js'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'test copy 2.js'); }); test('Increment file name with suffix version with leading zeros', function () { const name = 'test copy 005.js'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'test copy 6.js'); }); test('Increment file name with suffix version, too big number', function () { const name = 'test copy 9007199254740992.js'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'test copy 9007199254740992 copy.js'); }); test('Increment file name with just version in name', function () { const name = 'copy.js'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'copy copy.js'); }); test('Increment file name with just version in name, v2', function () { const name = 'copy 2.js'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'copy 2 copy.js'); }); test('Increment file name without any extension or version', function () { const name = 'test'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'test copy'); }); test('Increment file name without any extension or version, trailing dot', function () { const name = 'test.'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'test copy.'); }); test('Increment file name without any extension or version, leading dot', function () { const name = '.test'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, '.test copy'); }); test('Increment file name without any extension or version, leading dot v2', function () { const name = '..test'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, '. copy.test'); }); test('Increment file name without any extension but with suffix version', function () { const name = 'test copy 5'; - const result = incrementFileName(name, false); + const result = incrementFileName(name, false, 'simple'); assert.strictEqual(result, 'test copy 6'); }); test('Increment folder name without any version', function () { const name = 'test'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test copy'); }); test('Increment folder name with suffix version', function () { const name = 'test copy'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test copy 2'); }); test('Increment folder name with suffix version, leading zeros', function () { const name = 'test copy 005'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test copy 6'); }); test('Increment folder name with suffix version, too big number', function () { const name = 'test copy 9007199254740992'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test copy 9007199254740992 copy'); }); test('Increment folder name with just version in name', function () { const name = 'copy'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'copy copy'); }); test('Increment folder name with just version in name, v2', function () { const name = 'copy 2'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'copy 2 copy'); }); test('Increment folder name "with extension" but without any version', function () { const name = 'test.js'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test.js copy'); }); test('Increment folder name "with extension" and with suffix version', function () { const name = 'test.js copy 5'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test.js copy 6'); }); test('Increment file/folder name with suffix version, special case 1', function () { const name = 'test copy 0'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test copy'); }); test('Increment file/folder name with suffix version, special case 2', function () { const name = 'test copy 1'; - const result = incrementFileName(name, true); + const result = incrementFileName(name, true, 'simple'); assert.strictEqual(result, 'test copy 2'); }); }); + +suite('Files - Increment file name smart', () => { + + test('Increment file name without any version', function () { + const name = 'test.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test.1.js'); + }); + + test('Increment folder name without any version', function () { + const name = 'test'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, 'test.1'); + }); + + test('Increment file name with suffix version', function () { + const name = 'test.1.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test.2.js'); + }); + + test('Increment file name with suffix version with trailing zeros', function () { + const name = 'test.001.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test.002.js'); + }); + + test('Increment file name with suffix version with trailing zeros, changing length', function () { + const name = 'test.009.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test.010.js'); + }); + + test('Increment file name with suffix version with `-` as separator', function () { + const name = 'test-1.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test-2.js'); + }); + + test('Increment file name with suffix version with `-` as separator, trailing zeros', function () { + const name = 'test-001.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test-002.js'); + }); + + test('Increment file name with suffix version with `-` as separator, trailing zeros, changnig length', function () { + const name = 'test-099.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test-100.js'); + }); + + test('Increment file name with suffix version with `_` as separator', function () { + const name = 'test_1.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test_2.js'); + }); + + test('Increment folder name with suffix version', function () { + const name = 'test.1'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, 'test.2'); + }); + + test('Increment folder name with suffix version, trailing zeros', function () { + const name = 'test.001'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, 'test.002'); + }); + + test('Increment folder name with suffix version with `-` as separator', function () { + const name = 'test-1'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, 'test-2'); + }); + + test('Increment folder name with suffix version with `_` as separator', function () { + const name = 'test_1'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, 'test_2'); + }); + + test('Increment file name with suffix version, too big number', function () { + const name = 'test.9007199254740992.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, 'test.9007199254740992.1.js'); + }); + + test('Increment folder name with suffix version, too big number', function () { + const name = 'test.9007199254740992'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, 'test.9007199254740992.1'); + }); + + test('Increment file name with prefix version', function () { + const name = '1.test.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, '2.test.js'); + }); + + test('Increment file name with just version in name', function () { + const name = '1.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, '2.js'); + }); + + test('Increment file name with just version in name, too big number', function () { + const name = '9007199254740992.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, '9007199254740992.1.js'); + }); + + test('Increment file name with prefix version, trailing zeros', function () { + const name = '001.test.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, '002.test.js'); + }); + + test('Increment file name with prefix version with `-` as separator', function () { + const name = '1-test.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, '2-test.js'); + }); + + test('Increment file name with prefix version with `-` as separator', function () { + const name = '1_test.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, '2_test.js'); + }); + + test('Increment file name with prefix version, too big number', function () { + const name = '9007199254740992.test.js'; + const result = incrementFileName(name, false, 'smart'); + assert.strictEqual(result, '9007199254740992.test.1.js'); + }); + + test('Increment folder name with prefix version', function () { + const name = '1.test'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, '2.test'); + }); + + test('Increment folder name with prefix version, too big number', function () { + const name = '9007199254740992.test'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, '9007199254740992.test.1'); + }); + + test('Increment folder name with prefix version, trailing zeros', function () { + const name = '001.test'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, '002.test'); + }); + + test('Increment folder name with prefix version with `-` as separator', function () { + const name = '1-test'; + const result = incrementFileName(name, true, 'smart'); + assert.strictEqual(result, '2-test'); + }); + +}); diff --git a/src/vs/workbench/contrib/relauncher/common/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/common/relauncher.contribution.ts index 080e6d741e7..d3c0d4f064f 100644 --- a/src/vs/workbench/contrib/relauncher/common/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/common/relauncher.contribution.ts @@ -23,7 +23,6 @@ import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/ interface IConfiguration extends IWindowsConfiguration { update: { mode: string; }; telemetry: { enableCrashReporter: boolean }; - keyboard: { touchbar: { enabled: boolean } }; workbench: { list: { horizontalScrolling: boolean }, useExperimentalGridLayout: boolean }; debug: { console: { wordWrap: boolean } }; } @@ -36,7 +35,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private clickThroughInactive: boolean; private updateMode: string; private enableCrashReporter: boolean; - private touchbarEnabled: boolean; private treeHorizontalScrolling: boolean; private useGridLayout: boolean; private debugConsoleWordWrap: boolean; @@ -112,12 +110,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo this.enableCrashReporter = config.telemetry.enableCrashReporter; changed = true; } - - // macOS: Touchbar config - if (isMacintosh && config.keyboard && config.keyboard.touchbar && typeof config.keyboard.touchbar.enabled === 'boolean' && config.keyboard.touchbar.enabled !== this.touchbarEnabled) { - this.touchbarEnabled = config.keyboard.touchbar.enabled; - changed = true; - } } // Notify only when changed and we are the focused window (avoids notification spam across windows) diff --git a/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts b/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts index 088b2a19f97..99f4eefd121 100644 --- a/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts +++ b/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts @@ -85,7 +85,7 @@ export class RemoteWindowActiveIndicator extends Disposable implements IWorkbenc group: '6_close', command: { id: CLOSE_REMOTE_COMMAND_ID, - title: nls.localize({ key: 'miCloseRemote', comment: ['&& denotes a mnemonic'] }, "C&&lose Remote Connection") + title: nls.localize({ key: 'miCloseRemote', comment: ['&& denotes a mnemonic'] }, "Close Re&&mote Connection") }, order: 3.5 }); diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 8385ae8124d..84504b8b1f5 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -70,7 +70,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis properties: { 'scm.alwaysShowProviders': { type: 'boolean', - description: localize('alwaysShowProviders', "Controls whether to always show the Source Control Provider section."), + description: localize('alwaysShowProviders', "Controls whether to show the Source Control Provider section even when there's only one Provider registered."), default: false }, 'scm.providers.visible': { diff --git a/src/vs/workbench/contrib/scm/browser/scmViewlet.ts b/src/vs/workbench/contrib/scm/browser/scmViewlet.ts index 5c596e9ef7d..30ef53951d0 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewlet.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewlet.ts @@ -49,6 +49,7 @@ import { IExtensionService } from 'vs/workbench/services/extensions/common/exten import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IViewsRegistry, IViewDescriptor, Extensions } from 'vs/workbench/common/views'; import { Registry } from 'vs/platform/registry/common/platform'; +import { nextTick } from 'vs/base/common/process'; export interface ISpliceEvent { index: number; @@ -324,7 +325,7 @@ export class MainPanel extends ViewletPanel { } private onListSelectionChange(e: IListEvent): void { - if (e.elements.length > 0) { + if (e.browserEvent && e.elements.length > 0) { const scrollTop = this.list.scrollTop; this.viewModel.setVisibleRepositories(e.elements); this.list.scrollTop = scrollTop; @@ -332,7 +333,7 @@ export class MainPanel extends ViewletPanel { } private onListFocusChange(e: IListEvent): void { - if (e.elements.length > 0) { + if (e.browserEvent && e.elements.length > 0) { e.elements[0].focus(); } } @@ -1102,6 +1103,9 @@ export class SCMViewlet extends ViewContainerViewlet implements IViewModel { } } + private readonly _onDidChangeRepositories = new Emitter(); + private readonly onDidFinishStartup = Event.once(Event.debounce(this._onDidChangeRepositories.event, () => null, 1000)); + constructor( @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @ITelemetryService telemetryService: ITelemetryService, @@ -1130,6 +1134,9 @@ export class SCMViewlet extends ViewContainerViewlet implements IViewModel { this.onDidChangeRepositories(); } })); + + this._register(this.onDidFinishStartup(this.onAfterStartup, this)); + this._register(this.viewsModel.onDidRemove(this.onDidHideView, this)); } create(parent: HTMLElement): void { @@ -1190,20 +1197,28 @@ export class SCMViewlet extends ViewContainerViewlet implements IViewModel { if (alwaysShowProviders && repositoryCount > 0) { this.viewsModel.setVisible(MainPanel.ID, true); - } else if (!alwaysShowProviders && repositoryCount === 1) { - this.viewsModel.setVisible(MainPanel.ID, false); - } else if (this.repositoryCount < 2 && repositoryCount >= 2) { - this.viewsModel.setVisible(MainPanel.ID, true); - } else if (this.repositoryCount >= 2 && repositoryCount === 1) { - this.viewsModel.setVisible(MainPanel.ID, false); - } - - if (repositoryCount === 1) { - this.viewsModel.setVisible(this.viewDescriptors[0].id, true); } toggleClass(this.el, 'empty', repositoryCount === 0); this.repositoryCount = repositoryCount; + + this._onDidChangeRepositories.fire(); + } + + private onAfterStartup(): void { + if (this.repositoryCount > 0 && this.viewDescriptors.every(d => !this.viewsModel.isVisible(d.id))) { + this.viewsModel.setVisible(this.viewDescriptors[0].id, true); + } + } + + private onDidHideView(): void { + nextTick(() => { + if (this.repositoryCount > 0 && this.viewDescriptors.every(d => !this.viewsModel.isVisible(d.id))) { + const alwaysShowProviders = this.configurationService.getValue('scm.alwaysShowProviders') || false; + this.viewsModel.setVisible(MainPanel.ID, alwaysShowProviders || this.repositoryCount > 1); + this.viewsModel.setVisible(this.viewDescriptors[0].id, true); + } + }); } focus(): void { diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 2559188da9e..9d52970cf2c 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -766,7 +766,7 @@ export class TerminalTaskSystem implements ITaskSystem { let terminalName = this.createTerminalName(task, workspaceFolder); let originalCommand = task.command.name; if (isShellCommand) { - const defaultConfig = await this.terminalInstanceService.getDefaultShellAndArgs(platform); + const defaultConfig = await this.terminalInstanceService.getDefaultShellAndArgs(true, platform); shellLaunchConfig = { name: terminalName, executable: defaultConfig.shell, args: defaultConfig.args, waitOnExit }; let shellSpecified: boolean = false; let shellOptions: ShellConfiguration | undefined = task.command.options && task.command.options.shell; diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index f1481ee18f6..dbd0aa41286 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -71,6 +71,21 @@ configurationRegistry.registerConfiguration({ title: nls.localize('terminalIntegratedConfigurationTitle', "Integrated Terminal"), type: 'object', properties: { + 'terminal.integrated.automationShell.linux': { + markdownDescription: nls.localize('terminal.integrated.automationShell.linux', "A path that when set will override {0} and ignore {1} and {2} values for automation-related terminal usage like tasks and debug.", '`terminal.integrated.shell.linux`', '`shellArgs`', '`env`'), + type: ['string', 'null'], + default: null + }, + 'terminal.integrated.automationShell.osx': { + markdownDescription: nls.localize('terminal.integrated.automationShell.osx', "A path that when set will override {0} and ignore {1} and {2} values for automation-related terminal usage like tasks and debug.", '`terminal.integrated.shell.osx`', '`shellArgs`', '`env`'), + type: ['string', 'null'], + default: null + }, + 'terminal.integrated.automationShell.windows': { + markdownDescription: nls.localize('terminal.integrated.automationShell.windows', "A path that when set will override {0} and ignore {1} and {2} values for automation-related terminal usage like tasks and debug.", '`terminal.integrated.shell.windows`', '`shellArgs`', '`env`'), + type: ['string', 'null'], + default: null + }, 'terminal.integrated.shellArgs.linux': { markdownDescription: nls.localize('terminal.integrated.shellArgs.linux', "The command line arguments to use when on the Linux terminal. [Read more about configuring the shell](https://code.visualstudio.com/docs/editor/integrated-terminal#_configuration)."), type: 'array', diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 418b2900f17..42e98f25547 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -30,7 +30,7 @@ export interface ITerminalInstanceService { createWindowsShellHelper(shellProcessId: number, instance: ITerminalInstance, xterm: XTermTerminal): IWindowsShellHelper; createTerminalProcess(shellLaunchConfig: IShellLaunchConfig, cwd: string, cols: number, rows: number, env: IProcessEnvironment, windowsEnableConpty: boolean): ITerminalChildProcess; - getDefaultShellAndArgs(platformOverride?: Platform): Promise<{ shell: string, args: string[] | string | undefined }>; + getDefaultShellAndArgs(useAutomationShell: boolean, platformOverride?: Platform): Promise<{ shell: string, args: string[] | string | undefined }>; getMainProcessParentEnv(): Promise; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 9d6decb1a34..9ca4fa8fb71 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -482,6 +482,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } this._xterm.onLineFeed(() => this._onLineFeed()); this._xterm.onKey(e => this._onKey(e.key, e.domEvent)); + this._xterm.onSelectionChange(async () => this._onSelectionChange()); this._processManager.onProcessData(data => this._onProcessData(data)); this._xterm.onData(data => this._processManager.write(data)); @@ -814,6 +815,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } this._processManager.dispose(immediate); + // Process manager dispose/shutdown doesn't fire process exit, trigger with undefined if it + // hasn't happened yet + this._onProcessExit(undefined); if (!this._isDisposed) { this._isDisposed = true; @@ -1012,13 +1016,13 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { * through user action. */ private _onProcessExit(exitCode?: number): void { - this._logService.debug(`Terminal process exit (id: ${this.id}) with code ${exitCode}`); - // Prevent dispose functions being triggered multiple times if (this._isExiting) { return; } + this._logService.debug(`Terminal process exit (id: ${this.id}) with code ${exitCode}`); + this._isExiting = true; let exitCodeMessage: string | undefined; @@ -1188,6 +1192,14 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } } + private async _onSelectionChange(): Promise { + if (this._configurationService.getValue('terminal.integrated.copyOnSelection')) { + if (this.hasSelection()) { + await this.copySelection(); + } + } + } + @debounce(2000) private async _updateProcessCwd(): Promise { // reset cwd if it has changed, so file based url paths can be resolved diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts index 08980a94000..3ed29f8f3bc 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts @@ -52,11 +52,14 @@ export class TerminalInstanceService implements ITerminalInstanceService { throw new Error('Not implemented'); } - public getDefaultShellAndArgs(): Promise<{ shell: string, args: string[] | string | undefined }> { - return new Promise(r => this._onRequestDefaultShellAndArgs.fire((shell, args) => r({ shell, args }))); + public getDefaultShellAndArgs(useAutomationShell: boolean, ): Promise<{ shell: string, args: string[] | string | undefined }> { + return new Promise(r => this._onRequestDefaultShellAndArgs.fire({ + useAutomationShell, + callback: (shell, args) => r({ shell, args }) + })); } public async getMainProcessParentEnv(): Promise { return {}; } -} \ No newline at end of file +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalPanel.ts b/src/vs/workbench/contrib/terminal/browser/terminalPanel.ts index 77d6388d0ef..334c3ab0b13 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalPanel.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalPanel.ts @@ -239,20 +239,6 @@ export class TerminalPanel extends Panel { } } })); - this._register(dom.addDisposableListener(parentDomElement, 'mouseup', async (event: MouseEvent) => { - if (this._configurationService.getValue('terminal.integrated.copyOnSelection')) { - if (this._terminalService.terminalInstances.length === 0) { - return; - } - - if (event.which === 1) { - const terminal = this._terminalService.getActiveInstance(); - if (terminal && terminal.hasSelection()) { - await terminal.copySelection(); - } - } - } - })); this._register(dom.addDisposableListener(parentDomElement, 'contextmenu', (event: MouseEvent) => { if (!this._cancelContextMenu) { const standardEvent = new StandardMouseEvent(event); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 826122750b8..b3ff7db5e88 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -195,7 +195,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce const platformKey = platform.isWindows ? 'windows' : (platform.isMacintosh ? 'osx' : 'linux'); const lastActiveWorkspace = activeWorkspaceRootUri ? this._workspaceContextService.getWorkspaceFolder(activeWorkspaceRootUri) : null; if (!shellLaunchConfig.executable) { - const defaultConfig = await this._terminalInstanceService.getDefaultShellAndArgs(); + const defaultConfig = await this._terminalInstanceService.getDefaultShellAndArgs(false); shellLaunchConfig.executable = defaultConfig.shell; shellLaunchConfig.args = defaultConfig.args; } else { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 4f584f6d13a..bfa25469cea 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -5,7 +5,7 @@ import { ITerminalService, TERMINAL_PANEL_ID, ITerminalInstance, IShellLaunchConfig, ITerminalConfigHelper, ITerminalNativeService } from 'vs/workbench/contrib/terminal/common/terminal'; import { TerminalService as CommonTerminalService } from 'vs/workbench/contrib/terminal/common/terminalService'; -import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IPanelService } from 'vs/workbench/services/panel/common/panelService'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle'; @@ -49,18 +49,15 @@ export class TerminalService extends CommonTerminalService implements ITerminalS this._configHelper = this._instantiationService.createInstance(TerminalConfigHelper, this.terminalNativeService.linuxDistro); } - public createInstance(terminalFocusContextKey: IContextKey, configHelper: ITerminalConfigHelper, container: HTMLElement | undefined, shellLaunchConfig: IShellLaunchConfig): ITerminalInstance { - const instance = this._instantiationService.createInstance(TerminalInstance, terminalFocusContextKey, configHelper, container, shellLaunchConfig); + public createInstance(container: HTMLElement | undefined, shellLaunchConfig: IShellLaunchConfig): ITerminalInstance { + const instance = this._instantiationService.createInstance(TerminalInstance, this._terminalFocusContextKey, this._configHelper, container, shellLaunchConfig); this._onInstanceCreated.fire(instance); return instance; } public createTerminal(shell: IShellLaunchConfig = {}): ITerminalInstance { if (shell.hideFromUser) { - const instance = this.createInstance(this._terminalFocusContextKey, - this.configHelper, - undefined, - shell); + const instance = this.createInstance(undefined, shell); this._backgroundedTerminalInstances.push(instance); this._initInstanceListeners(instance); return instance; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTab.ts b/src/vs/workbench/contrib/terminal/browser/terminalTab.ts index 4b49a00187d..8f2fd4fbaee 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTab.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTab.ts @@ -240,11 +240,7 @@ export class TerminalTab extends Disposable implements ITerminalTab { if ('id' in shellLaunchConfigOrInstance) { instance = shellLaunchConfigOrInstance; } else { - instance = this._terminalService.createInstance( - terminalFocusContextKey, - configHelper, - undefined, - shellLaunchConfigOrInstance); + instance = this._terminalService.createInstance(undefined, shellLaunchConfigOrInstance); } this._terminalInstances.push(instance); this._initInstanceListeners(instance); @@ -385,11 +381,7 @@ export class TerminalTab extends Disposable implements ITerminalTab { if (newTerminalSize < TERMINAL_MIN_USEFUL_SIZE) { return undefined; } - const instance = this._terminalService.createInstance( - terminalFocusContextKey, - configHelper, - undefined, - shellLaunchConfig); + const instance = this._terminalService.createInstance(undefined, shellLaunchConfig); this._terminalInstances.splice(this._activeInstanceIndex + 1, 0, instance); this._initInstanceListeners(instance); this._setActiveInstance(instance); diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index eb991bcabfa..bb99965b26c 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -76,6 +76,11 @@ export interface ITerminalConfiguration { osx: string | null; windows: string | null; }; + automationShell: { + linux: string | null; + osx: string | null; + windows: string | null; + }; shellArgs: { linux: string[]; osx: string[]; @@ -242,7 +247,7 @@ export interface ITerminalService { /** * Creates a raw terminal instance, this should not be used outside of the terminal part. */ - createInstance(terminalFocusContextKey: IContextKey, configHelper: ITerminalConfigHelper, container: HTMLElement | undefined, shellLaunchConfig: IShellLaunchConfig): ITerminalInstance; + createInstance(container: HTMLElement | undefined, shellLaunchConfig: IShellLaunchConfig): ITerminalInstance; getInstanceFromId(terminalId: number): ITerminalInstance | undefined; getInstanceFromIndex(terminalIndex: number): ITerminalInstance; getTabLabels(): string[]; @@ -754,7 +759,8 @@ export interface IAvailableShellsRequest { } export interface IDefaultShellAndArgsRequest { - (shell: string, args: string[] | string | undefined): void; + useAutomationShell: boolean; + callback: (shell: string, args: string[] | string | undefined) => void; } export enum LinuxDistro { diff --git a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts index 4a75398475f..9d6215af30c 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts @@ -197,47 +197,61 @@ export function getDefaultShell( lastActiveWorkspace: IWorkspaceFolder | undefined, configurationResolverService: IConfigurationResolverService | undefined, logService: ILogService, + useAutomationShell: boolean, platformOverride: platform.Platform = platform.platform ): string { - const platformKey = platformOverride === platform.Platform.Windows ? 'windows' : platformOverride === platform.Platform.Mac ? 'osx' : 'linux'; - const shellConfigValue = fetchSetting(`terminal.integrated.shell.${platformKey}`); - let executable = (isWorkspaceShellAllowed ? shellConfigValue.value : shellConfigValue.user) || (shellConfigValue.default || defaultShell); + let maybeExecutable: string | null = null; + if (useAutomationShell) { + // If automationShell is specified, this should override the normal setting + maybeExecutable = getShellSetting(fetchSetting, isWorkspaceShellAllowed, 'automationShell', platformOverride); + } + if (!maybeExecutable) { + maybeExecutable = getShellSetting(fetchSetting, isWorkspaceShellAllowed, 'shell', platformOverride); + } + maybeExecutable = maybeExecutable || defaultShell; // Change Sysnative to System32 if the OS is Windows but NOT WoW64. It's // safe to assume that this was used by accident as Sysnative does not // exist and will break the terminal in non-WoW64 environments. if ((platformOverride === platform.Platform.Windows) && !isWoW64 && windir) { - const sysnativePath = path.join(windir, 'Sysnative').toLowerCase(); - if (executable && executable.toLowerCase().indexOf(sysnativePath) === 0) { - executable = path.join(windir, 'System32', executable.substr(sysnativePath.length)); + const sysnativePath = path.join(windir, 'Sysnative').replace(/\//g, '\\').toLowerCase(); + if (maybeExecutable && maybeExecutable.toLowerCase().indexOf(sysnativePath) === 0) { + maybeExecutable = path.join(windir, 'System32', maybeExecutable.substr(sysnativePath.length + 1)); } } // Convert / to \ on Windows for convenience - if (executable && platformOverride === platform.Platform.Windows) { - executable = executable.replace(/\//g, '\\'); + if (maybeExecutable && platformOverride === platform.Platform.Windows) { + maybeExecutable = maybeExecutable.replace(/\//g, '\\'); } if (configurationResolverService) { try { - executable = configurationResolverService.resolve(lastActiveWorkspace, executable); + maybeExecutable = configurationResolverService.resolve(lastActiveWorkspace, maybeExecutable); } catch (e) { - logService.error(`Could not resolve terminal.integrated.shell.${platformKey}`, e); - executable = executable; + logService.error(`Could not resolve shell`, e); + maybeExecutable = maybeExecutable; } } - return executable; + return maybeExecutable; } export function getDefaultShellArgs( fetchSetting: (key: string) => { user: string | string[] | undefined, value: string | string[] | undefined, default: string | string[] | undefined }, isWorkspaceShellAllowed: boolean, + useAutomationShell: boolean, lastActiveWorkspace: IWorkspaceFolder | undefined, configurationResolverService: IConfigurationResolverService | undefined, logService: ILogService, platformOverride: platform.Platform = platform.platform, ): string | string[] { + if (useAutomationShell) { + if (!!getShellSetting(fetchSetting, isWorkspaceShellAllowed, 'automationShell', platformOverride)) { + return []; + } + } + const platformKey = platformOverride === platform.Platform.Windows ? 'windows' : platformOverride === platform.Platform.Mac ? 'osx' : 'linux'; const shellArgsConfigValue = fetchSetting(`terminal.integrated.shellArgs.${platformKey}`); let args = ((isWorkspaceShellAllowed ? shellArgsConfigValue.value : shellArgsConfigValue.user) || shellArgsConfigValue.default); @@ -259,6 +273,18 @@ export function getDefaultShellArgs( return args; } +function getShellSetting( + fetchSetting: (key: string) => { user: string | string[] | undefined, value: string | string[] | undefined, default: string | string[] | undefined }, + isWorkspaceShellAllowed: boolean, + type: 'automationShell' | 'shell', + platformOverride: platform.Platform = platform.platform, +): string | null { + const platformKey = platformOverride === platform.Platform.Windows ? 'windows' : platformOverride === platform.Platform.Mac ? 'osx' : 'linux'; + const shellConfigValue = fetchSetting(`terminal.integrated.${type}.${platformKey}`); + const executable = (isWorkspaceShellAllowed ? shellConfigValue.value : shellConfigValue.user) || (shellConfigValue.default); + return executable; +} + export function createTerminalEnvironment( shellLaunchConfig: IShellLaunchConfig, lastActiveWorkspace: IWorkspaceFolder | null, diff --git a/src/vs/workbench/contrib/terminal/common/terminalService.ts b/src/vs/workbench/contrib/terminal/common/terminalService.ts index a9595156654..1b04048fdcc 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalService.ts @@ -123,7 +123,7 @@ export abstract class TerminalService implements ITerminalService { protected abstract _showBackgroundTerminal(instance: ITerminalInstance): void; public abstract createTerminal(shell?: IShellLaunchConfig, wasNewTerminalAction?: boolean): ITerminalInstance; - public abstract createInstance(terminalFocusContextKey: IContextKey, configHelper: ITerminalConfigHelper, container: HTMLElement, shellLaunchConfig: IShellLaunchConfig): ITerminalInstance; + public abstract createInstance(container: HTMLElement, shellLaunchConfig: IShellLaunchConfig): ITerminalInstance; public abstract setContainers(panelContainer: HTMLElement, terminalContainer: HTMLElement): void; public getActiveOrCreateInstance(wasNewTerminalAction?: boolean): ITerminalInstance { diff --git a/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts b/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts index 2b7fa1c4fa3..a7c3813e987 100644 --- a/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts +++ b/src/vs/workbench/contrib/terminal/electron-browser/terminalInstanceService.ts @@ -73,7 +73,7 @@ export class TerminalInstanceService implements ITerminalInstanceService { return this._storageService.getBoolean(IS_WORKSPACE_SHELL_ALLOWED_STORAGE_KEY, StorageScope.WORKSPACE, false); } - public getDefaultShellAndArgs(platformOverride: Platform = platform): Promise<{ shell: string, args: string | string[] }> { + public getDefaultShellAndArgs(useAutomationShell: boolean, platformOverride: Platform = platform): Promise<{ shell: string, args: string | string[] }> { const isWorkspaceShellAllowed = this._isWorkspaceShellAllowed(); const activeWorkspaceRootUri = this._historyService.getLastActiveWorkspaceRoot(); let lastActiveWorkspace = activeWorkspaceRootUri ? this._workspaceContextService.getWorkspaceFolder(activeWorkspaceRootUri) : undefined; @@ -87,11 +87,13 @@ export class TerminalInstanceService implements ITerminalInstanceService { lastActiveWorkspace, this._configurationResolverService, this._logService, + useAutomationShell, platformOverride ); const args = getDefaultShellArgs( (key) => this._configurationService.inspect(key), isWorkspaceShellAllowed, + useAutomationShell, lastActiveWorkspace, this._configurationResolverService, this._logService, diff --git a/src/vs/workbench/contrib/terminal/test/node/terminalEnvironment.test.ts b/src/vs/workbench/contrib/terminal/test/node/terminalEnvironment.test.ts index 2368badf9b8..1f8ecd44171 100644 --- a/src/vs/workbench/contrib/terminal/test/node/terminalEnvironment.test.ts +++ b/src/vs/workbench/contrib/terminal/test/node/terminalEnvironment.test.ts @@ -128,4 +128,48 @@ suite('Workbench - TerminalEnvironment', () => { assertPathsMatch(terminalEnvironment.getCwd({ executable: undefined, args: [], ignoreConfigurationCwd: true }, '/userHome/', undefined, undefined, Uri.file('/bar'), '/foo'), '/bar'); }); }); + + suite('getDefaultShell', () => { + test('should change Sysnative to System32 in non-WoW64 systems', () => { + const shell = terminalEnvironment.getDefaultShell(key => { + return ({ + 'terminal.integrated.shell.windows': { user: 'C:\\Windows\\Sysnative\\cmd.exe', value: undefined, default: undefined } + } as any)[key]; + }, false, 'DEFAULT', false, 'C:\\Windows', undefined, undefined, {} as any, false, platform.Platform.Windows); + assert.equal(shell, 'C:\\Windows\\System32\\cmd.exe'); + }); + + test('should not change Sysnative to System32 in WoW64 systems', () => { + const shell = terminalEnvironment.getDefaultShell(key => { + return ({ + 'terminal.integrated.shell.windows': { user: 'C:\\Windows\\Sysnative\\cmd.exe', value: undefined, default: undefined } + } as any)[key]; + }, false, 'DEFAULT', true, 'C:\\Windows', undefined, undefined, {} as any, false, platform.Platform.Windows); + assert.equal(shell, 'C:\\Windows\\Sysnative\\cmd.exe'); + }); + + test('should use automationShell when specified', () => { + const shell1 = terminalEnvironment.getDefaultShell(key => { + return ({ + 'terminal.integrated.shell.windows': { user: 'shell', value: undefined, default: undefined }, + 'terminal.integrated.automationShell.windows': { user: undefined, value: undefined, default: undefined } + } as any)[key]; + }, false, 'DEFAULT', false, 'C:\\Windows', undefined, undefined, {} as any, false, platform.Platform.Windows); + assert.equal(shell1, 'shell', 'automationShell was false'); + const shell2 = terminalEnvironment.getDefaultShell(key => { + return ({ + 'terminal.integrated.shell.windows': { user: 'shell', value: undefined, default: undefined }, + 'terminal.integrated.automationShell.windows': { user: undefined, value: undefined, default: undefined } + } as any)[key]; + }, false, 'DEFAULT', false, 'C:\\Windows', undefined, undefined, {} as any, true, platform.Platform.Windows); + assert.equal(shell2, 'shell', 'automationShell was true'); + const shell3 = terminalEnvironment.getDefaultShell(key => { + return ({ + 'terminal.integrated.shell.windows': { user: 'shell', value: undefined, default: undefined }, + 'terminal.integrated.automationShell.windows': { user: 'automationShell', value: undefined, default: undefined } + } as any)[key]; + }, false, 'DEFAULT', false, 'C:\\Windows', undefined, undefined, {} as any, true, platform.Platform.Windows); + assert.equal(shell3, 'automationShell', 'automationShell was true and specified in settings'); + }); + }); }); diff --git a/src/vs/workbench/electron-browser/actions/windowActions.ts b/src/vs/workbench/electron-browser/actions/windowActions.ts index 7bb652eb2f5..bc094a193fd 100644 --- a/src/vs/workbench/electron-browser/actions/windowActions.ts +++ b/src/vs/workbench/electron-browser/actions/windowActions.ts @@ -55,8 +55,12 @@ export class NewWindowAction extends Action { } export abstract class BaseZoomAction extends Action { + private static readonly SETTING_KEY = 'window.zoomLevel'; + private static readonly MAX_ZOOM_LEVEL = 9; + private static readonly MIN_ZOOM_LEVEL = -8; + constructor( id: string, label: string, @@ -68,6 +72,10 @@ export abstract class BaseZoomAction extends Action { protected async setConfiguredZoomLevel(level: number): Promise { level = Math.round(level); // when reaching smallest zoom, prevent fractional zoom levels + if (level > BaseZoomAction.MAX_ZOOM_LEVEL || level < BaseZoomAction.MIN_ZOOM_LEVEL) { + return; // https://github.com/microsoft/vscode/issues/48357 + } + const applyZoom = () => { webFrame.setZoomLevel(level); browser.setZoomFactor(webFrame.getZoomFactor()); diff --git a/src/vs/workbench/electron-browser/window.ts b/src/vs/workbench/electron-browser/window.ts index e10a49baee6..bfc7916011e 100644 --- a/src/vs/workbench/electron-browser/window.ts +++ b/src/vs/workbench/electron-browser/window.ts @@ -213,6 +213,8 @@ export class ElectronWindow extends Disposable { this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('window.zoomLevel')) { this.updateWindowZoomLevel(); + } else if (e.affectsConfiguration('keyboard.touchbar.enabled') || e.affectsConfiguration('keyboard.touchbar.ignored')) { + this.updateTouchbarMenu(); } })); @@ -340,11 +342,8 @@ export class ElectronWindow extends Disposable { } private updateTouchbarMenu(): void { - if ( - !isMacintosh || // macOS only - !this.configurationService.getValue('keyboard.touchbar.enabled') // disabled via setting - ) { - return; + if (!isMacintosh) { + return; // macOS only } // Dispose old @@ -366,31 +365,40 @@ export class ElectronWindow extends Disposable { const actions: Array = []; + const disabled = this.configurationService.getValue('keyboard.touchbar.enabled') === false; + const ignoredItems = this.configurationService.getValue('keyboard.touchbar.ignored') || []; + // Fill actions into groups respecting order this.touchBarDisposables.add(createAndFillInActionBarActions(this.touchBarMenu, undefined, actions)); // Convert into command action multi array const items: ICommandAction[][] = []; let group: ICommandAction[] = []; - for (const action of actions) { + if (!disabled) { + for (const action of actions) { - // Command - if (action instanceof MenuItemAction) { - group.push(action.item); - } + // Command + if (action instanceof MenuItemAction) { + if (ignoredItems.indexOf(action.item.id) >= 0) { + continue; // ignored + } - // Separator - else if (action instanceof Separator) { - if (group.length) { - items.push(group); + group.push(action.item); } - group = []; - } - } + // Separator + else if (action instanceof Separator) { + if (group.length) { + items.push(group); + } - if (group.length) { - items.push(group); + group = []; + } + } + + if (group.length) { + items.push(group); + } } // Only update if the actions have changed diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index d6c619eae10..b6c34540a10 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -32,11 +32,13 @@ export class BrowserWindowConfiguration implements IWindowConfiguration { nodeCachedDataDir?: string; backupPath?: string; + backupWorkspaceResource?: URI; workspace?: IWorkspaceIdentifier; folderUri?: ISingleFolderWorkspaceIdentifier; - remoteAuthority: string; + remoteAuthority?: string; + connectionToken?: string; zoomLevel?: number; fullscreen?: boolean; diff --git a/src/vs/workbench/services/environment/node/environmentService.ts b/src/vs/workbench/services/environment/node/environmentService.ts index 8b8b9354b63..2ebf10365a2 100644 --- a/src/vs/workbench/services/environment/node/environmentService.ts +++ b/src/vs/workbench/services/environment/node/environmentService.ts @@ -10,16 +10,18 @@ import { memoize } from 'vs/base/common/decorators'; import { URI } from 'vs/base/common/uri'; import { Schemas } from 'vs/base/common/network'; import { toBackupWorkspaceResource } from 'vs/workbench/services/backup/common/backup'; +import { ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation'; export class WorkbenchEnvironmentService extends EnvironmentService implements IWorkbenchEnvironmentService { - _serviceBrand: any; + _serviceBrand!: ServiceIdentifier; constructor( private _configuration: IWindowConfiguration, execPath: string ) { super(_configuration, execPath); + this._configuration.backupWorkspaceResource = this._configuration.backupPath ? toBackupWorkspaceResource(this._configuration.backupPath, this) : undefined; } diff --git a/src/vs/workbench/services/keybinding/browser/keybindingService.ts b/src/vs/workbench/services/keybinding/browser/keybindingService.ts index dec59cfca2c..c9394b52fc8 100644 --- a/src/vs/workbench/services/keybinding/browser/keybindingService.ts +++ b/src/vs/workbench/services/keybinding/browser/keybindingService.ts @@ -725,7 +725,6 @@ const keyboardConfiguration: IConfigurationNode = { 'markdownDescription': nls.localize('dispatch', "Controls the dispatching logic for key presses to use either `code` (recommended) or `keyCode`."), 'included': OS === OperatingSystem.Macintosh || OS === OperatingSystem.Linux } - // no touch bar support } }; diff --git a/src/vs/workbench/services/keybinding/electron-browser/keybinding.contribution.ts b/src/vs/workbench/services/keybinding/electron-browser/keybinding.contribution.ts index eec3ac01744..ae95d7b85f4 100644 --- a/src/vs/workbench/services/keybinding/electron-browser/keybinding.contribution.ts +++ b/src/vs/workbench/services/keybinding/electron-browser/keybinding.contribution.ts @@ -22,8 +22,17 @@ const keyboardConfiguration: IConfigurationNode = { 'default': true, 'description': nls.localize('touchbar.enabled', "Enables the macOS touchbar buttons on the keyboard if available."), 'included': OS === OperatingSystem.Macintosh && parseFloat(release()) >= 16 // Minimum: macOS Sierra (10.12.x = darwin 16.x) + }, + 'keyboard.touchbar.ignored': { + 'type': 'array', + 'items': { + 'type': 'string' + }, + 'default': [], + 'description': nls.localize('touchbar.ignored', 'A set of identifiers for entries in the touchbar that should not show up (for example `workbench.action.navigateBack`.'), + 'included': OS === OperatingSystem.Macintosh && parseFloat(release()) >= 16 // Minimum: macOS Sierra (10.12.x = darwin 16.x) } } }; -configurationRegistry.registerConfiguration(keyboardConfiguration); \ No newline at end of file +configurationRegistry.registerConfiguration(keyboardConfiguration); diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index 3ece543b7d3..4fa61e19c83 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -470,7 +470,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // 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(); + this.doMakeDirty(); if (this.autoSaveAfterMilliesEnabled) { this.doAutoSave(this.versionId); } @@ -549,7 +549,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.logService.trace('onModelContentChanged() - model content changed and marked as dirty', this.resource); // Mark as dirty - this.makeDirty(); + this.doMakeDirty(); // Start auto save process unless we are in conflict resolution mode and unless it is disabled if (this.autoSaveAfterMilliesEnabled) { @@ -564,7 +564,15 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.contentChangeEventScheduler.schedule(); } - private makeDirty(): void { + makeDirty(): void { + if (!this.isResolved()) { + return; // only resolved models can be marked dirty + } + + this.doMakeDirty(); + } + + private doMakeDirty(): void { // Track dirty state and version id const wasDirty = this.dirty; @@ -913,7 +921,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil TextFileEditorModel.saveErrorHandler.onSaveError(error, this); } - isDirty(): boolean { + isDirty(): this is IResolvedTextFileEditorModel { return this.dirty; } diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts index 13f60f4629b..4de574861a0 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts @@ -74,11 +74,11 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE return this._onModelsReverted; } - private mapResourceToDisposeListener: ResourceMap; - private mapResourceToStateChangeListener: ResourceMap; - private mapResourceToModelContentChangeListener: ResourceMap; - private mapResourceToModel: ResourceMap; - private mapResourceToPendingModelLoaders: ResourceMap>; + private mapResourceToDisposeListener = new ResourceMap(); + private mapResourceToStateChangeListener = new ResourceMap(); + private mapResourceToModelContentChangeListener = new ResourceMap(); + private mapResourceToModel = new ResourceMap(); + private mapResourceToPendingModelLoaders = new ResourceMap>(); constructor( @ILifecycleService private readonly lifecycleService: ILifecycleService, @@ -86,12 +86,6 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE ) { super(); - this.mapResourceToModel = new ResourceMap(); - this.mapResourceToDisposeListener = new ResourceMap(); - this.mapResourceToStateChangeListener = new ResourceMap(); - this.mapResourceToModelContentChangeListener = new ResourceMap(); - this.mapResourceToPendingModelLoaders = new ResourceMap>(); - this.registerListeners(); } diff --git a/src/vs/workbench/services/textfile/common/textFileService.ts b/src/vs/workbench/services/textfile/common/textFileService.ts index a061414b910..713ef13e395 100644 --- a/src/vs/workbench/services/textfile/common/textFileService.ts +++ b/src/vs/workbench/services/textfile/common/textFileService.ts @@ -443,9 +443,94 @@ export abstract class TextFileService extends Disposable implements ITextFileSer } async move(source: URI, target: URI, overwrite?: boolean): Promise { + + // await onWillMove event joiners + await this.notifyOnWillMove(source, target); + + // find all models that related to either source or target (can be many if resource is a folder) + const sourceModels: ITextFileEditorModel[] = []; + const conflictingModels: ITextFileEditorModel[] = []; + for (const model of this.getFileModels()) { + const resource = model.getResource(); + + if (isEqualOrParent(resource, target, false /* do not ignorecase, see https://github.com/Microsoft/vscode/issues/56384 */)) { + conflictingModels.push(model); + } + + if (isEqualOrParent(resource, source)) { + sourceModels.push(model); + } + } + + // remember each source model to load again after move is done + // with optional content to restore if it was dirty + type ModelToRestore = { resource: URI; snapshot?: ITextSnapshot }; + const modelsToRestore: ModelToRestore[] = []; + for (const sourceModel of sourceModels) { + const sourceModelResource = sourceModel.getResource(); + + // If the source is the actual model, just use target as new resource + let modelToRestoreResource: URI; + if (isEqual(sourceModelResource, source)) { + modelToRestoreResource = target; + } + + // Otherwise a parent folder of the source is being moved, so we need + // to compute the target resource based on that + else { + modelToRestoreResource = joinPath(target, sourceModelResource.path.substr(source.path.length + 1)); + } + + const modelToRestore: ModelToRestore = { resource: modelToRestoreResource }; + if (sourceModel.isDirty()) { + modelToRestore.snapshot = sourceModel.createSnapshot(); + } + + modelsToRestore.push(modelToRestore); + } + + // in order to move, we need to soft revert all dirty models, + // both from the source as well as the target if any + const dirtyModels = [...sourceModels, ...conflictingModels].filter(model => model.isDirty()); + await this.revertAll(dirtyModels.map(dirtyModel => dirtyModel.getResource()), { soft: true }); + + // now we can rename the source to target via file operation + let stat: IFileStatWithMetadata; + try { + stat = await this.fileService.move(source, target, overwrite); + } catch (error) { + + // in case of any error, ensure to set dirty flag back + dirtyModels.forEach(dirtyModel => dirtyModel.makeDirty()); + + throw error; + } + + // finally, restore models that we had loaded previously + await Promise.all(modelsToRestore.map(async modelToRestore => { + + // restore the model, forcing a reload. this is important because + // we know the file has changed on disk after the move and the + // model might have still existed with the previous state. this + // ensures we are not tracking a stale state. + const restoredModel = await this.models.loadOrCreate(modelToRestore.resource, { reload: { async: false } }); + + // restore previous dirty content if any and ensure to mark + // the model as dirty + if (modelToRestore.snapshot && restoredModel.isResolved()) { + this.modelService.updateModel(restoredModel.textEditorModel, createTextBufferFactoryFromSnapshot(modelToRestore.snapshot)); + + restoredModel.makeDirty(); + } + })); + + return stat; + } + + private async notifyOnWillMove(source: URI, target: URI): Promise { const waitForPromises: Promise[] = []; - // Event + // fire event this._onWillMove.fire({ oldResource: source, newResource: target, @@ -458,58 +543,6 @@ export abstract class TextFileService extends Disposable implements ITextFileSer Object.freeze(waitForPromises); await Promise.all(waitForPromises); - - // Handle target models if existing (if target URI is a folder, this can be multiple) - const dirtyTargetModels = this.getDirtyFileModels().filter(model => isEqualOrParent(model.getResource(), target, false /* do not ignorecase, see https://github.com/Microsoft/vscode/issues/56384 */)); - if (dirtyTargetModels.length) { - await this.revertAll(dirtyTargetModels.map(targetModel => targetModel.getResource()), { soft: true }); - } - - // Handle dirty source models if existing (if source URI is a folder, this can be multiple) - const dirtySourceModels = this.getDirtyFileModels().filter(model => isEqualOrParent(model.getResource(), source)); - const dirtyTargetModelUris: URI[] = []; - if (dirtySourceModels.length) { - await Promise.all(dirtySourceModels.map(async sourceModel => { - const sourceModelResource = sourceModel.getResource(); - let targetModelResource: URI; - - // If the source is the actual model, just use target as new resource - if (isEqual(sourceModelResource, source)) { - targetModelResource = target; - } - - // Otherwise a parent folder of the source is being moved, so we need - // to compute the target resource based on that - else { - targetModelResource = sourceModelResource.with({ path: joinPath(target, sourceModelResource.path.substr(source.path.length + 1)).path }); - } - - // Remember as dirty target model to load after the operation - dirtyTargetModelUris.push(targetModelResource); - - // Backup dirty source model to the target resource it will become later - await sourceModel.backup(targetModelResource); - })); - } - - // Soft revert the dirty source files if any - await this.revertAll(dirtySourceModels.map(dirtySourceModel => dirtySourceModel.getResource()), { soft: true }); - - // Rename to target - try { - const stat = await this.fileService.move(source, target, overwrite); - - // Load models that were dirty before - await Promise.all(dirtyTargetModelUris.map(dirtyTargetModel => this.models.loadOrCreate(dirtyTargetModel))); - - return stat; - } catch (error) { - - // In case of an error, discard any dirty target backups that were made - await Promise.all(dirtyTargetModelUris.map(dirtyTargetModel => this.backupFileService.discardResourceBackup(dirtyTargetModel))); - - throw error; - } } //#endregion @@ -857,7 +890,7 @@ export abstract class TextFileService extends Disposable implements ITextFileSer return false; } - // take over encoding, mode (only if more specific) and model value from source model + // take over model value, encoding and mode (only if more specific) from source model targetModel.updatePreferredEncoding(sourceModel.getEncoding()); if (sourceModel.isResolved() && targetModel.isResolved()) { this.modelService.updateModel(targetModel.textEditorModel, createTextBufferFactoryFromSnapshot(sourceModel.createSnapshot())); diff --git a/src/vs/workbench/services/textfile/common/textfiles.ts b/src/vs/workbench/services/textfile/common/textfiles.ts index 5438af0f575..02956fd1cdd 100644 --- a/src/vs/workbench/services/textfile/common/textfiles.ts +++ b/src/vs/workbench/services/textfile/common/textfiles.ts @@ -470,7 +470,9 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport hasBackup(): boolean; - isDirty(): boolean; + isDirty(): this is IResolvedTextFileEditorModel; + + makeDirty(): void; isResolved(): this is IResolvedTextFileEditorModel; diff --git a/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts b/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts index e22cbe034db..b5609d42d32 100644 --- a/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts +++ b/src/vs/workbench/services/textfile/test/textFileEditorModel.test.ts @@ -219,6 +219,33 @@ suite('Files - TextFileEditorModel', () => { assert.ok(model.isDirty()); }); + test('Make Dirty', async function () { + let eventCounter = 0; + + const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); + + model.makeDirty(); + assert.ok(!model.isDirty()); // needs to be resolved + + await model.load(); + model.textEditorModel!.setValue('foo'); + assert.ok(model.isDirty()); + + await model.revert(true /* soft revert */); + assert.ok(!model.isDirty()); + + model.onDidStateChange(e => { + if (e === StateChange.DIRTY) { + eventCounter++; + } + }); + + model.makeDirty(); + assert.ok(model.isDirty()); + assert.equal(eventCounter, 1); + model.dispose(); + }); + test('File not modified error is handled gracefully', async function () { let model: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/index_async.txt'), 'utf8', undefined); diff --git a/src/vs/workbench/services/textfile/test/textFileService.test.ts b/src/vs/workbench/services/textfile/test/textFileService.test.ts index 30079948c80..07676420066 100644 --- a/src/vs/workbench/services/textfile/test/textFileService.test.ts +++ b/src/vs/workbench/services/textfile/test/textFileService.test.ts @@ -272,8 +272,16 @@ suite('Files - TextFileService', () => { }); test('move - dirty file', async function () { - 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); + await testMove(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.txt')); + }); + + test('move - dirty file (target exists and is dirty)', async function () { + await testMove(toResource.call(this, '/path/file.txt'), toResource.call(this, '/path/file_target.txt'), true); + }); + + async function testMove(source: URI, target: URI, targetDirty?: boolean): Promise { + let sourceModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, source, 'utf8', undefined); + let targetModel: TextFileEditorModel = instantiationService.createInstance(TextFileEditorModel, target, 'utf8', undefined); (accessor.textFileService.models).add(sourceModel.getResource(), sourceModel); (accessor.textFileService.models).add(targetModel.getResource(), targetModel); @@ -283,11 +291,22 @@ suite('Files - TextFileService', () => { sourceModel.textEditorModel!.setValue('foo'); assert.ok(service.isDirty(sourceModel.getResource())); + if (targetDirty) { + await targetModel.load(); + targetModel.textEditorModel!.setValue('bar'); + assert.ok(service.isDirty(targetModel.getResource())); + } + await service.move(sourceModel.getResource(), targetModel.getResource(), true); + + assert.equal(targetModel.textEditorModel!.getValue(), 'foo'); + assert.ok(!service.isDirty(sourceModel.getResource())); + assert.ok(service.isDirty(targetModel.getResource())); + sourceModel.dispose(); targetModel.dispose(); - }); + } suite('Hot Exit', () => { suite('"onExit" setting', () => { diff --git a/test/smoke/package.json b/test/smoke/package.json index 11b5662b207..19378e70a50 100644 --- a/test/smoke/package.json +++ b/test/smoke/package.json @@ -22,7 +22,7 @@ "@types/webdriverio": "4.6.1", "concurrently": "^3.5.1", "cpx": "^1.5.0", - "electron": "4.2.7", + "electron": "4.2.9", "htmlparser2": "^3.9.2", "mkdirp": "^0.5.1", "mocha": "^5.2.0", diff --git a/test/smoke/yarn.lock b/test/smoke/yarn.lock index b86356d3ac3..3935a9baa4b 100644 --- a/test/smoke/yarn.lock +++ b/test/smoke/yarn.lock @@ -676,10 +676,10 @@ electron-download@^4.1.0: semver "^5.4.1" sumchecker "^2.0.2" -electron@4.2.7: - version "4.2.7" - resolved "https://registry.yarnpkg.com/electron/-/electron-4.2.7.tgz#bdd2dbf489a4a4255405bd8330cc8509831d29ba" - integrity sha512-Azpkw0OPzKVipSsN9/0DrBQhXOpG48Q1gTG7Akchtv37s8TijMe403TUgHxGGhw2ti117ek51kYf7NXLhjXqoA== +electron@4.2.9: + version "4.2.9" + resolved "https://registry.yarnpkg.com/electron/-/electron-4.2.9.tgz#81226aa1ba58e1b05388474faf5a815010a11ea2" + integrity sha512-zC7K3GOiZKmxqllVG/qq/Gx+qQvyolKj5xKKwXMqIGekfokEW2hvoIO5Yh7KCoAh5dqBtpzOJjS4fj1se+YBcg== dependencies: "@types/node" "^10.12.18" electron-download "^4.1.0" diff --git a/yarn.lock b/yarn.lock index 35c0842584e..1f75b23ba87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9888,10 +9888,10 @@ xterm-addon-web-links@0.1.0-beta10: resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.1.0-beta10.tgz#610fa9773a2a5ccd41c1c83ba0e2dd2c9eb66a23" integrity sha512-xfpjy0V6bB4BR44qIgZQPoCMVakxb65gMscPkHpO//QxvUxKzabV3dxOsIbeZRFkUGsWTFlvz2OoaBLoNtv5gg== -xterm@3.15.0-beta93: - version "3.15.0-beta93" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.15.0-beta93.tgz#ba1d5e4588f07be9bb36c70082a0e034f9bad565" - integrity sha512-MgzlwBOOwa/xYmWnLiTmqVOk3v/YRxzlPej940zpcp/chXW+ErsSPW6sehy68wedO9TWbR3oBUe8agfLH0uOuA== +xterm@3.15.0-beta94: + version "3.15.0-beta94" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.15.0-beta94.tgz#a2c48db73252021adc9d33d75f1f91c859b81b5f" + integrity sha512-JScndNQV90vicwBDsZiF2BAxMdruzXvVaN8TY6jFqMPC+YjXTXFDBFUij8iCONnGcTZBfNjbrVng+zLheAKphg== y18n@^3.2.1: version "3.2.1"