diff --git a/.github/commands.json b/.github/commands.json index de0643d56c9..65936b422a9 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -133,6 +133,18 @@ "action": "updateLabels", "addLabel": "~needs more info" }, + { + "type": "comment", + "name": "needsPerfInfo", + "allowUsers": [ + "cleidigh", + "usernamehw", + "gjsjohnmurray", + "IllusionMH" + ], + "addLabel": "needs more info", + "comment": "Thanks for creating this issue regarding performance! Please follow this guide to help us diagnose performance issues: https://github.com/microsoft/vscode/wiki/Performance-Issues \n\nHappy Coding!" + }, { "type": "comment", "name": "jsDebugLogs", diff --git a/build/package.json b/build/package.json index 3170776b039..d390717cb2d 100644 --- a/build/package.json +++ b/build/package.json @@ -42,7 +42,7 @@ "colors": "^1.4.0", "commander": "^7.0.0", "electron-osx-sign": "^0.4.16", - "esbuild": "^0.8.30", + "esbuild": "^0.12.1", "fs-extra": "^9.1.0", "got": "11.8.1", "iconv-lite-umd": "0.6.8", diff --git a/build/yarn.lock b/build/yarn.lock index fb31e52e1df..e45d9619d70 100644 --- a/build/yarn.lock +++ b/build/yarn.lock @@ -992,10 +992,10 @@ entities@^1.1.1, entities@~1.1.1: resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== -esbuild@^0.8.30: - version "0.8.30" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.8.30.tgz#3d057ff9ffe6d5d30bccb0afe8cc92a2e69622d3" - integrity sha512-gCJQYUMO9QNrfpNOIiCnFoX41nWiPFCvURBQF+qWckyJ7gmw2xCShdKCXvS+RZcQ5krcxEOLIkzujqclePKhfw== +esbuild@^0.12.1: + version "0.12.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.1.tgz#f652d5b3b9432dbb42fc2c034ddd62360296e03d" + integrity sha512-WfQ00MKm/Y4ysz1u9PCUAsV66k5lbrcEvS6aG9jhBIavpB94FBdaWeBkaZXxCZB4w+oqh+j4ozJFWnnFprOXbg== eslint-scope@^5.0.0: version "5.0.0" diff --git a/extensions/debug-auto-launch/src/extension.ts b/extensions/debug-auto-launch/src/extension.ts index b47641f2db2..1016f0af44a 100644 --- a/extensions/debug-auto-launch/src/extension.ts +++ b/extensions/debug-auto-launch/src/extension.ts @@ -315,12 +315,10 @@ function updateStatusBar(context: vscode.ExtensionContext, state: State, busy = } if (!statusItem) { - statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); + statusItem = vscode.window.createStatusBarItem('status.debug.autoAttach', vscode.StatusBarAlignment.Left); + statusItem.name = localize('status.name.auto.attach', "Debug Auto Attach"); statusItem.command = TOGGLE_COMMAND; - statusItem.tooltip = localize( - 'status.tooltip.auto.attach', - 'Automatically attach to node.js processes in debug mode', - ); + statusItem.tooltip = localize('status.tooltip.auto.attach', "Automatically attach to node.js processes in debug mode"); context.subscriptions.push(statusItem); } diff --git a/extensions/emmet/src/abbreviationActions.ts b/extensions/emmet/src/abbreviationActions.ts index c7c9d84fd30..f5bccfc5b9d 100644 --- a/extensions/emmet/src/abbreviationActions.ts +++ b/extensions/emmet/src/abbreviationActions.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import { Node, HtmlNode, Rule, Property, Stylesheet } from 'EmmetFlatNode'; -import { getEmmetHelper, getFlatNode, getMappingForIncludedLanguages, validate, getEmmetConfiguration, isStyleSheet, getEmmetMode, parsePartialStylesheet, isStyleAttribute, getEmbeddedCssNodeIfAny, allowedMimeTypesInScriptTag, toLSTextDocument, isOffsetInsideOpenOrCloseTag } from './util'; +import { getEmmetHelper, getFlatNode, getHtmlFlatNode, getMappingForIncludedLanguages, validate, getEmmetConfiguration, isStyleSheet, getEmmetMode, parsePartialStylesheet, isStyleAttribute, getEmbeddedCssNodeIfAny, allowedMimeTypesInScriptTag, toLSTextDocument, isOffsetInsideOpenOrCloseTag } from './util'; import { getRootNode as parseDocument } from './parseDocument'; const localize = nls.loadMessageBundle(); @@ -56,7 +56,8 @@ export async function wrapWithAbbreviation(args: any): Promise { let { start, end } = rangeToReplace; const startOffset = document.offsetAt(start); - const startNode = getFlatNode(rootNode, startOffset, true); + const documentText = document.getText(); + const startNode = getHtmlFlatNode(documentText, rootNode, startOffset, true); if (startNode && isOffsetInsideOpenOrCloseTag(startNode, startOffset)) { start = document.positionAt(startNode.start); const nodeEndPosition = document.positionAt(startNode.end); @@ -64,7 +65,7 @@ export async function wrapWithAbbreviation(args: any): Promise { } const endOffset = document.offsetAt(end); - const endNode = getFlatNode(rootNode, endOffset, true); + const endNode = getHtmlFlatNode(documentText, rootNode, endOffset, true); if (endNode && isOffsetInsideOpenOrCloseTag(endNode, endOffset)) { const nodeStartPosition = document.positionAt(endNode.start); start = nodeStartPosition.isBefore(start) ? nodeStartPosition : start; diff --git a/extensions/emmet/src/test/wrapWithAbbreviation.test.ts b/extensions/emmet/src/test/wrapWithAbbreviation.test.ts index 825776c782a..978d154ae97 100644 --- a/extensions/emmet/src/test/wrapWithAbbreviation.test.ts +++ b/extensions/emmet/src/test/wrapWithAbbreviation.test.ts @@ -219,6 +219,79 @@ suite('Tests for Wrap with Abbreviations', () => { return testWrapWithAbbreviation([new Selection(3, 2, 3, 2)], 'div', expectedContents, contents); }); + test('Wrap with abbreviation inner node in cdata', () => { + const contents = ` + +

Test 2

+ ]]> + hello + + `; + const expectedContents = ` + +
+

Test 2

+
+ ]]> + hello + + `; + return testWrapWithAbbreviation([new Selection(6, 5, 6, 5)], 'div', expectedContents, contents); + }); + + test('Wrap with abbreviation inner node in script in cdata', () => { + const contents = ` + + `; + const expectedContents = ` + + `; + return testWrapWithAbbreviation([new Selection(4, 10, 4, 10)], 'div', expectedContents, contents); + }); + + test('Wrap with abbreviation inner node in cdata one-liner', () => { + const contents = ` + + `; + // this result occurs because no selection on the open/close p tag was given + const expectedContents = ` + + `; + return testWrapWithAbbreviation([new Selection(2, 15, 2, 15)], 'div', expectedContents, contents); + }); + test('Wrap with multiline abbreviation doesnt add extra spaces', () => { // Issue #29898 const contents = ` diff --git a/extensions/emmet/src/util.ts b/extensions/emmet/src/util.ts index cb63f504d5d..cb4a876bdcb 100644 --- a/extensions/emmet/src/util.ts +++ b/extensions/emmet/src/util.ts @@ -381,13 +381,19 @@ export function getHtmlFlatNode(documentText: string, root: FlatNode | undefined // If the currentNode is a script one, first set up its subtree and then find HTML node. if (currentNode.name === 'script' && currentNode.children.length === 0) { - setUpScriptNodeSubtree(documentText, currentNode); - currentNode = getFlatNode(currentNode, offset, includeNodeBoundary) ?? currentNode; + const scriptNodeBody = setupScriptNodeSubtree(documentText, currentNode); + if (scriptNodeBody) { + currentNode = getHtmlFlatNode(scriptNodeBody, currentNode, offset, includeNodeBoundary) ?? currentNode; + } + } + else if (currentNode.type === 'cdata') { + const cdataBody = setupCdataNodeSubtree(documentText, currentNode); + currentNode = getHtmlFlatNode(cdataBody, currentNode, offset, includeNodeBoundary) ?? currentNode; } return currentNode; } -export function setUpScriptNodeSubtree(documentText: string, scriptNode: HtmlFlatNode): void { +export function setupScriptNodeSubtree(documentText: string, scriptNode: HtmlFlatNode): string { const isTemplateScript = scriptNode.name === 'script' && (scriptNode.attributes && scriptNode.attributes.some(x => x.name.toString() === 'type' @@ -403,7 +409,25 @@ export function setUpScriptNodeSubtree(documentText: string, scriptNode: HtmlFla scriptNode.children.push(child); child.parent = scriptNode; }); + return scriptBodyText; } + return ''; +} + +export function setupCdataNodeSubtree(documentText: string, cdataNode: HtmlFlatNode): string { + // blank out the rest of the document and generate the subtree. + const cdataStart = ''; + const startToUse = cdataNode.start + cdataStart.length; + const endToUse = cdataNode.end - cdataEnd.length; + const beforePadding = ' '.repeat(startToUse); + const cdataBody = beforePadding + documentText.substring(startToUse, endToUse); + const innerRoot: HtmlFlatNode = parse(cdataBody); + innerRoot.children.forEach(child => { + cdataNode.children.push(child); + child.parent = cdataNode; + }); + return cdataBody; } export function isOffsetInsideOpenOrCloseTag(node: FlatNode, offset: number): boolean { diff --git a/extensions/github-authentication/src/githubServer.ts b/extensions/github-authentication/src/githubServer.ts index fa81fd97d5d..937f47a681a 100644 --- a/extensions/github-authentication/src/githubServer.ts +++ b/extensions/github-authentication/src/githubServer.ts @@ -49,7 +49,7 @@ export class GitHubServer { // TODO@joaomoreno TODO@RMacfarlane private async isNoCorsEnvironment(): Promise { - const uri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate`)); + const uri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/dummy`)); return uri.scheme === 'https' && /^vscode\./.test(uri.authority); } @@ -179,7 +179,8 @@ export class GitHubServer { private updateStatusBarItem(isStart?: boolean) { if (isStart && !this._statusBarItem) { - this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); + this._statusBarItem = vscode.window.createStatusBarItem('status.git.signIn', vscode.StatusBarAlignment.Left); + this._statusBarItem.name = localize('status.git.signIn.name', "GitHub Sign-in"); this._statusBarItem.text = this.type === AuthProviderType.github ? localize('signingIn', "$(mark-github) Signing in to github.com...") : localize('signingInEnterprise', "$(mark-github) Signing in to {0}...", this.getServerUri().authority); diff --git a/extensions/image-preview/src/binarySizeStatusBarEntry.ts b/extensions/image-preview/src/binarySizeStatusBarEntry.ts index 0c983d37cd2..ce375fc19b8 100644 --- a/extensions/image-preview/src/binarySizeStatusBarEntry.ts +++ b/extensions/image-preview/src/binarySizeStatusBarEntry.ts @@ -39,12 +39,7 @@ class BinarySize { export class BinarySizeStatusBarEntry extends PreviewStatusBarEntry { constructor() { - super({ - id: 'imagePreview.binarySize', - name: localize('sizeStatusBar.name', "Image Binary Size"), - alignment: vscode.StatusBarAlignment.Right, - priority: 100, - }); + super('status.imagePreview.binarySize', localize('sizeStatusBar.name', "Image Binary Size"), vscode.StatusBarAlignment.Right, 100); } public show(owner: string, size: number | undefined) { diff --git a/extensions/image-preview/src/ownedStatusBarEntry.ts b/extensions/image-preview/src/ownedStatusBarEntry.ts index 51c9e25503c..31165f67d69 100644 --- a/extensions/image-preview/src/ownedStatusBarEntry.ts +++ b/extensions/image-preview/src/ownedStatusBarEntry.ts @@ -11,9 +11,10 @@ export abstract class PreviewStatusBarEntry extends Disposable { protected readonly entry: vscode.StatusBarItem; - constructor(options: vscode.StatusBarItemOptions) { + constructor(id: string, name: string, alignment: vscode.StatusBarAlignment, priority: number) { super(); - this.entry = this._register(vscode.window.createStatusBarItem(options)); + this.entry = this._register(vscode.window.createStatusBarItem(id, alignment, priority)); + this.entry.name = name; } protected showItem(owner: string, text: string) { diff --git a/extensions/image-preview/src/sizeStatusBarEntry.ts b/extensions/image-preview/src/sizeStatusBarEntry.ts index e74eea0fe60..68e5c34d232 100644 --- a/extensions/image-preview/src/sizeStatusBarEntry.ts +++ b/extensions/image-preview/src/sizeStatusBarEntry.ts @@ -12,12 +12,7 @@ const localize = nls.loadMessageBundle(); export class SizeStatusBarEntry extends PreviewStatusBarEntry { constructor() { - super({ - id: 'imagePreview.size', - name: localize('sizeStatusBar.name', "Image Size"), - alignment: vscode.StatusBarAlignment.Right, - priority: 101 /* to the left of editor status (100) */, - }); + super('status.imagePreview.size', localize('sizeStatusBar.name', "Image Size"), vscode.StatusBarAlignment.Right, 101 /* to the left of editor status (100) */); } public show(owner: string, text: string) { diff --git a/extensions/image-preview/src/zoomStatusBarEntry.ts b/extensions/image-preview/src/zoomStatusBarEntry.ts index 18adc19d6d2..a4fcdc2b604 100644 --- a/extensions/image-preview/src/zoomStatusBarEntry.ts +++ b/extensions/image-preview/src/zoomStatusBarEntry.ts @@ -19,12 +19,7 @@ export class ZoomStatusBarEntry extends OwnedStatusBarEntry { public readonly onDidChangeScale = this._onDidChangeScale.event; constructor() { - super({ - id: 'imagePreview.zoom', - name: localize('zoomStatusBar.name', "Image Zoom"), - alignment: vscode.StatusBarAlignment.Right, - priority: 102 /* to the left of editor size entry (101) */, - }); + super('status.imagePreview.zoom', localize('zoomStatusBar.name', "Image Zoom"), vscode.StatusBarAlignment.Right, 102 /* to the left of editor size entry (101) */); this._register(vscode.commands.registerCommand(selectZoomLevelCommandId, async () => { type MyPickItem = vscode.QuickPickItem & { scale: Scale }; diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index c925a9181f6..611327188ef 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -101,12 +101,8 @@ export function startClient(context: ExtensionContext, newLanguageClient: Langua const documentSelector = ['json', 'jsonc']; - const schemaResolutionErrorStatusBarItem = window.createStatusBarItem({ - id: 'status.json.resolveError', - name: localize('json.resolveError', "JSON: Schema Resolution Error"), - alignment: StatusBarAlignment.Right, - priority: 0, - }); + const schemaResolutionErrorStatusBarItem = window.createStatusBarItem('status.json.resolveError', StatusBarAlignment.Right, 0); + schemaResolutionErrorStatusBarItem.name = localize('json.resolveError', "JSON: Schema Resolution Error"); schemaResolutionErrorStatusBarItem.text = '$(alert)'; toDispose.push(schemaResolutionErrorStatusBarItem); diff --git a/extensions/markdown-basics/cgmanifest.json b/extensions/markdown-basics/cgmanifest.json index 0d320ab3376..f3f0717c5ad 100644 --- a/extensions/markdown-basics/cgmanifest.json +++ b/extensions/markdown-basics/cgmanifest.json @@ -33,7 +33,7 @@ "git": { "name": "microsoft/vscode-markdown-tm-grammar", "repositoryUrl": "https://github.com/microsoft/vscode-markdown-tm-grammar", - "commitHash": "399ff6f608a7bef3f68713be23cdcb4c6d475804" + "commitHash": "a612b96d62aa1ce305c4a55dc9d577316fab39da" } }, "license": "MIT", diff --git a/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json b/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json index 66f8114869a..aaa4c774b40 100644 --- a/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json +++ b/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/microsoft/vscode-markdown-tm-grammar/commit/399ff6f608a7bef3f68713be23cdcb4c6d475804", + "version": "https://github.com/microsoft/vscode-markdown-tm-grammar/commit/a612b96d62aa1ce305c4a55dc9d577316fab39da", "name": "Markdown", "scopeName": "text.html.markdown", "patterns": [ @@ -63,7 +63,7 @@ "while": "(^|\\G)\\s*(>) ?" }, "fenced_code_block_css": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(css|css.erb)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(css|css.erb)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -96,7 +96,7 @@ ] }, "fenced_code_block_basic": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(html|htm|shtml|xhtml|inc|tmpl|tpl)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(html|htm|shtml|xhtml|inc|tmpl|tpl)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -129,7 +129,7 @@ ] }, "fenced_code_block_ini": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ini|conf)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ini|conf)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -162,7 +162,7 @@ ] }, "fenced_code_block_java": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(java|bsh)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(java|bsh)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -195,7 +195,7 @@ ] }, "fenced_code_block_lua": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(lua)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(lua)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -228,7 +228,7 @@ ] }, "fenced_code_block_makefile": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(Makefile|makefile|GNUmakefile|OCamlMakefile)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(Makefile|makefile|GNUmakefile|OCamlMakefile)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -261,7 +261,7 @@ ] }, "fenced_code_block_perl": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(perl|pl|pm|pod|t|PL|psgi|vcl)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(perl|pl|pm|pod|t|PL|psgi|vcl)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -294,7 +294,7 @@ ] }, "fenced_code_block_r": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(R|r|s|S|Rprofile|\\{\\.r.+?\\})((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(R|r|s|S|Rprofile|\\{\\.r.+?\\})((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -327,7 +327,7 @@ ] }, "fenced_code_block_ruby": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ruby|rb|rbx|rjs|Rakefile|rake|cgi|fcgi|gemspec|irbrc|Capfile|ru|prawn|Cheffile|Gemfile|Guardfile|Hobofile|Vagrantfile|Appraisals|Rantfile|Berksfile|Berksfile.lock|Thorfile|Puppetfile)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ruby|rb|rbx|rjs|Rakefile|rake|cgi|fcgi|gemspec|irbrc|Capfile|ru|prawn|Cheffile|Gemfile|Guardfile|Hobofile|Vagrantfile|Appraisals|Rantfile|Berksfile|Berksfile.lock|Thorfile|Puppetfile)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -360,7 +360,7 @@ ] }, "fenced_code_block_php": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(php|php3|php4|php5|phpt|phtml|aw|ctp)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(php|php3|php4|php5|phpt|phtml|aw|ctp)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -396,7 +396,7 @@ ] }, "fenced_code_block_sql": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(sql|ddl|dml)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(sql|ddl|dml)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -429,7 +429,7 @@ ] }, "fenced_code_block_vs_net": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(vb)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(vb)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -462,7 +462,7 @@ ] }, "fenced_code_block_xml": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(xml|xsd|tld|jsp|pt|cpt|dtml|rss|opml)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(xml|xsd|tld|jsp|pt|cpt|dtml|rss|opml)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -495,7 +495,7 @@ ] }, "fenced_code_block_xsl": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(xsl|xslt)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(xsl|xslt)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -528,7 +528,7 @@ ] }, "fenced_code_block_yaml": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(yaml|yml)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(yaml|yml)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -561,7 +561,7 @@ ] }, "fenced_code_block_dosbatch": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(bat|batch)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(bat|batch)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -594,7 +594,7 @@ ] }, "fenced_code_block_clojure": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(clj|cljs|clojure)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(clj|cljs|clojure)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -627,7 +627,7 @@ ] }, "fenced_code_block_coffee": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(coffee|Cakefile|coffee.erb)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(coffee|Cakefile|coffee.erb)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -660,7 +660,7 @@ ] }, "fenced_code_block_c": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(c|h)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(c|h)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -693,7 +693,7 @@ ] }, "fenced_code_block_cpp": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(cpp|c\\+\\+|cxx)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(cpp|c\\+\\+|cxx)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -726,7 +726,7 @@ ] }, "fenced_code_block_diff": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(patch|diff|rej)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(patch|diff|rej)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -759,7 +759,7 @@ ] }, "fenced_code_block_dockerfile": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(dockerfile|Dockerfile)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(dockerfile|Dockerfile)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -792,7 +792,7 @@ ] }, "fenced_code_block_git_commit": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(COMMIT_EDITMSG|MERGE_MSG)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(COMMIT_EDITMSG|MERGE_MSG)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -825,7 +825,7 @@ ] }, "fenced_code_block_git_rebase": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(git-rebase-todo)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(git-rebase-todo)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -858,7 +858,7 @@ ] }, "fenced_code_block_go": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(go|golang)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(go|golang)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -891,7 +891,7 @@ ] }, "fenced_code_block_groovy": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(groovy|gvy)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(groovy|gvy)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -924,7 +924,7 @@ ] }, "fenced_code_block_pug": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(jade|pug)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(jade|pug)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -957,7 +957,7 @@ ] }, "fenced_code_block_js": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(js|jsx|javascript|es6|mjs|cjs|\\{\\.js.+?\\})((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(js|jsx|javascript|es6|mjs|cjs|\\{\\.js.+?\\})((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -990,7 +990,7 @@ ] }, "fenced_code_block_js_regexp": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(regexp)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(regexp)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1023,7 +1023,7 @@ ] }, "fenced_code_block_json": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(json|json5|sublime-settings|sublime-menu|sublime-keymap|sublime-mousemap|sublime-theme|sublime-build|sublime-project|sublime-completions)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(json|json5|sublime-settings|sublime-menu|sublime-keymap|sublime-mousemap|sublime-theme|sublime-build|sublime-project|sublime-completions)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1056,7 +1056,7 @@ ] }, "fenced_code_block_jsonc": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(jsonc)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(jsonc)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1089,7 +1089,7 @@ ] }, "fenced_code_block_less": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(less)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(less)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1122,7 +1122,7 @@ ] }, "fenced_code_block_objc": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(objectivec|objective-c|mm|objc|obj-c|m|h)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(objectivec|objective-c|mm|objc|obj-c|m|h)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1155,7 +1155,7 @@ ] }, "fenced_code_block_swift": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(swift)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(swift)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1188,7 +1188,7 @@ ] }, "fenced_code_block_scss": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(scss)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(scss)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1221,7 +1221,7 @@ ] }, "fenced_code_block_perl6": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(perl6|p6|pl6|pm6|nqp)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(perl6|p6|pl6|pm6|nqp)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1254,7 +1254,7 @@ ] }, "fenced_code_block_powershell": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(powershell|ps1|psm1|psd1)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(powershell|ps1|psm1|psd1)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1287,7 +1287,7 @@ ] }, "fenced_code_block_python": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(python|py|py3|rpy|pyw|cpy|SConstruct|Sconstruct|sconstruct|SConscript|gyp|gypi|\\{\\.python.+?\\})((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(python|py|py3|rpy|pyw|cpy|SConstruct|Sconstruct|sconstruct|SConscript|gyp|gypi|\\{\\.python.+?\\})((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1320,7 +1320,7 @@ ] }, "fenced_code_block_regexp_python": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(re)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(re)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1353,7 +1353,7 @@ ] }, "fenced_code_block_rust": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(rust|rs|\\{\\.rust.+?\\})((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(rust|rs|\\{\\.rust.+?\\})((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1386,7 +1386,7 @@ ] }, "fenced_code_block_scala": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(scala|sbt)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(scala|sbt)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1419,7 +1419,7 @@ ] }, "fenced_code_block_shell": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(shell|sh|bash|zsh|bashrc|bash_profile|bash_login|profile|bash_logout|.textmate_init|\\{\\.bash.+?\\})((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(shell|sh|bash|zsh|bashrc|bash_profile|bash_login|profile|bash_logout|.textmate_init|\\{\\.bash.+?\\})((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1452,7 +1452,7 @@ ] }, "fenced_code_block_ts": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(typescript|ts)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(typescript|ts)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1485,7 +1485,7 @@ ] }, "fenced_code_block_tsx": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(tsx)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(tsx)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1518,7 +1518,7 @@ ] }, "fenced_code_block_csharp": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(cs|csharp|c#)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(cs|csharp|c#)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1551,7 +1551,7 @@ ] }, "fenced_code_block_fsharp": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(fs|fsharp|f#)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(fs|fsharp|f#)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1584,7 +1584,7 @@ ] }, "fenced_code_block_dart": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(dart)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(dart)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1617,7 +1617,7 @@ ] }, "fenced_code_block_handlebars": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(handlebars|hbs)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(handlebars|hbs)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1650,7 +1650,7 @@ ] }, "fenced_code_block_markdown": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(markdown|md)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(markdown|md)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1683,7 +1683,7 @@ ] }, "fenced_code_block_log": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(log)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(log)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1716,7 +1716,7 @@ ] }, "fenced_code_block_erlang": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(erlang)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(erlang)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { @@ -1749,7 +1749,7 @@ ] }, "fenced_code_block_elixir": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(elixir)((\\s+|:|\\{)[^`~]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(elixir)((\\s+|:|\\{|\\?)[^`~]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { diff --git a/extensions/markdown-language-features/notebook/index.ts b/extensions/markdown-language-features/notebook/index.ts index b7c2df85817..7b44fba6583 100644 --- a/extensions/markdown-language-features/notebook/index.ts +++ b/extensions/markdown-language-features/notebook/index.ts @@ -5,35 +5,23 @@ const MarkdownIt = require('markdown-it'); -export async function activate(ctx: { - dependencies: ReadonlyArray<{ entrypoint: string }> -}) { +export function activate() { let markdownIt = new MarkdownIt({ html: true }); - // Should we load the deps before this point? - // Also could we await inside `renderMarkup`? - await Promise.all(ctx.dependencies.map(async (dep) => { - try { - const api = await import(dep.entrypoint); - if (api?.extendMarkdownIt) { - markdownIt = api.extendMarkdownIt(markdownIt); - } - } catch (e) { - console.error('Could not load markdown entryPoint', e); - } - })); - return { - renderMarkup: (context: { element: HTMLElement, content: string }) => { - const rendered = markdownIt.render(context.content); + renderCell: (_id: string, context: { element: HTMLElement, value: string }) => { + const rendered = markdownIt.render(context.value); context.element.innerHTML = rendered; // Insert styles into markdown preview shadow dom so that they are applied for (const markdownStyleNode of document.getElementsByClassName('markdown-style')) { context.element.insertAdjacentElement('beforebegin', markdownStyleNode.cloneNode(true) as Element); } + }, + extendMarkdownIt: (f: (md: typeof markdownIt) => void) => { + f(markdownIt); } }; } diff --git a/extensions/markdown-language-features/src/features/preview.ts b/extensions/markdown-language-features/src/features/preview.ts index 42d6f08fc07..23ce0b495b0 100644 --- a/extensions/markdown-language-features/src/features/preview.ts +++ b/extensions/markdown-language-features/src/features/preview.ts @@ -12,7 +12,7 @@ import { MarkdownContributionProvider } from '../markdownExtensions'; import { Disposable } from '../util/dispose'; import { isMarkdownFile } from '../util/file'; import { normalizeResource, WebviewResourceProvider } from '../util/resources'; -import { getVisibleLine, TopmostLineMonitor } from '../util/topmostLineMonitor'; +import { getVisibleLine, LastScrollLocation, TopmostLineMonitor } from '../util/topmostLineMonitor'; import { MarkdownPreviewConfigurationManager } from './previewConfig'; import { MarkdownContentProvider, MarkdownContentProviderOutput } from './previewContentProvider'; import { MarkdownEngine } from '../markdownEngine'; @@ -120,6 +120,8 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { private imageInfo: { readonly id: string, readonly width: number, readonly height: number; }[] = []; private readonly _fileWatchersBySrc = new Map(); + private readonly _onScrollEmitter = this._register(new vscode.EventEmitter()); + public readonly onScroll = this._onScrollEmitter.event; constructor( webview: vscode.WebviewPanel, @@ -324,7 +326,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { private onDidScrollPreview(line: number) { this.line = line; - + this._onScrollEmitter.fire({ line: this.line, uri: this._resource }); const config = this._previewConfigurations.loadAndCacheConfiguration(this._resource); if (!config.scrollEditorWithPreview) { return; @@ -336,13 +338,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { } this.isScrolling = true; - const sourceLine = Math.floor(line); - const fraction = line - sourceLine; - const text = editor.document.lineAt(sourceLine).text; - const start = Math.floor(fraction * text.length); - editor.revealRange( - new vscode.Range(sourceLine, start, sourceLine + 1, 0), - vscode.TextEditorRevealType.AtTop); + scrollEditorToLine(line, editor); } } @@ -497,11 +493,13 @@ export class StaticMarkdownPreview extends Disposable implements ManagedMarkdown webview: vscode.WebviewPanel, contentProvider: MarkdownContentProvider, previewConfigurations: MarkdownPreviewConfigurationManager, + topmostLineMonitor: TopmostLineMonitor, logger: Logger, contributionProvider: MarkdownContributionProvider, engine: MarkdownEngine, + scrollLine?: number, ): StaticMarkdownPreview { - return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, logger, contributionProvider, engine); + return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, engine, scrollLine); } private readonly preview: MarkdownPreview; @@ -511,13 +509,15 @@ export class StaticMarkdownPreview extends Disposable implements ManagedMarkdown resource: vscode.Uri, contentProvider: MarkdownContentProvider, private readonly _previewConfigurations: MarkdownPreviewConfigurationManager, + topmostLineMonitor: TopmostLineMonitor, logger: Logger, contributionProvider: MarkdownContributionProvider, engine: MarkdownEngine, + scrollLine?: number, ) { super(); - - this.preview = this._register(new MarkdownPreview(this._webviewPanel, resource, undefined, { + const topScrollLocation = scrollLine ? new StartingScrollLine(scrollLine) : undefined; + this.preview = this._register(new MarkdownPreview(this._webviewPanel, resource, topScrollLocation, { getAdditionalState: () => { return {}; }, openPreviewLinkToMarkdownFile: () => { /* todo */ } }, engine, contentProvider, _previewConfigurations, logger, contributionProvider)); @@ -529,6 +529,16 @@ export class StaticMarkdownPreview extends Disposable implements ManagedMarkdown this._register(this._webviewPanel.onDidChangeViewState(e => { this._onDidChangeViewState.fire(e); })); + + this._register(this.preview.onScroll((scrollInfo) => { + topmostLineMonitor.setPreviousEditorLine(scrollInfo); + })); + + this._register(topmostLineMonitor.onDidChanged(event => { + if (this.preview.isPreviewOf(event.resource)) { + this.preview.scrollTo(event.line); + } + })); } private readonly _onDispose = this._register(new vscode.EventEmitter()); @@ -789,3 +799,18 @@ export class DynamicMarkdownPreview extends Disposable implements ManagedMarkdow } } +/** + * Change the top-most visible line of `editor` to be at `line` + */ +export function scrollEditorToLine( + line: number, + editor: vscode.TextEditor +) { + const sourceLine = Math.floor(line); + const fraction = line - sourceLine; + const text = editor.document.lineAt(sourceLine).text; + const start = Math.floor(fraction * text.length); + editor.revealRange( + new vscode.Range(sourceLine, start, sourceLine + 1, 0), + vscode.TextEditorRevealType.AtTop); +} diff --git a/extensions/markdown-language-features/src/features/previewManager.ts b/extensions/markdown-language-features/src/features/previewManager.ts index b93a5061053..7f30abb1c84 100644 --- a/extensions/markdown-language-features/src/features/previewManager.ts +++ b/extensions/markdown-language-features/src/features/previewManager.ts @@ -9,9 +9,10 @@ import { MarkdownEngine } from '../markdownEngine'; import { MarkdownContributionProvider } from '../markdownExtensions'; import { Disposable, disposeAll } from '../util/dispose'; import { TopmostLineMonitor } from '../util/topmostLineMonitor'; -import { DynamicMarkdownPreview, ManagedMarkdownPreview, StartingScrollFragment, StaticMarkdownPreview } from './preview'; +import { DynamicMarkdownPreview, ManagedMarkdownPreview, StartingScrollFragment, StaticMarkdownPreview, scrollEditorToLine } from './preview'; import { MarkdownPreviewConfigurationManager } from './previewConfig'; import { MarkdownContentProvider } from './previewContentProvider'; +import { isMarkdownFile } from '../util/file'; export interface DynamicPreviewSettings { readonly resourceColumn: vscode.ViewColumn; @@ -75,6 +76,17 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview super(); this._register(vscode.window.registerWebviewPanelSerializer(DynamicMarkdownPreview.viewType, this)); this._register(vscode.window.registerCustomEditorProvider(this.customEditorViewType, this)); + + this._register(vscode.window.onDidChangeActiveTextEditor(textEditor => { + + // When at a markdown file, apply existing scroll settings + if (textEditor && textEditor.document && isMarkdownFile(textEditor.document)) { + const line = this._topmostLineMonitor.getPreviousEditorLineByUri(textEditor.document.uri); + if (line) { + scrollEditorToLine(line, textEditor); + } + } + })); } public refresh() { @@ -160,14 +172,18 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview document: vscode.TextDocument, webview: vscode.WebviewPanel ): Promise { + const lineNumber = this._topmostLineMonitor.getPreviousEditorLineByUri(document.uri); const preview = StaticMarkdownPreview.revive( document.uri, webview, this._contentProvider, this._previewConfigurations, + this._topmostLineMonitor, this._logger, this._contributions, - this._engine); + this._engine, + lineNumber + ); this.registerStaticPreview(preview); } diff --git a/extensions/markdown-language-features/src/markdownEngine.ts b/extensions/markdown-language-features/src/markdownEngine.ts index 3d84a24ad30..688a4ff92e4 100644 --- a/extensions/markdown-language-features/src/markdownEngine.ts +++ b/extensions/markdown-language-features/src/markdownEngine.ts @@ -269,14 +269,12 @@ export class MarkdownEngine { if (uri.path[0] === '/') { const root = vscode.workspace.getWorkspaceFolder(this.currentDocument!); if (root) { - const fileUri = vscode.Uri.joinPath(root.uri, uri.fsPath).with({ + uri = vscode.Uri.joinPath(root.uri, uri.fsPath).with({ fragment: uri.fragment, query: uri.query, + }).with({ + scheme: 'markdown-link' }); - - // Ensure fileUri is relative by prepending `/` so that it uses the element URI - // when resolving the absolute URL - uri = vscode.Uri.parse('markdown-link:' + '/' + fileUri.toString(true).replace(/^\S+?:/, fileUri.scheme)); } } diff --git a/extensions/markdown-language-features/src/util/topmostLineMonitor.ts b/extensions/markdown-language-features/src/util/topmostLineMonitor.ts index 57395fbc691..62f5b5c194b 100644 --- a/extensions/markdown-language-features/src/util/topmostLineMonitor.ts +++ b/extensions/markdown-language-features/src/util/topmostLineMonitor.ts @@ -7,18 +7,32 @@ import * as vscode from 'vscode'; import { Disposable } from '../util/dispose'; import { isMarkdownFile } from './file'; +export interface LastScrollLocation { + readonly line: number; + readonly uri: vscode.Uri; +} + export class TopmostLineMonitor extends Disposable { private readonly pendingUpdates = new Map(); private readonly throttle = 50; + private previousEditorInfo = new Map(); + public isPrevEditorCustom = false; constructor() { super(); + + if (vscode.window.activeTextEditor) { + const line = getVisibleLine(vscode.window.activeTextEditor); + this.setPreviousEditorLine({ uri: vscode.window.activeTextEditor.document.uri, line: line ?? 0 }); + } + this._register(vscode.window.onDidChangeTextEditorVisibleRanges(event => { if (isMarkdownFile(event.textEditor.document)) { const line = getVisibleLine(event.textEditor); if (typeof line === 'number') { this.updateLine(event.textEditor.document.uri, line); + this.setPreviousEditorLine({ uri: event.textEditor.document.uri, line: line }); } } })); @@ -27,7 +41,16 @@ export class TopmostLineMonitor extends Disposable { private readonly _onChanged = this._register(new vscode.EventEmitter<{ readonly resource: vscode.Uri, readonly line: number }>()); public readonly onDidChanged = this._onChanged.event; - private updateLine( + public setPreviousEditorLine(scrollLocation: LastScrollLocation): void { + this.previousEditorInfo.set(scrollLocation.uri.toString(), scrollLocation); + } + + public getPreviousEditorLineByUri(resource: vscode.Uri): number | undefined { + const scrollLoc = this.previousEditorInfo.get(resource.toString()); + return scrollLoc?.line; + } + + public updateLine( resource: vscode.Uri, line: number ) { diff --git a/extensions/notebook-markdown-extensions/notebook/emoji.ts b/extensions/notebook-markdown-extensions/notebook/emoji.ts index bf82f98ba0f..b842750a03c 100644 --- a/extensions/notebook-markdown-extensions/notebook/emoji.ts +++ b/extensions/notebook-markdown-extensions/notebook/emoji.ts @@ -6,6 +6,12 @@ import type * as markdownIt from 'markdown-it'; const emoji = require('markdown-it-emoji'); -export function extendMarkdownIt(md: markdownIt.MarkdownIt) { - return md.use(emoji); +export function activate(ctx: { + getRenderer: (id: string) => any +}) { + const markdownItRenderer = ctx.getRenderer('markdownItRenderer'); + + markdownItRenderer.extendMarkdownIt((md: markdownIt.MarkdownIt) => { + return md.use(emoji); + }); } diff --git a/extensions/notebook-markdown-extensions/notebook/katex.ts b/extensions/notebook-markdown-extensions/notebook/katex.ts index 910036babf2..ccb12569053 100644 --- a/extensions/notebook-markdown-extensions/notebook/katex.ts +++ b/extensions/notebook-markdown-extensions/notebook/katex.ts @@ -6,23 +6,28 @@ import type * as markdownIt from 'markdown-it'; const styleHref = import.meta.url.replace(/katex.js$/, 'katex.min.css'); -const link = document.createElement('link'); -link.rel = 'stylesheet'; -link.classList.add('markdown-style'); -link.href = styleHref; -document.head.append(link); +export function activate(ctx: { + getRenderer: (id: string) => any +}) { + const markdownItRenderer = ctx.getRenderer('markdownItRenderer'); -const style = document.createElement('style'); -style.classList.add('markdown-style'); -style.textContent = ` - .katex-error { - color: var(--vscode-editorError-foreground); - } -`; -document.head.append(style); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.classList.add('markdown-style'); + link.href = styleHref; + document.head.append(link); -const katex = require('@iktakahiro/markdown-it-katex'); + const style = document.createElement('style'); + style.classList.add('markdown-style'); + style.textContent = ` + .katex-error { + color: var(--vscode-editorError-foreground); + } + `; + document.head.append(style); -export function extendMarkdownIt(md: markdownIt.MarkdownIt) { - return md.use(katex); + const katex = require('@iktakahiro/markdown-it-katex'); + markdownItRenderer.extendMarkdownIt((md: markdownIt.MarkdownIt) => { + return md.use(katex); + }); } diff --git a/extensions/notebook-markdown-extensions/package.json b/extensions/notebook-markdown-extensions/package.json index a68a8b07114..ef3911f2eb9 100644 --- a/extensions/notebook-markdown-extensions/package.json +++ b/extensions/notebook-markdown-extensions/package.json @@ -25,24 +25,18 @@ { "id": "markdownItRenderer-katex", "displayName": "Markdown it katex renderer", - "entrypoint": "./notebook-out/katex.js", - "mimeTypes": [ - "text/markdown" - ], - "dependencies": [ - "markdownItRenderer" - ] + "entrypoint": { + "extends": "markdownItRenderer", + "path": "./notebook-out/katex.js" + } }, { "id": "markdownItRenderer-emoji", "displayName": "Markdown it emoji renderer", - "entrypoint": "./notebook-out/emoji.js", - "mimeTypes": [ - "text/markdown" - ], - "dependencies": [ - "markdownItRenderer" - ] + "entrypoint": { + "extends": "markdownItRenderer", + "path": "./notebook-out/emoji.js" + } } ] }, diff --git a/extensions/package.json b/extensions/package.json index f590e5cab37..9b5fd61b741 100644 --- a/extensions/package.json +++ b/extensions/package.json @@ -4,7 +4,7 @@ "license": "MIT", "description": "Dependencies shared by all extensions", "dependencies": { - "typescript": "^4.3.0-dev.20210507" + "typescript": "^4.3.1-rc" }, "scripts": { "postinstall": "node ./postinstall" diff --git a/extensions/php-language-features/src/features/validationProvider.ts b/extensions/php-language-features/src/features/validationProvider.ts index 162efdaa787..fb4111c874b 100644 --- a/extensions/php-language-features/src/features/validationProvider.ts +++ b/extensions/php-language-features/src/features/validationProvider.ts @@ -194,15 +194,17 @@ export default class PHPValidationProvider { if (vscode.workspace.isTrusted) { trigger(); } - } else if (this.config!.executableIsUserDefined !== undefined && !this.config!.executableIsUserDefined) { - const checkedExecutablePath = this.workspaceStore.get(Setting.CheckedExecutablePath, undefined); - if (!checkedExecutablePath || checkedExecutablePath !== this.config!.executable) { - if (await this.showCustomTrustDialog()) { - this.workspaceStore.update(Setting.CheckedExecutablePath, this.config!.executable); - vscode.commands.executeCommand('setContext', 'php.untrustValidationExecutableContext', true); - } else { - this.pauseValidation = true; - return; + } else { + if (this.config!.executableIsUserDefined !== undefined && !this.config!.executableIsUserDefined) { + const checkedExecutablePath = this.workspaceStore.get(Setting.CheckedExecutablePath, undefined); + if (!checkedExecutablePath || checkedExecutablePath !== this.config!.executable) { + if (await this.showCustomTrustDialog()) { + this.workspaceStore.update(Setting.CheckedExecutablePath, this.config!.executable); + vscode.commands.executeCommand('setContext', 'php.untrustValidationExecutableContext', true); + } else { + this.pauseValidation = true; + return; + } } } diff --git a/extensions/shellscript/package.json b/extensions/shellscript/package.json index a8ecba9b104..3e1e3f3e9c8 100644 --- a/extensions/shellscript/package.json +++ b/extensions/shellscript/package.json @@ -32,7 +32,6 @@ ".bash_profile", ".bash_login", ".ebuild", - ".install", ".profile", ".bash_logout", ".xprofile", diff --git a/extensions/theme-defaults/themes/light_vs.json b/extensions/theme-defaults/themes/light_vs.json index 5f59dbe7b77..b58b9f88321 100644 --- a/extensions/theme-defaults/themes/light_vs.json +++ b/extensions/theme-defaults/themes/light_vs.json @@ -25,7 +25,7 @@ "notebook.cellBorderColor": "#E8E8E8", "notebook.selectedCellBackground": "#c8ddf150", "statusBarItem.errorBackground": "#c72e0f", - "list.focusHighlightForeground": "#33B6FF" + "list.focusHighlightForeground": "#9DDDFF" }, "tokenColors": [ { diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 1e089784b79..fbf6217c68c 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -9,7 +9,10 @@ "aiKey": "AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217", "enableProposedApi": true, "capabilities": { - "virtualWorkspaces": true, + "virtualWorkspaces": { + "supported": "limited", + "description": "%virtualWorkspaces%" + }, "untrustedWorkspaces": { "supported": "limited", "description": "%workspaceTrust%", diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 3f400cf4abe..85c896bef3c 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -2,6 +2,7 @@ "displayName": "TypeScript and JavaScript Language Features", "description": "Provides rich language support for JavaScript and TypeScript.", "workspaceTrust": "The extension requires workspace trust when the workspace version is used because it executes code specified by the workspace.", + "virtualWorkspaces": "In virtual workspaces, resolving and finding references across files is not supported.", "reloadProjects.title": "Reload Project", "configuration.typescript": "TypeScript", "configuration.suggest.completeFunctionCalls": "Complete functions with their parameter signature.", diff --git a/extensions/typescript-language-features/src/commands/commandManager.ts b/extensions/typescript-language-features/src/commands/commandManager.ts index 7bf1d7e33aa..b7b58bf7ede 100644 --- a/extensions/typescript-language-features/src/commands/commandManager.ts +++ b/extensions/typescript-language-features/src/commands/commandManager.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; export interface Command { - readonly id: string | string[]; + readonly id: string; execute(...args: any[]): void; } @@ -22,17 +22,9 @@ export class CommandManager { } public register(command: T): T { - for (const id of Array.isArray(command.id) ? command.id : [command.id]) { - this.registerCommand(id, command.execute, command); + if (!this.commands.has(command.id)) { + this.commands.set(command.id, vscode.commands.registerCommand(command.id, command.execute, command)); } return command; } - - private registerCommand(id: string, impl: (...args: any[]) => void, thisArg?: any) { - if (this.commands.has(id)) { - return; - } - - this.commands.set(id, vscode.commands.registerCommand(id, impl, thisArg)); - } -} \ No newline at end of file +} diff --git a/extensions/typescript-language-features/src/languageFeatures/organizeImports.ts b/extensions/typescript-language-features/src/languageFeatures/organizeImports.ts index 3b8111b7c1f..10a399bad5b 100644 --- a/extensions/typescript-language-features/src/languageFeatures/organizeImports.ts +++ b/extensions/typescript-language-features/src/languageFeatures/organizeImports.ts @@ -29,7 +29,7 @@ class OrganizeImportsCommand implements Command { private readonly telemetryReporter: TelemetryReporter, ) { } - public async execute(file: string): Promise { + public async execute(file: string, sortOnly = false): Promise { /* __GDPR__ "organizeImports.execute" : { "${include}": [ @@ -45,7 +45,8 @@ class OrganizeImportsCommand implements Command { args: { file } - } + }, + skipDestructiveCodeActions: sortOnly, }; const response = await this.client.interruptGetErr(() => this.client.execute('organizeImports', args, nulToken)); if (response.type !== 'response' || !response.body) { @@ -57,23 +58,42 @@ class OrganizeImportsCommand implements Command { } } -export class OrganizeImportsCodeActionProvider implements vscode.CodeActionProvider { - public static readonly minVersion = API.v280; +class ImportsCodeActionProvider implements vscode.CodeActionProvider { + + static register( + client: ITypeScriptServiceClient, + minVersion: API, + kind: vscode.CodeActionKind, + title: string, + sortOnly: boolean, + commandManager: CommandManager, + fileConfigurationManager: FileConfigurationManager, + telemetryReporter: TelemetryReporter, + selector: DocumentSelector + ): vscode.Disposable { + return conditionalRegistration([ + requireMinVersion(client, minVersion), + requireSomeCapability(client, ClientCapability.Semantic), + ], () => { + const provider = new ImportsCodeActionProvider(client, kind, title, sortOnly, commandManager, fileConfigurationManager, telemetryReporter); + return vscode.languages.registerCodeActionsProvider(selector.semantic, provider, { + providedCodeActionKinds: [kind] + }); + }); + } public constructor( private readonly client: ITypeScriptServiceClient, + private readonly kind: vscode.CodeActionKind, + private readonly title: string, + private readonly sortOnly: boolean, commandManager: CommandManager, private readonly fileConfigManager: FileConfigurationManager, telemetryReporter: TelemetryReporter, - ) { commandManager.register(new OrganizeImportsCommand(client, telemetryReporter)); } - public readonly metadata: vscode.CodeActionProviderMetadata = { - providedCodeActionKinds: [vscode.CodeActionKind.SourceOrganizeImports] - }; - public provideCodeActions( document: vscode.TextDocument, _range: vscode.Range, @@ -85,16 +105,14 @@ export class OrganizeImportsCodeActionProvider implements vscode.CodeActionProvi return []; } - if (!context.only || !context.only.contains(vscode.CodeActionKind.SourceOrganizeImports)) { + if (!context.only || !context.only.contains(this.kind)) { return []; } this.fileConfigManager.ensureConfigurationForDocument(document, token); - const action = new vscode.CodeAction( - localize('organizeImportsAction.title', "Organize Imports"), - vscode.CodeActionKind.SourceOrganizeImports); - action.command = { title: '', command: OrganizeImportsCommand.Id, arguments: [file] }; + const action = new vscode.CodeAction(this.title, this.kind); + action.command = { title: '', command: OrganizeImportsCommand.Id, arguments: [file, this.sortOnly] }; return [action]; } } @@ -106,13 +124,28 @@ export function register( fileConfigurationManager: FileConfigurationManager, telemetryReporter: TelemetryReporter, ) { - return conditionalRegistration([ - requireMinVersion(client, OrganizeImportsCodeActionProvider.minVersion), - requireSomeCapability(client, ClientCapability.Semantic), - ], () => { - const organizeImportsProvider = new OrganizeImportsCodeActionProvider(client, commandManager, fileConfigurationManager, telemetryReporter); - return vscode.languages.registerCodeActionsProvider(selector.semantic, - organizeImportsProvider, - organizeImportsProvider.metadata); - }); + return vscode.Disposable.from( + ImportsCodeActionProvider.register( + client, + API.v280, + vscode.CodeActionKind.SourceOrganizeImports, + localize('organizeImportsAction.title', "Organize Imports"), + false, + commandManager, + fileConfigurationManager, + telemetryReporter, + selector + ), + ImportsCodeActionProvider.register( + client, + API.v430, + vscode.CodeActionKind.Source.append('sortImports'), + localize('sortImportsAction.title', "Sort Imports"), + true, + commandManager, + fileConfigurationManager, + telemetryReporter, + selector + ), + ); } diff --git a/extensions/typescript-language-features/src/tsServer/versionStatus.ts b/extensions/typescript-language-features/src/tsServer/versionStatus.ts index a868c3e2502..2560f3e8be9 100644 --- a/extensions/typescript-language-features/src/tsServer/versionStatus.ts +++ b/extensions/typescript-language-features/src/tsServer/versionStatus.ts @@ -135,12 +135,8 @@ export default class VersionStatus extends Disposable { ) { super(); - this._statusBarEntry = this._register(vscode.window.createStatusBarItem({ - id: 'status.typescript', - name: localize('projectInfo.name', "TypeScript: Project Info"), - alignment: vscode.StatusBarAlignment.Right, - priority: 99 /* to the right of editor status (100) */ - })); + this._statusBarEntry = this._register(vscode.window.createStatusBarItem('status.typescript', vscode.StatusBarAlignment.Right, 99 /* to the right of editor status (100) */)); + this._statusBarEntry.name = localize('projectInfo.name', "TypeScript: Project Info"); const command = new ProjectStatusCommand(this._client, () => this._state); commandManager.register(command); diff --git a/extensions/typescript-language-features/src/utils/largeProjectStatus.ts b/extensions/typescript-language-features/src/utils/largeProjectStatus.ts index 223d7cb4716..346f95c6979 100644 --- a/extensions/typescript-language-features/src/utils/largeProjectStatus.ts +++ b/extensions/typescript-language-features/src/utils/largeProjectStatus.ts @@ -23,12 +23,8 @@ class ExcludeHintItem { constructor( private readonly telemetryReporter: TelemetryReporter ) { - this._item = vscode.window.createStatusBarItem({ - id: 'status.typescript.exclude', - name: localize('statusExclude', "TypeScript: Configure Excludes"), - alignment: vscode.StatusBarAlignment.Right, - priority: 98 /* to the right of typescript version status (99) */ - }); + this._item = vscode.window.createStatusBarItem('status.typescript.exclude', vscode.StatusBarAlignment.Right, 98 /* to the right of typescript version status (99) */); + this._item.name = localize('statusExclude', "TypeScript: Configure Excludes"); this._item.command = 'js.projectStatus.command'; } diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts index 144f1a0da12..568295ccddf 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts @@ -47,10 +47,6 @@ suite('Notebook Document', function () { await utils.closeAllEditors(); utils.disposeAll(disposables); disposables.length = 0; - - for (let doc of vscode.notebook.notebookDocuments) { - assert.strictEqual(doc.isDirty, false, doc.uri.toString()); - } }); suiteSetup(function () { @@ -91,16 +87,27 @@ suite('Notebook Document', function () { test('notebook open/close, notebook ready when cell-document open event is fired', async function () { const uri = await utils.createRandomFile(undefined, undefined, '.nbdtest'); let didHappen = false; - const p = utils.asPromise(vscode.workspace.onDidOpenTextDocument).then(doc => { - if (doc.uri.scheme !== 'vscode-notebook-cell') { - return; - } - const notebook = vscode.notebook.notebookDocuments.find(notebook => { - const cell = notebook.getCells().find(cell => cell.document === doc); - return Boolean(cell); + + const p = new Promise((resolve, reject) => { + const sub = vscode.workspace.onDidOpenTextDocument(doc => { + if (doc.uri.scheme !== 'vscode-notebook-cell') { + // ignore other open events + return; + } + const notebook = vscode.notebook.notebookDocuments.find(notebook => { + const cell = notebook.getCells().find(cell => cell.document === doc); + return Boolean(cell); + }); + assert.ok(notebook, `notebook for cell ${doc.uri} NOT found`); + didHappen = true; + sub.dispose(); + resolve(); }); - assert.ok(notebook, `notebook for cell ${doc.uri} NOT found`); - didHappen = true; + + setTimeout(() => { + sub.dispose(); + reject(new Error('TIMEOUT')); + }, 15000); }); await vscode.notebook.openNotebookDocument(uri); @@ -129,6 +136,30 @@ suite('Notebook Document', function () { await p; }); + test('open untitled notebook', async function () { + const nb = await vscode.notebook.openNotebookDocument('notebook.nbdserializer'); + assert.strictEqual(nb.isUntitled, true); + assert.strictEqual(nb.isClosed, false); + assert.strictEqual(nb.uri.scheme, 'untitled'); + // assert.strictEqual(nb.cellCount, 0); // NotebookSerializer ALWAYS returns something here + }); + + test('open untitled with data', async function () { + const nb = await vscode.notebook.openNotebookDocument( + 'notebook.nbdserializer', + new vscode.NotebookData([ + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'console.log()', 'javascript'), + new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, 'Hey', 'markdown'), + ]) + ); + assert.strictEqual(nb.isUntitled, true); + assert.strictEqual(nb.isClosed, false); + assert.strictEqual(nb.uri.scheme, 'untitled'); + assert.strictEqual(nb.cellCount, 2); + assert.strictEqual(nb.cellAt(0).kind, vscode.NotebookCellKind.Code); + assert.strictEqual(nb.cellAt(1).kind, vscode.NotebookCellKind.Markup); + }); + test('workspace edit API (replaceCells)', async function () { const uri = await utils.createRandomFile(undefined, undefined, '.nbdtest'); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.test.ts index ca3456f934f..b00302b2808 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.test.ts @@ -104,8 +104,8 @@ suite('Notebook API tests', function () { suiteSetup(function () { suiteDisposables.push(vscode.notebook.registerNotebookContentProvider('notebookCoreTest', { - openNotebook: async (_resource: vscode.Uri): Promise => { - if (/.*empty\-.*\.vsctestnb$/.test(_resource.path)) { + openNotebook: async (resource: vscode.Uri): Promise => { + if (/.*empty\-.*\.vsctestnb$/.test(resource.path)) { return { metadata: new vscode.NotebookDocumentMetadata(), cells: [] diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/window.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/window.test.ts index b672c7705ac..0a403549ce2 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/window.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/window.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { workspace, window, commands, ViewColumn, TextEditorViewColumnChangeEvent, Uri, Selection, Position, CancellationTokenSource, TextEditorSelectionChangeKind, QuickPickItem, TextEditor } from 'vscode'; +import { workspace, window, commands, ViewColumn, TextEditorViewColumnChangeEvent, Uri, Selection, Position, CancellationTokenSource, TextEditorSelectionChangeKind, QuickPickItem, TextEditor, StatusBarAlignment } from 'vscode'; import { join } from 'path'; import { closeAllEditors, pathEquals, createRandomFile, assertNoRpc } from '../utils'; @@ -638,4 +638,22 @@ suite('vscode API - window', () => { }); }); + + test('createStatusBar', async function () { + const statusBarEntryWithoutId = window.createStatusBarItem(StatusBarAlignment.Left, 100); + assert.strictEqual(statusBarEntryWithoutId.id, 'vscode.vscode-api-tests'); + assert.strictEqual(statusBarEntryWithoutId.alignment, StatusBarAlignment.Left); + assert.strictEqual(statusBarEntryWithoutId.priority, 100); + assert.strictEqual(statusBarEntryWithoutId.name, undefined); + statusBarEntryWithoutId.name = 'Test Name'; + assert.strictEqual(statusBarEntryWithoutId.name, 'Test Name'); + + const statusBarEntryWithId = window.createStatusBarItem('testId', StatusBarAlignment.Right, 200); + assert.strictEqual(statusBarEntryWithId.alignment, StatusBarAlignment.Right); + assert.strictEqual(statusBarEntryWithId.priority, 200); + assert.strictEqual(statusBarEntryWithId.id, 'testId'); + assert.strictEqual(statusBarEntryWithId.name, undefined); + statusBarEntryWithId.name = 'Test Name'; + assert.strictEqual(statusBarEntryWithId.name, 'Test Name'); + }); }); diff --git a/extensions/vscode-api-tests/src/utils.ts b/extensions/vscode-api-tests/src/utils.ts index a36b7549626..b346efefce9 100644 --- a/extensions/vscode-api-tests/src/utils.ts +++ b/extensions/vscode-api-tests/src/utils.ts @@ -137,3 +137,15 @@ export async function asPromise(event: vscode.Event, timeout = vscode.env. }); }); } + +export function testRepeat(n: number, description: string, callback: (this: any) => any): void { + for (let i = 0; i < n; i++) { + test(`${description} (iteration ${i})`, callback); + } +} + +export function suiteRepeat(n: number, description: string, callback: (this: any) => any): void { + for (let i = 0; i < n; i++) { + suite(`${description} (iteration ${i})`, callback); + } +} diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index c338957146b..16a3d0cca57 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -216,6 +216,9 @@ export function activate(context: vscode.ExtensionContext) { } const authorityResolverDisposable = vscode.workspace.registerRemoteAuthorityResolver('test', { + async getCanonicalURI(uri: vscode.Uri): Promise { + return vscode.Uri.file(uri.path); + }, resolve(_authority: string): Thenable { return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, diff --git a/extensions/yarn.lock b/extensions/yarn.lock index 18e5f36add7..3d37fa10930 100644 --- a/extensions/yarn.lock +++ b/extensions/yarn.lock @@ -24,10 +24,10 @@ fast-plist@0.1.2: resolved "https://registry.yarnpkg.com/fast-plist/-/fast-plist-0.1.2.tgz#a45aff345196006d406ca6cdcd05f69051ef35b8" integrity sha1-pFr/NFGWAG1AbKbNzQX2kFHvNbg= -typescript@^4.3.0-dev.20210507: - version "4.3.0-dev.20210507" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.0-dev.20210507.tgz#07fdc0479bb1b215865aabb01ed1d920cf844ea0" - integrity sha512-SEZV+XOg8exwPXlTmxPT94v9kasblelh4TjL1I12FBv0DiorBHDtUs8GC2h2sg8zJOgFwj06QXiaLLGL5RhzDw== +typescript@^4.3.1-rc: + version "4.3.1-rc" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.1-rc.tgz#925149c8d8514e20a6bd8d4bd7f42adac67ab59c" + integrity sha512-L3uJ0gcntaRaKni9aV2amYB+pCDVodKe/B5+IREyvtKGsDOF7cYjchHb/B894skqkgD52ykRuWatIZMqEsHIqA== vscode-grammar-updater@^1.0.3: version "1.0.3" diff --git a/package.json b/package.json index e03293fb96e..4428a3c4a7b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.57.0", - "distro": "ecdb89a80900940251ce4a8083f6b2e4b032b583", + "distro": "8af067d7af4cb9a7acf8efcdfb255c1e68452728", "author": { "name": "Microsoft Corporation" }, diff --git a/product.json b/product.json index 1667a363aa1..3f00f8e1468 100644 --- a/product.json +++ b/product.json @@ -22,6 +22,7 @@ "licenseFileName": "LICENSE.txt", "reportIssueUrl": "https://github.com/microsoft/vscode/issues/new", "urlProtocol": "code-oss", + "webviewContentExternalBaseUrlTemplate": "https://{{uuid}}.vscode-webview.net/{{quality}}/{{commit}}/out/vs/workbench/contrib/webview/browser/pre/", "extensionAllowedProposedApi": [ "ms-vscode.vscode-js-profile-flame", "ms-vscode.vscode-js-profile-table", @@ -33,7 +34,7 @@ "builtInExtensions": [ { "name": "ms-vscode.node-debug", - "version": "1.44.27", + "version": "1.44.28", "repo": "https://github.com/microsoft/vscode-node-debug", "metadata": { "id": "b6ded8fb-a0a0-4c1c-acbd-ab2a3bc995a6", @@ -48,7 +49,7 @@ }, { "name": "ms-vscode.node-debug2", - "version": "1.42.6", + "version": "1.42.7", "repo": "https://github.com/microsoft/vscode-node-debug2", "metadata": { "id": "36d19e17-7569-4841-a001-947eb18602b2", diff --git a/remote/yarn.lock b/remote/yarn.lock index 69069f99d84..efeb6c53ebc 100644 --- a/remote/yarn.lock +++ b/remote/yarn.lock @@ -437,9 +437,9 @@ socks@^2.3.3: smart-buffer "^4.1.0" spdlog@^0.13.0: - version "0.13.4" - resolved "https://registry.yarnpkg.com/spdlog/-/spdlog-0.13.4.tgz#7393d436f077fca1d07500741e50cbf8928a838a" - integrity sha512-tdzk9ysc640emskx+pE/A2JdJ5IAr440ZIsNjRlD9aPK6U6IQ94VUGpl7u0NHamAB8O1H7RxLgtHyXT32V+RaA== + version "0.13.5" + resolved "https://registry.yarnpkg.com/spdlog/-/spdlog-0.13.5.tgz#a31027dcccbe032e9a53579f42cb45428af08bad" + integrity sha512-D1xA5tRXw7eZOoFBCAnOxCxLN3JpHVDjpPJG/xjJ0nFZvtfOUTAzK66MVxJCDht/ZFwjLcBAltvzjfz4JTuSEw== dependencies: bindings "^1.5.0" mkdirp "^0.5.5" diff --git a/src/vs/base/browser/globalMouseMoveMonitor.ts b/src/vs/base/browser/globalMouseMoveMonitor.ts index 4911b59e080..6745aafbb13 100644 --- a/src/vs/base/browser/globalMouseMoveMonitor.ts +++ b/src/vs/base/browser/globalMouseMoveMonitor.ts @@ -7,6 +7,7 @@ import * as dom from 'vs/base/browser/dom'; import { IframeUtils } from 'vs/base/browser/iframe'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { isIOS } from 'vs/base/common/platform'; export interface IStandardMouseMoveEventData { leftButton: boolean; @@ -88,7 +89,7 @@ export class GlobalMouseMoveMonitor implements I this._onStopCallback = onStopCallback; const windowChain = IframeUtils.getSameOriginWindowChain(); - const mouseMove = 'mousemove'; + const mouseMove = isIOS ? 'pointermove' : 'mousemove'; // Safari sends wrong event, workaround for #122653 const mouseUp = 'mouseup'; const listenTo: (Document | ShadowRoot)[] = windowChain.map(element => element.window.document); diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index 5378627e57a..67fae43f7ab 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -312,6 +312,14 @@ function getInsaneOptions(options: { readonly isTrusted?: boolean }): InsaneOpti }; } +/** + * Strips all markdown from `string`, if it's an IMarkdownString. For example + * `# Header` would be output as `Header`. If it's not, the string is returned. + */ +export function renderStringAsPlaintext(string: IMarkdownString | string) { + return typeof string === 'string' ? string : renderMarkdownAsPlaintext(string); +} + /** * Strips all markdown from `markdown`. For example `# Header` would be output as `Header`. */ diff --git a/src/vs/base/common/collections.ts b/src/vs/base/common/collections.ts index cc037ecfd71..24ed68bae5f 100644 --- a/src/vs/base/common/collections.ts +++ b/src/vs/base/common/collections.ts @@ -53,8 +53,8 @@ export function forEach(from: IStringDictionary | INumberDictionary, ca * Groups the collection into a dictionary based on the provided * group function. */ -export function groupBy(data: T[], groupFn: (element: T) => string): IStringDictionary { - const result: IStringDictionary = Object.create(null); +export function groupBy(data: V[], groupFn: (element: V) => K): Record { + const result: Record = Object.create(null); for (const element of data) { const key = groupFn(element); let target = result[key]; @@ -66,24 +66,6 @@ export function groupBy(data: T[], groupFn: (element: T) => string): IStringD return result; } -/** - * Groups the collection into a dictionary based on the provided - * group function. - */ -export function groupByNumber(data: T[], groupFn: (element: T) => number): Map { - const result = new Map(); - for (const element of data) { - const key = groupFn(element); - let target = result.get(key); - if (!target) { - target = []; - result.set(key, target); - } - target.push(element); - } - return result; -} - export function fromMap(original: Map): IStringDictionary { const result: IStringDictionary = Object.create(null); if (original) { diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index 4dbb96ccdb7..d992d0abe8a 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -645,13 +645,13 @@ export class Emitter { } dispose() { - this._listeners?.clear(); - this._deliveryQueue?.clear(); - if (this._options?.onLastListenerRemove) { - this._options.onLastListenerRemove(); + if (!this._disposed) { + this._disposed = true; + this._listeners?.clear(); + this._deliveryQueue?.clear(); + this._options?.onLastListenerRemove?.(); + this._leakageMon?.dispose(); } - this._leakageMon?.dispose(); - this._disposed = true; } } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 129b8dec4d9..3b53fd808df 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -25,6 +25,11 @@ export type ExtensionUntrustedWorkspaceSupport = { readonly override?: boolean | 'limited' }; +export type ExtensionVirtualWorkspaceSupport = { + readonly default?: boolean, + readonly override?: boolean +}; + export interface IProductConfiguration { readonly version: string; readonly date?: string; @@ -122,7 +127,7 @@ export interface IProductConfiguration { readonly extensionSyncedKeys?: { readonly [extensionId: string]: string[]; }; readonly extensionAllowedProposedApi?: readonly string[]; readonly extensionUntrustedWorkspaceSupport?: { readonly [extensionId: string]: ExtensionUntrustedWorkspaceSupport }; - readonly extensionVirtualWorkspacesSupport?: { readonly [extensionId: string]: { default?: boolean, override?: boolean } }; + readonly extensionVirtualWorkspacesSupport?: { readonly [extensionId: string]: ExtensionVirtualWorkspaceSupport }; readonly msftInternalDomains?: string[]; readonly linkProtectionTrustedDomains?: readonly string[]; @@ -130,6 +135,8 @@ export interface IProductConfiguration { readonly 'configurationSync.store'?: ConfigurationSyncStore; readonly darwinUniversalAssetId?: string; + + readonly webviewContentExternalBaseUrlTemplate: string; } export type ImportantExtensionTip = { name: string; languages?: string[]; pattern?: string; isExtensionPack?: boolean }; diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index c2b0b02eb86..da8c4de410d 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -327,13 +327,15 @@ export class URI implements UriComponents { } static from(components: { scheme: string; authority?: string; path?: string; query?: string; fragment?: string }): URI { - return new Uri( + const result = new Uri( components.scheme, components.authority, components.path, components.query, components.fragment, ); + _validateUri(result, true); + return result; } /** diff --git a/src/vs/base/node/extpath.ts b/src/vs/base/node/extpath.ts index eaf62cd8e12..325c0d2d1dc 100644 --- a/src/vs/base/node/extpath.ts +++ b/src/vs/base/node/extpath.ts @@ -4,10 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; -import { promisify } from 'util'; import { rtrim } from 'vs/base/common/strings'; import { sep, join, normalize, dirname, basename } from 'vs/base/common/path'; -import { readdirSync } from 'vs/base/node/pfs'; +import { Promises, readdirSync } from 'vs/base/node/pfs'; /** * Copied from: https://github.com/microsoft/vscode-node-debug/blob/master/src/node/pathUtilities.ts#L83 @@ -57,7 +56,7 @@ export async function realpath(path: string): Promise { // calls `fs.native.realpath` which will result in subst // drives to be resolved to their target on Windows // https://github.com/microsoft/vscode/issues/118562 - return await promisify(fs.realpath)(path); + return await Promises.realpath(path); } catch (error) { // We hit an error calling fs.realpath(). Since fs.realpath() is doing some path normalization @@ -67,7 +66,7 @@ export async function realpath(path: string): Promise { // to not resolve links but to simply see if the path is read accessible or not. const normalizedPath = normalizePath(path); - await fs.promises.access(normalizedPath, fs.constants.R_OK); + await Promises.access(normalizedPath, fs.constants.R_OK); return normalizedPath; } diff --git a/src/vs/base/node/pfs.ts b/src/vs/base/node/pfs.ts index 30d25a8cc33..40a81246b02 100644 --- a/src/vs/base/node/pfs.ts +++ b/src/vs/base/node/pfs.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import { tmpdir } from 'os'; +import { promisify } from 'util'; import { join } from 'vs/base/common/path'; import { ResourceQueue } from 'vs/base/common/async'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; @@ -56,7 +57,7 @@ async function rimrafMove(path: string): Promise { try { const pathInTemp = join(tmpdir(), generateUuid()); try { - await fs.promises.rename(path, pathInTemp); + await Promises.rename(path, pathInTemp); } catch (error) { return rimrafUnlink(path); // if rename fails, delete without tmp dir } @@ -71,7 +72,7 @@ async function rimrafMove(path: string): Promise { } async function rimrafUnlink(path: string): Promise { - return fs.promises.rmdir(path, { recursive: true, maxRetries: 3 }); + return Promises.rmdir(path, { recursive: true, maxRetries: 3 }); } export function rimrafSync(path: string): void { @@ -102,12 +103,12 @@ export interface IDirent { export async function readdir(path: string): Promise; export async function readdir(path: string, options: { withFileTypes: true }): Promise; export async function readdir(path: string, options?: { withFileTypes: true }): Promise<(string | IDirent)[]> { - return handleDirectoryChildren(await (options ? safeReaddirWithFileTypes(path) : fs.promises.readdir(path))); + return handleDirectoryChildren(await (options ? safeReaddirWithFileTypes(path) : Promises.readdir(path))); } async function safeReaddirWithFileTypes(path: string): Promise { try { - return await fs.promises.readdir(path, { withFileTypes: true }); + return await Promises.readdir(path, { withFileTypes: true }); } catch (error) { console.warn('[node.js fs] readdir with filetypes failed with error: ', error); } @@ -126,7 +127,7 @@ async function safeReaddirWithFileTypes(path: string): Promise { let isSymbolicLink = false; try { - const lstat = await fs.promises.lstat(join(path, child)); + const lstat = await Promises.lstat(join(path, child)); isFile = lstat.isFile(); isDirectory = lstat.isDirectory(); @@ -251,7 +252,7 @@ export namespace SymlinkSupport { // First stat the link let lstats: fs.Stats | undefined; try { - lstats = await fs.promises.lstat(path); + lstats = await Promises.lstat(path); // Return early if the stat is not a symbolic link at all if (!lstats.isSymbolicLink()) { @@ -264,7 +265,7 @@ export namespace SymlinkSupport { // If the stat is a symbolic link or failed to stat, use fs.stat() // which for symbolic links will stat the target they point to try { - const stats = await fs.promises.stat(path); + const stats = await Promises.stat(path); return { stat: stats, symbolicLink: lstats?.isSymbolicLink() ? { dangling: false } : undefined }; } catch (error) { @@ -279,7 +280,7 @@ export namespace SymlinkSupport { // are not supported (https://github.com/nodejs/node/issues/36790) if (isWindows && error.code === 'EACCES') { try { - const stats = await fs.promises.stat(await fs.promises.readlink(path)); + const stats = await Promises.stat(await Promises.readlink(path)); return { stat: stats, symbolicLink: { dangling: false } }; } catch (error) { @@ -488,24 +489,19 @@ export async function move(source: string, target: string): Promise { // as well because conceptually it is a change of a similar category. async function updateMtime(path: string): Promise { try { - const stat = await fs.promises.lstat(path); + const stat = await Promises.lstat(path); if (stat.isDirectory() || stat.isSymbolicLink()) { return; // only for files } - const fh = await fs.promises.open(path, 'a'); - try { - await fh.utimes(stat.atime, new Date()); - } finally { - await fh.close(); - } + await Promises.utimes(path, stat.atime, new Date()); } catch (error) { // Ignore any error } } try { - await fs.promises.rename(source, target); + await Promises.rename(source, target); await updateMtime(target); } catch (error) { @@ -594,7 +590,7 @@ async function doCopy(source: string, target: string, payload: ICopyPayload): Pr async function doCopyDirectory(source: string, target: string, mode: number, payload: ICopyPayload): Promise { // Create folder - await fs.promises.mkdir(target, { recursive: true, mode }); + await Promises.mkdir(target, { recursive: true, mode }); // Copy each file recursively const files = await readdir(source); @@ -606,16 +602,16 @@ async function doCopyDirectory(source: string, target: string, mode: number, pay async function doCopyFile(source: string, target: string, mode: number): Promise { // Copy file - await fs.promises.copyFile(source, target); + await Promises.copyFile(source, target); // restore mode (https://github.com/nodejs/node/issues/1104) - await fs.promises.chmod(target, mode); + await Promises.chmod(target, mode); } async function doCopySymlink(source: string, target: string, payload: ICopyPayload): Promise { // Figure out link target - let linkTarget = await fs.promises.readlink(source); + let linkTarget = await Promises.readlink(source); // Special case: the symlink points to a target that is // actually within the path that is being copied. In that @@ -626,7 +622,7 @@ async function doCopySymlink(source: string, target: string, payload: ICopyPaylo } // Create symlink - await fs.promises.symlink(linkTarget, target); + await Promises.symlink(linkTarget, target); } //#endregion @@ -635,7 +631,7 @@ async function doCopySymlink(source: string, target: string, payload: ICopyPaylo export async function exists(path: string): Promise { try { - await fs.promises.access(path); + await Promises.access(path); return true; } catch { @@ -644,3 +640,56 @@ export async function exists(path: string): Promise { } //#endregion + +//#region Promise based fs methods + +/** + * Note: prefer this namespace over the `fs.promises` API + * to enable `graceful-fs` to function properly. Given issue + * https://github.com/isaacs/node-graceful-fs/issues/160 it + * is evident that the module only takes care of the non-promise + * based fs methods. + * + * Another reason is `realpath` being entirely different in + * the promise based implementation compared to the other + * one (https://github.com/microsoft/vscode/issues/118562) + */ +export namespace Promises { + export const access = promisify(fs.access); + + export const stat = promisify(fs.stat); + export const lstat = promisify(fs.lstat); + export const utimes = promisify(fs.utimes); + + export const read = promisify(fs.read); + export const readFile = promisify(fs.readFile); + + export const write = promisify(fs.write); + export const writeFile = promisify(fs.writeFile); + + export const appendFile = promisify(fs.appendFile); + + export const fdatasync = promisify(fs.fdatasync); + export const truncate = promisify(fs.truncate); + + export const rename = promisify(fs.rename); + export const copyFile = promisify(fs.copyFile); + + export const open = promisify(fs.open); + export const close = promisify(fs.close); + + export const symlink = promisify(fs.symlink); + export const readlink = promisify(fs.readlink); + + export const chmod = promisify(fs.chmod); + + export const readdir = promisify(fs.readdir); + export const mkdir = promisify(fs.mkdir); + + export const unlink = promisify(fs.unlink); + export const rmdir = promisify(fs.rmdir); + + export const realpath = promisify(fs.realpath); +} + +//#endregion diff --git a/src/vs/base/node/processes.ts b/src/vs/base/node/processes.ts index 363dc229f62..f1b1b398212 100644 --- a/src/vs/base/node/processes.ts +++ b/src/vs/base/node/processes.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'vs/base/common/path'; -import * as fs from 'fs'; import * as pfs from 'vs/base/node/pfs'; import * as cp from 'child_process'; import * as nls from 'vs/nls'; @@ -458,7 +457,7 @@ export namespace win32 { async function fileExists(path: string): Promise { if (await pfs.exists(path)) { - return !((await fs.promises.stat(path)).isDirectory()); + return !((await pfs.Promises.stat(path)).isDirectory()); } return false; } diff --git a/src/vs/base/node/zip.ts b/src/vs/base/node/zip.ts index 0d74501d997..3c289c297d1 100644 --- a/src/vs/base/node/zip.ts +++ b/src/vs/base/node/zip.ts @@ -5,10 +5,10 @@ import * as nls from 'vs/nls'; import * as path from 'vs/base/common/path'; -import { promises, createWriteStream, WriteStream } from 'fs'; +import { createWriteStream, WriteStream } from 'fs'; import { Readable } from 'stream'; import { Sequencer, createCancelablePromise } from 'vs/base/common/async'; -import { rimraf } from 'vs/base/node/pfs'; +import { Promises, rimraf } from 'vs/base/node/pfs'; import { open as _openZip, Entry, ZipFile } from 'yauzl'; import * as yazl from 'yazl'; import { CancellationToken } from 'vs/base/common/cancellation'; @@ -86,7 +86,7 @@ function extractEntry(stream: Readable, fileName: string, mode: number, targetPa } }); - return Promise.resolve(promises.mkdir(targetDirName, { recursive: true })).then(() => new Promise((c, e) => { + return Promise.resolve(Promises.mkdir(targetDirName, { recursive: true })).then(() => new Promise((c, e) => { if (token.isCancellationRequested) { return; } @@ -149,7 +149,7 @@ function extractZip(zipfile: ZipFile, targetPath: string, options: IOptions, tok // directory file names end with '/' if (/\/$/.test(fileName)) { const targetFileName = path.join(targetPath, fileName); - last = createCancelablePromise(token => promises.mkdir(targetFileName, { recursive: true }).then(() => readNextEntry(token)).then(undefined, e)); + last = createCancelablePromise(token => Promises.mkdir(targetFileName, { recursive: true }).then(() => readNextEntry(token)).then(undefined, e)); return; } diff --git a/src/vs/base/parts/storage/node/storage.ts b/src/vs/base/parts/storage/node/storage.ts index e497862ddaf..e79d41f9e0d 100644 --- a/src/vs/base/parts/storage/node/storage.ts +++ b/src/vs/base/parts/storage/node/storage.ts @@ -4,12 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import type { Database, Statement } from 'vscode-sqlite3'; -import { promises } from 'fs'; import { Event } from 'vs/base/common/event'; import { timeout } from 'vs/base/common/async'; import { mapToString, setToString } from 'vs/base/common/map'; import { basename } from 'vs/base/common/path'; -import { copy } from 'vs/base/node/pfs'; +import { copy, Promises } from 'vs/base/node/pfs'; import { IStorageDatabase, IStorageItemsChangeEvent, IUpdateRequest } from 'vs/base/parts/storage/common/storage'; interface IDatabaseConnection { @@ -187,7 +186,7 @@ export class SQLiteStorageDatabase implements IStorageDatabase { // Delete the existing DB. If the path does not exist or fails to // be deleted, we do not try to recover anymore because we assume // that the path is no longer writeable for us. - return promises.unlink(this.path).then(() => { + return Promises.unlink(this.path).then(() => { // Re-open the DB fresh return this.doConnect(this.path).then(recoveryConnection => { @@ -273,9 +272,9 @@ export class SQLiteStorageDatabase implements IStorageDatabase { // folder is really not writeable for us. // try { - await promises.unlink(path); + await Promises.unlink(path); try { - await promises.rename(this.toBackupPath(path), path); + await Promises.rename(this.toBackupPath(path), path); } catch (error) { // ignore } diff --git a/src/vs/base/parts/storage/test/node/storage.test.ts b/src/vs/base/parts/storage/test/node/storage.test.ts index fd963590dc4..07846cf4a4f 100644 --- a/src/vs/base/parts/storage/test/node/storage.test.ts +++ b/src/vs/base/parts/storage/test/node/storage.test.ts @@ -7,9 +7,8 @@ import { SQLiteStorageDatabase, ISQLiteStorageDatabaseOptions } from 'vs/base/pa import { Storage, IStorageDatabase, IStorageItemsChangeEvent } from 'vs/base/parts/storage/common/storage'; import { join } from 'vs/base/common/path'; import { tmpdir } from 'os'; -import { promises } from 'fs'; import { strictEqual, ok } from 'assert'; -import { writeFile, exists, rimraf } from 'vs/base/node/pfs'; +import { writeFile, exists, rimraf, Promises } from 'vs/base/node/pfs'; import { timeout } from 'vs/base/common/async'; import { Event, Emitter } from 'vs/base/common/event'; import { isWindows } from 'vs/base/common/platform'; @@ -23,7 +22,7 @@ flakySuite('Storage Library', function () { setup(function () { testDir = getRandomTestPath(tmpdir(), 'vsctests', 'storagelibrary'); - return promises.mkdir(testDir, { recursive: true }); + return Promises.mkdir(testDir, { recursive: true }); }); teardown(function () { @@ -296,7 +295,7 @@ flakySuite('SQLite Storage Library', function () { setup(function () { testdir = getRandomTestPath(tmpdir(), 'vsctests', 'storagelibrary'); - return promises.mkdir(testdir, { recursive: true }); + return Promises.mkdir(testdir, { recursive: true }); }); teardown(function () { @@ -477,7 +476,7 @@ flakySuite('SQLite Storage Library', function () { // on shutdown. await storage.checkIntegrity(true).then(null, error => { } /* error is expected here but we do not want to fail */); - await promises.unlink(backupPath); // also test that the recovery DB is backed up properly + await Promises.unlink(backupPath); // also test that the recovery DB is backed up properly let recoveryCalled = false; await storage.close(() => { diff --git a/src/vs/base/test/common/collections.test.ts b/src/vs/base/test/common/collections.test.ts index 881525a006b..a86eeab2de0 100644 --- a/src/vs/base/test/common/collections.test.ts +++ b/src/vs/base/test/common/collections.test.ts @@ -53,26 +53,4 @@ suite('Collections', () => { assert.strictEqual(grouped[group2].length, 1); assert.strictEqual(grouped[group2][0].value, value3); }); - - test('groupByNumber', () => { - - const group1 = 1, group2 = 2; - const value1 = 'a', value2 = 'b', value3 = 'c'; - let source = [ - { key: group1, value: value1 }, - { key: group1, value: value2 }, - { key: group2, value: value3 }, - ]; - - let grouped = collections.groupByNumber(source, x => x.key); - - // Group 1 - assert.strictEqual(grouped.get(group1)!.length, 2); - assert.strictEqual(grouped.get(group1)![0].value, value1); - assert.strictEqual(grouped.get(group1)![1].value, value2); - - // Group 2 - assert.strictEqual(grouped.get(group2)!.length, 1); - assert.strictEqual(grouped.get(group2)![0].value, value3); - }); }); diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts index a0f55cd405c..3fea2b43b4e 100644 --- a/src/vs/base/test/common/event.test.ts +++ b/src/vs/base/test/common/event.test.ts @@ -894,4 +894,14 @@ suite('Event utils', () => { listener.dispose(); }); + test('dispose is reentrant', () => { + const emitter = new Emitter({ + onLastListenerRemove: () => { + emitter.dispose(); + } + }); + + const listener = emitter.event(() => undefined); + listener.dispose(); // should not crash + }); }); diff --git a/src/vs/base/test/common/stream.test.ts b/src/vs/base/test/common/stream.test.ts index 4cb309a84ea..913cf6e0817 100644 --- a/src/vs/base/test/common/stream.test.ts +++ b/src/vs/base/test/common/stream.test.ts @@ -91,7 +91,7 @@ suite('Stream', () => { }); test('WriteableStream - end with error works', async () => { - const reducer = (errors: Error[]) => errors.length > 0 ? errors[0] : null as unknown as Error; + const reducer = (errors: Error[]) => errors[0]; const stream = newWriteableStream(reducer); stream.end(new Error('error')); diff --git a/src/vs/base/test/node/crypto.test.ts b/src/vs/base/test/node/crypto.test.ts index 67c188de8f0..f1b7ef70b94 100644 --- a/src/vs/base/test/node/crypto.test.ts +++ b/src/vs/base/test/node/crypto.test.ts @@ -6,8 +6,7 @@ import { checksum } from 'vs/base/node/crypto'; import { join } from 'vs/base/common/path'; import { tmpdir } from 'os'; -import { promises } from 'fs'; -import { rimraf, writeFile } from 'vs/base/node/pfs'; +import { Promises, rimraf, writeFile } from 'vs/base/node/pfs'; import { flakySuite, getRandomTestPath } from 'vs/base/test/node/testUtils'; flakySuite('Crypto', () => { @@ -17,7 +16,7 @@ flakySuite('Crypto', () => { setup(function () { testDir = getRandomTestPath(tmpdir(), 'vsctests', 'crypto'); - return promises.mkdir(testDir, { recursive: true }); + return Promises.mkdir(testDir, { recursive: true }); }); teardown(function () { diff --git a/src/vs/base/test/node/extpath.test.ts b/src/vs/base/test/node/extpath.test.ts index a1c8a7f94d2..0487ffcaebf 100644 --- a/src/vs/base/test/node/extpath.test.ts +++ b/src/vs/base/test/node/extpath.test.ts @@ -5,8 +5,7 @@ import * as assert from 'assert'; import { tmpdir } from 'os'; -import { promises } from 'fs'; -import { rimraf } from 'vs/base/node/pfs'; +import { Promises, rimraf } from 'vs/base/node/pfs'; import { realcaseSync, realpath, realpathSync } from 'vs/base/node/extpath'; import { flakySuite, getRandomTestPath } from 'vs/base/test/node/testUtils'; @@ -16,7 +15,7 @@ flakySuite('Extpath', () => { setup(() => { testDir = getRandomTestPath(tmpdir(), 'vsctests', 'extpath'); - return promises.mkdir(testDir, { recursive: true }); + return Promises.mkdir(testDir, { recursive: true }); }); teardown(() => { diff --git a/src/vs/base/test/node/pfs/pfs.test.ts b/src/vs/base/test/node/pfs/pfs.test.ts index 0eceb128b9a..2139f6cbe44 100644 --- a/src/vs/base/test/node/pfs/pfs.test.ts +++ b/src/vs/base/test/node/pfs/pfs.test.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import { tmpdir } from 'os'; import { join, sep } from 'vs/base/common/path'; import { generateUuid } from 'vs/base/common/uuid'; -import { copy, exists, move, readdir, readDirsInDir, rimraf, RimRafMode, rimrafSync, SymlinkSupport, writeFile, writeFileSync } from 'vs/base/node/pfs'; +import { copy, exists, move, Promises, readdir, readDirsInDir, rimraf, RimRafMode, rimrafSync, SymlinkSupport, writeFile, writeFileSync } from 'vs/base/node/pfs'; import { timeout } from 'vs/base/common/async'; import { canNormalize } from 'vs/base/common/normalization'; import { VSBuffer } from 'vs/base/common/buffer'; @@ -22,7 +22,7 @@ flakySuite('PFS', function () { setup(() => { testDir = getRandomTestPath(tmpdir(), 'vsctests', 'pfs'); - return fs.promises.mkdir(testDir, { recursive: true }); + return Promises.mkdir(testDir, { recursive: true }); }); teardown(() => { @@ -36,7 +36,7 @@ flakySuite('PFS', function () { await writeFile(testFile, 'Hello World', (null!)); - assert.strictEqual((await fs.promises.readFile(testFile)).toString(), 'Hello World'); + assert.strictEqual((await Promises.readFile(testFile)).toString(), 'Hello World'); }); test('writeFile - parallel write on different files works', async () => { @@ -200,7 +200,7 @@ flakySuite('PFS', function () { const id3 = generateUuid(); const copyTarget = join(testDir, id3); - await fs.promises.mkdir(symbolicLinkTarget, { recursive: true }); + await Promises.mkdir(symbolicLinkTarget, { recursive: true }); fs.symlinkSync(symbolicLinkTarget, symLink, 'junction'); @@ -217,7 +217,7 @@ flakySuite('PFS', function () { assert.ok(symbolicLink); assert.ok(!symbolicLink.dangling); - const target = await fs.promises.readlink(copyTarget); + const target = await Promises.readlink(copyTarget); assert.strictEqual(target, symbolicLinkTarget); // Copy does not preserve symlinks if configured as such @@ -253,7 +253,7 @@ flakySuite('PFS', function () { const sourceLinkTestFolder = join(sourceFolder, 'link-test'); // copy-test/link-test const sourceLinkMD5JSFolder = join(sourceLinkTestFolder, 'md5'); // copy-test/link-test/md5 const sourceLinkMD5JSFile = join(sourceLinkMD5JSFolder, 'md5.js'); // copy-test/link-test/md5/md5.js - await fs.promises.mkdir(sourceLinkMD5JSFolder, { recursive: true }); + await Promises.mkdir(sourceLinkMD5JSFolder, { recursive: true }); await writeFile(sourceLinkMD5JSFile, 'Hello from MD5'); const sourceLinkMD5JSFolderLinked = join(sourceLinkTestFolder, 'md5-linked'); // copy-test/link-test/md5-linked @@ -278,10 +278,10 @@ flakySuite('PFS', function () { assert.ok(fs.existsSync(targetLinkMD5JSFolderLinked)); assert.ok(fs.lstatSync(targetLinkMD5JSFolderLinked).isSymbolicLink()); - const linkTarget = await fs.promises.readlink(targetLinkMD5JSFolderLinked); + const linkTarget = await Promises.readlink(targetLinkMD5JSFolderLinked); assert.strictEqual(linkTarget, targetLinkMD5JSFolder); - await fs.promises.rmdir(targetLinkTestFolder, { recursive: true }); + await Promises.rmdir(targetLinkTestFolder, { recursive: true }); } // Copy with `preserveSymlinks: false` and verify result @@ -315,7 +315,7 @@ flakySuite('PFS', function () { const id2 = generateUuid(); const symbolicLink = join(testDir, id2); - await fs.promises.mkdir(directory, { recursive: true }); + await Promises.mkdir(directory, { recursive: true }); fs.symlinkSync(directory, symbolicLink, 'junction'); @@ -334,7 +334,7 @@ flakySuite('PFS', function () { const id2 = generateUuid(); const symbolicLink = join(testDir, id2); - await fs.promises.mkdir(directory, { recursive: true }); + await Promises.mkdir(directory, { recursive: true }); fs.symlinkSync(directory, symbolicLink, 'junction'); @@ -350,7 +350,7 @@ flakySuite('PFS', function () { const id = generateUuid(); const newDir = join(testDir, 'pfs', id, 'öäü'); - await fs.promises.mkdir(newDir, { recursive: true }); + await Promises.mkdir(newDir, { recursive: true }); assert.ok(fs.existsSync(newDir)); @@ -362,7 +362,7 @@ flakySuite('PFS', function () { test('readdir (with file types)', async () => { if (canNormalize && typeof process.versions['electron'] !== 'undefined' /* needs electron */) { const newDir = join(testDir, 'öäü'); - await fs.promises.mkdir(newDir, { recursive: true }); + await Promises.mkdir(newDir, { recursive: true }); await writeFile(join(testDir, 'somefile.txt'), 'contents'); diff --git a/src/vs/base/test/node/zip/zip.test.ts b/src/vs/base/test/node/zip/zip.test.ts index 4f151af16e9..5e5470ce155 100644 --- a/src/vs/base/test/node/zip/zip.test.ts +++ b/src/vs/base/test/node/zip/zip.test.ts @@ -6,9 +6,8 @@ import * as assert from 'assert'; import * as path from 'vs/base/common/path'; import { tmpdir } from 'os'; -import { promises } from 'fs'; import { extract } from 'vs/base/node/zip'; -import { rimraf, exists } from 'vs/base/node/pfs'; +import { rimraf, exists, Promises } from 'vs/base/node/pfs'; import { createCancelablePromise } from 'vs/base/common/async'; import { getRandomTestPath, getPathFromAmdModule } from 'vs/base/test/node/testUtils'; @@ -19,7 +18,7 @@ suite('Zip', () => { setup(() => { testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip'); - return promises.mkdir(testDir, { recursive: true }); + return Promises.mkdir(testDir, { recursive: true }); }); teardown(() => { diff --git a/src/vs/code/browser/workbench/workbench.ts b/src/vs/code/browser/workbench/workbench.ts index 4aebea3e0da..9c222a0048e 100644 --- a/src/vs/code/browser/workbench/workbench.ts +++ b/src/vs/code/browser/workbench/workbench.ts @@ -278,8 +278,8 @@ class WorkspaceProvider implements IWorkspaceProvider { readonly trusted = true; constructor( - public readonly workspace: IWorkspace, - public readonly payload: object + readonly workspace: IWorkspace, + readonly payload: object ) { } async open(workspace: IWorkspace, options?: { reuse?: boolean, payload?: object }): Promise { diff --git a/src/vs/code/electron-browser/sharedProcess/contrib/languagePackCachedDataCleaner.ts b/src/vs/code/electron-browser/sharedProcess/contrib/languagePackCachedDataCleaner.ts index 00df213a37c..b76d7668802 100644 --- a/src/vs/code/electron-browser/sharedProcess/contrib/languagePackCachedDataCleaner.ts +++ b/src/vs/code/electron-browser/sharedProcess/contrib/languagePackCachedDataCleaner.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; import * as path from 'vs/base/common/path'; import * as pfs from 'vs/base/node/pfs'; import { IStringDictionary } from 'vs/base/common/collections'; @@ -55,7 +54,7 @@ export class LanguagePackCachedDataCleaner extends Disposable { this._logService.info('Starting to clean up unused language packs.'); try { const installed: IStringDictionary = Object.create(null); - const metaData: LanguagePackFile = JSON.parse(await fs.promises.readFile(path.join(this._environmentService.userDataPath, 'languagepacks.json'), 'utf8')); + const metaData: LanguagePackFile = JSON.parse(await pfs.Promises.readFile(path.join(this._environmentService.userDataPath, 'languagepacks.json'), 'utf8')); for (let locale of Object.keys(metaData)) { const entry = metaData[locale]; installed[`${entry.hash}.${locale}`] = true; @@ -83,7 +82,7 @@ export class LanguagePackCachedDataCleaner extends Disposable { continue; } const candidate = path.join(folder, entry); - const stat = await fs.promises.stat(candidate); + const stat = await pfs.Promises.stat(candidate); if (stat.isDirectory()) { const diff = now - stat.mtime.getTime(); if (diff > this._DataMaxAge) { diff --git a/src/vs/code/electron-browser/sharedProcess/contrib/nodeCachedDataCleaner.ts b/src/vs/code/electron-browser/sharedProcess/contrib/nodeCachedDataCleaner.ts index fdb9aa7bb40..a6afc697617 100644 --- a/src/vs/code/electron-browser/sharedProcess/contrib/nodeCachedDataCleaner.ts +++ b/src/vs/code/electron-browser/sharedProcess/contrib/nodeCachedDataCleaner.ts @@ -3,11 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { promises } from 'fs'; import { basename, dirname, join } from 'vs/base/common/path'; import { onUnexpectedError } from 'vs/base/common/errors'; import { toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { readdir, rimraf } from 'vs/base/node/pfs'; +import { Promises, readdir, rimraf } from 'vs/base/node/pfs'; import { IProductService } from 'vs/platform/product/common/productService'; export class NodeCachedDataCleaner { @@ -56,7 +55,7 @@ export class NodeCachedDataCleaner { if (entry !== nodeCachedDataCurrent) { const path = join(nodeCachedDataRootDir, entry); - deletes.push(promises.stat(path).then(stats => { + deletes.push(Promises.stat(path).then(stats => { // stat check // * only directories // * only when old enough diff --git a/src/vs/code/electron-browser/sharedProcess/contrib/storageDataCleaner.ts b/src/vs/code/electron-browser/sharedProcess/contrib/storageDataCleaner.ts index fc26cb229c3..12055754970 100644 --- a/src/vs/code/electron-browser/sharedProcess/contrib/storageDataCleaner.ts +++ b/src/vs/code/electron-browser/sharedProcess/contrib/storageDataCleaner.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { promises } from 'fs'; import { INativeEnvironmentService } from 'vs/platform/environment/common/environment'; import { join } from 'vs/base/common/path'; -import { readdir, rimraf } from 'vs/base/node/pfs'; +import { Promises, readdir, rimraf } from 'vs/base/node/pfs'; import { onUnexpectedError } from 'vs/base/common/errors'; import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { IBackupWorkspacesFormat } from 'vs/platform/backup/node/backup'; @@ -33,7 +32,7 @@ export class StorageDataCleaner extends Disposable { try { // Leverage the backup workspace file to find out which empty workspace is currently in use to // determine which empty workspace storage can safely be deleted - const contents = await promises.readFile(this.backupWorkspacesPath, 'utf8'); + const contents = await Promises.readFile(this.backupWorkspacesPath, 'utf8'); const workspaces = JSON.parse(contents) as IBackupWorkspacesFormat; const emptyWorkspaces = workspaces.emptyWorkspaceInfos.map(info => info.backupFolder); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index a615da5cccf..630c577a640 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -717,8 +717,16 @@ export class CodeApplication extends Disposable { // protocol invocations outside of VSCode. const app = this; const environmentService = this.environmentMainService; + const productService = this.productService; urlService.registerHandler({ async handleURL(uri: URI, options?: IOpenURLOptions): Promise { + if (uri.scheme === productService.urlProtocol && uri.path === 'workspace') { + uri = uri.with({ + authority: 'file', + path: URI.parse(uri.query).path, + query: '' + }); + } // If URI should be blocked, behave as if it's handled if (app.shouldBlockURI(uri)) { diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index b47c3368c80..8c9be0c67f0 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -5,7 +5,8 @@ import 'vs/platform/update/common/update.config.contribution'; import { app, dialog } from 'electron'; -import { promises, unlinkSync } from 'fs'; +import { unlinkSync } from 'fs'; +import { Promises as FSPromises } from 'vs/base/node/pfs'; import { localize } from 'vs/nls'; import { isWindows, IProcessEnvironment, isMacintosh } from 'vs/base/common/platform'; import { mark } from 'vs/base/common/performance'; @@ -213,7 +214,7 @@ class CodeMain { environmentMainService.globalStorageHome.fsPath, environmentMainService.workspaceStorageHome.fsPath, environmentMainService.backupHome - ].map(path => path ? promises.mkdir(path, { recursive: true }) : undefined)), + ].map(path => path ? FSPromises.mkdir(path, { recursive: true }) : undefined)), // Configuration service configurationService.initialize(), diff --git a/src/vs/code/electron-sandbox/issue/issueReporterMain.ts b/src/vs/code/electron-sandbox/issue/issueReporterMain.ts index 5d28a61c896..6e93aa1e4b8 100644 --- a/src/vs/code/electron-sandbox/issue/issueReporterMain.ts +++ b/src/vs/code/electron-sandbox/issue/issueReporterMain.ts @@ -11,6 +11,7 @@ import { ipcRenderer } from 'vs/base/parts/sandbox/electron-sandbox/globals'; import { applyZoom, zoomIn, zoomOut } from 'vs/platform/windows/electron-sandbox/window'; import { $, reset, safeInnerHtml, windowOpenNoOpener } from 'vs/base/browser/dom'; import { Button } from 'vs/base/browser/ui/button/button'; +import { Delayer } from 'vs/base/common/async'; import { groupBy } from 'vs/base/common/collections'; import { debounce } from 'vs/base/common/decorators'; import { Disposable } from 'vs/base/common/lifecycle'; @@ -62,6 +63,7 @@ export class IssueReporter extends Disposable { private receivedPerformanceInfo = false; private shouldQueueSearch = false; private hasBeenSubmitted = false; + private delayedSubmit = new Delayer(300); private readonly previewButton!: Button; @@ -356,7 +358,11 @@ export class IssueReporter extends Disposable { this.searchIssues(title, fileOnExtension, fileOnMarketplace); }); - this.previewButton.onDidClick(() => this.createIssue()); + this.previewButton.onDidClick(async () => { + this.delayedSubmit.trigger(async () => { + this.createIssue(); + }); + }); function sendWorkbenchCommand(commandId: string) { ipcRenderer.send('vscode:workbenchCommand', { id: commandId, from: 'issueReporter' }); @@ -383,9 +389,11 @@ export class IssueReporter extends Disposable { const cmdOrCtrlKey = isMacintosh ? e.metaKey : e.ctrlKey; // Cmd/Ctrl+Enter previews issue and closes window if (cmdOrCtrlKey && e.keyCode === 13) { - if (await this.createIssue()) { - ipcRenderer.send('vscode:closeIssueReporter'); - } + this.delayedSubmit.trigger(async () => { + if (await this.createIssue()) { + ipcRenderer.send('vscode:closeIssueReporter'); + } + }); } // Cmd/Ctrl + w closes issue window diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index 83b09f0b3ee..ade2dd62737 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -6,6 +6,7 @@ import { release, hostname } from 'os'; import * as fs from 'fs'; import { gracefulify } from 'graceful-fs'; +import { Promises } from 'vs/base/node/pfs'; import { isAbsolute, join } from 'vs/base/common/path'; import { raceTimeout } from 'vs/base/common/async'; import product from 'vs/platform/product/common/product'; @@ -102,7 +103,7 @@ class CliMain extends Disposable { services.set(INativeEnvironmentService, environmentService); // Init folders - await Promise.all([environmentService.appSettingsHome.fsPath, environmentService.extensionsPath].map(path => path ? fs.promises.mkdir(path, { recursive: true }) : undefined)); + await Promise.all([environmentService.appSettingsHome.fsPath, environmentService.extensionsPath].map(path => path ? Promises.mkdir(path, { recursive: true }) : undefined)); // Log const logLevel = getLogLevel(environmentService); @@ -155,7 +156,7 @@ class CliMain extends Disposable { commonProperties: (async () => { let machineId: string | undefined = undefined; try { - const storageContents = await fs.promises.readFile(join(environmentService.userDataPath, 'storage.json')); + const storageContents = await Promises.readFile(join(environmentService.userDataPath, 'storage.json')); machineId = JSON.parse(storageContents.toString())[machineIdKey]; } catch (error) { if (error.code !== 'ENOENT') { diff --git a/src/vs/editor/browser/services/openerService.ts b/src/vs/editor/browser/services/openerService.ts index bc54879c7dc..0a3b2796127 100644 --- a/src/vs/editor/browser/services/openerService.ts +++ b/src/vs/editor/browser/services/openerService.ts @@ -191,31 +191,41 @@ export class OpenerService implements IOpenerService { async resolveExternalUri(resource: URI, options?: ResolveExternalUriOptions): Promise { for (const resolver of this._resolvers) { - const result = await resolver.resolveExternalUri(resource, options); - if (result) { - if (!this._resolvedUriTargets.has(result.resolved)) { - this._resolvedUriTargets.set(result.resolved, resource); + try { + const result = await resolver.resolveExternalUri(resource, options); + if (result) { + if (!this._resolvedUriTargets.has(result.resolved)) { + this._resolvedUriTargets.set(result.resolved, resource); + } + return result; } - return result; + } catch { + // noop } } - return { resolved: resource, dispose: () => { } }; + throw new Error('Could not resolve external URI: ' + resource.toString()); } private async _doOpenExternal(resource: URI | string, options: OpenOptions | undefined): Promise { //todo@jrieken IExternalUriResolver should support `uri: URI | string` const uri = typeof resource === 'string' ? URI.parse(resource) : resource; - const { resolved } = await this.resolveExternalUri(uri, options); + let externalUri: URI; + + try { + externalUri = (await this.resolveExternalUri(uri, options)).resolved; + } catch { + externalUri = uri; + } let href: string; - if (typeof resource === 'string' && uri.toString() === resolved.toString()) { + if (typeof resource === 'string' && uri.toString() === externalUri.toString()) { // open the url-string AS IS href = resource; } else { // open URI using the toString(noEncode)+encodeURI-trick - href = encodeURI(resolved.toString(true)); + href = encodeURI(externalUri.toString(true)); } if (options?.allowContributedOpeners) { diff --git a/src/vs/editor/common/config/commonEditorConfig.ts b/src/vs/editor/common/config/commonEditorConfig.ts index 4a888eb21e7..3ec9a2093e7 100644 --- a/src/vs/editor/common/config/commonEditorConfig.ts +++ b/src/vs/editor/common/config/commonEditorConfig.ts @@ -204,6 +204,7 @@ function migrateOptions(options: IEditorOptions): void { mapping['method'] = 'showMethods'; mapping['function'] = 'showFunctions'; mapping['constructor'] = 'showConstructors'; + mapping['deprecated'] = 'showDeprecated'; mapping['field'] = 'showFields'; mapping['variable'] = 'showVariables'; mapping['class'] = 'showClasses'; diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 83ca88204c5..dce74ab6954 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -3194,6 +3194,10 @@ export interface ISuggestOptions { * Show constructor-suggestions. */ showConstructors?: boolean; + /** + * Show deprecated-suggestions. + */ + showDeprecated?: boolean; /** * Show field-suggestions. */ @@ -3310,6 +3314,7 @@ class EditorSuggest extends BaseEditorOption(), readonly providerFilter = new Set(), + readonly showDeprecated = true ) { } } @@ -216,6 +217,10 @@ export async function provideSuggestionItems( } for (let suggestion of container.suggestions) { if (!options.kindFilter.has(suggestion.kind)) { + // skip if not showing deprecated suggestions + if (!options.showDeprecated && suggestion?.tags?.includes(modes.CompletionItemTag.Deprecated)) { + continue; + } // fill in default range when missing if (!suggestion.range) { suggestion.range = defaultRange; diff --git a/src/vs/editor/contrib/suggest/suggestModel.ts b/src/vs/editor/contrib/suggest/suggestModel.ts index 747f78a4b4c..1880ef1bd9d 100644 --- a/src/vs/editor/contrib/suggest/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/suggestModel.ts @@ -428,13 +428,13 @@ export class SuggestModel implements IDisposable { break; } - const itemKindFilter = SuggestModel._createItemKindFilter(this._editor); + const { itemKind: itemKindFilter, showDeprecated } = SuggestModel._createSuggestFilter(this._editor); const wordDistance = WordDistance.create(this._editorWorkerService, this._editor); const completions = provideSuggestionItems( model, this._editor.getPosition(), - new CompletionOptions(snippetSortOrder, itemKindFilter, onlyFrom), + new CompletionOptions(snippetSortOrder, itemKindFilter, onlyFrom, showDeprecated), suggestCtx, this._requestToken.token ); @@ -502,7 +502,7 @@ export class SuggestModel implements IDisposable { }); } - private static _createItemKindFilter(editor: ICodeEditor): Set { + private static _createSuggestFilter(editor: ICodeEditor): { itemKind: Set; showDeprecated: boolean } { // kind filter and snippet sort rules const result = new Set(); @@ -543,7 +543,7 @@ export class SuggestModel implements IDisposable { if (!suggestOptions.showUsers) { result.add(CompletionItemKind.User); } if (!suggestOptions.showIssues) { result.add(CompletionItemKind.Issue); } - return result; + return { itemKind: result, showDeprecated: suggestOptions.showDeprecated }; } private _onNewContext(ctx: LineContext): void { diff --git a/src/vs/editor/contrib/suggest/test/completionModel.test.ts b/src/vs/editor/contrib/suggest/test/completionModel.test.ts index 20b56f9bec4..b87f4634a5d 100644 --- a/src/vs/editor/contrib/suggest/test/completionModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/completionModel.test.ts @@ -43,6 +43,7 @@ suite('CompletionModel', function () { showMethods: true, showFunctions: true, showConstructors: true, + showDeprecated: true, showFields: true, showVariables: true, showClasses: true, diff --git a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInput.css b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInput.css index fc302f49d68..c48737cfaf4 100644 --- a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInput.css +++ b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInput.css @@ -14,7 +14,7 @@ .vs .quick-input-widget .monaco-list-row.focused .monaco-highlighted-label .highlight, .vs .quick-input-widget .monaco-list-row.focused .monaco-highlighted-label .highlight { - color: #33B6FF; + color: #9DDDFF; } .vs-dark .quick-input-widget .monaco-highlighted-label .highlight, diff --git a/src/vs/editor/standalone/common/themes.ts b/src/vs/editor/standalone/common/themes.ts index 24084dacb5e..cf0b7945091 100644 --- a/src/vs/editor/standalone/common/themes.ts +++ b/src/vs/editor/standalone/common/themes.ts @@ -74,7 +74,7 @@ export const vs: IStandaloneThemeData = { [editorIndentGuides]: '#D3D3D3', [editorActiveIndentGuides]: '#939393', [editorSelectionHighlight]: '#ADD6FF4D', - [listFocusHighlightForeground]: '#33B6FF' + [listFocusHighlightForeground]: '#9DDDFF' } }; /* -------------------------------- End vs theme -------------------------------- */ diff --git a/src/vs/editor/test/browser/services/openerService.test.ts b/src/vs/editor/test/browser/services/openerService.test.ts index 08c904f0764..ffcb441ad89 100644 --- a/src/vs/editor/test/browser/services/openerService.test.ts +++ b/src/vs/editor/test/browser/services/openerService.test.ts @@ -245,4 +245,25 @@ suite('OpenerService', function () { assert.ok(!matchesScheme(URI.parse('htt://microsoft.com'), 'http')); assert.ok(!matchesScheme(URI.parse('z://microsoft.com'), 'http')); }); + + test('resolveExternalUri', async function () { + const openerService = new OpenerService(editorService, NullCommandService); + + try { + await openerService.resolveExternalUri(URI.parse('file:///Users/user/folder')); + assert.fail('Should not reach here'); + } catch { + // OK + } + + const disposable = openerService.registerExternalUriResolver({ + async resolveExternalUri(uri) { + return { resolved: uri, dispose() { } }; + } + }); + + const result = await openerService.resolveExternalUri(URI.parse('file:///Users/user/folder')); + assert.deepStrictEqual(result.resolved.toString(), 'file:///Users/user/folder'); + disposable.dispose(); + }); }); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 6aa43588b02..62054af056b 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -3864,6 +3864,10 @@ declare namespace monaco.editor { * Show constructor-suggestions. */ showConstructors?: boolean; + /** + * Show deprecated-suggestions. + */ + showDeprecated?: boolean; /** * Show field-suggestions. */ diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index abe86723b10..c0673645936 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -41,6 +41,7 @@ export type Icon = { dark?: URI; light?: URI; } | ThemeIcon; export interface ICommandAction { id: string; title: string | ICommandActionTitle; + shortTitle?: string | ICommandActionTitle; category?: string | ILocalizedString; tooltip?: string; icon?: Icon; @@ -129,6 +130,7 @@ export class MenuId { static readonly TouchBarContext = new MenuId('TouchBarContext'); static readonly TitleBarContext = new MenuId('TitleBarContext'); static readonly TunnelContext = new MenuId('TunnelContext'); + static readonly TunnelProtocol = new MenuId('TunnelProtocol'); static readonly TunnelPortInline = new MenuId('TunnelInline'); static readonly TunnelTitle = new MenuId('TunnelTitle'); static readonly TunnelLocalAddressInline = new MenuId('TunnelLocalAddressInline'); @@ -143,6 +145,7 @@ export class MenuId { static readonly CommentTitle = new MenuId('CommentTitle'); static readonly CommentActions = new MenuId('CommentActions'); static readonly NotebookToolbar = new MenuId('NotebookToolbar'); + static readonly NotebookRightToolbar = new MenuId('NotebookRightToolbar'); static readonly NotebookCellTitle = new MenuId('NotebookCellTitle'); static readonly NotebookCellInsert = new MenuId('NotebookCellInsert'); static readonly NotebookCellBetween = new MenuId('NotebookCellBetween'); @@ -151,6 +154,7 @@ export class MenuId { static readonly NotebookDiffCellInputTitle = new MenuId('NotebookDiffCellInputTitle'); static readonly NotebookDiffCellMetadataTitle = new MenuId('NotebookDiffCellMetadataTitle'); static readonly NotebookDiffCellOutputsTitle = new MenuId('NotebookDiffCellOutputsTitle'); + static readonly NotebookOutputToolbar = new MenuId('NotebookOutputToolbar'); static readonly BulkEditTitle = new MenuId('BulkEditTitle'); static readonly BulkEditContext = new MenuId('BulkEditContext'); static readonly TimelineItemContext = new MenuId('TimelineItemContext'); @@ -177,6 +181,7 @@ export class MenuId { export interface IMenuActionOptions { arg?: any; shouldForwardArgs?: boolean; + renderShortTitle?: boolean; } export interface IMenu extends IDisposable { @@ -386,7 +391,9 @@ export class MenuItemAction implements IAction { @ICommandService private _commandService: ICommandService ) { this.id = item.id; - this.label = typeof item.title === 'string' ? item.title : item.title.value; + this.label = options?.renderShortTitle && item.shortTitle + ? (typeof item.shortTitle === 'string' ? item.shortTitle : item.shortTitle.value) + : (typeof item.title === 'string' ? item.title : item.title.value); this.tooltip = item.tooltip ?? ''; this.enabled = !item.precondition || contextKeyService.contextMatchesRules(item.precondition); this.checked = false; diff --git a/src/vs/platform/backup/electron-main/backupMainService.ts b/src/vs/platform/backup/electron-main/backupMainService.ts index ac5bb248927..ac9d7f2a86d 100644 --- a/src/vs/platform/backup/electron-main/backupMainService.ts +++ b/src/vs/platform/backup/electron-main/backupMainService.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import { createHash } from 'crypto'; import { join } from 'vs/base/common/path'; import { isLinux } from 'vs/base/common/platform'; -import { writeFileSync, writeFile, readdir, exists, rimraf, RimRafMode } from 'vs/base/node/pfs'; +import { writeFileSync, writeFile, readdir, exists, rimraf, RimRafMode, Promises } from 'vs/base/node/pfs'; import { IBackupMainService, IWorkspaceBackupInfo, isWorkspaceBackupInfo } from 'vs/platform/backup/electron-main/backup'; import { IBackupWorkspacesFormat, IEmptyWindowBackupInfo } from 'vs/platform/backup/node/backup'; import { IEnvironmentMainService } from 'vs/platform/environment/electron-main/environmentMainService'; @@ -49,7 +49,7 @@ export class BackupMainService implements IBackupMainService { async initialize(): Promise { let backups: IBackupWorkspacesFormat; try { - backups = JSON.parse(await fs.promises.readFile(this.workspacesJsonPath, 'utf8')); // invalid JSON or permission issue can happen here + backups = JSON.parse(await Promises.readFile(this.workspacesJsonPath, 'utf8')); // invalid JSON or permission issue can happen here } catch (error) { backups = Object.create(null); } @@ -328,7 +328,7 @@ export class BackupMainService implements IBackupMainService { // Rename backupPath to new empty window backup path const newEmptyWindowBackupPath = this.getBackupPath(newBackupFolder); try { - await fs.promises.rename(backupPath, newEmptyWindowBackupPath); + await Promises.rename(backupPath, newEmptyWindowBackupPath); } catch (error) { this.logService.error(`Backup: Could not rename backup folder: ${error.toString()}`); return false; diff --git a/src/vs/platform/backup/test/electron-main/backupMainService.test.ts b/src/vs/platform/backup/test/electron-main/backupMainService.test.ts index 9968de89189..a2101b983e9 100644 --- a/src/vs/platform/backup/test/electron-main/backupMainService.test.ts +++ b/src/vs/platform/backup/test/electron-main/backupMainService.test.ts @@ -107,7 +107,7 @@ flakySuite('BackupMainService', () => { environmentService = new EnvironmentMainService(parseArgs(process.argv, OPTIONS), { _serviceBrand: undefined, ...product }); - await fs.promises.mkdir(backupHome, { recursive: true }); + await pfs.Promises.mkdir(backupHome, { recursive: true }); configService = new TestConfigurationService(); service = new class TestBackupMainService extends BackupMainService { @@ -446,7 +446,7 @@ flakySuite('BackupMainService', () => { await pfs.writeFile(backupWorkspacesPath, JSON.stringify(workspacesJson)); await service.initialize(); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = JSON.parse(buffer); assert.deepStrictEqual(json.folderURIWorkspaces, [existingTestFolder1.toString()]); }); @@ -462,7 +462,7 @@ flakySuite('BackupMainService', () => { }; await pfs.writeFile(backupWorkspacesPath, JSON.stringify(workspacesJson)); await service.initialize(); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = JSON.parse(buffer); assert.deepStrictEqual(json.folderURIWorkspaces, [existingTestFolder1.toString()]); }); @@ -484,7 +484,7 @@ flakySuite('BackupMainService', () => { await pfs.writeFile(backupWorkspacesPath, JSON.stringify(workspacesJson)); await service.initialize(); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = JSON.parse(buffer); assert.strictEqual(json.rootURIWorkspaces.length, platform.isLinux ? 3 : 1); if (platform.isLinux) { @@ -500,7 +500,7 @@ flakySuite('BackupMainService', () => { service.registerFolderBackupSync(fooFile); service.registerFolderBackupSync(barFile); assertEqualUris(service.getFolderBackupPaths(), [fooFile, barFile]); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = JSON.parse(buffer); assert.deepStrictEqual(json.folderURIWorkspaces, [fooFile.toString(), barFile.toString()]); }); @@ -515,7 +515,7 @@ flakySuite('BackupMainService', () => { assert.strictEqual(ws1.workspace.id, service.getWorkspaceBackups()[0].workspace.id); assert.strictEqual(ws2.workspace.id, service.getWorkspaceBackups()[1].workspace.id); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = JSON.parse(buffer); assert.deepStrictEqual(json.rootURIWorkspaces.map(b => b.configURIPath), [fooFile.toString(), barFile.toString()]); @@ -528,7 +528,7 @@ flakySuite('BackupMainService', () => { service.registerFolderBackupSync(URI.file(fooFile.fsPath.toUpperCase())); assertEqualUris(service.getFolderBackupPaths(), [URI.file(fooFile.fsPath.toUpperCase())]); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = JSON.parse(buffer); assert.deepStrictEqual(json.folderURIWorkspaces, [URI.file(fooFile.fsPath.toUpperCase()).toString()]); }); @@ -538,7 +538,7 @@ flakySuite('BackupMainService', () => { service.registerWorkspaceBackupSync(toWorkspaceBackupInfo(upperFooPath)); assertEqualUris(service.getWorkspaceBackups().map(b => b.workspace.configPath), [URI.file(upperFooPath)]); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = (JSON.parse(buffer)); assert.deepStrictEqual(json.rootURIWorkspaces.map(b => b.configURIPath), [URI.file(upperFooPath).toString()]); }); @@ -549,12 +549,12 @@ flakySuite('BackupMainService', () => { service.registerFolderBackupSync(barFile); service.unregisterFolderBackupSync(fooFile); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = (JSON.parse(buffer)); assert.deepStrictEqual(json.folderURIWorkspaces, [barFile.toString()]); service.unregisterFolderBackupSync(barFile); - const content = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const content = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json2 = (JSON.parse(content)); assert.deepStrictEqual(json2.folderURIWorkspaces, []); }); @@ -566,12 +566,12 @@ flakySuite('BackupMainService', () => { service.registerWorkspaceBackupSync(ws2); service.unregisterWorkspaceBackupSync(ws1.workspace); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = (JSON.parse(buffer)); assert.deepStrictEqual(json.rootURIWorkspaces.map(r => r.configURIPath), [barFile.toString()]); service.unregisterWorkspaceBackupSync(ws2.workspace); - const content = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const content = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json2 = (JSON.parse(content)); assert.deepStrictEqual(json2.rootURIWorkspaces, []); }); @@ -581,12 +581,12 @@ flakySuite('BackupMainService', () => { service.registerEmptyWindowBackupSync('bar'); service.unregisterEmptyWindowBackupSync('foo'); - const buffer = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const buffer = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = (JSON.parse(buffer)); assert.deepStrictEqual(json.emptyWorkspaceInfos, [{ backupFolder: 'bar' }]); service.unregisterEmptyWindowBackupSync('bar'); - const content = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const content = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json2 = (JSON.parse(content)); assert.deepStrictEqual(json2.emptyWorkspaceInfos, []); }); @@ -600,7 +600,7 @@ flakySuite('BackupMainService', () => { await service.initialize(); service.unregisterFolderBackupSync(barFile); service.unregisterEmptyWindowBackupSync('test'); - const content = await fs.promises.readFile(backupWorkspacesPath, 'utf-8'); + const content = await pfs.Promises.readFile(backupWorkspacesPath, 'utf-8'); const json = (JSON.parse(content)); assert.deepStrictEqual(json.folderURIWorkspaces, [existingTestFolder1.toString()]); }); @@ -670,8 +670,8 @@ flakySuite('BackupMainService', () => { assert.strictEqual(((await service.getDirtyWorkspaces()).length), 0); try { - await fs.promises.mkdir(path.join(folderBackupPath, Schemas.file), { recursive: true }); - await fs.promises.mkdir(path.join(workspaceBackupPath, Schemas.untitled), { recursive: true }); + await pfs.Promises.mkdir(path.join(folderBackupPath, Schemas.file), { recursive: true }); + await pfs.Promises.mkdir(path.join(workspaceBackupPath, Schemas.untitled), { recursive: true }); } catch (error) { // ignore - folder might exist already } diff --git a/src/vs/platform/contextview/browser/contextMenuService.ts b/src/vs/platform/contextview/browser/contextMenuService.ts index 77c92d6dd26..afc0b1c930c 100644 --- a/src/vs/platform/contextview/browser/contextMenuService.ts +++ b/src/vs/platform/contextview/browser/contextMenuService.ts @@ -12,12 +12,15 @@ import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { Disposable } from 'vs/base/common/lifecycle'; import { ModifierKeyEmitter } from 'vs/base/browser/dom'; +import { Emitter } from 'vs/base/common/event'; export class ContextMenuService extends Disposable implements IContextMenuService { declare readonly _serviceBrand: undefined; private contextMenuHandler: ContextMenuHandler; + readonly onDidShowContextMenu = new Emitter().event; + constructor( @ITelemetryService telemetryService: ITelemetryService, @INotificationService notificationService: INotificationService, diff --git a/src/vs/platform/contextview/browser/contextView.ts b/src/vs/platform/contextview/browser/contextView.ts index b33d652cf12..9a37ae69038 100644 --- a/src/vs/platform/contextview/browser/contextView.ts +++ b/src/vs/platform/contextview/browser/contextView.ts @@ -7,6 +7,7 @@ import { IDisposable } from 'vs/base/common/lifecycle'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IContextMenuDelegate } from 'vs/base/browser/contextmenu'; import { AnchorAlignment, AnchorAxisAlignment, IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; +import { Event } from 'vs/base/common/event'; export const IContextViewService = createDecorator('contextViewService'); @@ -40,5 +41,7 @@ export interface IContextMenuService { readonly _serviceBrand: undefined; + readonly onDidShowContextMenu: Event; + showContextMenu(delegate: IContextMenuDelegate): void; } diff --git a/src/vs/platform/dialogs/test/common/testDialogService.ts b/src/vs/platform/dialogs/test/common/testDialogService.ts index 8d3d2465b76..1d2b2f08f71 100644 --- a/src/vs/platform/dialogs/test/common/testDialogService.ts +++ b/src/vs/platform/dialogs/test/common/testDialogService.ts @@ -10,8 +10,23 @@ export class TestDialogService implements IDialogService { declare readonly _serviceBrand: undefined; - confirm(_confirmation: IConfirmation): Promise { return Promise.resolve({ confirmed: false }); } - show(_severity: Severity, _message: string, _buttons: string[], _options?: IDialogOptions): Promise { return Promise.resolve({ choice: 0 }); } - input(): Promise { { return Promise.resolve({ choice: 0, values: [] }); } } - about(): Promise { return Promise.resolve(); } + private confirmResult: IConfirmationResult | undefined = undefined; + setConfirmResult(result: IConfirmationResult) { + this.confirmResult = result; + } + + async confirm(confirmation: IConfirmation): Promise { + if (this.confirmResult) { + const confirmResult = this.confirmResult; + this.confirmResult = undefined; + + return confirmResult; + } + + return { confirmed: false }; + } + + async show(severity: Severity, message: string, buttons: string[], options?: IDialogOptions): Promise { return { choice: 0 }; } + async input(): Promise { { return { choice: 0, values: [] }; } } + async about(): Promise { } } diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 1b5db388b7f..647829f6159 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -54,7 +54,7 @@ export const OPTIONS: OptionDescriptions> = { 'builtin-extensions-dir': { type: 'string' }, 'list-extensions': { type: 'boolean', cat: 'e', description: localize('listExtensions', "List the installed extensions.") }, 'show-versions': { type: 'boolean', cat: 'e', description: localize('showVersions', "Show versions of installed extensions, when using --list-extensions.") }, - 'category': { type: 'string', cat: 'e', description: localize('category', "Filters installed extensions by provided category, when using --list-extensions.") }, + 'category': { type: 'string', cat: 'e', description: localize('category', "Filters installed extensions by provided category, when using --list-extensions."), args: 'category' }, 'install-extension': { type: 'string[]', cat: 'e', args: 'extension-id[@version] | path-to-vsix', description: localize('installExtension', "Installs or updates the extension. The identifier of an extension is always `${publisher}.${name}`. Use `--force` argument to update to latest version. To install a specific version provide `@${version}`. For example: 'vscode.csharp@1.2.3'.") }, 'uninstall-extension': { type: 'string[]', cat: 'e', args: 'extension-id', description: localize('uninstallExtension', "Uninstalls an extension.") }, 'enable-proposed-api': { type: 'string[]', cat: 'e', args: 'extension-id', description: localize('experimentalApis', "Enables proposed API features for extensions. Can receive one or more extension IDs to enable individually.") }, @@ -63,18 +63,18 @@ export const OPTIONS: OptionDescriptions> = { 'verbose': { type: 'boolean', cat: 't', description: localize('verbose', "Print verbose output (implies --wait).") }, 'log': { type: 'string', cat: 't', args: 'level', description: localize('log', "Log level to use. Default is 'info'. Allowed values are 'critical', 'error', 'warn', 'info', 'debug', 'trace', 'off'.") }, 'status': { type: 'boolean', alias: 's', cat: 't', description: localize('status', "Print process usage and diagnostics information.") }, - 'prof-startup': { type: 'boolean', cat: 't', description: localize('prof-startup', "Run CPU profiler during startup") }, + 'prof-startup': { type: 'boolean', cat: 't', description: localize('prof-startup', "Run CPU profiler during startup.") }, 'prof-append-timers': { type: 'string' }, 'prof-startup-prefix': { type: 'string' }, 'prof-v8-extensions': { type: 'boolean' }, 'disable-extensions': { type: 'boolean', deprecates: 'disableExtensions', cat: 't', description: localize('disableExtensions', "Disable all installed extensions.") }, 'disable-extension': { type: 'string[]', cat: 't', args: 'extension-id', description: localize('disableExtension', "Disable an extension.") }, - 'sync': { type: 'string', cat: 't', description: localize('turn sync', "Turn sync on or off"), args: ['on', 'off'] }, + 'sync': { type: 'string', cat: 't', description: localize('turn sync', "Turn sync on or off."), args: ['on', 'off'] }, 'inspect-extensions': { type: 'string', deprecates: 'debugPluginHost', args: 'port', cat: 't', description: localize('inspect-extensions', "Allow debugging and profiling of extensions. Check the developer tools for the connection URI.") }, 'inspect-brk-extensions': { type: 'string', deprecates: 'debugBrkPluginHost', args: 'port', cat: 't', description: localize('inspect-brk-extensions', "Allow debugging and profiling of extensions with the extension host being paused after start. Check the developer tools for the connection URI.") }, 'disable-gpu': { type: 'boolean', cat: 't', description: localize('disableGPU', "Disable GPU hardware acceleration.") }, - 'max-memory': { type: 'string', cat: 't', description: localize('maxMemory', "Max memory size for a window (in Mbytes).") }, + 'max-memory': { type: 'string', cat: 't', description: localize('maxMemory', "Max memory size for a window (in Mbytes)."), args: 'memory' }, 'telemetry': { type: 'boolean', cat: 't', description: localize('telemetry', "Shows all telemetry events which VS code collects.") }, 'remote': { type: 'string' }, diff --git a/src/vs/platform/extensionManagement/node/extensionDownloader.ts b/src/vs/platform/extensionManagement/node/extensionDownloader.ts index f118888f897..97fcf5cc46d 100644 --- a/src/vs/platform/extensionManagement/node/extensionDownloader.ts +++ b/src/vs/platform/extensionManagement/node/extensionDownloader.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { promises } from 'fs'; +import { Promises as FSPromises } from 'vs/base/node/pfs'; import { Disposable } from 'vs/base/common/lifecycle'; import { IFileService, IFileStatWithMetadata } from 'vs/platform/files/common/files'; import { IExtensionGalleryService, IGalleryExtension, InstallOperation } from 'vs/platform/extensionManagement/common/extensionManagement'; @@ -77,7 +77,7 @@ export class ExtensionsDownloader extends Disposable { private async rename(from: URI, to: URI, retryUntil: number): Promise { try { - await promises.rename(from.fsPath, to.fsPath); + await FSPromises.rename(from.fsPath, to.fsPath); } catch (error) { if (isWindows && error && error.code === 'EPERM' && Date.now() < retryUntil) { this.logService.info(`Failed renaming ${from} to ${to} with 'EPERM' error. Trying again...`); diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 871c6f93080..3a4d06801a0 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; import * as nls from 'vs/nls'; import * as path from 'vs/base/common/path'; import * as pfs from 'vs/base/node/pfs'; @@ -140,7 +139,7 @@ export class ExtensionManagementService extends Disposable implements IExtension const collectFilesFromDirectory = async (dir: string): Promise => { let entries = await pfs.readdir(dir); entries = entries.map(e => path.join(dir, e)); - const stats = await Promise.all(entries.map(e => fs.promises.stat(e))); + const stats = await Promise.all(entries.map(e => pfs.Promises.stat(e))); let promise: Promise = Promise.resolve([]); stats.forEach((stat, index) => { const entry = entries[index]; diff --git a/src/vs/platform/extensionManagement/node/extensionsScanner.ts b/src/vs/platform/extensionManagement/node/extensionsScanner.ts index 06de9aea9a2..f489f82362b 100644 --- a/src/vs/platform/extensionManagement/node/extensionsScanner.ts +++ b/src/vs/platform/extensionManagement/node/extensionsScanner.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; import * as semver from 'vs/base/common/semver/semver'; import { Disposable } from 'vs/base/common/lifecycle'; import * as pfs from 'vs/base/node/pfs'; @@ -159,7 +158,7 @@ export class ExtensionsScanner extends Disposable { storedMetadata.isBuiltin = storedMetadata.isBuiltin || undefined; storedMetadata.installedTimestamp = storedMetadata.installedTimestamp || undefined; const manifestPath = path.join(local.location.fsPath, 'package.json'); - const raw = await fs.promises.readFile(manifestPath, 'utf8'); + const raw = await pfs.Promises.readFile(manifestPath, 'utf8'); const { manifest } = await this.parseManifest(raw); (manifest as ILocalExtensionManifest).__metadata = storedMetadata; await pfs.writeFile(manifestPath, JSON.stringify(manifest, null, '\t')); @@ -192,7 +191,7 @@ export class ExtensionsScanner extends Disposable { return this.uninstalledFileLimiter.queue(async () => { let raw: string | undefined; try { - raw = await fs.promises.readFile(this.uninstalledPath, 'utf8'); + raw = await pfs.Promises.readFile(this.uninstalledPath, 'utf8'); } catch (err) { if (err.code !== 'ENOENT') { throw err; @@ -251,7 +250,7 @@ export class ExtensionsScanner extends Disposable { private async rename(identifier: IExtensionIdentifier, extractPath: string, renamePath: string, retryUntil: number): Promise { try { - await fs.promises.rename(extractPath, renamePath); + await pfs.Promises.rename(extractPath, renamePath); } catch (error) { if (isWindows && error && error.code === 'EPERM' && Date.now() < retryUntil) { this.logService.info(`Failed renaming ${extractPath} to ${renamePath} with 'EPERM' error. Trying again...`, identifier.id); @@ -393,9 +392,9 @@ export class ExtensionsScanner extends Disposable { private async readManifest(extensionPath: string): Promise<{ manifest: IExtensionManifest; metadata: IStoredMetadata | null; }> { const promises = [ - fs.promises.readFile(path.join(extensionPath, 'package.json'), 'utf8') + pfs.Promises.readFile(path.join(extensionPath, 'package.json'), 'utf8') .then(raw => this.parseManifest(raw)), - fs.promises.readFile(path.join(extensionPath, 'package.nls.json'), 'utf8') + pfs.Promises.readFile(path.join(extensionPath, 'package.nls.json'), 'utf8') .then(undefined, err => err.code !== 'ENOENT' ? Promise.reject(err) : '{}') .then(raw => JSON.parse(raw)) ]; diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index 2b6d00385f9..b603722ec4a 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -166,13 +166,30 @@ export interface IExtensionContributions { } export interface IExtensionCapabilities { - readonly virtualWorkspaces?: boolean; + readonly virtualWorkspaces?: ExtensionVirtualWorkpaceSupport; readonly untrustedWorkspaces?: ExtensionUntrustedWorkspaceSupport; } + + export type ExtensionKind = 'ui' | 'workspace' | 'web'; -export type ExtensionUntrustedWorkpaceSupportType = boolean | 'limited'; -export type ExtensionUntrustedWorkspaceSupport = { supported: true; } | { supported: false, description: string } | { supported: 'limited', description: string, restrictedConfigurations?: string[] }; + +export type LimitedWorkpaceSupportType = 'limited'; +export type ExtensionUntrustedWorkpaceSupportType = boolean | LimitedWorkpaceSupportType; +export type ExtensionUntrustedWorkspaceSupport = { supported: true; } | { supported: false, description: string } | { supported: LimitedWorkpaceSupportType, description: string, restrictedConfigurations?: string[] }; + +export type ExtensionVirtualWorkpaceSupportType = boolean | LimitedWorkpaceSupportType; +export type ExtensionVirtualWorkpaceSupport = boolean | { supported: true; } | { supported: false | LimitedWorkpaceSupportType, description: string }; + +export function getWorkpaceSupportTypeMessage(supportType: ExtensionUntrustedWorkspaceSupport | ExtensionVirtualWorkpaceSupport | undefined): string | undefined { + if (typeof supportType === 'object' && supportType !== null) { + if (supportType.supported !== true) { + return supportType.description; + } + } + return undefined; +} + export function isIExtensionIdentifier(thing: any): thing is IExtensionIdentifier { return thing diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index 4f02e2945d2..5d09c7e26d5 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -488,7 +488,7 @@ export enum FileSystemProviderErrorCode { export class FileSystemProviderError extends Error { - constructor(message: string, public readonly code: FileSystemProviderErrorCode) { + constructor(message: string, readonly code: FileSystemProviderErrorCode) { super(message); } } @@ -605,7 +605,7 @@ export class FileOperationEvent { constructor(resource: URI, operation: FileOperation.DELETE); constructor(resource: URI, operation: FileOperation.CREATE | FileOperation.MOVE | FileOperation.COPY, target: IFileStatWithMetadata); - constructor(public readonly resource: URI, public readonly operation: FileOperation, public readonly target?: IFileStatWithMetadata) { } + constructor(readonly resource: URI, readonly operation: FileOperation, readonly target?: IFileStatWithMetadata) { } isOperation(operation: FileOperation.DELETE): boolean; isOperation(operation: FileOperation.MOVE | FileOperation.COPY | FileOperation.CREATE): this is { readonly target: IFileStatWithMetadata }; diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index f4d9fd4fc80..c950ddc09d4 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -3,14 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { open, close, read, write, fdatasync, Stats, promises } from 'fs'; -import { promisify } from 'util'; +import { Stats } from 'fs'; import { IDisposable, Disposable, toDisposable, dispose, combinedDisposable } from 'vs/base/common/lifecycle'; import { FileSystemProviderCapabilities, IFileChange, IWatchOptions, IStat, FileType, FileDeleteOptions, FileOverwriteOptions, FileWriteOptions, FileOpenOptions, FileSystemProviderErrorCode, createFileSystemProviderError, FileSystemProviderError, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, FileReadStreamOptions, IFileSystemProviderWithFileFolderCopyCapability, isFileOpenForWriteOptions } 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 { SymlinkSupport, move, copy, rimraf, RimRafMode, exists, readdir, IDirent } from 'vs/base/node/pfs'; +import { SymlinkSupport, move, copy, rimraf, RimRafMode, exists, readdir, IDirent, Promises } 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'; @@ -152,7 +151,7 @@ export class DiskFileSystemProvider extends Disposable implements try { const filePath = this.toFilePath(resource); - return await promises.readFile(filePath); + return await Promises.readFile(filePath); } catch (error) { throw this.toFileSystemProviderError(error); } @@ -216,7 +215,7 @@ export class DiskFileSystemProvider extends Disposable implements try { const { stat } = await SymlinkSupport.stat(filePath); if (!(stat.mode & 0o200 /* File mode indicating writable by owner */)) { - await promises.chmod(filePath, stat.mode | 0o200); + await Promises.chmod(filePath, stat.mode | 0o200); } } catch (error) { this.logService.trace(error); // ignore any errors here and try to just write @@ -232,7 +231,7 @@ export class DiskFileSystemProvider extends Disposable implements // by first truncating the file and then writing with r+ flag. This helps to save hidden files on Windows // (see https://github.com/microsoft/vscode/issues/931) and prevent removing alternate data streams // (see https://github.com/microsoft/vscode/issues/6363) - await promises.truncate(filePath, 0); + await Promises.truncate(filePath, 0); // After a successful truncate() the flag can be set to 'r+' which will not truncate. flags = 'r+'; @@ -256,7 +255,7 @@ export class DiskFileSystemProvider extends Disposable implements flags = 'r'; } - const handle = await promisify(open)(filePath, flags); + const handle = await Promises.open(filePath, flags); // remember this handle to track file position of the handle // we init the position to 0 since the file descriptor was @@ -290,7 +289,7 @@ export class DiskFileSystemProvider extends Disposable implements // to flush the contents to disk if possible. if (this.writeHandles.delete(fd) && this.canFlush) { try { - await promisify(fdatasync)(fd); + await Promises.fdatasync(fd); } catch (error) { // In some exotic setups it is well possible that node fails to sync // In that case we disable flushing and log the error to our logger @@ -299,7 +298,7 @@ export class DiskFileSystemProvider extends Disposable implements } } - return await promisify(close)(fd); + return await Promises.close(fd); } catch (error) { throw this.toFileSystemProviderError(error); } @@ -310,7 +309,7 @@ export class DiskFileSystemProvider extends Disposable implements let bytesRead: number | null = null; try { - const result = await promisify(read)(fd, data, offset, length, normalizedPos); + const result = await Promises.read(fd, data, offset, length, normalizedPos); if (typeof result === 'number') { bytesRead = result; // node.d.ts fail @@ -396,7 +395,7 @@ export class DiskFileSystemProvider extends Disposable implements let bytesWritten: number | null = null; try { - const result = await promisify(write)(fd, data, offset, length, normalizedPos); + const result = await Promises.write(fd, data, offset, length, normalizedPos); if (typeof result === 'number') { bytesWritten = result; // node.d.ts fail @@ -418,7 +417,7 @@ export class DiskFileSystemProvider extends Disposable implements async mkdir(resource: URI): Promise { try { - await promises.mkdir(this.toFilePath(resource)); + await Promises.mkdir(this.toFilePath(resource)); } catch (error) { throw this.toFileSystemProviderError(error); } @@ -438,7 +437,7 @@ export class DiskFileSystemProvider extends Disposable implements if (opts.recursive) { await rimraf(filePath, RimRafMode.MOVE); } else { - await promises.unlink(filePath); + await Promises.unlink(filePath); } } diff --git a/src/vs/platform/files/test/electron-browser/diskFileService.test.ts b/src/vs/platform/files/test/electron-browser/diskFileService.test.ts index 7048ce0225b..dc170c38139 100644 --- a/src/vs/platform/files/test/electron-browser/diskFileService.test.ts +++ b/src/vs/platform/files/test/electron-browser/diskFileService.test.ts @@ -10,9 +10,9 @@ import { Schemas } from 'vs/base/common/network'; import { DiskFileSystemProvider } from 'vs/platform/files/node/diskFileSystemProvider'; import { flakySuite, getRandomTestPath, getPathFromAmdModule } from 'vs/base/test/node/testUtils'; import { join, basename, dirname, posix } from 'vs/base/common/path'; -import { copy, rimraf, rimrafSync } from 'vs/base/node/pfs'; +import { copy, Promises, rimraf, rimrafSync } from 'vs/base/node/pfs'; import { URI } from 'vs/base/common/uri'; -import { existsSync, statSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, createReadStream, promises } from 'fs'; +import { existsSync, statSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, createReadStream } from 'fs'; import { FileOperation, FileOperationEvent, IFileStat, FileOperationResult, FileSystemProviderCapabilities, FileChangeType, IFileChange, FileChangesEvent, FileOperationError, etag, IStat, IFileStatWithMetadata, IReadFileOptions, FilePermission } from 'vs/platform/files/common/files'; import { NullLogService } from 'vs/platform/log/common/log'; import { isLinux, isWindows } from 'vs/base/common/platform'; @@ -417,7 +417,7 @@ flakySuite('Disk File Service', function () { test('resolve - folder symbolic link', async () => { const link = URI.file(join(testDir, 'deep-link')); - await promises.symlink(join(testDir, 'deep'), link.fsPath, 'junction'); + await Promises.symlink(join(testDir, 'deep'), link.fsPath, 'junction'); const resolved = await service.resolve(link); assert.strictEqual(resolved.children!.length, 4); @@ -427,7 +427,7 @@ flakySuite('Disk File Service', function () { (isWindows ? test.skip /* windows: cannot create file symbolic link without elevated context */ : test)('resolve - file symbolic link', async () => { const link = URI.file(join(testDir, 'lorem.txt-linked')); - await promises.symlink(join(testDir, 'lorem.txt'), link.fsPath); + await Promises.symlink(join(testDir, 'lorem.txt'), link.fsPath); const resolved = await service.resolve(link); assert.strictEqual(resolved.isDirectory, false); @@ -435,7 +435,7 @@ flakySuite('Disk File Service', function () { }); test('resolve - symbolic link pointing to non-existing file does not break', async () => { - await promises.symlink(join(testDir, 'foo'), join(testDir, 'bar'), 'junction'); + await Promises.symlink(join(testDir, 'foo'), join(testDir, 'bar'), 'junction'); const resolved = await service.resolve(URI.file(testDir)); assert.strictEqual(resolved.isDirectory, true); @@ -486,7 +486,7 @@ flakySuite('Disk File Service', function () { (isWindows ? test.skip /* windows: cannot create file symbolic link without elevated context */ : test)('deleteFile - symbolic link (exists)', async () => { const target = URI.file(join(testDir, 'lorem.txt')); const link = URI.file(join(testDir, 'lorem.txt-linked')); - await promises.symlink(target.fsPath, link.fsPath); + await Promises.symlink(target.fsPath, link.fsPath); const source = await service.resolve(link); @@ -508,7 +508,7 @@ flakySuite('Disk File Service', function () { (isWindows ? test.skip /* windows: cannot create file symbolic link without elevated context */ : test)('deleteFile - symbolic link (pointing to non-existing file)', async () => { const target = URI.file(join(testDir, 'foo')); const link = URI.file(join(testDir, 'bar')); - await promises.symlink(target.fsPath, link.fsPath); + await Promises.symlink(target.fsPath, link.fsPath); let event: FileOperationEvent; disposables.add(service.onDidRunOperation(e => event = e)); @@ -1601,7 +1601,7 @@ flakySuite('Disk File Service', function () { (isWindows ? test.skip /* windows: cannot create file symbolic link without elevated context */ : test)('readFile - dangling symbolic link - https://github.com/microsoft/vscode/issues/116049', async () => { const link = URI.file(join(testDir, 'small.js-link')); - await promises.symlink(join(testDir, 'small.js'), link.fsPath); + await Promises.symlink(join(testDir, 'small.js'), link.fsPath); let error: FileOperationError | undefined = undefined; try { @@ -1937,8 +1937,8 @@ flakySuite('Disk File Service', function () { await service.writeFile(lockedFile, VSBuffer.fromString('Locked File')); - const stats = await promises.stat(lockedFile.fsPath); - await promises.chmod(lockedFile.fsPath, stats.mode & ~0o200); + const stats = await Promises.stat(lockedFile.fsPath); + await Promises.chmod(lockedFile.fsPath, stats.mode & ~0o200); let error; const newContent = 'Updates to locked file'; @@ -2100,7 +2100,7 @@ flakySuite('Disk File Service', function () { (runWatchTests && !isWindows /* windows: cannot create file symbolic link without elevated context */ ? test : test.skip)('watch - file symbolic link', async () => { const toWatch = URI.file(join(testDir, 'lorem.txt-linked')); - await promises.symlink(join(testDir, 'lorem.txt'), toWatch.fsPath); + await Promises.symlink(join(testDir, 'lorem.txt'), toWatch.fsPath); const promise = assertWatch(toWatch, [[FileChangeType.UPDATED, toWatch]]); setTimeout(() => writeFileSync(toWatch.fsPath, 'Changes'), 50); @@ -2228,7 +2228,7 @@ flakySuite('Disk File Service', function () { (runWatchTests ? test : test.skip)('watch - folder (non recursive) - symbolic link - change file', async () => { const watchDir = URI.file(join(testDir, 'deep-link')); - await promises.symlink(join(testDir, 'deep'), watchDir.fsPath, 'junction'); + await Promises.symlink(join(testDir, 'deep'), watchDir.fsPath, 'junction'); const file = URI.file(join(watchDir.fsPath, 'index.html')); writeFileSync(file.fsPath, 'Init'); diff --git a/src/vs/platform/localizations/node/localizations.ts b/src/vs/platform/localizations/node/localizations.ts index 02f3df5a911..e1ef23c7b36 100644 --- a/src/vs/platform/localizations/node/localizations.ts +++ b/src/vs/platform/localizations/node/localizations.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { writeFile } from 'vs/base/node/pfs'; -import { promises } from 'fs'; +import { Promises, writeFile } from 'vs/base/node/pfs'; import { createHash } from 'crypto'; import { IExtensionManagementService, ILocalExtension, IExtensionIdentifier } from 'vs/platform/extensionManagement/common/extensionManagement'; import { Disposable } from 'vs/base/common/lifecycle'; @@ -158,7 +157,7 @@ class LanguagePacksCache extends Disposable { private withLanguagePacks(fn: (languagePacks: { [language: string]: ILanguagePack }) => T | null = () => null): Promise { return this.languagePacksFileLimiter.queue(() => { let result: T | null = null; - return promises.readFile(this.languagePacksFilePath, 'utf8') + return Promises.readFile(this.languagePacksFilePath, 'utf8') .then(undefined, err => err.code === 'ENOENT' ? Promise.resolve('{}') : Promise.reject(err)) .then<{ [language: string]: ILanguagePack }>(raw => { try { return JSON.parse(raw); } catch (e) { return {}; } }) .then(languagePacks => { result = fn(languagePacks); return languagePacks; }) diff --git a/src/vs/platform/log/node/spdlogLog.ts b/src/vs/platform/log/node/spdlogLog.ts index e9fd6a709ee..61c411cc675 100644 --- a/src/vs/platform/log/node/spdlogLog.ts +++ b/src/vs/platform/log/node/spdlogLog.ts @@ -11,6 +11,7 @@ async function createSpdLogLogger(name: string, logfilePath: string, filesize: n // Do not crash if spdlog cannot be loaded try { const _spdlog = await import('spdlog'); + _spdlog.setFlushOn(LogLevel.Info); return _spdlog.createAsyncRotatingLogger(name, logfilePath, filesize, filecount); } catch (e) { console.error(e); @@ -20,6 +21,7 @@ async function createSpdLogLogger(name: string, logfilePath: string, filesize: n export function createRotatingLogger(name: string, filename: string, filesize: number, filecount: number): Promise { const _spdlog: typeof spdlog = require.__$__nodeRequire('spdlog'); + _spdlog.setFlushOn(LogLevel.Info); return _spdlog.createRotatingLogger(name, filename, filesize, filecount); } @@ -38,7 +40,6 @@ function log(logger: spdlog.Logger, level: LogLevel, message: string): void { case LogLevel.Critical: logger.critical(message); break; default: throw new Error('Invalid log level'); } - logger.flush(); } export class SpdLogLogger extends AbstractMessageLogger implements ILogger { diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index b75c64d506c..4d4317e9d65 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; import { exec } from 'child_process'; import { promisify } from 'util'; import { localize } from 'vs/nls'; @@ -20,7 +19,7 @@ import { ISerializableCommandAction } from 'vs/platform/actions/common/actions'; import { IEnvironmentMainService } from 'vs/platform/environment/electron-main/environmentMainService'; import { AddFirstParameterToFunctions } from 'vs/base/common/types'; import { IDialogMainService } from 'vs/platform/dialogs/electron-main/dialogMainService'; -import { exists, SymlinkSupport } from 'vs/base/node/pfs'; +import { exists, Promises, SymlinkSupport } from 'vs/base/node/pfs'; import { URI } from 'vs/base/common/uri'; import { ITelemetryData, ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -277,7 +276,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } // Different source, delete it first - await fs.promises.unlink(source); + await Promises.unlink(source); } catch (error) { if (error.code !== 'ENOENT') { throw error; // throw on any error but file not found @@ -285,7 +284,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } try { - await fs.promises.symlink(target, source); + await Promises.symlink(target, source); } catch (error) { if (error.code !== 'EACCES' && error.code !== 'ENOENT') { throw error; @@ -313,7 +312,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain const { source } = await this.getShellCommandLink(); try { - await fs.promises.unlink(source); + await Promises.unlink(source); } catch (error) { switch (error.code) { case 'EACCES': diff --git a/src/vs/platform/opener/common/opener.ts b/src/vs/platform/opener/common/opener.ts index 33c465736b7..fad7d23c761 100644 --- a/src/vs/platform/opener/common/opener.ts +++ b/src/vs/platform/opener/common/opener.ts @@ -109,6 +109,7 @@ export interface IOpenerService { /** * Resolve a resource to its external form. + * @throws whenever resolvers couldn't resolve this resource externally. */ resolveExternalUri(resource: URI, options?: ResolveExternalUriOptions): Promise; } diff --git a/src/vs/platform/product/common/product.ts b/src/vs/platform/product/common/product.ts index 28e36704128..b6b67a0b5ac 100644 --- a/src/vs/platform/product/common/product.ts +++ b/src/vs/platform/product/common/product.ts @@ -63,6 +63,7 @@ else { reportIssueUrl: 'https://github.com/microsoft/vscode/issues/new', licenseName: 'MIT', licenseUrl: 'https://github.com/microsoft/vscode/blob/main/LICENSE.txt', + webviewContentExternalBaseUrlTemplate: 'https://{{uuid}}.vscode-webview.net/{{quality}}/{{commit}}/out/vs/workbench/contrib/webview/browser/pre/', extensionAllowedProposedApi: [ 'ms-vscode.vscode-js-profile-flame', 'ms-vscode.vscode-js-profile-table', diff --git a/src/vs/platform/protocol/electron-main/protocolMainService.ts b/src/vs/platform/protocol/electron-main/protocolMainService.ts index 4215275cf05..798aa745d4d 100644 --- a/src/vs/platform/protocol/electron-main/protocolMainService.ts +++ b/src/vs/platform/protocol/electron-main/protocolMainService.ts @@ -22,7 +22,7 @@ export class ProtocolMainService extends Disposable implements IProtocolMainServ declare readonly _serviceBrand: undefined; private readonly validRoots = TernarySearchTree.forUris(() => !isLinux); - private readonly validExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp']); // https://github.com/microsoft/vscode/issues/119384 + private readonly validExtensions = new Set(['.svg', '.png', '.jpg', '.jpeg', '.gif', '.bmp']); // https://github.com/microsoft/vscode/issues/119384 constructor( @INativeEnvironmentService environmentService: INativeEnvironmentService, @@ -47,10 +47,10 @@ export class ProtocolMainService extends Disposable implements IProtocolMainServ const { defaultSession } = session; // Register vscode-file:// handler - defaultSession.protocol.registerFileProtocol(Schemas.vscodeFileResource, (request, callback) => this.handleResourceRequest(request, callback as unknown as ProtocolCallback)); + defaultSession.protocol.registerFileProtocol(Schemas.vscodeFileResource, (request, callback) => this.handleResourceRequest(request, callback)); // Intercept any file:// access - defaultSession.protocol.interceptFileProtocol(Schemas.file, (request, callback) => this.handleFileRequest(request, callback as unknown as ProtocolCallback)); + defaultSession.protocol.interceptFileProtocol(Schemas.file, (request, callback) => this.handleFileRequest(request, callback)); // Cleanup this._register(toDisposable(() => { diff --git a/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts b/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts index 3d5d29b8e00..e7f177ceb17 100644 --- a/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts +++ b/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts @@ -40,6 +40,10 @@ export class RemoteAuthorityResolverService extends Disposable implements IRemot return this._cache.get(authority)!; } + async getCanonicalURI(uri: URI): Promise { + return uri; + } + getConnectionData(authority: string): IRemoteConnectionData | null { if (!this._cache.has(authority)) { return null; @@ -76,4 +80,7 @@ export class RemoteAuthorityResolverService extends Disposable implements IRemot RemoteAuthorities.setConnectionToken(authority, connectionToken); this._onDidChangeConnectionData.fire(); } + + _setCanonicalURIProvider(provider: (uri: URI) => Promise): void { + } } diff --git a/src/vs/platform/remote/common/remoteAuthorityResolver.ts b/src/vs/platform/remote/common/remoteAuthorityResolver.ts index 52902edfcbc..9a310877998 100644 --- a/src/vs/platform/remote/common/remoteAuthorityResolver.ts +++ b/src/vs/platform/remote/common/remoteAuthorityResolver.ts @@ -5,6 +5,7 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { Event } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; export const IRemoteAuthorityResolverService = createDecorator('remoteAuthorityResolverService'); @@ -15,15 +16,9 @@ export interface ResolvedAuthority { readonly connectionToken: string | undefined; } -export enum RemoteTrustOption { - Unknown = 0, - DisableTrust = 1, - MachineTrusted = 2 -} - export interface ResolvedOptions { readonly extensionHostEnv?: { [key: string]: string | null }; - readonly trust?: RemoteTrustOption; + readonly isTrusted?: boolean; } export interface TunnelDescription { @@ -98,9 +93,18 @@ export interface IRemoteAuthorityResolverService { resolveAuthority(authority: string): Promise; getConnectionData(authority: string): IRemoteConnectionData | null; + /** + * Get the canonical URI for a `vscode-remote://` URI. + * + * **NOTE**: This can throw e.g. in cases where there is no resolver installed for the specific remote authority. + * + * @param uri The `vscode-remote://` URI + */ + getCanonicalURI(uri: URI): Promise; _clearResolvedAuthority(authority: string): void; _setResolvedAuthority(resolvedAuthority: ResolvedAuthority, resolvedOptions?: ResolvedOptions): void; _setResolvedAuthorityError(authority: string, err: any): void; _setAuthorityConnectionToken(authority: string, connectionToken: string): void; + _setCanonicalURIProvider(provider: (uri: URI) => Promise): void; } diff --git a/src/vs/platform/remote/common/remoteHosts.ts b/src/vs/platform/remote/common/remoteHosts.ts index 6894d782f5d..ca7c21b3f18 100644 --- a/src/vs/platform/remote/common/remoteHosts.ts +++ b/src/vs/platform/remote/common/remoteHosts.ts @@ -42,3 +42,7 @@ export function getVirtualWorkspaceLocation(workspace: IWorkspace): { scheme: st export function getVirtualWorkspaceScheme(workspace: IWorkspace): string | undefined { return getVirtualWorkspaceLocation(workspace)?.scheme; } + +export function isVirtualWorkspace(workspace: IWorkspace): boolean { + return getVirtualWorkspaceLocation(workspace) !== undefined; +} diff --git a/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts b/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts index 41529a21aee..93081c02147 100644 --- a/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts +++ b/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts @@ -8,22 +8,27 @@ import * as errors from 'vs/base/common/errors'; import { RemoteAuthorities } from 'vs/base/common/network'; import { Disposable } from 'vs/base/common/lifecycle'; import { Emitter } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; -class PendingResolveAuthorityRequest { +class PendingPromise { + public readonly promise: Promise; + public readonly input: I; + public result: R | null; + private _resolve!: (value: R) => void; + private _reject!: (err: any) => void; - public value: ResolverResult | null; - - constructor( - private readonly _resolve: (value: ResolverResult) => void, - private readonly _reject: (err: any) => void, - public readonly promise: Promise, - ) { - this.value = null; + constructor(request: I) { + this.input = request; + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + this.result = null; } - resolve(value: ResolverResult): void { - this.value = value; - this._resolve(this.value); + resolve(result: R): void { + this.result = result; + this._resolve(this.result); } reject(err: any): void { @@ -38,40 +43,50 @@ export class RemoteAuthorityResolverService extends Disposable implements IRemot private readonly _onDidChangeConnectionData = this._register(new Emitter()); public readonly onDidChangeConnectionData = this._onDidChangeConnectionData.event; - private readonly _resolveAuthorityRequests: Map; + private readonly _resolveAuthorityRequests: Map>; private readonly _connectionTokens: Map; + private readonly _canonicalURIRequests: Map>; + private _canonicalURIProvider: ((uri: URI) => Promise) | null; constructor() { super(); - this._resolveAuthorityRequests = new Map(); + this._resolveAuthorityRequests = new Map>(); this._connectionTokens = new Map(); + this._canonicalURIRequests = new Map>(); + this._canonicalURIProvider = null; } resolveAuthority(authority: string): Promise { if (!this._resolveAuthorityRequests.has(authority)) { - let resolve: (value: ResolverResult) => void; - let reject: (err: any) => void; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - this._resolveAuthorityRequests.set(authority, new PendingResolveAuthorityRequest(resolve!, reject!, promise)); + this._resolveAuthorityRequests.set(authority, new PendingPromise(authority)); } return this._resolveAuthorityRequests.get(authority)!.promise; } + async getCanonicalURI(uri: URI): Promise { + const key = uri.toString(); + if (!this._canonicalURIRequests.has(key)) { + const request = new PendingPromise(uri); + if (this._canonicalURIProvider) { + this._canonicalURIProvider(request.input).then((uri) => request.resolve(uri), (err) => request.reject(err)); + } + this._canonicalURIRequests.set(key, request); + } + return this._canonicalURIRequests.get(key)!.promise; + } + getConnectionData(authority: string): IRemoteConnectionData | null { if (!this._resolveAuthorityRequests.has(authority)) { return null; } const request = this._resolveAuthorityRequests.get(authority)!; - if (!request.value) { + if (!request.result) { return null; } const connectionToken = this._connectionTokens.get(authority); return { - host: request.value.authority.host, - port: request.value.authority.port, + host: request.result.authority.host, + port: request.result.authority.port, connectionToken: connectionToken }; } @@ -107,4 +122,11 @@ export class RemoteAuthorityResolverService extends Disposable implements IRemot RemoteAuthorities.setConnectionToken(authority, connectionToken); this._onDidChangeConnectionData.fire(); } + + _setCanonicalURIProvider(provider: (uri: URI) => Promise): void { + this._canonicalURIProvider = provider; + this._canonicalURIRequests.forEach((value) => { + this._canonicalURIProvider!(value.input).then((uri) => value.resolve(uri), (err) => value.reject(err)); + }); + } } diff --git a/src/vs/platform/state/test/electron-main/state.test.ts b/src/vs/platform/state/test/electron-main/state.test.ts index 568aa978a5a..40b751c6c1e 100644 --- a/src/vs/platform/state/test/electron-main/state.test.ts +++ b/src/vs/platform/state/test/electron-main/state.test.ts @@ -5,11 +5,11 @@ import * as assert from 'assert'; import { tmpdir } from 'os'; -import { promises, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; import { join } from 'vs/base/common/path'; import { flakySuite, getRandomTestPath } from 'vs/base/test/node/testUtils'; import { FileStorage } from 'vs/platform/state/electron-main/stateMainService'; -import { rimraf, writeFileSync } from 'vs/base/node/pfs'; +import { Promises, rimraf, writeFileSync } from 'vs/base/node/pfs'; import { ILogService, NullLogService } from 'vs/platform/log/common/log'; import { IFileService } from 'vs/platform/files/common/files'; import { FileService } from 'vs/platform/files/common/fileService'; @@ -33,7 +33,7 @@ flakySuite('StateMainService', () => { diskFileSystemProvider = new DiskFileSystemProvider(logService); fileService.registerProvider(Schemas.file, diskFileSystemProvider); - return promises.mkdir(testDir, { recursive: true }); + return Promises.mkdir(testDir, { recursive: true }); }); teardown(() => { diff --git a/src/vs/platform/storage/electron-main/storageMain.ts b/src/vs/platform/storage/electron-main/storageMain.ts index 3cd5051587f..16ca034a873 100644 --- a/src/vs/platform/storage/electron-main/storageMain.ts +++ b/src/vs/platform/storage/electron-main/storageMain.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { promises } from 'fs'; -import { exists, writeFile } from 'vs/base/node/pfs'; +import { exists, Promises, writeFile } from 'vs/base/node/pfs'; import { Event, Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { ILogService, LogLevel } from 'vs/platform/log/common/log'; @@ -276,7 +275,7 @@ export class WorkspaceStorageMain extends BaseStorageMain implements IStorageMai } // Ensure storage folder exists - await promises.mkdir(workspaceStorageFolderPath, { recursive: true }); + await Promises.mkdir(workspaceStorageFolderPath, { recursive: true }); // Write metadata into folder (but do not await) this.ensureWorkspaceStorageFolderMeta(workspaceStorageFolderPath); diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 282aa660ced..82b064a99af 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -8,6 +8,7 @@ import { Event } from 'vs/base/common/event'; import { IProcessEnvironment, OperatingSystem } from 'vs/base/common/platform'; import { URI, UriComponents } from 'vs/base/common/uri'; import { IGetTerminalLayoutInfoArgs, IProcessDetails, IPtyHostProcessReplayEvent, ISetTerminalLayoutInfoArgs } from 'vs/platform/terminal/common/terminalProcess'; +import { ThemeIcon } from 'vs/platform/theme/common/themeService'; export const enum TerminalSettingId { ShellLinux = 'terminal.integrated.shell.linux', @@ -69,7 +70,7 @@ export const enum TerminalSettingId { SplitCwd = 'terminal.integrated.splitCwd', WindowsEnableConpty = 'terminal.integrated.windowsEnableConpty', WordSeparators = 'terminal.integrated.wordSeparators', - ExperimentalUseTitleEvent = 'terminal.integrated.experimentalUseTitleEvent', + TitleMode = 'terminal.integrated.titleMode', EnableFileLinks = 'terminal.integrated.enableFileLinks', UnicodeVersion = 'terminal.integrated.unicodeVersion', ExperimentalLinkProvider = 'terminal.integrated.experimentalLinkProvider', @@ -102,7 +103,6 @@ export interface IRawTerminalTabLayoutInfo { } export type ITerminalTabLayoutInfoById = IRawTerminalTabLayoutInfo; -export type ITerminalTabLayoutInfo = IRawTerminalTabLayoutInfo; export interface IRawTerminalsLayoutInfo { tabs: IRawTerminalTabLayoutInfo[]; @@ -112,11 +112,21 @@ export interface IPtyHostAttachTarget { id: number; pid: number; title: string; + titleSource: TitleEventSource; cwd: string; workspaceId: string; workspaceName: string; isOrphan: boolean; - icon: string | undefined; + icon: TerminalIcon | undefined; +} + +export enum TitleEventSource { + /** From the API or the rename command that overrides any other type */ + Api, + /** From the process name property*/ + Process, + /** From the VT sequence */ + Sequence } export type ITerminalsLayoutInfo = IRawTerminalsLayoutInfo; @@ -173,8 +183,8 @@ export interface IOffProcessTerminalService { getEnvironment(): Promise; getShellEnvironment(): Promise; setTerminalLayoutInfo(layoutInfo?: ITerminalsLayoutInfoById): Promise; - updateTitle(id: number, title: string): Promise; - updateIcon(id: number, icon: string, color?: string): Promise; + updateTitle(id: number, title: string, titleSource: TitleEventSource): Promise; + updateIcon(id: number, icon: TerminalIcon, color?: string): Promise; getTerminalLayoutInfo(): Promise; reduceConnectionGraceTime(): Promise; } @@ -239,8 +249,8 @@ export interface IPtyService { processBinary(id: number, data: string): Promise; /** Confirm the process is _not_ an orphan. */ orphanQuestionReply(id: number): Promise; - updateTitle(id: number, title: string): Promise; - updateIcon(id: number, icon: string, color?: string): Promise; + updateTitle(id: number, title: string, titleSource: TitleEventSource): Promise; + updateIcon(id: number, icon: TerminalIcon, color?: string): Promise; getDefaultSystemShell(osOverride?: OperatingSystem): Promise; getProfiles?(includeDetectedProfiles?: boolean): Promise; getEnvironment(): Promise; @@ -348,7 +358,7 @@ export interface IShellLaunchConfig { /** * This is a terminal that attaches to an already running terminal. */ - attachPersistentProcess?: { id: number; pid: number; title: string; cwd: string; icon?: string; color?: string }; + attachPersistentProcess?: { id: number; pid: number; title: string; titleSource: TitleEventSource; cwd: string; icon?: TerminalIcon; color?: string }; /** * Whether the terminal process environment should be exactly as provided in @@ -388,10 +398,9 @@ export interface IShellLaunchConfig { isExtensionOwnedTerminal?: boolean; /** - * The codicon ID to use for this terminal. If not specified it will use the default fallback - * icon. + * The icon for the terminal, used primarily in the terminal tab. */ - icon?: string; + icon?: TerminalIcon; /** * The color ID to use for this terminal. If not specified it will use the default fallback @@ -399,6 +408,8 @@ export interface IShellLaunchConfig { color?: string; } +export type TerminalIcon = ThemeIcon | URI | { light: URI; dark: URI }; + export interface IShellLaunchConfigDto { name?: string; executable?: string; @@ -549,7 +560,7 @@ export interface ITerminalProfile { args?: string | string[] | undefined; env?: ITerminalEnvironment; overrideName?: boolean; - icon?: string; + icon?: ThemeIcon | URI | { light: URI, dark: URI }; } export interface ITerminalDimensionsOverride extends Readonly { @@ -570,7 +581,7 @@ export interface IBaseUnresolvedTerminalProfile { args?: string | string[] | undefined; isAutoDetected?: boolean; overrideName?: boolean; - icon?: string; + icon?: ThemeIcon | URI | { light: URI, dark: URI }; env?: ITerminalEnvironment; } diff --git a/src/vs/platform/terminal/common/terminalProcess.ts b/src/vs/platform/terminal/common/terminalProcess.ts index 3fd0e6bb77b..bc51bd66e7d 100644 --- a/src/vs/platform/terminal/common/terminalProcess.ts +++ b/src/vs/platform/terminal/common/terminalProcess.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { UriComponents } from 'vs/base/common/uri'; -import { IRawTerminalTabLayoutInfo, ITerminalEnvironment, ITerminalTabLayoutInfoById } from 'vs/platform/terminal/common/terminal'; +import { IRawTerminalTabLayoutInfo, ITerminalEnvironment, ITerminalTabLayoutInfoById, TerminalIcon, TitleEventSource } from 'vs/platform/terminal/common/terminal'; import { ISerializableEnvironmentVariableCollection } from 'vs/platform/terminal/common/environmentVariable'; export interface ISingleTerminalConfiguration { @@ -51,11 +51,12 @@ export interface IProcessDetails { id: number; pid: number; title: string; + titleSource: TitleEventSource; cwd: string; workspaceId: string; workspaceName: string; isOrphan: boolean; - icon: string | undefined; + icon: TerminalIcon | undefined; color: string | undefined; } diff --git a/src/vs/platform/terminal/node/ptyHostService.ts b/src/vs/platform/terminal/node/ptyHostService.ts index b57f55bc25e..27eb9176773 100644 --- a/src/vs/platform/terminal/node/ptyHostService.ts +++ b/src/vs/platform/terminal/node/ptyHostService.ts @@ -5,7 +5,7 @@ import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { ILogService } from 'vs/platform/log/common/log'; -import { IPtyService, IProcessDataEvent, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, ITerminalsLayoutInfo, TerminalIpcChannels, IHeartbeatService, HeartbeatConstants, TerminalShellType, ITerminalProfile, IRequestResolveVariablesEvent, SafeConfigProvider, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; +import { IPtyService, IProcessDataEvent, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, ITerminalsLayoutInfo, TerminalIpcChannels, IHeartbeatService, HeartbeatConstants, TerminalShellType, ITerminalProfile, IRequestResolveVariablesEvent, SafeConfigProvider, TerminalSettingId, TitleEventSource, TerminalIcon } from 'vs/platform/terminal/common/terminal'; import { Client } from 'vs/base/parts/ipc/node/ipc.cp'; import { FileAccess } from 'vs/base/common/network'; import { ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; @@ -167,10 +167,10 @@ export class PtyHostService extends Disposable implements IPtyService { lastPtyId = Math.max(lastPtyId, id); return id; } - updateTitle(id: number, title: string): Promise { - return this._proxy.updateTitle(id, title); + updateTitle(id: number, title: string, titleSource: TitleEventSource): Promise { + return this._proxy.updateTitle(id, title, titleSource); } - updateIcon(id: number, icon: string, color?: string): Promise { + updateIcon(id: number, icon: TerminalIcon, color?: string): Promise { return this._proxy.updateIcon(id, icon, color); } attachToProcess(id: number): Promise { diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index 6951a9c4bc4..f10f2af6d6f 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -5,7 +5,7 @@ import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { IProcessEnvironment, isWindows, OperatingSystem, OS } from 'vs/base/common/platform'; -import { IPtyService, IProcessDataEvent, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, LocalReconnectConstants, ITerminalsLayoutInfo, IRawTerminalInstanceLayoutInfo, ITerminalTabLayoutInfoById, ITerminalInstanceLayoutInfoById, TerminalShellType, IProcessReadyEvent } from 'vs/platform/terminal/common/terminal'; +import { IPtyService, IProcessDataEvent, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, LocalReconnectConstants, ITerminalsLayoutInfo, IRawTerminalInstanceLayoutInfo, ITerminalTabLayoutInfoById, ITerminalInstanceLayoutInfoById, TerminalShellType, IProcessReadyEvent, TitleEventSource, TerminalIcon } from 'vs/platform/terminal/common/terminal'; import { AutoOpenBarrier, Queue, RunOnceScheduler } from 'vs/base/common/async'; import { Emitter } from 'vs/base/common/event'; import { TerminalRecorder } from 'vs/platform/terminal/common/terminalRecorder'; @@ -17,6 +17,7 @@ import { getSystemShell } from 'vs/base/node/shell'; import { getWindowsBuildNumber } from 'vs/platform/terminal/node/terminalEnvironment'; import { execFile } from 'child_process'; import { escapeNonWindowsPath } from 'vs/platform/terminal/common/terminalEnvironment'; +import { URI } from 'vs/base/common/uri'; type WorkspaceId = string; @@ -114,11 +115,11 @@ export class PtyService extends Disposable implements IPtyService { } } - async updateTitle(id: number, title: string): Promise { - this._throwIfNoPty(id).setTitle(title); + async updateTitle(id: number, title: string, titleSource: TitleEventSource): Promise { + this._throwIfNoPty(id).setTitle(title, titleSource); } - async updateIcon(id: number, icon: string, color?: string): Promise { + async updateIcon(id: number, icon: URI | { light: URI; dark: URI } | { id: string, color?: { id: string } }, color?: string): Promise { this._throwIfNoPty(id).setIcon(icon, color); } @@ -204,10 +205,8 @@ export class PtyService extends Disposable implements IPtyService { const layout = this._workspaceLayoutInfos.get(args.workspaceId); if (layout) { const expandedTabs = await Promise.all(layout.tabs.map(async tab => this._expandTerminalTab(tab))); - const filtered = expandedTabs.filter(t => t.terminals.length > 0); - return { - tabs: filtered - }; + const tabs = expandedTabs.filter(t => t.terminals.length > 0); + return { tabs }; } return undefined; } @@ -245,6 +244,7 @@ export class PtyService extends Disposable implements IPtyService { return { id, title: persistentProcess.title, + titleSource: persistentProcess.titleSource, pid: persistentProcess.pid, workspaceId: persistentProcess.workspaceId, workspaceName: persistentProcess.workspaceName, @@ -299,18 +299,20 @@ export class PersistentTerminalProcess extends Disposable { private _pid = -1; private _cwd = ''; private _title: string | undefined; - + private _titleSource: TitleEventSource = TitleEventSource.Process; get pid(): number { return this._pid; } get title(): string { return this._title || this._terminalProcess.currentTitle; } - get icon(): string | undefined { return this._icon; } + get titleSource(): TitleEventSource { return this._titleSource; } + get icon(): TerminalIcon | undefined { return this._icon; } get color(): string | undefined { return this._color; } - setTitle(title: string): void { + setTitle(title: string, titleSource: TitleEventSource): void { this._title = title; + this._titleSource = titleSource; } - setIcon(icon: string, color?: string): void { + setIcon(icon: TerminalIcon, color?: string): void { this._icon = icon; this._color = color; } @@ -323,7 +325,7 @@ export class PersistentTerminalProcess extends Disposable { readonly shouldPersistTerminal: boolean, cols: number, rows: number, private readonly _logService: ILogService, - private _icon?: string, + private _icon?: TerminalIcon, private _color?: string ) { super(); @@ -377,7 +379,7 @@ export class PersistentTerminalProcess extends Disposable { } this._isStarted = true; } else { - this._onProcessReady.fire({ pid: this._pid, cwd: this._cwd, requiresWindowsMode: isWindows && getWindowsBuildNumber() <= 19041 }); + this._onProcessReady.fire({ pid: this._pid, cwd: this._cwd, requiresWindowsMode: isWindows && getWindowsBuildNumber() < 21376 }); this._onProcessTitleChanged.fire(this._terminalProcess.currentTitle); this._onProcessShellTypeChanged.fire(this._terminalProcess.shellType); this.triggerReplay(); diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 4fca95085ee..b37b6ab2c15 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -18,6 +18,7 @@ import { localize } from 'vs/nls'; import { WindowsShellHelper } from 'vs/platform/terminal/node/windowsShellHelper'; import { IProcessEnvironment, isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { timeout } from 'vs/base/common/async'; +import { Promises } from 'vs/base/node/pfs'; // Writing large amounts of data can be corrupted for some reason, after looking into this is // appears to be a race condition around writing to the FD which may be based on how powerful the @@ -182,7 +183,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess private async _validateCwd(): Promise { try { - const result = await fs.promises.stat(this._initialCwd); + const result = await Promises.stat(this._initialCwd); if (!result.isDirectory()) { return { message: localize('launchFail.cwdNotDirectory', "Starting directory (cwd) \"{0}\" is not a directory", this._initialCwd.toString()) }; } @@ -200,7 +201,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess throw new Error('IShellLaunchConfig.executable not set'); } try { - const result = await fs.promises.stat(slc.executable); + const result = await Promises.stat(slc.executable); if (!result.isFile() && !result.isSymbolicLink()) { return { message: localize('launchFail.executableIsNotFileOrSymlink', "Path to shell executable \"{0}\" is not a file of a symlink", slc.executable) }; } @@ -324,7 +325,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess } private _sendProcessId(pid: number) { - this._onProcessReady.fire({ pid, cwd: this._initialCwd, requiresWindowsMode: isWindows && getWindowsBuildNumber() <= 19041 }); + this._onProcessReady.fire({ pid, cwd: this._initialCwd, requiresWindowsMode: isWindows && getWindowsBuildNumber() < 21376 }); } private _sendProcessTitle(ptyProcess: pty.IPty): void { diff --git a/src/vs/platform/terminal/node/terminalProfiles.ts b/src/vs/platform/terminal/node/terminalProfiles.ts index 150b2dca5bf..bdfae607ad6 100644 --- a/src/vs/platform/terminal/node/terminalProfiles.ts +++ b/src/vs/platform/terminal/node/terminalProfiles.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; import { normalize, basename, delimiter } from 'vs/base/common/path'; import { enumeratePowerShellInstallations } from 'vs/base/node/powershell'; import { findExecutable, getWindowsBuildNumber } from 'vs/platform/terminal/node/terminalEnvironment'; @@ -13,6 +12,8 @@ import * as pfs from 'vs/base/node/pfs'; import { ITerminalEnvironment, ITerminalProfile, ITerminalProfileObject, ProfileSource, SafeConfigProvider, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; import { Codicon } from 'vs/base/common/codicons'; import { isMacintosh, isWindows } from 'vs/base/common/platform'; +import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { URI } from 'vs/base/common/uri'; let profileSources: Map | undefined; @@ -26,14 +27,14 @@ export function detectAvailableProfiles( ): Promise { fsProvider = fsProvider || { existsFile: pfs.SymlinkSupport.existsFile, - readFile: fs.promises.readFile + readFile: pfs.Promises.readFile }; if (isWindows) { return detectAvailableWindowsProfiles( includeDetectedProfiles, fsProvider, logService, - safeConfigProvider(TerminalSettingId.UseWslProfiles) || true, + safeConfigProvider(TerminalSettingId.UseWslProfiles) !== false, safeConfigProvider(TerminalSettingId.ProfilesWindows), safeConfigProvider(TerminalSettingId.DefaultProfileWindows), variableResolver @@ -80,12 +81,12 @@ async function detectAvailableWindowsProfiles( if (includeDetectedProfiles) { detectedProfiles.set('PowerShell', { source: ProfileSource.Pwsh, - icon: Codicon.terminalPowershell.id, + icon: Codicon.terminalPowershell, isAutoDetected: true }); detectedProfiles.set('Windows PowerShell', { path: `${system32Path}\\WindowsPowerShell\\v1.0\\powershell.exe`, - icon: Codicon.terminalPowershell.id, + icon: Codicon.terminalPowershell, isAutoDetected: true }); detectedProfiles.set('Git Bash', { @@ -102,7 +103,7 @@ async function detectAvailableWindowsProfiles( }); detectedProfiles.set('Command Prompt', { path: `${system32Path}\\cmd.exe`, - icon: Codicon.terminalCmd.id, + icon: Codicon.terminalCmd, isAutoDetected: true }); } @@ -137,7 +138,7 @@ async function transformToTerminalProfiles( if (profile === null) { continue; } let originalPaths: string[]; let args: string[] | string | undefined; - let icon: string | undefined; + let icon: ThemeIcon | URI | { light: URI, dark: URI } | undefined = undefined; if ('source' in profile) { const source = profileSources?.get(profile.source); if (!source) { @@ -147,11 +148,15 @@ async function transformToTerminalProfiles( // if there are configured args, override the default ones args = profile.args || source.args; - icon = profile.icon || source.icon; + if (profile.icon) { + icon = profile.icon; + } else if (source.icon) { + icon = source.icon; + } } else { originalPaths = Array.isArray(profile.path) ? profile.path : [profile.path]; args = isWindows ? profile.args : Array.isArray(profile.args) ? profile.args : undefined; - icon = profile.icon; + icon = profile.icon || undefined; } const paths = (await variableResolver?.(originalPaths)) || originalPaths.slice(); @@ -190,7 +195,7 @@ async function initializeWindowsProfiles(): Promise { profileSources.set('PowerShell', { profileName: 'PowerShell', paths: await getPowershellPaths(), - icon: 'terminal-powershell' + icon: ThemeIcon.asThemeIcon(Codicon.terminalPowershell) }); } @@ -240,12 +245,12 @@ async function getWslProfiles(wslPath: string, defaultProfileName: string | unde isDefault: profileName === defaultProfileName }; if (distroName.includes('Ubuntu')) { - profile.icon = 'terminal-ubuntu'; + profile.icon = ThemeIcon.asThemeIcon(Codicon.terminalUbuntu); } else if (distroName.includes('Debian')) { - profile.icon = 'terminal-debian'; + profile.icon = ThemeIcon.asThemeIcon(Codicon.terminalDebian); } else { - profile.icon = 'terminal-linux'; + profile.icon = ThemeIcon.asThemeIcon(Codicon.terminalLinux); } // Add the profile @@ -267,7 +272,7 @@ async function detectAvailableUnixProfiles( // Add non-quick launch profiles if (includeDetectedProfiles) { - const contents = await fsProvider.readFile('/etc/shells', 'utf8'); + const contents = (await fsProvider.readFile('/etc/shells')).toString(); const profiles = testPaths || contents.split('\n').filter(e => e.trim().indexOf('#') !== 0 && e.trim().length > 0); const counts: Map = new Map(); for (const profile of profiles) { @@ -332,7 +337,7 @@ async function validateProfilePaths(profileName: string, defaultProfileName: str export interface IFsProvider { existsFile(path: string): Promise, - readFile(path: string, options: { encoding: BufferEncoding, flag?: string | number } | BufferEncoding): Promise; + readFile(path: string): Promise; } export interface IProfileVariableResolver { @@ -343,5 +348,5 @@ interface IPotentialTerminalProfile { profileName: string; paths: string[]; args?: string[]; - icon?: string; + icon?: ThemeIcon | URI | { light: URI, dark: URI }; } diff --git a/src/vs/platform/theme/common/themeService.ts b/src/vs/platform/theme/common/themeService.ts index 4032bab91fc..9867c464ae3 100644 --- a/src/vs/platform/theme/common/themeService.ts +++ b/src/vs/platform/theme/common/themeService.ts @@ -67,6 +67,10 @@ export namespace ThemeIcon { return ti1.id === ti2.id && ti1.color?.id === ti2.color?.id; } + export function asThemeIcon(codicon: Codicon): ThemeIcon { + return { id: codicon.id }; + } + export const asClassNameArray: (icon: ThemeIcon) => string[] = CSSIcon.asClassNameArray; export const asClassName: (icon: ThemeIcon) => string = CSSIcon.asClassName; export const asCSSSelector: (icon: ThemeIcon) => string = CSSIcon.asCSSSelector; diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index eab8c3f1807..93424cad965 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -54,7 +54,7 @@ export class Win32UpdateService extends AbstractUpdateService { @memoize get cachePath(): Promise { const result = path.join(tmpdir(), `vscode-update-${this.productService.target}-${process.arch}`); - return fs.promises.mkdir(result, { recursive: true }).then(() => result); + return pfs.Promises.mkdir(result, { recursive: true }).then(() => result); } constructor( @@ -145,7 +145,7 @@ export class Win32UpdateService extends AbstractUpdateService { return this.requestService.request({ url }, CancellationToken.None) .then(context => this.fileService.writeFile(URI.file(downloadPath), context.stream)) .then(hash ? () => checksum(downloadPath, update.hash) : () => undefined) - .then(() => fs.promises.rename(downloadPath, updatePackagePath)) + .then(() => pfs.Promises.rename(downloadPath, updatePackagePath)) .then(() => updatePackagePath); }); }).then(packagePath => { @@ -195,7 +195,7 @@ export class Win32UpdateService extends AbstractUpdateService { const promises = versions.filter(filter).map(async one => { try { - await fs.promises.unlink(path.join(cachePath, one)); + await pfs.Promises.unlink(path.join(cachePath, one)); } catch (err) { // ignore } diff --git a/src/vs/platform/windows/electron-main/window.ts b/src/vs/platform/windows/electron-main/window.ts index 12c49860d48..c597738aa69 100644 --- a/src/vs/platform/windows/electron-main/window.ts +++ b/src/vs/platform/windows/electron-main/window.ts @@ -451,13 +451,16 @@ export class CodeWindow extends Disposable implements ICodeWindow { return false; }; + const isRequestFromSafeContext = (details: Electron.OnBeforeRequestListenerDetails | Electron.OnHeadersReceivedListenerDetails): boolean => { + return details.resourceType === 'xhr' || isSafeFrame(details.frame); + }; + this._win.webContents.session.webRequest.onBeforeRequest((details, callback) => { const uri = URI.parse(details.url); if (uri.path.endsWith('.svg')) { const isSafeResourceUrl = supportedSvgSchemes.has(uri.scheme) || uri.path.includes(Schemas.vscodeRemoteResource); if (!isSafeResourceUrl) { - const isSafeContext = isSafeFrame(details.frame); - return callback({ cancel: !isSafeContext }); + return callback({ cancel: !isRequestFromSafeContext(details) }); } } @@ -483,8 +486,7 @@ export class CodeWindow extends Disposable implements ICodeWindow { // remote extension schemes have the following format // http://127.0.0.1:/vscode-remote-resource?path= if (!uri.path.includes(Schemas.vscodeRemoteResource) && contentTypes.some(contentType => contentType.toLowerCase().includes('image/svg'))) { - const isSafeContext = isSafeFrame(details.frame); - return callback({ cancel: !isSafeContext }); + return callback({ cancel: !isRequestFromSafeContext(details) }); } } diff --git a/src/vs/platform/workspace/common/workspaceTrust.ts b/src/vs/platform/workspace/common/workspaceTrust.ts index 0c6c469cfaa..ac579788e62 100644 --- a/src/vs/platform/workspace/common/workspaceTrust.ts +++ b/src/vs/platform/workspace/common/workspaceTrust.ts @@ -41,10 +41,12 @@ export interface IWorkspaceTrustManagementService { onDidChangeTrust: WorkspaceTrustChangeEvent; onDidChangeTrustedFolders: Event; + get acceptsOutOfWorkspaceFiles(): boolean; + set acceptsOutOfWorkspaceFiles(value: boolean); addWorkspaceTrustTransitionParticipant(participant: IWorkspaceTrustTransitionParticipant): IDisposable; isWorkpaceTrusted(): boolean; canSetParentFolderTrust(): boolean; - setParentFolderTrust(trusted: boolean): void; + setParentFolderTrust(trusted: boolean): Promise; canSetWorkspaceTrust(): boolean; setWorkspaceTrust(trusted: boolean): Promise; getUriTrustInfo(folder: URI): IWorkspaceTrustUriInfo; @@ -53,6 +55,12 @@ export interface IWorkspaceTrustManagementService { setTrustedFolders(folders: URI[]): Promise; } +export const enum WorkspaceTrustUriResponse { + Open = 1, + OpenInNewWindow = 2, + Cancel = 3 +} + export const IWorkspaceTrustRequestService = createDecorator('workspaceTrustRequestService'); export interface IWorkspaceTrustRequestService { @@ -60,6 +68,7 @@ export interface IWorkspaceTrustRequestService { readonly onDidInitiateWorkspaceTrustRequest: Event; + requestOpenUris(uris: URI[]): Promise; cancelRequest(): void; completeRequest(trusted?: boolean): Promise; requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Promise; diff --git a/src/vs/platform/workspaces/electron-main/workspacesManagementMainService.ts b/src/vs/platform/workspaces/electron-main/workspacesManagementMainService.ts index 8edbdc00143..47a5be15c90 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesManagementMainService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesManagementMainService.ts @@ -6,8 +6,8 @@ import { toWorkspaceFolders, IWorkspaceIdentifier, hasWorkspaceFileExtension, UNTITLED_WORKSPACE_NAME, IResolvedWorkspace, IStoredWorkspaceFolder, isStoredWorkspaceFolder, IWorkspaceFolderCreationData, IUntitledWorkspaceInfo, getStoredWorkspaceFolder, IEnterWorkspaceResult, isUntitledWorkspace, isWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { IEnvironmentMainService } from 'vs/platform/environment/electron-main/environmentMainService'; import { join, dirname } from 'vs/base/common/path'; -import { writeFile, rimrafSync, readdirSync, writeFileSync } from 'vs/base/node/pfs'; -import { promises, readFileSync, existsSync, mkdirSync, statSync, Stats } from 'fs'; +import { writeFile, rimrafSync, readdirSync, writeFileSync, Promises } from 'vs/base/node/pfs'; +import { readFileSync, existsSync, mkdirSync, statSync, Stats } from 'fs'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { Event, Emitter } from 'vs/base/common/event'; import { ILogService } from 'vs/platform/log/common/log'; @@ -142,7 +142,7 @@ export class WorkspacesManagementMainService extends Disposable implements IWork const { workspace, storedWorkspace } = this.newUntitledWorkspace(folders, remoteAuthority); const configPath = workspace.configPath.fsPath; - await promises.mkdir(dirname(configPath), { recursive: true }); + await Promises.mkdir(dirname(configPath), { recursive: true }); await writeFile(configPath, JSON.stringify(storedWorkspace, null, '\t')); return workspace; diff --git a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts index 1b524310e9c..3769d7324bb 100644 --- a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts +++ b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts @@ -109,7 +109,7 @@ flakySuite('WorkspacesManagementMainService', () => { service = new WorkspacesManagementMainService(environmentMainService, new NullLogService(), new TestBackupMainService(), new TestDialogMainService(), productService); - return fs.promises.mkdir(untitledWorkspacesHomePath, { recursive: true }); + return pfs.Promises.mkdir(untitledWorkspacesHomePath, { recursive: true }); }); teardown(() => { diff --git a/src/vs/vscode.d.ts b/src/vs/vscode.d.ts index 0e680b8175f..e02819b27f8 100644 --- a/src/vs/vscode.d.ts +++ b/src/vs/vscode.d.ts @@ -1314,6 +1314,15 @@ declare module 'vscode' { */ static joinPath(base: Uri, ...pathSegments: string[]): Uri; + /** + * Create an URI from its component parts + * + * @see {@link Uri.toString} + * @param components The component parts of an Uri. + * @return A new Uri instance. + */ + static from(components: { scheme: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri; + /** * Use the `file` and `parse` factory functions to create new `Uri` objects. */ @@ -2052,7 +2061,7 @@ declare module 'vscode' { * * Kinds are a hierarchical list of identifiers separated by `.`, e.g. `"refactor.extract.function"`. * - * Code action kinds are used by VS Code for UI elements such as the refactoring context menu. Users + * Code action kinds are used by the editor for UI elements such as the refactoring context menu. Users * can also trigger code actions with a specific kind with the `editor.action.codeAction` command. */ export class CodeActionKind { @@ -2238,7 +2247,7 @@ declare module 'vscode' { /** * A {@link Command} this code action executes. * - * If this command throws an exception, VS Code displays the exception message to users in the editor at the + * If this command throws an exception, the editor displays the exception message to users in the editor at the * current cursor position. */ command?: Command; @@ -2269,7 +2278,7 @@ declare module 'vscode' { * of code action, such as refactorings. * * - If the user has a [keybinding](https://code.visualstudio.com/docs/editor/refactoring#_keybindings-for-code-actions) - * that auto applies a code action and only a disabled code actions are returned, VS Code will show the user an + * that auto applies a code action and only a disabled code actions are returned, the editor will show the user an * error message with `reason` in the editor. */ disabled?: { @@ -2346,17 +2355,17 @@ declare module 'vscode' { * list of kinds may either be generic, such as `[CodeActionKind.Refactor]`, or list out every kind provided, * such as `[CodeActionKind.Refactor.Extract.append('function'), CodeActionKind.Refactor.Extract.append('constant'), ...]`. */ - readonly providedCodeActionKinds?: ReadonlyArray; + readonly providedCodeActionKinds?: readonly CodeActionKind[]; /** * Static documentation for a class of code actions. * * Documentation from the provider is shown in the code actions menu if either: * - * - Code actions of `kind` are requested by VS Code. In this case, VS Code will show the documentation that + * - Code actions of `kind` are requested by the editor. In this case, the editor will show the documentation that * most closely matches the requested code action kind. For example, if a provider has documentation for * both `Refactor` and `RefactorExtract`, when the user requests code actions for `RefactorExtract`, - * VS Code will use the documentation for `RefactorExtract` instead of the documentation for `Refactor`. + * the editor will use the documentation for `RefactorExtract` instead of the documentation for `Refactor`. * * - Any code actions of `kind` are returned by the provider. * @@ -2375,7 +2384,7 @@ declare module 'vscode' { /** * Command that displays the documentation to the user. * - * This can display the documentation directly in VS Code or open a website using {@link env.openExternal `env.openExternal`}; + * This can display the documentation directly in the editor or open a website using {@link env.openExternal `env.openExternal`}; * * The title of this documentation code action is taken from {@link Command.title `Command.title`} */ @@ -2687,13 +2696,13 @@ declare module 'vscode' { /** * The evaluatable expression provider interface defines the contract between extensions and * the debug hover. In this contract the provider returns an evaluatable expression for a given position - * in a document and VS Code evaluates this expression in the active debug session and shows the result in a debug hover. + * in a document and the editor evaluates this expression in the active debug session and shows the result in a debug hover. */ export interface EvaluatableExpressionProvider { /** * Provide an evaluatable expression for the given document and position. - * VS Code will evaluate this expression in the active debug session and will show the result in the debug hover. + * The editor will evaluate this expression in the active debug session and will show the result in the debug hover. * The expression can be implicitly specified by the range in the underlying document or by explicitly returning an expression. * * @param document The document for which the debug hover is about to appear. @@ -5575,6 +5584,14 @@ declare module 'vscode' { */ export interface StatusBarItem { + /** + * The identifier of this item. + * + * *Note*: if no identifier was provided by the {@link window.createStatusBarItem `window.createStatusBarItem`} + * method, the identifier will match the {@link Extension.id extension identifier}. + */ + readonly id: string; + /** * The alignment of this item. */ @@ -5586,6 +5603,13 @@ declare module 'vscode' { */ readonly priority?: number; + /** + * The name of the entry, like 'Python Language Indicator', 'Git Status' etc. + * Try to keep the length of the name short, yet descriptive enough that + * users can understand what the status bar item is about. + */ + name: string | undefined; + /** * The text to show for the entry. You can embed icons in the text by leveraging the syntax: * @@ -7758,7 +7782,7 @@ declare module 'vscode' { /** * Event triggered by extensions to signal to VS Code that an edit has occurred on an {@link CustomDocument `CustomDocument`}. * - * @see {@link CustomDocumentProvider.onDidChangeCustomDocument `CustomDocumentProvider.onDidChangeCustomDocument`}. + * @see {@link CustomEditorProvider.onDidChangeCustomDocument `CustomEditorProvider.onDidChangeCustomDocument`}. */ interface CustomDocumentEditEvent { @@ -7797,7 +7821,7 @@ declare module 'vscode' { * Event triggered by extensions to signal to VS Code that the content of a {@link CustomDocument `CustomDocument`} * has changed. * - * @see {@link CustomDocumentProvider.onDidChangeCustomDocument `CustomDocumentProvider.onDidChangeCustomDocument`}. + * @see {@link CustomEditorProvider.onDidChangeCustomDocument `CustomEditorProvider.onDidChangeCustomDocument`}. */ interface CustomDocumentContentChangeEvent { /** @@ -8795,6 +8819,16 @@ declare module 'vscode' { */ export function createStatusBarItem(alignment?: StatusBarAlignment, priority?: number): StatusBarItem; + /** + * Creates a status bar {@link StatusBarItem item}. + * + * @param id The unique identifier of the item. + * @param alignment The alignment of the item. + * @param priority The priority of the item. Higher values mean the item should be shown more to the left. + * @return A new status bar item. + */ + export function createStatusBarItem(id: string, alignment?: StatusBarAlignment, priority?: number): StatusBarItem; + /** * Creates a {@link Terminal} with a backing shell process. The cwd of the terminal will be the workspace * directory if it exists. @@ -8902,7 +8936,7 @@ declare module 'vscode' { * * Normally the webview's html context is created when the view becomes visible * and destroyed when it is hidden. Extensions that have complex state - * or UI can set the `retainContextWhenHidden` to make VS Code keep the webview + * or UI can set the `retainContextWhenHidden` to make the editor keep the webview * context around, even when the webview moves to a background tab. When a webview using * `retainContextWhenHidden` becomes hidden, its scripts and other dynamic content are suspended. * When the view becomes visible again, the context is automatically restored @@ -8919,7 +8953,7 @@ declare module 'vscode' { /** * Register a provider for custom editors for the `viewType` contributed by the `customEditors` extension point. * - * When a custom editor is opened, VS Code fires an `onCustomEditor:viewType` activation event. Your extension + * When a custom editor is opened, an `onCustomEditor:viewType` activation event is fired. Your extension * must register a {@link CustomTextEditorProvider `CustomTextEditorProvider`}, {@link CustomReadonlyEditorProvider `CustomReadonlyEditorProvider`}, * {@link CustomEditorProvider `CustomEditorProvider`}for `viewType` as part of activation. * @@ -8942,7 +8976,7 @@ declare module 'vscode' { * Indicates that the provider allows multiple editor instances to be open at the same time for * the same resource. * - * By default, VS Code only allows one editor instance to be open at a time for each resource. If the + * By default, the editor only allows one editor instance to be open at a time for each resource. If the * user tries to open a second editor instance for the resource, the first one is instead moved to where * the second one was to be opened. * @@ -10308,7 +10342,7 @@ declare module 'vscode' { export const rootPath: string | undefined; /** - * List of workspace folders that are open in VS Code. `undefined when no workspace + * List of workspace folders that are open in VS Code. `undefined` when no workspace * has been opened. * * Refer to https://code.visualstudio.com/docs/editor/workspaces for more information @@ -11573,6 +11607,12 @@ declare module 'vscode' { */ readonly type: string; + /** + * The parent session of this debug session, if it was created as a child. + * @see DebugSessionOptions.parentSession + */ + readonly parentSession?: DebugSession; + /** * The debug session's name is initially taken from the {@link DebugConfiguration debug configuration}. * Any changes will be properly reflected in the UI. diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 73adbd19046..5d0471788b4 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -80,16 +80,10 @@ declare module 'vscode' { constructor(host: string, port: number, connectionToken?: string); } - export enum RemoteTrustOption { - Unknown = 0, - DisableTrust = 1, - MachineTrusted = 2 - } - export interface ResolvedOptions { extensionHostEnv?: { [key: string]: string | null; }; - trust?: RemoteTrustOption; + isTrusted?: boolean; } export interface TunnelOptions { @@ -150,7 +144,24 @@ declare module 'vscode' { } export interface RemoteAuthorityResolver { + /** + * Resolve the authority part of the current opened `vscode-remote://` URI. + * + * This method will be invoked once during the startup of VS Code and again each time + * VS Code detects a disconnection. + * + * @param authority The authority part of the current opened `vscode-remote://` URI. + * @param context A context indicating if this is the first call or a subsequent call. + */ resolve(authority: string, context: RemoteAuthorityResolverContext): ResolverResult | Thenable; + + /** + * Get the canonical URI (if applicable) for a `vscode-remote://` URI. + * + * @returns The canonical URI or undefined if the uri is already canonical. + */ + getCanonicalURI?(uri: Uri): ProviderResult; + /** * Can be optionally implemented if the extension can forward ports better than the core. * When not implemented, the core will use its default forwarding logic. @@ -891,9 +902,16 @@ declare module 'vscode' { export interface TerminalOptions { /** - * A codicon ID to associate with this terminal. + * The icon path or {@link ThemeIcon} for the terminal. */ - readonly icon?: string; + readonly iconPath?: Uri | { light: Uri; dark: Uri } | { id: string, color?: { id: string } }; + } + + export interface ExtensionTerminalOptions { + /** + * A themeIcon, Uri, or light and dark Uris to use as the terminal tab icon + */ + readonly iconPath?: Uri | { light: Uri; dark: Uri } | { id: string, color?: { id: string } }; } //#endregion @@ -938,51 +956,6 @@ declare module 'vscode' { } //#endregion - //#region Status bar item with ID and Name: https://github.com/microsoft/vscode/issues/74972 - - /** - * Options to configure the status bar item. - */ - export interface StatusBarItemOptions { - - /** - * An identifier for the item that should be unique. - */ - id: string; - - /** - * A human readable name of the item that explains the purpose - * of the item to the user. - */ - name: string; - - /** - * The alignment of the item. - */ - alignment?: StatusBarAlignment; - - /** - * The priority of the item. Higher values mean the item should be shown more to the left. - */ - priority?: number; - } - - export namespace window { - - /** - * Creates a status bar {@link StatusBarItem item}. - * - * @param options The options of the item. If not provided, some default values - * will be assumed. For example, the {@link StatusBarItemOptions.id `StatusBarItemOptions.id`} - * will be the id of the extension and the {@link StatusBarItemOptions.name `StatusBarItemOptions.name`} - * will be the extension name. - * @return A new status bar item. - */ - export function createStatusBarItem(options?: StatusBarItemOptions): StatusBarItem; - } - - //#endregion - //#region Custom editor move https://github.com/microsoft/vscode/issues/86146 // TODO: Also for custom editor @@ -1043,7 +1016,6 @@ declare module 'vscode' { * * NotebookCell instances are immutable and are kept in sync for as long as they are part of their notebook. */ - // todo@API support ids https://github.com/jupyter/enhancement-proposals/blob/master/62-cell-id/cell-id.md export interface NotebookCell { /** @@ -1308,7 +1280,6 @@ declare module 'vscode' { /** * NotebookCellData is the raw representation of notebook cells. Its is part of {@link NotebookData `NotebookData`}. */ - // todo@API support ids https://github.com/jupyter/enhancement-proposals/blob/master/62-cell-id/cell-id.md export class NotebookCellData { /** @@ -1724,6 +1695,17 @@ declare module 'vscode' { */ export function openNotebookDocument(uri: Uri): Thenable; + /** + * Open an untitled notebook. The editor will prompt the user for a file + * path when the document is to be saved. + * + * @see {@link openNotebookDocument} + * @param viewType The notebook view type that should be used. + * @param content The initial contents of the notebook. + * @returns A promise that resolves to a {@link NotebookDocument notebook}. + */ + export function openNotebookDocument(viewType: string, content?: NotebookData): Thenable; + /** * An event that is emitted when a {@link NotebookDocument notebook} is opened. */ @@ -2141,6 +2123,52 @@ declare module 'vscode' { //#endregion + //#region @connor4312 - notebook messaging: https://github.com/microsoft/vscode/issues/123601 + + export interface NotebookRendererMessage { + /** + * Editor that sent the message. + */ + editor: NotebookEditor; + + /** + * Message sent from the webview. + */ + message: T; + } + + /** + * Renderer messaging is used to communicate with a single renderer. It's + * returned from {@link notebook.createRendererMessaging}. + */ + export interface NotebookRendererMessaging { + /** + * Events that fires when a message is received from a renderer. + */ + onDidReceiveMessage: Event>; + + /** + * Sends a message to the renderer. + * @param editor Editor to target with the message + * @param message Message to send + */ + postMessage(editor: NotebookEditor, message: TSend): void; + } + + export namespace notebook { + /** + * Creates a new messaging instance used to communicate with a specific + * renderer. The renderer only has access to messaging if `requiresMessaging` + * is set in its contribution. + * + * @see https://github.com/microsoft/vscode/issues/123601 + * @param rendererId The renderer ID to communicate with + */ + export function createRendererMessaging(rendererId: string): NotebookRendererMessaging; + } + + //#endregion + //#region @eamodio - timeline: https://github.com/microsoft/vscode/issues/84297 export class TimelineItem { @@ -3173,6 +3201,69 @@ declare module 'vscode' { //#endregion + //#region @joaomoreno https://github.com/microsoft/vscode/issues/124263 + // This API change only affects behavior and documentation, not API surface. + + namespace env { + + /** + * Resolves a uri to form that is accessible externally. + * + * #### `http:` or `https:` scheme + * + * Resolves an *external* uri, such as a `http:` or `https:` link, from where the extension is running to a + * uri to the same resource on the client machine. + * + * This is a no-op if the extension is running on the client machine. + * + * If the extension is running remotely, this function automatically establishes a port forwarding tunnel + * from the local machine to `target` on the remote and returns a local uri to the tunnel. The lifetime of + * the port forwarding tunnel is managed by VS Code and the tunnel can be closed by the user. + * + * *Note* that uris passed through `openExternal` are automatically resolved and you should not call `asExternalUri` on them. + * + * #### `vscode.env.uriScheme` + * + * Creates a uri that - if opened in a browser (e.g. via `openExternal`) - will result in a registered {@link UriHandler} + * to trigger. + * + * Extensions should not make any assumptions about the resulting uri and should not alter it in anyway. + * Rather, extensions can e.g. use this uri in an authentication flow, by adding the uri as callback query + * argument to the server to authenticate to. + * + * *Note* that if the server decides to add additional query parameters to the uri (e.g. a token or secret), it + * will appear in the uri that is passed to the {@link UriHandler}. + * + * **Example** of an authentication flow: + * ```typescript + * vscode.window.registerUriHandler({ + * handleUri(uri: vscode.Uri): vscode.ProviderResult { + * if (uri.path === '/did-authenticate') { + * console.log(uri.toString()); + * } + * } + * }); + * + * const callableUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://my.extension/did-authenticate`)); + * await vscode.env.openExternal(callableUri); + * ``` + * + * *Note* that extensions should not cache the result of `asExternalUri` as the resolved uri may become invalid due to + * a system or user action — for example, in remote cases, a user may close a port forwarding tunnel that was opened by + * `asExternalUri`. + * + * #### Any other scheme + * + * Any other scheme will be handled as if the provided URI is a workspace URI. In that case, the method will return + * a URI which, when handled, will make VS Code open the workspace. + * + * @return A uri that can be used on the client machine. + */ + export function asExternalUri(target: Uri): Thenable; + } + + //#endregion + //#region https://github.com/Microsoft/vscode/issues/15178 // TODO@API must be a class @@ -3367,4 +3458,16 @@ declare module 'vscode' { } //#endregion + + //#region https://github.com/microsoft/vscode/issues/87110 @eamodio + + export interface Memento { + + /** + * The stored keys. + */ + readonly keys: readonly string[]; + } + + //#endregion } diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 524697c3426..427e98ba553 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -65,6 +65,7 @@ import './mainThreadComments'; import './mainThreadNotebook'; import './mainThreadNotebookKernels'; import './mainThreadNotebookDocumentsAndEditors'; +import './mainThreadNotebookRenderers'; import './mainThreadTask'; import './mainThreadLabelService'; import './mainThreadTunnelService'; diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts index 66a82a4dade..c860e4ea7fd 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts @@ -16,7 +16,7 @@ import { isEqual, isEqualOrParent, toLocalResource } from 'vs/base/common/resour import { URI, UriComponents } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { FileSystemProviderCapabilities, IFileService } from 'vs/platform/files/common/files'; +import { FileOperation, FileSystemProviderCapabilities, IFileService } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { IUndoRedoService, UndoRedoElementType } from 'vs/platform/undoRedo/common/undoRedo'; @@ -35,7 +35,7 @@ import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editor import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IPathService } from 'vs/workbench/services/path/common/pathService'; -import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { IWorkingCopyFileService, WorkingCopyFileEvent } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { IWorkingCopy, IWorkingCopyBackup, NO_TYPE_ID, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; import { ResourceWorkingCopy } from 'vs/workbench/services/workingCopy/common/resourceWorkingCopy'; @@ -51,6 +51,8 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc private readonly _editorProviders = new Map(); + private readonly _editorRenameBackups = new Map(); + constructor( context: extHostProtocol.IExtHostContext, private readonly mainThreadWebview: MainThreadWebviews, @@ -61,7 +63,7 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc @ICustomEditorService private readonly _customEditorService: ICustomEditorService, @IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService, @IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService, - @IInstantiationService private readonly _instantiationService: IInstantiationService + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -90,6 +92,9 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc }, resolveWebview: () => { throw new Error('not implemented'); } })); + + // Working copy operations + this._register(workingCopyFileService.onWillRunWorkingCopyFileOperation(async e => this.onWillRunWorkingCopyFileOperation(e))); } override dispose() { @@ -138,9 +143,18 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc webviewInput.webview.options = options; webviewInput.webview.extension = extension; + // If there's an old resource this was a move and we must resolve the backup at the same time as the webview + // This is because the backup must be ready upon model creation, and the input resolve method comes after + let backupId = webviewInput.backupId; + if (webviewInput.oldResource && !webviewInput.backupId) { + const backup = this._editorRenameBackups.get(webviewInput.oldResource.toString()); + backupId = backup?.backupId; + this._editorRenameBackups.delete(webviewInput.oldResource.toString()); + } + let modelRef: IReference; try { - modelRef = await this.getOrCreateCustomEditorModel(modelType, resource, viewType, { backupId: webviewInput.backupId }, cancellation); + modelRef = await this.getOrCreateCustomEditorModel(modelType, resource, viewType, { backupId }, cancellation); } catch (error) { onUnexpectedError(error); webviewInput.webview.html = this.mainThreadWebview.getWebviewResolvedFailedContent(viewType); @@ -253,6 +267,31 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc } return model; } + + //#region Working Copy + private async onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent) { + if (e.operation !== FileOperation.MOVE) { + return; + } + e.waitUntil((async () => { + const models = []; + for (const file of e.files) { + if (file.source) { + models.push(...(await this._customEditorService.models.getAllModels(file.source))); + } + } + for (const model of models) { + if (model instanceof MainThreadCustomEditorModel && model.isDirty()) { + const workingCopy = await model.backup(CancellationToken.None); + if (workingCopy.meta) { + // This cast is safe because we do an instanceof check above and a custom document backup data is always returned + this._editorRenameBackups.set(model.editorResource.toString(), workingCopy.meta as CustomDocumentBackupData); + } + } + } + })()); + } + //#endregion } namespace HotExitState { diff --git a/src/vs/workbench/api/browser/mainThreadDebugService.ts b/src/vs/workbench/api/browser/mainThreadDebugService.ts index 287bcd08305..60c3a74c017 100644 --- a/src/vs/workbench/api/browser/mainThreadDebugService.ts +++ b/src/vs/workbench/api/browser/mainThreadDebugService.ts @@ -329,7 +329,8 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb type: session.configuration.type, name: session.name, folderUri: session.root ? session.root.uri : undefined, - configuration: session.configuration + configuration: session.configuration, + parent: session.parentSession?.getId(), }; } } diff --git a/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts b/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts index 3d2785cd483..a406d945ee0 100644 --- a/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts +++ b/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts @@ -9,13 +9,14 @@ import { URI, UriComponents } from 'vs/base/common/uri'; import { BoundModelReferenceCollection } from 'vs/workbench/api/browser/mainThreadDocuments'; import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; -import { IImmediateCellEditOperation, IMainCellDto, NotebookCellsChangeType } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IImmediateCellEditOperation, IMainCellDto, NotebookCellsChangeType, NotebookDataDto } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotebookEditorModelResolverService } from 'vs/workbench/contrib/notebook/common/notebookEditorModelResolverService'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { ExtHostContext, ExtHostNotebookShape, IExtHostContext, MainThreadNotebookDocumentsShape } from '../common/extHost.protocol'; import { MainThreadNotebooksAndEditors } from 'vs/workbench/api/browser/mainThreadNotebookDocumentsAndEditors'; import { onUnexpectedError } from 'vs/base/common/errors'; +import { Schemas } from 'vs/base/common/network'; export class MainThreadNotebookDocuments implements MainThreadNotebookDocumentsShape { @@ -47,7 +48,6 @@ export class MainThreadNotebookDocuments implements MainThreadNotebookDocumentsS this._disposables.dispose(); this._modelReferenceCollection.dispose(); dispose(this._documentEventListenersMapping.values()); - } private _handleNotebooksAdded(notebooks: readonly NotebookTextModel[]): void { @@ -119,14 +119,48 @@ export class MainThreadNotebookDocuments implements MainThreadNotebookDocumentsS }; } - async $tryOpenDocument(uriComponents: UriComponents): Promise { + async $tryCreateNotebook(options: { viewType: string, content?: NotebookDataDto }): Promise { + + // find a free URI for the untitled case + let uri: URI; + for (let counter = 1; ; counter++) { + let candidate = URI.from({ scheme: Schemas.untitled, path: `Untitled-${counter}`, query: options.viewType }); + if (!this._notebookService.getNotebookTextModel(candidate)) { + uri = candidate; + break; + } + } + + const ref = await this._notebookEditorModelResolverService.resolve(uri, options.viewType); + + // untitled notebooks are disposed when they get saved. we should not hold a reference + // to such a disposed notebook and therefore dispose the reference as well + ref.object.notebook.onWillDispose(() => { + ref.dispose(); + }); + + // untitled notebooks are dirty by default + this._proxy.$acceptDirtyStateChanged(uri, true); + + // apply content changes... slightly HACKY -> this triggers a change event + if (options.content) { + ref.object.notebook.reset( + options.content.cells, + options.content.metadata, + ref.object.notebook.transientOptions + ); + } + return uri; + } + + async $tryOpenNotebook(uriComponents: UriComponents): Promise { const uri = URI.revive(uriComponents); const ref = await this._notebookEditorModelResolverService.resolve(uri, undefined); this._modelReferenceCollection.add(uri, ref); return uri; } - async $trySaveDocument(uriComponents: UriComponents) { + async $trySaveNotebook(uriComponents: UriComponents) { const uri = URI.revive(uriComponents); const ref = await this._notebookEditorModelResolverService.resolve(uri); diff --git a/src/vs/workbench/api/browser/mainThreadNotebookRenderers.ts b/src/vs/workbench/api/browser/mainThreadNotebookRenderers.ts new file mode 100644 index 00000000000..5adf89ce7a7 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadNotebookRenderers.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { ExtHostContext, ExtHostNotebookRenderersShape, IExtHostContext, MainContext, MainThreadNotebookRenderersShape } from 'vs/workbench/api/common/extHost.protocol'; +import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; +import { INotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; + +@extHostNamedCustomer(MainContext.MainThreadNotebookRenderers) +export class MainThreadNotebookRenderers extends Disposable implements MainThreadNotebookRenderersShape { + private readonly proxy: ExtHostNotebookRenderersShape; + + constructor( + extHostContext: IExtHostContext, + @INotebookRendererMessagingService private readonly messaging: INotebookRendererMessagingService, + ) { + super(); + this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostNotebookRenderers); + this._register(messaging.onShouldPostMessage(e => { + this.proxy.$postRendererMessage(e.editorId, e.rendererId, e.message); + })); + } + + $postMessage(editorId: string, rendererId: string, message: unknown): void { + this.messaging.fireDidReceiveMessage(editorId, rendererId, message); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadStatusBar.ts b/src/vs/workbench/api/browser/mainThreadStatusBar.ts index 3ba387186db..69f8d2cf92a 100644 --- a/src/vs/workbench/api/browser/mainThreadStatusBar.ts +++ b/src/vs/workbench/api/browser/mainThreadStatusBar.ts @@ -27,7 +27,7 @@ export class MainThreadStatusBar implements MainThreadStatusBarShape { this.entries.clear(); } - $setEntry(id: number, statusId: string, statusName: string, text: string, tooltip: string | undefined, command: Command | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignment: MainThreadStatusBarAlignment, priority: number | undefined, accessibilityInformation: IAccessibilityInformation): void { + $setEntry(entryId: number, id: string, name: string, text: string, tooltip: string | undefined, command: Command | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignment: MainThreadStatusBarAlignment, priority: number | undefined, accessibilityInformation: IAccessibilityInformation): void { // if there are icons in the text use the tooltip for the aria label let ariaLabel: string; let role: string | undefined = undefined; @@ -37,23 +37,23 @@ export class MainThreadStatusBar implements MainThreadStatusBarShape { } else { ariaLabel = getCodiconAriaLabel(text); } - const entry: IStatusbarEntry = { text, tooltip, command, color, backgroundColor, ariaLabel, role }; + const entry: IStatusbarEntry = { name, text, tooltip, command, color, backgroundColor, ariaLabel, role }; if (typeof priority === 'undefined') { priority = 0; } // Reset existing entry if alignment or priority changed - let existingEntry = this.entries.get(id); + let existingEntry = this.entries.get(entryId); if (existingEntry && (existingEntry.alignment !== alignment || existingEntry.priority !== priority)) { dispose(existingEntry.accessor); - this.entries.delete(id); + this.entries.delete(entryId); existingEntry = undefined; } // Create new entry if not existing if (!existingEntry) { - this.entries.set(id, { accessor: this.statusbarService.addEntry(entry, statusId, statusName, alignment, priority), alignment, priority }); + this.entries.set(entryId, { accessor: this.statusbarService.addEntry(entry, id, alignment, priority), alignment, priority }); } // Otherwise update diff --git a/src/vs/workbench/api/browser/mainThreadTerminalService.ts b/src/vs/workbench/api/browser/mainThreadTerminalService.ts index 7c81d6928d0..56d4075670c 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalService.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalService.ts @@ -10,13 +10,13 @@ import { URI } from 'vs/base/common/uri'; import { StopWatch } from 'vs/base/common/stopwatch'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; -import { IShellLaunchConfig, IShellLaunchConfigDto, ITerminalDimensions } from 'vs/platform/terminal/common/terminal'; +import { IShellLaunchConfig, IShellLaunchConfigDto, ITerminalDimensions, TitleEventSource } from 'vs/platform/terminal/common/terminal'; import { TerminalDataBufferer } from 'vs/platform/terminal/common/terminalDataBuffering'; import { ITerminalExternalLinkProvider, ITerminalInstance, ITerminalInstanceService, ITerminalLink, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalProcessExtHostProxy } from 'vs/workbench/contrib/terminal/browser/terminalProcessExtHostProxy'; import { IEnvironmentVariableService, ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable'; import { deserializeEnvironmentVariableCollection, serializeEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariableShared'; -import { IStartExtensionTerminalRequest, ITerminalProcessExtHostProxy, ITerminalProfileResolverService, TitleEventSource } from 'vs/workbench/contrib/terminal/common/terminal'; +import { IStartExtensionTerminalRequest, ITerminalProcessExtHostProxy, ITerminalProfileResolverService } from 'vs/workbench/contrib/terminal/common/terminal'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { withNullAsUndefined } from 'vs/base/common/types'; import { OperatingSystem, OS } from 'vs/base/common/platform'; diff --git a/src/vs/workbench/api/browser/mainThreadWindow.ts b/src/vs/workbench/api/browser/mainThreadWindow.ts index 7f10aa334c6..5dc01ce8c76 100644 --- a/src/vs/workbench/api/browser/mainThreadWindow.ts +++ b/src/vs/workbench/api/browser/mainThreadWindow.ts @@ -60,8 +60,7 @@ export class MainThreadWindow implements MainThreadWindowShape { } async $asExternalUri(uriComponents: UriComponents, options: IOpenUriOptions): Promise { - const uri = URI.revive(uriComponents); - const result = await this.openerService.resolveExternalUri(uri, options); + const result = await this.openerService.resolveExternalUri(URI.revive(uriComponents), options); return result.resolved; } } diff --git a/src/vs/workbench/api/common/apiCommands.ts b/src/vs/workbench/api/common/apiCommands.ts index da918ae31e8..e6a50589663 100644 --- a/src/vs/workbench/api/common/apiCommands.ts +++ b/src/vs/workbench/api/common/apiCommands.ts @@ -78,23 +78,6 @@ export class RemoveFromRecentlyOpenedAPICommand { } CommandsRegistry.registerCommand(RemoveFromRecentlyOpenedAPICommand.ID, adjustHandler(RemoveFromRecentlyOpenedAPICommand.execute)); -export interface OpenIssueReporterArgs { - readonly extensionId: string; - readonly issueTitle?: string; - readonly issueBody?: string; -} - -export class OpenIssueReporter { - public static readonly ID = 'vscode.openIssueReporter'; - - public static execute(executor: ICommandsExecutor, args: string | OpenIssueReporterArgs): Promise { - const commandArgs = typeof args === 'string' - ? { extensionId: args } - : args; - return executor.executeCommand('workbench.action.openIssueReporter', commandArgs); - } -} - interface RecentEntry { uri: URI; type: 'workspace' | 'folder' | 'file'; diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 8818247826c..be56e3f5f2f 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as nls from 'vs/nls'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import * as errors from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; @@ -85,8 +84,10 @@ import { IExtHostSecretState } from 'vs/workbench/api/common/exHostSecretState'; import { IExtHostEditorTabs } from 'vs/workbench/api/common/extHostEditorTabs'; import { IExtHostTelemetry } from 'vs/workbench/api/common/extHostTelemetry'; import { ExtHostNotebookKernels } from 'vs/workbench/api/common/extHostNotebookKernels'; -import { RemoteTrustOption } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { TextSearchCompleteMessageType } from 'vs/workbench/services/search/common/searchExtTypes'; +import { ExtHostNotebookRenderers } from 'vs/workbench/api/common/extHostNotebookRenderers'; +import { Schemas } from 'vs/base/common/network'; +import { matchesScheme } from 'vs/platform/opener/common/opener'; export interface IExtensionApiFactory { (extension: IExtensionDescription, registry: ExtensionDescriptionRegistry, configProvider: ExtHostConfigProvider): typeof vscode; @@ -146,9 +147,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostDocumentSaveParticipant = rpcProtocol.set(ExtHostContext.ExtHostDocumentSaveParticipant, new ExtHostDocumentSaveParticipant(extHostLogService, extHostDocuments, rpcProtocol.getProxy(MainContext.MainThreadBulkEdits))); const extHostNotebook = rpcProtocol.set(ExtHostContext.ExtHostNotebook, new ExtHostNotebookController(rpcProtocol, extHostCommands, extHostDocumentsAndEditors, extHostDocuments, extHostLogService, extensionStoragePaths)); const extHostNotebookKernels = rpcProtocol.set(ExtHostContext.ExtHostNotebookKernels, new ExtHostNotebookKernels(rpcProtocol, initData, extHostNotebook, extHostLogService)); + const extHostNotebookRenderers = rpcProtocol.set(ExtHostContext.ExtHostNotebookRenderers, new ExtHostNotebookRenderers(rpcProtocol, extHostNotebook)); const extHostEditors = rpcProtocol.set(ExtHostContext.ExtHostEditors, new ExtHostEditors(rpcProtocol, extHostDocumentsAndEditors)); const extHostTreeViews = rpcProtocol.set(ExtHostContext.ExtHostTreeViews, new ExtHostTreeViews(rpcProtocol.getProxy(MainContext.MainThreadTreeViews), extHostCommands, extHostLogService)); - const extHostEditorInsets = rpcProtocol.set(ExtHostContext.ExtHostEditorInsets, new ExtHostEditorInsets(rpcProtocol.getProxy(MainContext.MainThreadEditorInsets), extHostEditors, { ...initData.environment, remote: initData.remote })); + const extHostEditorInsets = rpcProtocol.set(ExtHostContext.ExtHostEditorInsets, new ExtHostEditorInsets(rpcProtocol.getProxy(MainContext.MainThreadEditorInsets), extHostEditors, initData)); const extHostDiagnostics = rpcProtocol.set(ExtHostContext.ExtHostDiagnostics, new ExtHostDiagnostics(rpcProtocol, extHostLogService)); const extHostLanguageFeatures = rpcProtocol.set(ExtHostContext.ExtHostLanguageFeatures, new ExtHostLanguageFeatures(rpcProtocol, uriTransformer, extHostDocuments, extHostCommands, extHostDiagnostics, extHostLogService, extHostApiDeprecation)); const extHostFileSystem = rpcProtocol.set(ExtHostContext.ExtHostFileSystem, new ExtHostFileSystem(rpcProtocol, extHostLanguageFeatures)); @@ -161,7 +163,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); const extHostAuthentication = rpcProtocol.set(ExtHostContext.ExtHostAuthentication, new ExtHostAuthentication(rpcProtocol)); const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands)); - const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, { ...initData.environment, remote: initData.remote }, extHostWorkspace, extHostLogService, extHostApiDeprecation)); + const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, { remote: initData.remote }, extHostWorkspace, extHostLogService, extHostApiDeprecation)); const extHostWebviewPanels = rpcProtocol.set(ExtHostContext.ExtHostWebviewPanels, new ExtHostWebviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace)); const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews, extHostWebviewPanels)); const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); @@ -319,6 +321,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostUrls.createAppUri(uri); } + if (!matchesScheme(uri.scheme, Schemas.http) && !matchesScheme(uri.scheme, Schemas.https)) { + checkProposedApiEnabled(extension); // https://github.com/microsoft/vscode/issues/124263 + } + return extHostWindow.asExternalUri(uri, { allowTunneling: !!initData.remote.authority }); }, get remoteName() { @@ -601,23 +607,21 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I showSaveDialog(options) { return extHostDialogs.showSaveDialog(options); }, - createStatusBarItem(alignmentOrOptions?: vscode.StatusBarAlignment | vscode.StatusBarItemOptions, priority?: number): vscode.StatusBarItem { - let id: string; - let name: string; + createStatusBarItem(alignmentOrId?: vscode.StatusBarAlignment | string, priorityOrAlignment?: number | vscode.StatusBarAlignment, priorityArg?: number): vscode.StatusBarItem { + let id: string | undefined; let alignment: number | undefined; + let priority: number | undefined; - if (alignmentOrOptions && typeof alignmentOrOptions !== 'number') { - id = alignmentOrOptions.id; - name = alignmentOrOptions.name; - alignment = alignmentOrOptions.alignment; - priority = alignmentOrOptions.priority; + if (typeof alignmentOrId === 'string') { + id = alignmentOrId; + alignment = priorityOrAlignment; + priority = priorityArg; } else { - id = extension.identifier.value; - name = nls.localize('extensionLabel', "{0} (Extension)", extension.displayName || extension.name); - alignment = alignmentOrOptions; + alignment = alignmentOrId; + priority = priorityOrAlignment; } - return extHostStatusBar.createStatusBarEntry(id, name, alignment, priority); + return extHostStatusBar.createStatusBarEntry(extension, id, alignment, priority); }, setStatusBarMessage(text: string, timeoutOrThenable?: number | Thenable): vscode.Disposable { return extHostStatusBar.setStatusBarMessage(text, timeoutOrThenable); @@ -646,7 +650,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I if ('pty' in nameOrOptions) { return extHostTerminalService.createExtensionTerminal(nameOrOptions); } - if (nameOrOptions.icon) { + if (nameOrOptions.iconPath) { checkProposedApiEnabled(extension); } return extHostTerminalService.createTerminalFromOptions(nameOrOptions); @@ -1040,9 +1044,18 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I // namespace: notebook const notebook: typeof vscode.notebook = { - openNotebookDocument: (uriComponents) => { + async openNotebookDocument(uriOrOptions?: URI | string, content?: vscode.NotebookData) { checkProposedApiEnabled(extension); - return extHostNotebook.openNotebookDocument(uriComponents); + let uri: URI; + if (URI.isUri(uriOrOptions)) { + uri = uriOrOptions; + await extHostNotebook.openNotebookDocument(uriOrOptions); + } else if (typeof uriOrOptions === 'string') { + uri = URI.revive(await extHostNotebook.createNotebookDocument({ viewType: uriOrOptions, content })); + } else { + throw new Error('Invalid arguments'); + } + return extHostNotebook.getNotebookDocument(uri).apiNotebook; }, get onDidOpenNotebookDocument(): Event { checkProposedApiEnabled(extension); @@ -1076,6 +1089,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension); return extHostNotebook.createNotebookEditorDecorationType(options); }, + createRendererMessaging(rendererId) { + checkProposedApiEnabled(extension); + return extHostNotebookRenderers.createRendererMessaging(rendererId); + }, onDidChangeNotebookDocumentMetadata(listener, thisArgs?, disposables?) { checkProposedApiEnabled(extension); return extHostNotebook.onDidChangeNotebookDocumentMetadata(listener, thisArgs, disposables); @@ -1239,7 +1256,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I InlayHint: extHostTypes.InlayHint, InlayHintKind: extHostTypes.InlayHintKind, RemoteAuthorityResolverError: extHostTypes.RemoteAuthorityResolverError, - RemoteTrustOption: RemoteTrustOption, ResolvedAuthority: extHostTypes.ResolvedAuthority, SourceControlInputBoxValidationType: extHostTypes.SourceControlInputBoxValidationType, ExtensionRuntime: extHostTypes.ExtensionRuntime, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 8356bae2cc6..3bea115021f 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -40,7 +40,7 @@ import { ProvidedPortAttributes, TunnelCreationOptions, TunnelOptions, TunnelPro import { ClassifiedEvent, GDPRClassification, StrictPropertyCheck } from 'vs/platform/telemetry/common/gdprTypings'; import { ITelemetryInfo } from 'vs/platform/telemetry/common/telemetry'; import { IShellLaunchConfig, IShellLaunchConfigDto, ITerminalDimensions, ITerminalEnvironment, ITerminalLaunchError, ITerminalProfile } from 'vs/platform/terminal/common/terminal'; -import { ThemeColor } from 'vs/platform/theme/common/themeService'; +import { ThemeColor, ThemeIcon } from 'vs/platform/theme/common/themeService'; import { IExtensionIdWithVersion } from 'vs/platform/userDataSync/common/extensionsStorageSync'; import { WorkspaceTrustRequestOptions } from 'vs/platform/workspace/common/workspaceTrust'; import { ExtensionActivationReason } from 'vs/workbench/api/common/extHostExtensionActivator'; @@ -74,8 +74,6 @@ export interface IEnvironment { extensionTestsLocationURI?: URI; globalStorageHome: URI; workspaceStorageHome: URI; - webviewResourceRoot: string; - webviewCspSource: string; useHostProxy?: boolean; } @@ -457,7 +455,7 @@ export interface TerminalLaunchConfig { shellArgs?: string[] | string; cwd?: string | UriComponents; env?: ITerminalEnvironment; - icon?: string; + icon?: URI | { light: URI; dark: URI } | ThemeIcon; initialText?: string; waitOnExit?: boolean; strictEnv?: boolean; @@ -889,8 +887,9 @@ export interface MainThreadNotebookEditorsShape extends IDisposable { } export interface MainThreadNotebookDocumentsShape extends IDisposable { - $tryOpenDocument(uriComponents: UriComponents): Promise; - $trySaveDocument(uri: UriComponents): Promise; + $tryCreateNotebook(options: { viewType: string, content?: NotebookDataDto }): Promise; + $tryOpenNotebook(uriComponents: UriComponents): Promise; + $trySaveNotebook(uri: UriComponents): Promise; $applyEdits(resource: UriComponents, edits: IImmediateCellEditOperation[], computeUndoRedo?: boolean): Promise; } @@ -916,6 +915,10 @@ export interface MainThreadNotebookKernelsShape extends IDisposable { $updateNotebookPriority(handle: number, uri: UriComponents, value: number | undefined): void; } +export interface MainThreadNotebookRenderersShape extends IDisposable { + $postMessage(editorId: string, rendererId: string, message: unknown): void; +} + export interface MainThreadUrlsShape extends IDisposable { $registerUriHandler(handle: number, extensionId: ExtensionIdentifier): Promise; $unregisterUriHandler(handle: number): Promise; @@ -1295,6 +1298,7 @@ export type IResolveAuthorityResult = IResolveAuthorityErrorResult | IResolveAut export interface ExtHostExtensionServiceShape { $resolveAuthority(remoteAuthority: string, resolveAttempt: number): Promise; + $getCanonicalURI(remoteAuthority: string, uri: UriComponents): Promise; $startExtensionHost(enabledExtensionIds: ExtensionIdentifier[]): Promise; $extensionTestsExecute(): Promise; $extensionTestsExit(code: number): Promise; @@ -1782,6 +1786,7 @@ export interface IDebugSessionFullDto { id: DebugSessionUUID; type: string; name: string; + parent: DebugSessionUUID | undefined; folderUri: UriComponents | undefined; configuration: IConfig; } @@ -1917,6 +1922,10 @@ export interface ExtHostNotebookShape extends ExtHostNotebookDocumentsAndEditors $notebookToData(handle: number, data: NotebookDataDto, token: CancellationToken): Promise; } +export interface ExtHostNotebookRenderersShape { + $postRendererMessage(editorId: string, rendererId: string, message: unknown): void; +} + export interface ExtHostNotebookDocumentsAndEditorsShape { $acceptDocumentAndEditorsDelta(delta: INotebookDocumentsAndEditorsDelta): void; } @@ -2070,6 +2079,7 @@ export const MainContext = { MainThreadNotebookDocuments: createMainId('MainThreadNotebookDocumentsShape'), MainThreadNotebookEditors: createMainId('MainThreadNotebookEditorsShape'), MainThreadNotebookKernels: createMainId('MainThreadNotebookKernels'), + MainThreadNotebookRenderers: createMainId('MainThreadNotebookRenderers'), MainThreadTheming: createMainId('MainThreadTheming'), MainThreadTunnelService: createMainId('MainThreadTunnelService'), MainThreadTimeline: createMainId('MainThreadTimeline'), @@ -2117,6 +2127,7 @@ export const ExtHostContext = { ExtHosLabelService: createMainId('ExtHostLabelService'), ExtHostNotebook: createMainId('ExtHostNotebook'), ExtHostNotebookKernels: createMainId('ExtHostNotebookKernels'), + ExtHostNotebookRenderers: createMainId('ExtHostNotebookRenderers'), ExtHostTheming: createMainId('ExtHostTheming'), ExtHostTunnelService: createMainId('ExtHostTunnelService'), ExtHostAuthentication: createMainId('ExtHostAuthentication'), diff --git a/src/vs/workbench/api/common/extHostApiCommands.ts b/src/vs/workbench/api/common/extHostApiCommands.ts index d70f0a3f8da..844206bbd72 100644 --- a/src/vs/workbench/api/common/extHostApiCommands.ts +++ b/src/vs/workbench/api/common/extHostApiCommands.ts @@ -14,7 +14,7 @@ import * as search from 'vs/workbench/contrib/search/common/search'; import { ICommandHandlerDescription } from 'vs/platform/commands/common/commands'; import { ApiCommand, ApiCommandArgument, ApiCommandResult, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; import { CustomCodeAction } from 'vs/workbench/api/common/extHostLanguageFeatures'; -import { ICommandsExecutor, RemoveFromRecentlyOpenedAPICommand, OpenIssueReporter, OpenIssueReporterArgs } from './apiCommands'; +import { ICommandsExecutor, RemoveFromRecentlyOpenedAPICommand } from './apiCommands'; import { isFalsyOrEmpty } from 'vs/base/common/arrays'; import { IRange } from 'vs/editor/common/core/range'; import { IPosition } from 'vs/editor/common/core/position'; @@ -456,13 +456,6 @@ export class ExtHostApiCommands { { name: 'path', description: 'Path to remove from recently opened.', constraint: (value: any) => typeof value === 'string' } ] }); - - this._register(OpenIssueReporter.ID, adjustHandler(OpenIssueReporter.execute), { - description: 'Opens the issue reporter with the provided extension id as the selected source', - args: [ - { name: 'extensionId', description: 'extensionId to report an issue on', constraint: (value: unknown) => typeof value === 'string' || (typeof value === 'object' && typeof (value as OpenIssueReporterArgs).extensionId === 'string') } - ] - }); } // --- command impl diff --git a/src/vs/workbench/api/common/extHostCodeInsets.ts b/src/vs/workbench/api/common/extHostCodeInsets.ts index 7db034cd81f..fd7e9e715e5 100644 --- a/src/vs/workbench/api/common/extHostCodeInsets.ts +++ b/src/vs/workbench/api/common/extHostCodeInsets.ts @@ -8,10 +8,9 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { ExtHostTextEditor } from 'vs/workbench/api/common/extHostTextEditor'; import { ExtHostEditors } from 'vs/workbench/api/common/extHostTextEditors'; +import { asWebviewUri, webviewGenericCspSource, WebviewInitData } from 'vs/workbench/api/common/shared/webview'; import type * as vscode from 'vscode'; import { ExtHostEditorInsetsShape, MainThreadEditorInsetsShape } from './extHost.protocol'; -import { asWebviewUri, WebviewInitData } from 'vs/workbench/api/common/shared/webview'; -import { generateUuid } from 'vs/base/common/uuid'; export class ExtHostEditorInsets implements ExtHostEditorInsetsShape { @@ -61,16 +60,15 @@ export class ExtHostEditorInsets implements ExtHostEditorInsetsShape { const webview = new class implements vscode.Webview { - private readonly _uuid = generateUuid(); private _html: string = ''; private _options: vscode.WebviewOptions = Object.create(null); asWebviewUri(resource: vscode.Uri): vscode.Uri { - return asWebviewUri(that._initData, this._uuid, resource); + return asWebviewUri(resource, that._initData.remote); } get cspSource(): string { - return that._initData.webviewCspSource; + return webviewGenericCspSource; } set options(value: vscode.WebviewOptions) { diff --git a/src/vs/workbench/api/common/extHostDebugService.ts b/src/vs/workbench/api/common/extHostDebugService.ts index 7dfe656ec43..b2fd2327d03 100644 --- a/src/vs/workbench/api/common/extHostDebugService.ts +++ b/src/vs/workbench/api/common/extHostDebugService.ts @@ -845,7 +845,8 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E let ds = this._debugSessions.get(dto.id); if (!ds) { const folder = await this.getFolder(dto.folderUri); - ds = new ExtHostDebugSession(this._debugServiceProxy, dto.id, dto.type, dto.name, folder, dto.configuration); + const parent = dto.parent ? this._debugSessions.get(dto.parent) : undefined; + ds = new ExtHostDebugSession(this._debugServiceProxy, dto.id, dto.type, dto.name, folder, dto.configuration, parent); this._debugSessions.set(ds.id, ds); this._debugServiceProxy.$sessionCached(ds.id); } @@ -872,7 +873,8 @@ export class ExtHostDebugSession implements vscode.DebugSession { private _type: string, private _name: string, private _workspaceFolder: vscode.WorkspaceFolder | undefined, - private _configuration: vscode.DebugConfiguration) { + private _configuration: vscode.DebugConfiguration, + private _parentSession: vscode.DebugSession | undefined) { } public get id(): string { @@ -886,12 +888,15 @@ export class ExtHostDebugSession implements vscode.DebugSession { public get name(): string { return this._name; } - public set name(name: string) { this._name = name; this._debugServiceProxy.$setDebugSessionName(this._id, name); } + public get parentSession(): vscode.DebugSession | undefined { + return this._parentSession; + } + _acceptNameChanged(name: string) { this._name = name; } diff --git a/src/vs/workbench/api/common/extHostExtensionService.ts b/src/vs/workbench/api/common/extHostExtensionService.ts index 324f7cf3297..abb761d9dbe 100644 --- a/src/vs/workbench/api/common/extHostExtensionService.ts +++ b/src/vs/workbench/api/common/extHostExtensionService.ts @@ -7,10 +7,10 @@ import * as nls from 'vs/nls'; import * as path from 'vs/base/common/path'; import * as performance from 'vs/base/common/performance'; import { originalFSPath, joinPath } from 'vs/base/common/resources'; -import { Barrier, timeout } from 'vs/base/common/async'; +import { asPromise, Barrier, timeout } from 'vs/base/common/async'; import { dispose, toDisposable, DisposableStore, Disposable } from 'vs/base/common/lifecycle'; import { TernarySearchTree } from 'vs/base/common/map'; -import { URI } from 'vs/base/common/uri'; +import { URI, UriComponents } from 'vs/base/common/uri'; import { ILogService } from 'vs/platform/log/common/log'; import { ExtHostExtensionServiceShape, IInitData, MainContext, MainThreadExtensionServiceShape, MainThreadTelemetryShape, MainThreadWorkspaceShape, IResolveAuthorityResult } from 'vs/workbench/api/common/extHost.protocol'; import { ExtHostConfiguration, IExtHostConfiguration } from 'vs/workbench/api/common/extHostConfiguration'; @@ -631,7 +631,7 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme // -- called by main thread - public async $resolveAuthority(remoteAuthority: string, resolveAttempt: number): Promise { + private async _activateAndGetResolver(remoteAuthority: string): Promise<{ authorityPrefix: string; resolver: vscode.RemoteAuthorityResolver | undefined; }> { const authorityPlusIndex = remoteAuthority.indexOf('+'); if (authorityPlusIndex === -1) { throw new Error(`Not an authority that can be resolved!`); @@ -641,7 +641,12 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme await this._almostReadyToRunExtensions.wait(); await this._activateByEvent(`onResolveRemoteAuthority:${authorityPrefix}`, false); - const resolver = this._resolvers[authorityPrefix]; + return { authorityPrefix, resolver: this._resolvers[authorityPrefix] }; + } + + public async $resolveAuthority(remoteAuthority: string, resolveAttempt: number): Promise { + + const { authorityPrefix, resolver } = await this._activateAndGetResolver(remoteAuthority); if (!resolver) { return { type: 'error', @@ -668,7 +673,7 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme }; const options: ResolvedOptions = { extensionHostEnv: result.extensionHostEnv, - trust: result.trust + isTrusted: result.isTrusted }; return { @@ -695,6 +700,28 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme } } + public async $getCanonicalURI(remoteAuthority: string, uriComponents: UriComponents): Promise { + + const { authorityPrefix, resolver } = await this._activateAndGetResolver(remoteAuthority); + if (!resolver) { + throw new Error(`Cannot get canonical URI because no remote extension is installed to resolve ${authorityPrefix}`); + } + + const uri = URI.revive(uriComponents); + + if (typeof resolver.getCanonicalURI === 'undefined') { + // resolver cannot compute canonical URI + return uri; + } + + const result = await asPromise(() => resolver.getCanonicalURI!(uri)); + if (!result) { + return uri; + } + + return result; + } + public $startExtensionHost(enabledExtensionIds: ExtensionIdentifier[]): Promise { this._registry.keepOnly(enabledExtensionIds); return this._startExtensionHost(); diff --git a/src/vs/workbench/api/common/extHostMemento.ts b/src/vs/workbench/api/common/extHostMemento.ts index 31c1676e696..e4f029c0b12 100644 --- a/src/vs/workbench/api/common/extHostMemento.ts +++ b/src/vs/workbench/api/common/extHostMemento.ts @@ -56,6 +56,11 @@ export class ExtensionMemento implements vscode.Memento { }, 0); } + get keys(): readonly string[] { + // Filter out `undefined` values, as they can stick around in the `_value` until the `onDidChangeStorage` event runs + return Object.entries(this._value ?? {}).filter(([, value]) => value !== undefined).map(([key]) => key); + } + get whenReady(): Promise { return this._init; } diff --git a/src/vs/workbench/api/common/extHostNotebook.ts b/src/vs/workbench/api/common/extHostNotebook.ts index e7956730308..1d2e22cd8ec 100644 --- a/src/vs/workbench/api/common/extHostNotebook.ts +++ b/src/vs/workbench/api/common/extHostNotebook.ts @@ -254,12 +254,20 @@ export class ExtHostNotebookController implements ExtHostNotebookShape { return new NotebookEditorDecorationType(this._notebookEditorsProxy, options).value; } + async createNotebookDocument(options: { viewType: string, content?: vscode.NotebookData }): Promise { + const canonicalUri = await this._notebookDocumentsProxy.$tryCreateNotebook({ + viewType: options.viewType, + content: options.content && typeConverters.NotebookData.from(options.content) + }); + return URI.revive(canonicalUri); + } + async openNotebookDocument(uri: URI): Promise { const cached = this._documents.get(uri); if (cached) { return cached.apiNotebook; } - const canonicalUri = await this._notebookDocumentsProxy.$tryOpenDocument(uri); + const canonicalUri = await this._notebookDocumentsProxy.$tryOpenNotebook(uri); const document = this._documents.get(URI.revive(canonicalUri)); return assertIsDefined(document?.apiNotebook); } @@ -358,19 +366,8 @@ export class ExtHostNotebookController implements ExtHostNotebookShape { if (!serializer) { throw new Error('NO serializer found'); } - const data = await serializer.deserializeNotebook(bytes.buffer, token); - const res: NotebookDataDto = { - metadata: typeConverters.NotebookDocumentMetadata.from(data.metadata), - cells: [], - }; - - for (let cell of data.cells) { - extHostTypes.NotebookCellData.validate(cell); - res.cells.push(typeConverters.NotebookCellData.from(cell)); - } - - return res; + return typeConverters.NotebookData.from(data); } async $notebookToData(handle: number, data: NotebookDataDto, token: CancellationToken): Promise { @@ -378,10 +375,7 @@ export class ExtHostNotebookController implements ExtHostNotebookShape { if (!serializer) { throw new Error('NO serializer found'); } - const bytes = await serializer.serializeNotebook({ - metadata: typeConverters.NotebookDocumentMetadata.to(data.metadata), - cells: data.cells.map(typeConverters.NotebookCellData.to) - }, token); + const bytes = await serializer.serializeNotebook(typeConverters.NotebookData.to(data), token); return VSBuffer.wrap(bytes); } diff --git a/src/vs/workbench/api/common/extHostNotebookDocument.ts b/src/vs/workbench/api/common/extHostNotebookDocument.ts index 131569c1f74..7fd979ad543 100644 --- a/src/vs/workbench/api/common/extHostNotebookDocument.ts +++ b/src/vs/workbench/api/common/extHostNotebookDocument.ts @@ -258,7 +258,7 @@ export class ExtHostNotebookDocument { if (this._disposed) { return Promise.reject(new Error('Notebook has been closed')); } - return this._proxy.$trySaveDocument(this.uri); + return this._proxy.$trySaveNotebook(this.uri); } private _spliceNotebookCells(splices: NotebookCellsSplice2[], initialization: boolean): void { diff --git a/src/vs/workbench/api/common/extHostNotebookEditor.ts b/src/vs/workbench/api/common/extHostNotebookEditor.ts index 30f8c6d52e4..9804ba2be5b 100644 --- a/src/vs/workbench/api/common/extHostNotebookEditor.ts +++ b/src/vs/workbench/api/common/extHostNotebookEditor.ts @@ -73,6 +73,8 @@ class NotebookEditorCellEditBuilder implements vscode.NotebookEditorEdit { export class ExtHostNotebookEditor { + public static readonly apiEditorsToExtHost = new WeakMap(); + private _selections: vscode.NotebookRange[] = []; private _visibleRanges: vscode.NotebookRange[] = []; private _viewColumn?: vscode.ViewColumn; @@ -127,6 +129,8 @@ export class ExtHostNotebookEditor { return that.setDecorations(decorationType, range); } }; + + ExtHostNotebookEditor.apiEditorsToExtHost.set(this._editor, this); } return this._editor; } diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index f95d887f8f2..6eb1e1f6e53 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -190,7 +190,7 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { return that._proxy.$postMessage(handle, editor && that._extHostNotebook.getIdByEditor(editor), message); }, asWebviewUri(uri: URI) { - return asWebviewUri({ ...that._initData.environment, remote: that._initData.remote }, String(handle), uri); + return asWebviewUri(uri, that._initData.remote); }, // --- priority updateNotebookAffinity(notebook, priority) { diff --git a/src/vs/workbench/api/common/extHostNotebookRenderers.ts b/src/vs/workbench/api/common/extHostNotebookRenderers.ts new file mode 100644 index 00000000000..fcf5eb9cfd5 --- /dev/null +++ b/src/vs/workbench/api/common/extHostNotebookRenderers.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; +import { ExtHostNotebookRenderersShape, IMainContext, MainContext, MainThreadNotebookRenderersShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebook'; +import { ExtHostNotebookEditor } from 'vs/workbench/api/common/extHostNotebookEditor'; +import * as vscode from 'vscode'; + +export class ExtHostNotebookRenderers implements ExtHostNotebookRenderersShape { + private readonly _rendererMessageEmitters = new Map>>(); + private readonly proxy: MainThreadNotebookRenderersShape; + + constructor(mainContext: IMainContext, private readonly _extHostNotebook: ExtHostNotebookController) { + this.proxy = mainContext.getProxy(MainContext.MainThreadNotebookRenderers); + } + + public $postRendererMessage(editorId: string, rendererId: string, message: unknown): void { + const editor = this._extHostNotebook.getEditorById(editorId); + if (!editor) { + return; + } + + this._rendererMessageEmitters.get(rendererId)?.fire({ editor: editor.apiEditor, message }); + } + + public createRendererMessaging(rendererId: string): vscode.NotebookRendererMessaging { + const messaging: vscode.NotebookRendererMessaging = { + onDidReceiveMessage: (...args) => + this.getOrCreateEmitterFor(rendererId).event(...args), + postMessage: (editor, message) => { + const extHostEditor = ExtHostNotebookEditor.apiEditorsToExtHost.get(editor); + if (!extHostEditor) { + throw new Error(`The first argument to postMessage() must be a NotebookEditor`); + } + + this.proxy.$postMessage(extHostEditor.id, rendererId, message); + }, + }; + + return messaging; + } + + private getOrCreateEmitterFor(rendererId: string) { + let emitter = this._rendererMessageEmitters.get(rendererId); + if (emitter) { + return emitter; + } + + emitter = new Emitter({ + onLastListenerRemove: () => { + emitter?.dispose(); + this._rendererMessageEmitters.delete(rendererId); + } + }); + + this._rendererMessageEmitters.set(rendererId, emitter); + + return emitter; + } +} diff --git a/src/vs/workbench/api/common/extHostStatusBar.ts b/src/vs/workbench/api/common/extHostStatusBar.ts index 9c875cdc084..f85f182011e 100644 --- a/src/vs/workbench/api/common/extHostStatusBar.ts +++ b/src/vs/workbench/api/common/extHostStatusBar.ts @@ -10,8 +10,10 @@ import { MainContext, MainThreadStatusBarShape, IMainContext, ICommandDto } from import { localize } from 'vs/nls'; import { CommandsConverter } from 'vs/workbench/api/common/extHostCommands'; import { DisposableStore } from 'vs/base/common/lifecycle'; +import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; export class ExtHostStatusBarEntry implements vscode.StatusBarItem { + private static ID_GEN = 0; private static ALLOWED_BACKGROUND_COLORS = new Map( @@ -21,17 +23,20 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { #proxy: MainThreadStatusBarShape; #commands: CommandsConverter; - private _id: number; + private _entryId: number; + + private _extension?: IExtensionDescription; + + private _id?: string; private _alignment: number; private _priority?: number; + private _disposed: boolean = false; private _visible: boolean = false; - private _statusId: string; - private _statusName: string; - private _text: string = ''; private _tooltip?: string; + private _name?: string; private _color?: string | ThemeColor; private _backgroundColor?: ThemeColor; private readonly _internalCommandRegistration = new DisposableStore(); @@ -43,19 +48,23 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { private _timeoutHandle: any; private _accessibilityInformation?: vscode.AccessibilityInformation; - constructor(proxy: MainThreadStatusBarShape, commands: CommandsConverter, id: string, name: string, alignment: ExtHostStatusBarAlignment = ExtHostStatusBarAlignment.Left, priority?: number) { + constructor(proxy: MainThreadStatusBarShape, commands: CommandsConverter, extension: IExtensionDescription, id?: string, alignment?: ExtHostStatusBarAlignment, priority?: number); + constructor(proxy: MainThreadStatusBarShape, commands: CommandsConverter, extension: IExtensionDescription | undefined, id: string, alignment?: ExtHostStatusBarAlignment, priority?: number); + constructor(proxy: MainThreadStatusBarShape, commands: CommandsConverter, extension?: IExtensionDescription, id?: string, alignment: ExtHostStatusBarAlignment = ExtHostStatusBarAlignment.Left, priority?: number) { this.#proxy = proxy; this.#commands = commands; - this._id = ExtHostStatusBarEntry.ID_GEN++; - this._statusId = id; - this._statusName = name; + this._entryId = ExtHostStatusBarEntry.ID_GEN++; + + this._extension = extension; + + this._id = id; this._alignment = alignment; this._priority = priority; } - public get id(): number { - return this._id; + public get id(): string { + return this._id ?? this._extension!.identifier.value; } public get alignment(): vscode.StatusBarAlignment { @@ -70,6 +79,10 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { return this._text; } + public get name(): string | undefined { + return this._name; + } + public get tooltip(): string | undefined { return this._tooltip; } @@ -95,6 +108,11 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { this.update(); } + public set name(name: string | undefined) { + this._name = name; + this.update(); + } + public set tooltip(tooltip: string | undefined) { this._tooltip = tooltip; this.update(); @@ -149,7 +167,7 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { public hide(): void { clearTimeout(this._timeoutHandle); this._visible = false; - this.#proxy.$dispose(this.id); + this.#proxy.$dispose(this._entryId); } private update(): void { @@ -163,6 +181,28 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { this._timeoutHandle = setTimeout(() => { this._timeoutHandle = undefined; + // If the id is not set, derive it from the extension identifier, + // otherwise make sure to prefix it with the extension identifier + // to get a more unique value across extensions. + let id: string; + if (this._extension) { + if (this._id) { + id = `${this._extension.identifier.value}.${this._id}`; + } else { + id = this._extension.identifier.value; + } + } else { + id = this._id!; + } + + // If the name is not set, derive it from the extension descriptor + let name: string; + if (this._name) { + name = this._name; + } else { + name = localize('extensionLabel', "{0} (Extension)", this._extension!.displayName || this._extension!.name); + } + // If a background color is set, the foreground is determined let color = this._color; if (this._backgroundColor) { @@ -170,7 +210,7 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { } // Set to status bar - this.#proxy.$setEntry(this.id, this._statusId, this._statusName, this._text, this._tooltip, this._command?.internal, color, + this.#proxy.$setEntry(this._entryId, id, name, this._text, this._tooltip, this._command?.internal, color, this._backgroundColor, this._alignment === ExtHostStatusBarAlignment.Left ? MainThreadStatusBarAlignment.LEFT : MainThreadStatusBarAlignment.RIGHT, this._priority, this._accessibilityInformation); }, 0); @@ -188,7 +228,8 @@ class StatusBarMessage { private _messages: { message: string }[] = []; constructor(statusBar: ExtHostStatusBar) { - this._item = statusBar.createStatusBarEntry('status.extensionMessage', localize('status.extensionMessage', "Extension Status"), ExtHostStatusBarAlignment.Left, Number.MIN_VALUE); + this._item = statusBar.createStatusBarEntry(undefined, 'status.extensionMessage', ExtHostStatusBarAlignment.Left, Number.MIN_VALUE); + this._item.name = localize('status.extensionMessage', "Extension Status"); } dispose() { @@ -232,12 +273,13 @@ export class ExtHostStatusBar { this._statusMessage = new StatusBarMessage(this); } - createStatusBarEntry(id: string, name: string, alignment?: ExtHostStatusBarAlignment, priority?: number): vscode.StatusBarItem { - return new ExtHostStatusBarEntry(this._proxy, this._commands, id, name, alignment, priority); + createStatusBarEntry(extension: IExtensionDescription | undefined, id: string, alignment?: ExtHostStatusBarAlignment, priority?: number): vscode.StatusBarItem; + createStatusBarEntry(extension: IExtensionDescription, id?: string, alignment?: ExtHostStatusBarAlignment, priority?: number): vscode.StatusBarItem; + createStatusBarEntry(extension: IExtensionDescription, id: string, alignment?: ExtHostStatusBarAlignment, priority?: number): vscode.StatusBarItem { + return new ExtHostStatusBarEntry(this._proxy, this._commands, extension, id, alignment, priority); } setStatusBarMessage(text: string, timeoutOrThenable?: number | Thenable): Disposable { - const d = this._statusMessage.setMessage(text); let handle: any; diff --git a/src/vs/workbench/api/common/extHostTerminalService.ts b/src/vs/workbench/api/common/extHostTerminalService.ts index 25b677b7041..be6ce95e65f 100644 --- a/src/vs/workbench/api/common/extHostTerminalService.ts +++ b/src/vs/workbench/api/common/extHostTerminalService.ts @@ -20,6 +20,7 @@ import { generateUuid } from 'vs/base/common/uuid'; import { ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable'; import { IProcessReadyEvent, IShellLaunchConfigDto, ITerminalChildProcess, ITerminalDimensionsOverride, ITerminalEnvironment, ITerminalLaunchError, ITerminalProfile, TerminalShellType } from 'vs/platform/terminal/common/terminal'; import { TerminalDataBufferer } from 'vs/platform/terminal/common/terminalDataBuffering'; +import { ThemeIcon } from 'vs/platform/theme/common/themeService'; export interface IExtHostTerminalService extends ExtHostTerminalServiceShape, IDisposable { @@ -116,7 +117,7 @@ export class ExtHostTerminal { shellArgs?: string[] | string, cwd?: string | URI, env?: ITerminalEnvironment, - icon?: string, + icon?: URI | { light: URI; dark: URI } | ThemeIcon, initialText?: string, waitOnExit?: boolean, strictEnv?: boolean, @@ -130,11 +131,11 @@ export class ExtHostTerminal { await this._proxy.$createTerminal(this._id, { name: this._name, shellPath, shellArgs, cwd, env, icon, initialText, waitOnExit, strictEnv, hideFromUser, isFeatureTerminal, isExtensionOwnedTerminal }); } - public async createExtensionTerminal(): Promise { + public async createExtensionTerminal(iconPath?: URI | { light: URI; dark: URI } | ThemeIcon): Promise { if (typeof this._id !== 'string') { throw new Error('Terminal has already been created'); } - await this._proxy.$createTerminal(this._id, { name: this._name, isExtensionCustomPtyTerminal: true }); + await this._proxy.$createTerminal(this._id, { name: this._name, isExtensionCustomPtyTerminal: true, icon: iconPath }); // At this point, the id has been set via `$acceptTerminalOpened` if (typeof this._id === 'string') { throw new Error('Terminal creation failed'); @@ -348,7 +349,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I public createExtensionTerminal(options: vscode.ExtensionTerminalOptions): vscode.Terminal { const terminal = new ExtHostTerminal(this._proxy, generateUuid(), options, options.name); const p = new ExtHostPseudoterminal(options.pty); - terminal.createExtensionTerminal().then(id => { + terminal.createExtensionTerminal(options.iconPath).then(id => { const disposable = this._setupExtHostProcessListeners(id, p); this._terminalProcessDisposables[id] = disposable; }); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 72eccc15c02..9aaec755c6b 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -1485,6 +1485,28 @@ export namespace NotebookCellKind { } } +export namespace NotebookData { + + export function from(data: vscode.NotebookData): notebooks.NotebookDataDto { + const res: notebooks.NotebookDataDto = { + metadata: NotebookDocumentMetadata.from(data.metadata), + cells: [], + }; + for (let cell of data.cells) { + types.NotebookCellData.validate(cell); + res.cells.push(NotebookCellData.from(cell)); + } + return res; + } + + export function to(data: notebooks.NotebookDataDto): vscode.NotebookData { + return { + metadata: NotebookDocumentMetadata.to(data.metadata), + cells: data.cells.map(NotebookCellData.to) + }; + } +} + export namespace NotebookCellData { export function from(data: vscode.NotebookCellData): notebooks.ICellDto2 { diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index d7cd5f11644..9de76732f60 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -12,7 +12,7 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IExtHostApiDeprecationService } from 'vs/workbench/api/common/extHostApiDeprecationService'; import { serializeWebviewMessage, deserializeWebviewMessage } from 'vs/workbench/api/common/extHostWebviewMessaging'; import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace'; -import { asWebviewUri, WebviewInitData } from 'vs/workbench/api/common/shared/webview'; +import { asWebviewUri, webviewGenericCspSource, WebviewInitData } from 'vs/workbench/api/common/shared/webview'; import type * as vscode from 'vscode'; import * as extHostProtocol from './extHost.protocol'; @@ -69,12 +69,11 @@ export class ExtHostWebview implements vscode.Webview { public asWebviewUri(resource: vscode.Uri): vscode.Uri { this.#hasCalledAsWebviewUri = true; - return asWebviewUri(this.#initData, this.#handle, resource); + return asWebviewUri(resource, this.#initData.remote); } public get cspSource(): string { - return this.#initData.webviewCspSource - .replace('{{uuid}}', this.#handle); + return webviewGenericCspSource; } public get html(): string { diff --git a/src/vs/workbench/api/common/extHostWindow.ts b/src/vs/workbench/api/common/extHostWindow.ts index a8c05964b01..46e6191675f 100644 --- a/src/vs/workbench/api/common/extHostWindow.ts +++ b/src/vs/workbench/api/common/extHostWindow.ts @@ -61,8 +61,6 @@ export class ExtHostWindow implements ExtHostWindowShape { async asExternalUri(uri: URI, options: IOpenUriOptions): Promise { if (isFalsyOrWhitespace(uri.scheme)) { return Promise.reject('Invalid scheme - cannot be empty'); - } else if (!new Set([Schemas.http, Schemas.https]).has(uri.scheme)) { - return Promise.reject(`Invalid scheme '${uri.scheme}'`); } const result = await this._proxy.$asExternalUri(uri, options); diff --git a/src/vs/workbench/api/common/menusExtensionPoint.ts b/src/vs/workbench/api/common/menusExtensionPoint.ts index 3046d981ff3..71aa38a9fee 100644 --- a/src/vs/workbench/api/common/menusExtensionPoint.ts +++ b/src/vs/workbench/api/common/menusExtensionPoint.ts @@ -182,6 +182,12 @@ const apiMenus: IAPIMenu[] = [ description: localize('notebook.toolbar', "The contributed notebook toolbar menu"), proposed: true }, + { + key: 'notebook/toolbar/right', + id: MenuId.NotebookRightToolbar, + description: localize('notebook.toolbar.right', "The contributed notebook right toolbar menu"), + proposed: true + }, { key: 'notebook/cell/title', id: MenuId.NotebookCellTitle, @@ -432,6 +438,7 @@ namespace schema { export interface IUserFriendlyCommand { command: string; title: string | ILocalizedString; + shortTitle?: string | ILocalizedString; enablement?: string; category?: string | ILocalizedString; icon?: IUserFriendlyIcon; @@ -451,6 +458,9 @@ namespace schema { if (!isValidLocalizedString(command.title, collector, 'title')) { return false; } + if (command.shortTitle && !isValidLocalizedString(command.shortTitle, collector, 'shortTitle')) { + return false; + } if (command.enablement && typeof command.enablement !== 'string') { collector.error(localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'precondition')); return false; @@ -504,6 +514,10 @@ namespace schema { description: localize('vscode.extension.contributes.commandType.title', 'Title by which the command is represented in the UI'), type: 'string' }, + shortTitle: { + description: localize('vscode.extension.contributes.commandType.shortTitle', 'Short title by which the command is represented in the UI'), + type: 'string' + }, category: { description: localize('vscode.extension.contributes.commandType.category', '(Optional) Category string by the command is grouped in the UI'), type: 'string' @@ -561,7 +575,7 @@ commandsExtensionPoint.setHandler(extensions => { return; } - const { icon, enablement, category, title, command } = userFriendlyCommand; + const { icon, enablement, category, title, shortTitle, command } = userFriendlyCommand; let absoluteIcon: { dark: URI; light?: URI; } | ThemeIcon | undefined; if (icon) { @@ -582,6 +596,7 @@ commandsExtensionPoint.setHandler(extensions => { bucket.push({ id: command, title, + shortTitle: extension.description.enableProposedApi ? shortTitle : undefined, category, precondition: ContextKeyExpr.deserialize(enablement), icon: absoluteIcon diff --git a/src/vs/workbench/api/common/shared/webview.ts b/src/vs/workbench/api/common/shared/webview.ts index f1de57db839..e65663eace3 100644 --- a/src/vs/workbench/api/common/shared/webview.ts +++ b/src/vs/workbench/api/common/shared/webview.ts @@ -3,16 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Schemas } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import type * as vscode from 'vscode'; export interface WebviewInitData { - readonly isExtensionDevelopmentDebug: boolean; - readonly webviewResourceRoot: string; - readonly webviewCspSource: string; - readonly remote: { readonly authority: string | undefined }; + readonly remote: { + readonly isRemote: boolean; + readonly authority: string | undefined + }; } +/** + * Root from which resources in webviews are loaded. + * + * This is hardcoded because we never expect to actually hit it. Instead these requests + * should always go to a service worker. + */ +export const webviewResourceBaseHost = 'vscode-webview.net'; + +export const webviewRootResourceAuthority = `vscode-resource.${webviewResourceBaseHost}`; + +export const webviewGenericCspSource = `https://*.${webviewResourceBaseHost}`; + /** * Construct a uri that can load resources inside a webview * @@ -20,18 +33,32 @@ export interface WebviewInitData { * we know where to load the resource from (remote or truly local): * * ```txt - * /remote-authority?/scheme/resource-authority/path... + * ${scheme}+${resource-authority}.vscode-resource.vscode-webview.net/${path} * ``` + * + * @param resource Uri of the resource to load. + * @param remoteInfo Optional information about the remote that specifies where `resource` should be resolved from. */ export function asWebviewUri( - initData: WebviewInitData, - uuid: string, resource: vscode.Uri, + remoteInfo?: { authority: string | undefined, isRemote: boolean } ): vscode.Uri { - const uri = initData.webviewResourceRoot - .replace('{{resource}}', (initData.remote.authority ?? '') + '/' + resource.scheme + '/' + encodeURIComponent(resource.authority) + resource.path) - .replace('{{uuid}}', uuid); - return URI.parse(uri).with({ + if (resource.scheme === Schemas.http || resource.scheme === Schemas.https) { + return resource; + } + + if (remoteInfo && remoteInfo.authority && remoteInfo.isRemote && resource.scheme === Schemas.file) { + resource = URI.from({ + scheme: Schemas.vscodeRemote, + authority: remoteInfo.authority, + path: resource.path, + }); + } + + return URI.from({ + scheme: Schemas.https, + authority: `${resource.scheme}+${encodeURIComponent(resource.authority)}.${webviewRootResourceAuthority}`, + path: resource.path, fragment: resource.fragment, query: resource.query, }); diff --git a/src/vs/workbench/api/node/extHostOutputService.ts b/src/vs/workbench/api/node/extHostOutputService.ts index 8daae98da4e..9935633b766 100644 --- a/src/vs/workbench/api/node/extHostOutputService.ts +++ b/src/vs/workbench/api/node/extHostOutputService.ts @@ -8,8 +8,7 @@ import type * as vscode from 'vscode'; import { URI } from 'vs/base/common/uri'; import { join } from 'vs/base/common/path'; import { toLocalISOString } from 'vs/base/common/date'; -import { SymlinkSupport } from 'vs/base/node/pfs'; -import { promises } from 'fs'; +import { Promises, SymlinkSupport } from 'vs/base/node/pfs'; import { AbstractExtHostOutputChannel, ExtHostPushOutputChannel, ExtHostOutputService, LazyOutputChannel } from 'vs/workbench/api/common/extHostOutput'; import { IExtHostInitDataService } from 'vs/workbench/api/common/extHostInitDataService'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; @@ -32,7 +31,6 @@ class OutputAppender { append(content: string): void { this.appender.critical(content); - this.flush(); } flush(): void { @@ -112,7 +110,7 @@ export class ExtHostOutputService2 extends ExtHostOutputService { const outputDirPath = join(this._logsLocation.fsPath, `output_logging_${toLocalISOString(new Date()).replace(/-|:|\.\d+Z$/g, '')}`); const exists = await SymlinkSupport.existsDirectory(outputDirPath); if (!exists) { - await promises.mkdir(outputDirPath, { recursive: true }); + await Promises.mkdir(outputDirPath, { recursive: true }); } const fileName = `${this._namePool++}-${name.replace(/[\\/:\*\?"<>\|]/g, '')}`; const file = URI.file(join(outputDirPath, `${fileName}.log`)); diff --git a/src/vs/workbench/api/node/extHostTerminalService.ts b/src/vs/workbench/api/node/extHostTerminalService.ts index 91043450d7e..8173c051ff2 100644 --- a/src/vs/workbench/api/node/extHostTerminalService.ts +++ b/src/vs/workbench/api/node/extHostTerminalService.ts @@ -32,7 +32,7 @@ export class ExtHostTerminalService extends BaseExtHostTerminalService { withNullAsUndefined(options.shellArgs), withNullAsUndefined(options.cwd), withNullAsUndefined(options.env), - withNullAsUndefined(options.icon), + withNullAsUndefined(options.iconPath), withNullAsUndefined(options.message), /*options.waitOnExit*/ undefined, withNullAsUndefined(options.strictEnv), diff --git a/src/vs/workbench/api/node/extHostTunnelService.ts b/src/vs/workbench/api/node/extHostTunnelService.ts index a33f7851f34..caefe078c1a 100644 --- a/src/vs/workbench/api/node/extHostTunnelService.ts +++ b/src/vs/workbench/api/node/extHostTunnelService.ts @@ -11,7 +11,6 @@ import { IExtHostInitDataService } from 'vs/workbench/api/common/extHostInitData import { URI } from 'vs/base/common/uri'; import { exec } from 'child_process'; import * as resources from 'vs/base/common/resources'; -import * as fs from 'fs'; import * as pfs from 'vs/base/node/pfs'; import * as types from 'vs/workbench/api/common/extHostTypes'; import { isLinux } from 'vs/base/common/platform'; @@ -365,8 +364,8 @@ export class ExtHostTunnelService extends Disposable implements IExtHostTunnelSe let tcp: string = ''; let tcp6: string = ''; try { - tcp = await fs.promises.readFile('/proc/net/tcp', 'utf8'); - tcp6 = await fs.promises.readFile('/proc/net/tcp6', 'utf8'); + tcp = await pfs.Promises.readFile('/proc/net/tcp', 'utf8'); + tcp6 = await pfs.Promises.readFile('/proc/net/tcp6', 'utf8'); } catch (e) { // File reading error. No additional handling needed. } @@ -387,10 +386,10 @@ export class ExtHostTunnelService extends Disposable implements IExtHostTunnelSe try { const pid: number = Number(childName); const childUri = resources.joinPath(URI.file('/proc'), childName); - const childStat = await fs.promises.stat(childUri.fsPath); + const childStat = await pfs.Promises.stat(childUri.fsPath); if (childStat.isDirectory() && !isNaN(pid)) { - const cwd = await fs.promises.readlink(resources.joinPath(childUri, 'cwd').fsPath); - const cmd = await fs.promises.readFile(resources.joinPath(childUri, 'cmdline').fsPath, 'utf8'); + const cwd = await pfs.Promises.readlink(resources.joinPath(childUri, 'cwd').fsPath); + const cmd = await pfs.Promises.readFile(resources.joinPath(childUri, 'cmdline').fsPath, 'utf8'); processes.push({ pid, cwd, cmd }); } } catch (e) { diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index 441bb0124d9..17c55ea7b13 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -44,12 +44,12 @@ interface ISerializedDraggedResource { export class DraggedEditorIdentifier { - constructor(public readonly identifier: IEditorIdentifier) { } + constructor(readonly identifier: IEditorIdentifier) { } } export class DraggedEditorGroupIdentifier { - constructor(public readonly identifier: GroupIdentifier) { } + constructor(readonly identifier: GroupIdentifier) { } } interface IDraggedEditorProps { diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index e199ec8a9b0..651f3cf61f5 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -1366,6 +1366,10 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.workbenchGrid.setViewVisible(this.activityBarPartView, !hidden); } + setBannerHidden(hidden: boolean): void { + this.workbenchGrid.setViewVisible(this.bannerPartView, !hidden); + } + setEditorHidden(hidden: boolean, skipLayout?: boolean): void { this.state.editor.hidden = hidden; @@ -1806,7 +1810,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi { type: 'leaf', data: { type: Parts.BANNER_PART }, - size: bannerHeight + size: bannerHeight, + visible: false }, { type: 'branch', diff --git a/src/vs/workbench/browser/parts/banner/bannerPart.ts b/src/vs/workbench/browser/parts/banner/bannerPart.ts index 1665bb78406..0a9e336bb29 100644 --- a/src/vs/workbench/browser/parts/banner/bannerPart.ts +++ b/src/vs/workbench/browser/parts/banner/bannerPart.ts @@ -10,7 +10,7 @@ import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Codicon, registerCodicon } from 'vs/base/common/codicons'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; -import { IStorageService, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IStorageService } from 'vs/platform/storage/common/storage'; import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { Part } from 'vs/workbench/browser/part'; import { IWorkbenchLayoutService, Parts } from 'vs/workbench/services/layout/browser/layoutService'; @@ -96,9 +96,9 @@ export class BannerPart extends Part implements IBannerService { constructor( @IThemeService themeService: IThemeService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IStorageService storageService: IStorageService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IStorageService private readonly storageService: IStorageService, ) { super(Parts.BANNER_PART, { hasTitle: false }, themeService, storageService, layoutService); @@ -131,8 +131,8 @@ export class BannerPart extends Part implements IBannerService { clearNode(this.element); // Remember choice - if (item.scope) { - this.storageService.store(item.id, true, item.scope, StorageTarget.USER); + if (typeof item.onClose === 'function') { + item.onClose(); } this.item = undefined; @@ -178,6 +178,7 @@ export class BannerPart extends Part implements IBannerService { this.visible = visible; this.focusedActionIndex = -1; + this.layoutService.setBannerHidden(!visible); this._onDidChangeSize.fire(undefined); } } @@ -210,10 +211,6 @@ export class BannerPart extends Part implements IBannerService { } show(item: IBannerItem): void { - if (item.scope && this.storageService.getBoolean(item.id, item.scope, false)) { - return; - } - if (item.id === this.item?.id) { this.setVisibility(true); return; @@ -288,6 +285,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ id: 'workbench.banner.focusNextAction', weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.RightArrow, + secondary: [KeyCode.DownArrow], when: CONTEXT_BANNER_FOCUSED, handler: (accessor: ServicesAccessor) => { const bannerService = accessor.get(IBannerService); @@ -299,6 +297,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ id: 'workbench.banner.focusPreviousAction', weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.LeftArrow, + secondary: [KeyCode.UpArrow], when: CONTEXT_BANNER_FOCUSED, handler: (accessor: ServicesAccessor) => { const bannerService = accessor.get(IBannerService); diff --git a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts index b3a6d19de50..dd119d85c00 100644 --- a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts +++ b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts @@ -343,7 +343,7 @@ class DropOverlay extends Themable { targetGroup = ensureTargetGroup(); } - await targetGroup.openEditor(untitledTextEditor); + await this.editorService.openEditor(untitledTextEditor, undefined, targetGroup.id); } }; } diff --git a/src/vs/workbench/browser/parts/editor/editorPane.ts b/src/vs/workbench/browser/parts/editor/editorPane.ts index 081a0a8ae31..5af2b24f169 100644 --- a/src/vs/workbench/browser/parts/editor/editorPane.ts +++ b/src/vs/workbench/browser/parts/editor/editorPane.ts @@ -193,7 +193,7 @@ export class EditorMemento implements IEditorMemento { private editorDisposables: Map | undefined; constructor( - public readonly id: string, + readonly id: string, private key: string, private memento: MementoObject, private limit: number, diff --git a/src/vs/workbench/browser/parts/editor/editorStatus.ts b/src/vs/workbench/browser/parts/editor/editorStatus.ts index 4c2e352c455..75d2c9bc6a4 100644 --- a/src/vs/workbench/browser/parts/editor/editorStatus.ts +++ b/src/vs/workbench/browser/parts/editor/editorStatus.ts @@ -404,13 +404,14 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { if (!this.tabFocusModeElement.value) { const text = localize('tabFocusModeEnabled', "Tab Moves Focus"); this.tabFocusModeElement.value = this.statusbarService.addEntry({ + name: localize('status.editor.tabFocusMode', "Accessibility Mode"), text, ariaLabel: text, tooltip: localize('disableTabMode', "Disable Accessibility Mode"), command: 'editor.action.toggleTabFocusMode', backgroundColor: themeColorFromId(STATUS_BAR_PROMINENT_ITEM_BACKGROUND), color: themeColorFromId(STATUS_BAR_PROMINENT_ITEM_FOREGROUND) - }, 'status.editor.tabFocusMode', localize('status.editor.tabFocusMode', "Accessibility Mode"), StatusbarAlignment.RIGHT, 100.7); + }, 'status.editor.tabFocusMode', StatusbarAlignment.RIGHT, 100.7); } } else { this.tabFocusModeElement.clear(); @@ -422,13 +423,14 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { if (!this.columnSelectionModeElement.value) { const text = localize('columnSelectionModeEnabled', "Column Selection"); this.columnSelectionModeElement.value = this.statusbarService.addEntry({ + name: localize('status.editor.columnSelectionMode', "Column Selection Mode"), text, ariaLabel: text, tooltip: localize('disableColumnSelectionMode', "Disable Column Selection Mode"), command: 'editor.action.toggleColumnSelection', backgroundColor: themeColorFromId(STATUS_BAR_PROMINENT_ITEM_BACKGROUND), color: themeColorFromId(STATUS_BAR_PROMINENT_ITEM_FOREGROUND) - }, 'status.editor.columnSelectionMode', localize('status.editor.columnSelectionMode', "Column Selection Mode"), StatusbarAlignment.RIGHT, 100.8); + }, 'status.editor.columnSelectionMode', StatusbarAlignment.RIGHT, 100.8); } } else { this.columnSelectionModeElement.clear(); @@ -440,12 +442,13 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { if (!this.screenRedearModeElement.value) { const text = localize('screenReaderDetected', "Screen Reader Optimized"); this.screenRedearModeElement.value = this.statusbarService.addEntry({ + name: localize('status.editor.screenReaderMode', "Screen Reader Mode"), text, ariaLabel: text, command: 'showEditorScreenReaderNotification', backgroundColor: themeColorFromId(STATUS_BAR_PROMINENT_ITEM_BACKGROUND), color: themeColorFromId(STATUS_BAR_PROMINENT_ITEM_FOREGROUND) - }, 'status.editor.screenReaderMode', localize('status.editor.screenReaderMode', "Screen Reader Mode"), StatusbarAlignment.RIGHT, 100.6); + }, 'status.editor.screenReaderMode', StatusbarAlignment.RIGHT, 100.6); } } else { this.screenRedearModeElement.clear(); @@ -459,13 +462,14 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { } const props: IStatusbarEntry = { + name: localize('status.editor.selection', "Editor Selection"), text, ariaLabel: text, tooltip: localize('gotoLine', "Go to Line/Column"), command: 'workbench.action.gotoLine' }; - this.updateElement(this.selectionElement, props, 'status.editor.selection', localize('status.editor.selection', "Editor Selection"), StatusbarAlignment.RIGHT, 100.5); + this.updateElement(this.selectionElement, props, 'status.editor.selection', StatusbarAlignment.RIGHT, 100.5); } private updateIndentationElement(text: string | undefined): void { @@ -475,13 +479,14 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { } const props: IStatusbarEntry = { + name: localize('status.editor.indentation', "Editor Indentation"), text, ariaLabel: text, tooltip: localize('selectIndentation', "Select Indentation"), command: 'changeEditorIndentation' }; - this.updateElement(this.indentationElement, props, 'status.editor.indentation', localize('status.editor.indentation', "Editor Indentation"), StatusbarAlignment.RIGHT, 100.4); + this.updateElement(this.indentationElement, props, 'status.editor.indentation', StatusbarAlignment.RIGHT, 100.4); } private updateEncodingElement(text: string | undefined): void { @@ -491,13 +496,14 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { } const props: IStatusbarEntry = { + name: localize('status.editor.encoding', "Editor Encoding"), text, ariaLabel: text, tooltip: localize('selectEncoding', "Select Encoding"), command: 'workbench.action.editor.changeEncoding' }; - this.updateElement(this.encodingElement, props, 'status.editor.encoding', localize('status.editor.encoding', "Editor Encoding"), StatusbarAlignment.RIGHT, 100.3); + this.updateElement(this.encodingElement, props, 'status.editor.encoding', StatusbarAlignment.RIGHT, 100.3); } private updateEOLElement(text: string | undefined): void { @@ -507,13 +513,14 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { } const props: IStatusbarEntry = { + name: localize('status.editor.eol', "Editor End of Line"), text, ariaLabel: text, tooltip: localize('selectEOL', "Select End of Line Sequence"), command: 'workbench.action.editor.changeEOL' }; - this.updateElement(this.eolElement, props, 'status.editor.eol', localize('status.editor.eol', "Editor End of Line"), StatusbarAlignment.RIGHT, 100.2); + this.updateElement(this.eolElement, props, 'status.editor.eol', StatusbarAlignment.RIGHT, 100.2); } private updateModeElement(text: string | undefined): void { @@ -523,13 +530,14 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { } const props: IStatusbarEntry = { + name: localize('status.editor.mode', "Editor Language"), text, ariaLabel: text, tooltip: localize('selectLanguageMode', "Select Language Mode"), command: 'workbench.action.editor.changeLanguageMode' }; - this.updateElement(this.modeElement, props, 'status.editor.mode', localize('status.editor.mode', "Editor Language"), StatusbarAlignment.RIGHT, 100.1); + this.updateElement(this.modeElement, props, 'status.editor.mode', StatusbarAlignment.RIGHT, 100.1); } private updateMetadataElement(text: string | undefined): void { @@ -539,17 +547,18 @@ export class EditorStatus extends Disposable implements IWorkbenchContribution { } const props: IStatusbarEntry = { + name: localize('status.editor.info', "File Information"), text, ariaLabel: text, tooltip: localize('fileInfo', "File Information") }; - this.updateElement(this.metadataElement, props, 'status.editor.info', localize('status.editor.info', "File Information"), StatusbarAlignment.RIGHT, 100); + this.updateElement(this.metadataElement, props, 'status.editor.info', StatusbarAlignment.RIGHT, 100); } - private updateElement(element: MutableDisposable, props: IStatusbarEntry, id: string, name: string, alignment: StatusbarAlignment, priority: number) { + private updateElement(element: MutableDisposable, props: IStatusbarEntry, id: string, alignment: StatusbarAlignment, priority: number) { if (!element.value) { - element.value = this.statusbarService.addEntry(props, id, name, alignment, priority); + element.value = this.statusbarService.addEntry(props, id, alignment, priority); } else { element.value.update(props); } @@ -923,9 +932,9 @@ class ShowCurrentMarkerInStatusbarContribution extends Disposable { const line = splitLines(this.currentMarker.message)[0]; const text = `${this.getType(this.currentMarker)} ${line}`; if (!this.statusBarEntryAccessor.value) { - this.statusBarEntryAccessor.value = this.statusbarService.addEntry({ text: '', ariaLabel: '' }, 'statusbar.currentProblem', localize('currentProblem', "Current Problem"), StatusbarAlignment.LEFT); + this.statusBarEntryAccessor.value = this.statusbarService.addEntry({ name: localize('currentProblem', "Current Problem"), text: '', ariaLabel: '' }, 'statusbar.currentProblem', StatusbarAlignment.LEFT); } - this.statusBarEntryAccessor.value.update({ text, ariaLabel: text }); + this.statusBarEntryAccessor.value.update({ name: localize('currentProblem', "Current Problem"), text, ariaLabel: text }); } else { this.statusBarEntryAccessor.clear(); } diff --git a/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts b/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts index 5a857f4cc0a..d27103d4dbf 100644 --- a/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts +++ b/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts @@ -94,11 +94,11 @@ export class SideBySideEditor extends EditorPane { this.updateStyles(); } - override async setInput(newInput: EditorInput, options: EditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { - const oldInput = this.input as SideBySideEditorInput; - await super.setInput(newInput, options, context, token); + override async setInput(input: SideBySideEditorInput, options: EditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + const oldInput = this.input; + await super.setInput(input, options, context, token); - return this.updateInput(oldInput, (newInput as SideBySideEditorInput), options, context, token); + return this.updateInput(oldInput, input, options, context, token); } override setOptions(options: EditorOptions | undefined): void { @@ -162,7 +162,7 @@ export class SideBySideEditor extends EditorPane { return this.secondaryEditorPane; } - private async updateInput(oldInput: SideBySideEditorInput, newInput: SideBySideEditorInput, options: EditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + private async updateInput(oldInput: EditorInput | undefined, newInput: SideBySideEditorInput, options: EditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { if (!newInput.matches(oldInput)) { if (oldInput) { this.disposeEditors(); diff --git a/src/vs/workbench/browser/parts/editor/titleControl.ts b/src/vs/workbench/browser/parts/editor/titleControl.ts index e2d0ed9a806..028cf936329 100644 --- a/src/vs/workbench/browser/parts/editor/titleControl.ts +++ b/src/vs/workbench/browser/parts/editor/titleControl.ts @@ -290,7 +290,7 @@ export abstract class TitleControl extends Themable { return false; } - const editorOptions: ITextEditorOptions = { + let editorOptions: ITextEditorOptions = { viewState: (() => { if (this.group.activeEditor === editor) { const activeControl = this.group.activeEditorPane?.getControl(); @@ -304,6 +304,14 @@ export abstract class TitleControl extends Themable { sticky: this.group.isSticky(editor) }; + // If it's a custom editor or a notebook add the viewtype + if ((editor as object).hasOwnProperty('viewType')) { + interface EditorInputWithViewType extends IEditorInput { + viewType: string; + } + editorOptions = { ...editorOptions, override: (editor as EditorInputWithViewType).viewType }; + } + this.instantiationService.invokeFunction(fillResourceDataTransfers, [resource], () => editorOptions, e); return true; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index a7af76e8d88..27fc5cdb963 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -122,7 +122,7 @@ export class ConfigureNotificationAction extends Action { constructor( id: string, label: string, - public readonly configurationActions: readonly IAction[] + readonly configurationActions: readonly IAction[] ) { super(id, label, ThemeIcon.asClassName(configureIcon)); } diff --git a/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts b/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts index 228208bd180..063cf045525 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts @@ -71,6 +71,7 @@ export class NotificationsStatus extends Disposable { // Show the bell with a dot if there are unread or in-progress notifications const statusProperties: IStatusbarEntry = { + name: localize('status.notifications', "Notifications"), text: `${notificationsInProgress > 0 || this.newNotificationsCount > 0 ? '$(bell-dot)' : '$(bell)'}`, ariaLabel: localize('status.notifications', "Notifications"), command: this.isNotificationsCenterVisible ? HIDE_NOTIFICATIONS_CENTER : SHOW_NOTIFICATIONS_CENTER, @@ -82,7 +83,6 @@ export class NotificationsStatus extends Disposable { this.notificationsCenterStatusItem = this.statusbarService.addEntry( statusProperties, 'status.notifications', - localize('status.notifications', "Notifications"), StatusbarAlignment.RIGHT, -Number.MAX_VALUE /* towards the far end of the right hand side */ ); @@ -180,9 +180,12 @@ export class NotificationsStatus extends Disposable { let statusMessageEntry: IStatusbarEntryAccessor; let showHandle: any = setTimeout(() => { statusMessageEntry = this.statusbarService.addEntry( - { text: message, ariaLabel: message }, + { + name: localize('status.message', "Status Message"), + text: message, + ariaLabel: message + }, 'status.message', - localize('status.message', "Status Message"), StatusbarAlignment.LEFT, -Number.MAX_VALUE /* far right on left hand side */ ); diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index a2e0dcc2ffc..aa59b862c83 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -66,7 +66,6 @@ interface IStatusbarEntryPriority { interface IPendingStatusbarEntry { id: string; - name: string; entry: IStatusbarEntry; alignment: StatusbarAlignment; priority: IStatusbarEntryPriority; @@ -445,7 +444,7 @@ export class StatusbarPart extends Part implements IStatusbarService { this._register(this.contextService.onDidChangeWorkbenchState(() => this.updateStyles())); } - addEntry(entry: IStatusbarEntry, id: string, name: string, alignment: StatusbarAlignment, primaryPriority = 0): IStatusbarEntryAccessor { + addEntry(entry: IStatusbarEntry, id: string, alignment: StatusbarAlignment, primaryPriority = 0): IStatusbarEntryAccessor { const priority: IStatusbarEntryPriority = { primary: primaryPriority, secondary: hash(id) // derive from identifier to accomplish uniqueness @@ -454,15 +453,15 @@ export class StatusbarPart extends Part implements IStatusbarService { // As long as we have not been created into a container yet, record all entries // that are pending so that they can get created at a later point if (!this.element) { - return this.doAddPendingEntry(entry, id, name, alignment, priority); + return this.doAddPendingEntry(entry, id, alignment, priority); } // Otherwise add to view - return this.doAddEntry(entry, id, name, alignment, priority); + return this.doAddEntry(entry, id, alignment, priority); } - private doAddPendingEntry(entry: IStatusbarEntry, id: string, name: string, alignment: StatusbarAlignment, priority: IStatusbarEntryPriority): IStatusbarEntryAccessor { - const pendingEntry: IPendingStatusbarEntry = { entry, id, name, alignment, priority }; + private doAddPendingEntry(entry: IStatusbarEntry, id: string, alignment: StatusbarAlignment, priority: IStatusbarEntryPriority): IStatusbarEntryAccessor { + const pendingEntry: IPendingStatusbarEntry = { entry, id, alignment, priority }; this.pendingEntries.push(pendingEntry); const accessor: IStatusbarEntryAccessor = { @@ -486,7 +485,7 @@ export class StatusbarPart extends Part implements IStatusbarService { return accessor; } - private doAddEntry(entry: IStatusbarEntry, id: string, name: string, alignment: StatusbarAlignment, priority: IStatusbarEntryPriority): IStatusbarEntryAccessor { + private doAddEntry(entry: IStatusbarEntry, id: string, alignment: StatusbarAlignment, priority: IStatusbarEntryPriority): IStatusbarEntryAccessor { // Create item const itemContainer = this.doCreateStatusItem(id, alignment, ...coalesce([entry.showBeak ? 'has-beak' : undefined])); @@ -496,7 +495,15 @@ export class StatusbarPart extends Part implements IStatusbarService { this.appendOneStatusbarEntry(itemContainer, alignment, priority); // Add to view model - const viewModelEntry: IStatusbarViewModelEntry = { id, name, alignment, priority, container: itemContainer, labelContainer: item.labelContainer }; + const viewModelEntry: IStatusbarViewModelEntry = new class implements IStatusbarViewModelEntry { + readonly id = id; + readonly alignment = alignment; + readonly priority = priority; + readonly container = itemContainer; + readonly labelContainer = item.labelContainer; + + get name() { return item.name; } + }; const viewModelEntryDispose = this.viewModel.add(viewModelEntry); return { @@ -580,7 +587,7 @@ export class StatusbarPart extends Part implements IStatusbarService { while (this.pendingEntries.length) { const pending = this.pendingEntries.shift(); if (pending) { - pending.accessor = this.addEntry(pending.entry, pending.id, pending.name, pending.alignment, pending.priority.primary); + pending.accessor = this.addEntry(pending.entry, pending.id, pending.alignment, pending.priority.primary); } } } @@ -815,6 +822,7 @@ class StatusbarEntryItem extends Disposable { private readonly label: StatusBarCodiconLabel; private entry: IStatusbarEntry | undefined = undefined; + get name(): string { return assertIsDefined(this.entry).name; } private readonly foregroundListener = this._register(new MutableDisposable()); private readonly backgroundListener = this._register(new MutableDisposable()); diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index 29cb607489a..8e906203cc8 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -859,12 +859,18 @@ class TreeRenderer extends Disposable implements ITreeRenderer { - return this.hoverService.showHover(options); + return this.hoverService.showHover({ + ...options, + linkHandler: (url: string) => { + return openerService.open(url, { allowCommands: (!isString(options.text) && options.text.isTrusted) }); + } + }); }, delay: this.configurationService.getValue('workbench.hover.delay') }; diff --git a/src/vs/workbench/common/editor/binaryEditorModel.ts b/src/vs/workbench/common/editor/binaryEditorModel.ts index 98bbf9360ba..b591076f47c 100644 --- a/src/vs/workbench/common/editor/binaryEditorModel.ts +++ b/src/vs/workbench/common/editor/binaryEditorModel.ts @@ -12,20 +12,18 @@ import { MIME_BINARY } from 'vs/base/common/mime'; * An editor model that just represents a resource that can be loaded. */ export class BinaryEditorModel extends EditorModel { + + private readonly mime = MIME_BINARY; + private size: number | undefined; private etag: string | undefined; - private readonly mime: string; constructor( - public readonly resource: URI, + readonly resource: URI, private readonly name: string, @IFileService private readonly fileService: IFileService ) { super(); - - this.resource = resource; - this.name = name; - this.mime = MIME_BINARY; } /** diff --git a/src/vs/workbench/common/editor/diffEditorInput.ts b/src/vs/workbench/common/editor/diffEditorInput.ts index 7a21a642408..53800a77d3b 100644 --- a/src/vs/workbench/common/editor/diffEditorInput.ts +++ b/src/vs/workbench/common/editor/diffEditorInput.ts @@ -32,8 +32,8 @@ export class DiffEditorInput extends SideBySideEditorInput { constructor( name: string | undefined, description: string | undefined, - public readonly originalInput: EditorInput, - public readonly modifiedInput: EditorInput, + readonly originalInput: EditorInput, + readonly modifiedInput: EditorInput, private readonly forceOpenAsBinary: boolean | undefined, @ILabelService private readonly labelService: ILabelService, @IFileService private readonly fileService: IFileService diff --git a/src/vs/workbench/common/editor/resourceEditorInput.ts b/src/vs/workbench/common/editor/resourceEditorInput.ts index 83fc944940e..dcc12fdcac3 100644 --- a/src/vs/workbench/common/editor/resourceEditorInput.ts +++ b/src/vs/workbench/common/editor/resourceEditorInput.ts @@ -19,7 +19,7 @@ export abstract class AbstractResourceEditorInput extends EditorInput implements get preferredResource(): URI { return this._preferredResource; } constructor( - public readonly resource: URI, + readonly resource: URI, preferredResource: URI | undefined, @ILabelService protected readonly labelService: ILabelService, @IFileService protected readonly fileService: IFileService diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts index 7334c3f1d34..e19caa20fc8 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts @@ -32,7 +32,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { resource: URI, viewType: string, group: GroupIdentifier | undefined, - options?: { readonly customClasses?: string }, + options?: { readonly customClasses?: string, readonly oldResource?: URI }, ): IEditorInput { return instantiationService.invokeFunction(accessor => { if (viewType === defaultCustomEditor.id) { @@ -43,7 +43,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { let untitledDocumentData = untitledString ? VSBuffer.fromString(untitledString) : undefined; const id = generateUuid(); const webview = accessor.get(IWebviewService).createWebviewOverlay(id, { customClasses: options?.customClasses }, {}, undefined); - const input = instantiationService.createInstance(CustomEditorInput, resource, viewType, id, webview, { untitledDocumentData: untitledDocumentData }); + const input = instantiationService.createInstance(CustomEditorInput, resource, viewType, id, webview, { untitledDocumentData: untitledDocumentData, oldResource: options?.oldResource }); if (typeof group !== 'undefined') { input.updateGroup(group); } @@ -54,6 +54,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { public static override readonly typeId = 'workbench.editors.webviewEditor'; private readonly _editorResource: URI; + public readonly oldResource?: URI; private _defaultDirtyState: boolean | undefined; private readonly _backupId: string | undefined; @@ -69,7 +70,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { viewType: string, id: string, webview: WebviewOverlay, - options: { startsDirty?: boolean, backupId?: string, untitledDocumentData?: VSBuffer }, + options: { startsDirty?: boolean, backupId?: string, untitledDocumentData?: VSBuffer, readonly oldResource?: URI }, @IWebviewWorkbenchService webviewWorkbenchService: IWebviewWorkbenchService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILabelService private readonly labelService: ILabelService, @@ -80,6 +81,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { ) { super(id, viewType, '', webview, webviewWorkbenchService); this._editorResource = resource; + this.oldResource = options.oldResource; this._defaultDirtyState = options.startsDirty; this._backupId = options.backupId; this._untitledDocumentData = options.untitledDocumentData; @@ -241,7 +243,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { private doMove(group: GroupIdentifier, newResource: URI): IEditorInput { if (!this._moveHandler) { - return CustomEditorInput.create(this.instantiationService, newResource, this.viewType, group); + return CustomEditorInput.create(this.instantiationService, newResource, this.viewType, group, { oldResource: this.resource }); } this._moveHandler(newResource); diff --git a/src/vs/workbench/contrib/customEditor/common/customEditor.ts b/src/vs/workbench/contrib/customEditor/common/customEditor.ts index c7aa1260e3f..bfaa0a181f8 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditor.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditor.ts @@ -41,6 +41,8 @@ export interface ICustomEditorService { } export interface ICustomEditorModelManager { + getAllModels(resource: URI): Promise + get(resource: URI, viewType: string): Promise; tryRetain(resource: URI, viewType: string): Promise> | undefined; diff --git a/src/vs/workbench/contrib/customEditor/common/customEditorModelManager.ts b/src/vs/workbench/contrib/customEditor/common/customEditorModelManager.ts index b2b185ed874..0d8176fadc4 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditorModelManager.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditorModelManager.ts @@ -16,6 +16,16 @@ export class CustomEditorModelManager implements ICustomEditorModelManager { counter: number }>(); + public async getAllModels(resource: URI): Promise { + const keyStart = `${resource.toString()}@@@`; + const models = []; + for (const [key, entry] of this._references) { + if (key.startsWith(keyStart) && entry.model) { + models.push(await entry.model); + } + } + return models; + } public async get(resource: URI, viewType: string): Promise { const key = this.key(resource, viewType); const entry = this._references.get(key); diff --git a/src/vs/workbench/contrib/debug/browser/breakpointsView.ts b/src/vs/workbench/contrib/debug/browser/breakpointsView.ts index e0f4fa1c42a..bba2df5f6c2 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointsView.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointsView.ts @@ -102,6 +102,7 @@ export class BreakpointsView extends ViewPane { this.breakpointSupportsCondition = CONTEXT_BREAKPOINT_SUPPORTS_CONDITION.bindTo(contextKeyService); this.breakpointInputFocused = CONTEXT_BREAKPOINT_INPUT_FOCUSED.bindTo(contextKeyService); this._register(this.debugService.getModel().onDidChangeBreakpoints(() => this.onBreakpointsChange())); + this._register(this.debugService.onDidChangeState(() => this.onStateChange())); } override renderBody(container: HTMLElement): void { @@ -254,6 +255,22 @@ export class BreakpointsView extends ViewPane { } } + private onStateChange(): void { + const thread = this.debugService.getViewModel().focusedThread; + if (thread && thread.stoppedDetails && thread.stoppedDetails.hitBreakpointIds && thread.stoppedDetails.hitBreakpointIds.length > 0) { + const hitBreakpointIds = thread.stoppedDetails.hitBreakpointIds; + const elements = this.elements; + const index = elements.findIndex(e => { + const id = e.getIdFromAdapter(thread.session.getId()); + return typeof id === 'number' && hitBreakpointIds.indexOf(id) !== -1; + }); + if (index >= 0) { + this.list.setFocus([index]); + this.list.setSelection([index]); + } + } + } + private get elements(): BreakpointItem[] { const model = this.debugService.getModel(); const elements = (>model.getExceptionBreakpoints()).concat(model.getFunctionBreakpoints()).concat(model.getDataBreakpoints()).concat(model.getBreakpoints()); diff --git a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts index befa9ebfdae..634f016ea68 100644 --- a/src/vs/workbench/contrib/debug/browser/debug.contribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debug.contribution.ts @@ -306,6 +306,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarDebugMenu, { id: 'debug.installAdditionalDebuggers', title: nls.localize({ key: 'miInstallAdditionalDebuggers', comment: ['&& denotes a mnemonic'] }, "&&Install Additional Debuggers...") }, + when: CONTEXT_DEBUGGERS_AVAILABLE, order: 1 }); diff --git a/src/vs/workbench/contrib/debug/browser/debugCommands.ts b/src/vs/workbench/contrib/debug/browser/debugCommands.ts index 05a3439914b..c862f63c9cc 100644 --- a/src/vs/workbench/contrib/debug/browser/debugCommands.ts +++ b/src/vs/workbench/contrib/debug/browser/debugCommands.ts @@ -338,7 +338,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ id: CONTINUE_ID, weight: KeybindingWeight.WorkbenchContrib + 10, // Use a stronger weight to get priority over start debugging F5 shortcut primary: KeyCode.F5, - when: CONTEXT_IN_DEBUG_MODE, + when: CONTEXT_DEBUG_STATE.isEqualTo('stopped'), handler: (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { getThreadAndRun(accessor, context, thread => thread.continue()); } @@ -389,7 +389,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ id: DEBUG_START_COMMAND_ID, weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.F5, - when: ContextKeyExpr.and(CONTEXT_DEBUGGERS_AVAILABLE, CONTEXT_DEBUG_STATE.notEqualsTo(getStateLabel(State.Initializing))), + when: ContextKeyExpr.and(CONTEXT_DEBUGGERS_AVAILABLE, CONTEXT_DEBUG_STATE.isEqualTo('inactive')), handler: async (accessor: ServicesAccessor, debugStartOptions?: { config?: Partial; noDebug?: boolean }) => { const debugService = accessor.get(IDebugService); let { launch, name, getConfig } = debugService.getConfigurationManager().selectedConfiguration; diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index edec9fd5d18..979f3ef9d94 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts @@ -45,6 +45,8 @@ import { Event } from 'vs/base/common/event'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { Expression } from 'vs/workbench/contrib/debug/common/debugModel'; +import { themeColorFromId } from 'vs/platform/theme/common/themeService'; +import { registerColor } from 'vs/platform/theme/common/colorRegistry'; const LAUNCH_JSON_REGEX = /\.vscode\/launch\.json$/; const INLINE_VALUE_DECORATION_KEY = 'inlinevaluedecoration'; @@ -52,6 +54,18 @@ const MAX_NUM_INLINE_VALUES = 100; // JS Global scope can have 700+ entries. We const MAX_INLINE_DECORATOR_LENGTH = 150; // Max string length of each inline decorator when debugging. If exceeded ... is added const MAX_TOKENIZATION_LINE_LEN = 500; // If line is too long, then inline values for the line are skipped +export const debugInlineForeground = registerColor('editor.inlineValuesForeground', { + dark: '#ffffff80', + light: '#00000080', + hc: '#ffffff80' +}, nls.localize('editor.inlineValuesForeground', "Color for the debug inline value text.")); + +export const debugInlineBackground = registerColor('editor.inlineValuesBackground', { + dark: '#ffc80033', + light: '#ffc80033', + hc: '#ffc80033' +}, nls.localize('editor.inlineValuesBackground', "Color for the debug inline value background.")); + class InlineSegment { constructor(public column: number, public text: string) { } @@ -73,18 +87,9 @@ function createInlineValueDecoration(lineNumber: number, contentText: string, co renderOptions: { after: { contentText, - backgroundColor: 'rgba(255, 200, 0, 0.2)', - margin: '10px' - }, - dark: { - after: { - color: 'rgba(255, 255, 255, 0.5)', - } - }, - light: { - after: { - color: 'rgba(0, 0, 0, 0.5)', - } + backgroundColor: themeColorFromId(debugInlineBackground), + margin: '10px', + color: themeColorFromId(debugInlineForeground) } } }; diff --git a/src/vs/workbench/contrib/debug/browser/debugService.ts b/src/vs/workbench/contrib/debug/browser/debugService.ts index 29ecf50f63f..a0c444ab666 100644 --- a/src/vs/workbench/contrib/debug/browser/debugService.ts +++ b/src/vs/workbench/contrib/debug/browser/debugService.ts @@ -285,8 +285,7 @@ export class DebugService implements IDebugService { // make sure to save all files and that the configuration is up to date await this.extensionService.activateByEvent('onDebug'); if (!options?.parentSession) { - const saveBeforeStartConfig: string = this.configurationService.getValue('debug.saveBeforeStart'); - + const saveBeforeStartConfig: string = this.configurationService.getValue('debug.saveBeforeStart', { overrideIdentifier: this.editorService.activeTextEditorMode }); if (saveBeforeStartConfig !== 'none') { await this.editorService.saveAll(); if (saveBeforeStartConfig === 'allEditorsInActiveGroup') { diff --git a/src/vs/workbench/contrib/debug/browser/debugStatus.ts b/src/vs/workbench/contrib/debug/browser/debugStatus.ts index a83612abdca..91806d76519 100644 --- a/src/vs/workbench/contrib/debug/browser/debugStatus.ts +++ b/src/vs/workbench/contrib/debug/browser/debugStatus.ts @@ -23,7 +23,7 @@ export class DebugStatusContribution implements IWorkbenchContribution { ) { const addStatusBarEntry = () => { - this.entryAccessor = this.statusBarService.addEntry(this.entry, 'status.debug', nls.localize('status.debug', "Debug"), StatusbarAlignment.LEFT, 30 /* Low Priority */); + this.entryAccessor = this.statusBarService.addEntry(this.entry, 'status.debug', StatusbarAlignment.LEFT, 30 /* Low Priority */); }; const setShowInStatusBar = () => { @@ -65,6 +65,7 @@ export class DebugStatusContribution implements IWorkbenchContribution { } return { + name: nls.localize('status.debug', "Debug"), text: '$(debug-alt-small) ' + text, ariaLabel: nls.localize('debugTarget', "Debug: {0}", text), tooltip: nls.localize('selectAndStartDebug', "Select and start debug configuration"), diff --git a/src/vs/workbench/contrib/debug/common/debug.ts b/src/vs/workbench/contrib/debug/common/debug.ts index c005f81bd2a..3a46f18c937 100644 --- a/src/vs/workbench/contrib/debug/common/debug.ts +++ b/src/vs/workbench/contrib/debug/common/debug.ts @@ -106,6 +106,7 @@ export interface IRawStoppedDetails { totalFrames?: number; allThreadsStopped?: boolean; framesErrorMessage?: string; + hitBreakpointIds?: number[]; } // model diff --git a/src/vs/workbench/contrib/debug/node/terminals.ts b/src/vs/workbench/contrib/debug/node/terminals.ts index 7b3326f6e22..a4ef4b4c84f 100644 --- a/src/vs/workbench/contrib/debug/node/terminals.ts +++ b/src/vs/workbench/contrib/debug/node/terminals.ts @@ -12,6 +12,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { ExtHostConfigProvider } from 'vs/workbench/api/common/extHostConfiguration'; + function spawnAsPromised(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { let stdout = ''; @@ -50,13 +51,15 @@ export function runInExternalTerminal(args: DebugProtocol.RunInTerminalRequestAr export function hasChildProcesses(processId: number | undefined): Promise { if (processId) { + // if shell has at least one child process, assume that shell is busy if (platform.isWindows) { - return spawnAsPromised('wmic', ['process', 'get', 'ParentProcessId']).then(stdout => { - const pids = stdout.split('\r\n'); - return pids.some(p => parseInt(p) === processId); - }, error => { - return true; + return new Promise(async (resolve) => { + // See #123296 + const windowsProcessTree = await import('windows-process-tree'); + windowsProcessTree.getProcessTree(processId, (processTree) => { + resolve(processTree.children.length > 0); + }); }); } else { return spawnAsPromised('/usr/bin/pgrep', ['-lP', String(processId)]).then(stdout => { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts index 5477d2147cf..b353d267df2 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts @@ -21,6 +21,7 @@ import { KeymapRecommendations } from 'vs/workbench/contrib/extensions/browser/k import { ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { ConfigBasedRecommendations } from 'vs/workbench/contrib/extensions/browser/configBasedRecommendations'; import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; +import { timeout } from 'vs/base/common/async'; type IgnoreRecommendationClassification = { recommendationReason: { classification: 'SystemMetaData', purpose: 'FeatureInsight', isMeasurement: true }; @@ -232,16 +233,20 @@ export class ExtensionRecommendationsService extends Disposable implements IExte return !this.extensionRecommendationsManagementService.ignoredRecommendations.includes(extensionId.toLowerCase()); } + // for testing + protected get workbenchRecommendationDelay() { + // remote extensions might still being installed #124119 + return 5000; + } + private async promptWorkspaceRecommendations(): Promise { const allowedRecommendations = [...this.workspaceRecommendations.recommendations, ...this.configBasedRecommendations.importantRecommendations] .map(({ extensionId }) => extensionId) .filter(extensionId => this.isExtensionAllowedToBeRecommended(extensionId)); if (allowedRecommendations.length) { + await timeout(this.workbenchRecommendationDelay); await this.extensionRecommendationNotificationService.promptWorkspaceRecommendations(allowedRecommendations); } } - - - } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index ca8a3e6f58d..6e4fea0a6a8 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -18,7 +18,7 @@ import { IGalleryExtension, IExtensionGalleryService, INSTALL_ERROR_MALICIOUS, I import { IWorkbenchExtensionEnablementService, EnablementState, IExtensionManagementServerService, IExtensionManagementServer, IWorkbenchExtensionManagementService, IWebExtensionsScannerService } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; import { ExtensionRecommendationReason, IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; -import { ExtensionType, ExtensionIdentifier, IExtensionDescription, IExtensionManifest, isLanguagePackExtension } from 'vs/platform/extensions/common/extensions'; +import { ExtensionType, ExtensionIdentifier, IExtensionDescription, IExtensionManifest, isLanguagePackExtension, getWorkpaceSupportTypeMessage } from 'vs/platform/extensions/common/extensions'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { IFileService, IFileContent } from 'vs/platform/files/common/files'; @@ -62,6 +62,7 @@ import { isWeb } from 'vs/base/common/platform'; import { isWorkspaceTrustEnabled } from 'vs/workbench/services/workspaces/common/workspaceTrust'; import { IExtensionManifestPropertiesService } from 'vs/workbench/services/extensions/common/extensionManifestPropertiesService'; import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; +import { isVirtualWorkspace } from 'vs/platform/remote/common/remoteHosts'; function getRelativeDateLabel(date: Date): string { const delta = new Date().getTime() - date.getTime(); @@ -2092,6 +2093,7 @@ export class SystemDisabledWarningAction extends ExtensionAction { @IExtensionService private readonly extensionService: IExtensionService, @IConfigurationService private readonly configurationService: IConfigurationService, @IExtensionManifestPropertiesService private readonly extensionManifestPropertiesService: IExtensionManifestPropertiesService, + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, ) { super('extensions.install', '', `${SystemDisabledWarningAction.CLASS} hide`, false); this._register(this.labelService.onDidChangeFormatters(() => this.update(), this)); @@ -2117,10 +2119,17 @@ export class SystemDisabledWarningAction extends ExtensionAction { ) { return; } - if (this.extension.enablementState === EnablementState.DisabledByVirtualWorkspace) { - this.class = `${SystemDisabledWarningAction.INFO_CLASS}`; - this.tooltip = localize('disabled because of virtual workspace', "This extension has been disabled because it does not support virtual workspaces."); - return; + + if (isVirtualWorkspace(this.contextService.getWorkspace())) { + const virtualSupportType = this.extensionManifestPropertiesService.getExtensionVirtualWorkspaceSupportType(this.extension.local.manifest); + if (virtualSupportType !== true) { + this.class = `${SystemDisabledWarningAction.INFO_CLASS}`; + const details = getWorkpaceSupportTypeMessage(this.extension.local.manifest.capabilities?.virtualWorkspaces); + this.tooltip = details || (virtualSupportType === 'limited' ? + localize('extension limited because of virtual workspace', "This extension has limited features because the current workspace is virtual.") : + localize('disabled because of virtual workspace', "This extension has been disabled because it does not support virtual workspaces.")); + return; + } } if (this.extensionManagementServerService.localExtensionManagementServer && this.extensionManagementServerService.remoteExtensionManagementServer) { if (isLanguagePackExtension(this.extension.local.manifest)) { @@ -2166,8 +2175,7 @@ export class SystemDisabledWarningAction extends ExtensionAction { const untrustedSupportType = this.extensionManifestPropertiesService.getExtensionUntrustedWorkspaceSupportType(this.extension.local.manifest); if (isWorkspaceTrustEnabled(this.configurationService) && untrustedSupportType !== true && !this.workspaceTrustService.isWorkpaceTrusted()) { - const untrustedWorkspaceSupport = this.extension.local.manifest.capabilities?.untrustedWorkspaces; - const untrustedDetails = untrustedWorkspaceSupport?.supported !== true ? untrustedWorkspaceSupport?.description : undefined; + const untrustedDetails = getWorkpaceSupportTypeMessage(this.extension.local.manifest.capabilities?.untrustedWorkspaces); this.enabled = true; this.class = `${SystemDisabledWarningAction.TRUST_CLASS}`; this.tooltip = untrustedDetails || (untrustedSupportType === 'limited' ? diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts index 3ecddfc19bb..2a29f50fdb5 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts @@ -22,7 +22,7 @@ import { InstallLocalExtensionsInRemoteAction, InstallRemoteExtensionsInLocalAct import { IExtensionManagementService } from 'vs/platform/extensionManagement/common/extensionManagement'; import { IWorkbenchExtensionEnablementService, IExtensionManagementServerService, IExtensionManagementServer } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; import { ExtensionsInput } from 'vs/workbench/contrib/extensions/common/extensionsInput'; -import { ExtensionsListView, EnabledExtensionsView, DisabledExtensionsView, RecommendedExtensionsView, WorkspaceRecommendedExtensionsView, BuiltInFeatureExtensionsView, BuiltInThemesExtensionsView, BuiltInProgrammingLanguageExtensionsView, ServerInstalledExtensionsView, DefaultRecommendedExtensionsView, UntrustedWorkspaceUnsupportedExtensionsView, UntrustedWorkspacePartiallySupportedExtensionsView, VirtualWorkspaceUnsupportedExtensionsView } from 'vs/workbench/contrib/extensions/browser/extensionsViews'; +import { ExtensionsListView, EnabledExtensionsView, DisabledExtensionsView, RecommendedExtensionsView, WorkspaceRecommendedExtensionsView, BuiltInFeatureExtensionsView, BuiltInThemesExtensionsView, BuiltInProgrammingLanguageExtensionsView, ServerInstalledExtensionsView, DefaultRecommendedExtensionsView, UntrustedWorkspaceUnsupportedExtensionsView, UntrustedWorkspacePartiallySupportedExtensionsView, VirtualWorkspaceUnsupportedExtensionsView, VirtualWorkspacePartiallySupportedExtensionsView } from 'vs/workbench/contrib/extensions/browser/extensionsViews'; import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import Severity from 'vs/base/common/severity'; @@ -54,12 +54,13 @@ import { DragAndDropObserver } from 'vs/workbench/browser/dnd'; import { URI } from 'vs/base/common/uri'; import { SIDE_BAR_DRAG_AND_DROP_BACKGROUND } from 'vs/workbench/common/theme'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; -import { WorkbenchStateContext } from 'vs/workbench/browser/contextkeys'; +import { VirtualWorkspaceContext, WorkbenchStateContext } from 'vs/workbench/browser/contextkeys'; import { ICommandService } from 'vs/platform/commands/common/commands'; -import { isWeb } from 'vs/base/common/platform'; +import { isIOS, isWeb } from 'vs/base/common/platform'; import { installLocalInRemoteIcon } from 'vs/workbench/contrib/extensions/browser/extensionsIcons'; import { registerAction2, Action2, MenuId } from 'vs/platform/actions/common/actions'; import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; +import { WorkspaceTrustContext } from 'vs/workbench/services/workspaces/common/workspaceTrust'; const SearchMarketplaceExtensionsContext = new RawContextKey('searchMarketplaceExtensions', false); const SearchIntalledExtensionsContext = new RawContextKey('searchInstalledExtensions', false); @@ -70,7 +71,6 @@ const HasInstalledExtensionsContext = new RawContextKey('hasInstalledEx const HasInstalledWebExtensionsContext = new RawContextKey('hasInstalledWebExtensions', false); const BuiltInExtensionsContext = new RawContextKey('builtInExtensions', false); const SearchBuiltInExtensionsContext = new RawContextKey('searchBuiltInExtensions', false); -const UnsupportedWorkspaceExtensionsContext = new RawContextKey('unsupportedWorkspaceExtensions', false); const SearchUnsupportedWorkspaceExtensionsContext = new RawContextKey('searchUnsupportedWorkspaceExtensions', false); const RecommendedExtensionsContext = new RawContextKey('recommendedExtensions', false); @@ -412,21 +412,28 @@ export class ExtensionsViewletViewsContribution implements IWorkbenchContributio id: 'workbench.views.extensions.untrustedUnsupportedExtensions', name: localize('untrustedUnsupportedExtensions', "Disabled in Restricted Mode"), ctorDescriptor: new SyncDescriptor(UntrustedWorkspaceUnsupportedExtensionsView, [{}]), - when: ContextKeyExpr.has('unsupportedWorkspaceExtensions'), + when: ContextKeyExpr.and(WorkspaceTrustContext.IsTrusted.negate(), SearchUnsupportedWorkspaceExtensionsContext), }); viewDescriptors.push({ id: 'workbench.views.extensions.untrustedPartiallySupportedExtensions', name: localize('untrustedPartiallySupportedExtensions', "Limited in Restricted Mode"), ctorDescriptor: new SyncDescriptor(UntrustedWorkspacePartiallySupportedExtensionsView, [{}]), - when: ContextKeyExpr.has('unsupportedWorkspaceExtensions'), + when: ContextKeyExpr.and(WorkspaceTrustContext.IsTrusted.negate(), SearchUnsupportedWorkspaceExtensionsContext), }); viewDescriptors.push({ id: 'workbench.views.extensions.virtualUnsupportedExtensions', name: localize('virtualUnsupportedExtensions', "Disabled in Virtual Workspaces"), ctorDescriptor: new SyncDescriptor(VirtualWorkspaceUnsupportedExtensionsView, [{}]), - when: ContextKeyExpr.and(ContextKeyExpr.has('unsupportedWorkspaceExtensions'), ContextKeyExpr.has('virtualWorkspace')), + when: ContextKeyExpr.and(VirtualWorkspaceContext, SearchUnsupportedWorkspaceExtensionsContext), + }); + + viewDescriptors.push({ + id: 'workbench.views.extensions.virtualPartiallySupportedExtensions', + name: localize('virtualPartiallySupportedExtensions', "Limited in Virtual Workspaces"), + ctorDescriptor: new SyncDescriptor(VirtualWorkspacePartiallySupportedExtensionsView, [{}]), + when: ContextKeyExpr.and(VirtualWorkspaceContext, SearchUnsupportedWorkspaceExtensionsContext), }); return viewDescriptors; @@ -447,7 +454,6 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE private hasInstalledWebExtensionsContextKey: IContextKey; private builtInExtensionsContextKey: IContextKey; private searchBuiltInExtensionsContextKey: IContextKey; - private workspaceUnsupportedExtensionsContextKey: IContextKey; private searchWorkspaceUnsupportedExtensionsContextKey: IContextKey; private recommendedExtensionsContextKey: IContextKey; @@ -485,7 +491,6 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE this.sortByContextKey = ExtensionsSortByContext.bindTo(contextKeyService); this.searchMarketplaceExtensionsContextKey = SearchMarketplaceExtensionsContext.bindTo(contextKeyService); this.searchInstalledExtensionsContextKey = SearchIntalledExtensionsContext.bindTo(contextKeyService); - this.workspaceUnsupportedExtensionsContextKey = UnsupportedWorkspaceExtensionsContext.bindTo(contextKeyService); this.searchWorkspaceUnsupportedExtensionsContextKey = SearchUnsupportedWorkspaceExtensionsContext.bindTo(contextKeyService); this.searchOutdatedExtensionsContextKey = SearchOutdatedExtensionsContext.bindTo(contextKeyService); this.searchEnabledExtensionsContextKey = SearchEnabledExtensionsContext.bindTo(contextKeyService); @@ -552,7 +557,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE this._register(this.searchBox.onShouldFocusResults(() => this.focusListView(), this)); this._register(this.onDidChangeVisibility(visible => { - if (visible) { + if (visible && !isIOS) { this.searchBox!.focus(); } })); @@ -604,7 +609,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE } override focus(): void { - if (this.searchBox) { + if (this.searchBox && !isIOS) { this.searchBox.focus(); } } @@ -678,7 +683,6 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE this.searchEnabledExtensionsContextKey.set(ExtensionsListView.isEnabledExtensionsQuery(value)); this.searchDisabledExtensionsContextKey.set(ExtensionsListView.isDisabledExtensionsQuery(value)); this.searchBuiltInExtensionsContextKey.set(ExtensionsListView.isSearchBuiltInExtensionsQuery(value)); - this.workspaceUnsupportedExtensionsContextKey.set(ExtensionsListView.isWorkspaceUnsupportedExtensionsQuery(value)); this.searchWorkspaceUnsupportedExtensionsContextKey.set(ExtensionsListView.isSearchWorkspaceUnsupportedExtensionsQuery(value)); this.builtInExtensionsContextKey.set(ExtensionsListView.isBuiltInExtensionsQuery(value)); this.recommendedExtensionsContextKey.set(isRecommendedExtensionsQuery); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index e2e6efc6ced..79110057fb3 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -37,7 +37,7 @@ import { alert } from 'vs/base/browser/ui/aria/aria'; import { IListContextMenuEvent } from 'vs/base/browser/ui/list/list'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IAction, Action, Separator, ActionRunner } from 'vs/base/common/actions'; -import { ExtensionIdentifier, IExtensionDescription, isLanguagePackExtension } from 'vs/platform/extensions/common/extensions'; +import { ExtensionIdentifier, ExtensionUntrustedWorkpaceSupportType, ExtensionVirtualWorkpaceSupportType, IExtensionDescription, isLanguagePackExtension } from 'vs/platform/extensions/common/extensions'; import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; import { IProductService } from 'vs/platform/product/common/productService'; import { SeverityIcon } from 'vs/platform/severityIcon/common/severityIcon'; @@ -49,7 +49,8 @@ import { IPreferencesService } from 'vs/workbench/services/preferences/common/pr import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IExtensionManifestPropertiesService } from 'vs/workbench/services/extensions/common/extensionManifestPropertiesService'; -import { getVirtualWorkspaceScheme } from 'vs/platform/remote/common/remoteHosts'; +import { isVirtualWorkspace } from 'vs/platform/remote/common/remoteHosts'; +import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; // Extensions that are automatically classified as Programming Language extensions, but should be Feature extensions const FORCE_FEATURE_EXTENSIONS = ['vscode.git', 'vscode.search-result']; @@ -129,6 +130,7 @@ export class ExtensionsListView extends ViewPane { @IOpenerService openerService: IOpenerService, @IPreferencesService private readonly preferencesService: IPreferencesService, @IStorageService private readonly storageService: IStorageService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService ) { super({ ...(viewletViewOptions as IViewPaneOptions), @@ -551,45 +553,43 @@ export class ExtensionsListView extends ViewPane { } private filterWorkspaceUnsupportedExtensions(local: IExtension[], query: Query, options: IQueryOptions): IExtension[] { - let value = query.value; - const untrustedPartiallySupportedOnly = /@workspaceUnsupported:untrustedPartial/i.test(value); - if (untrustedPartiallySupportedOnly) { - value = value.replace(/@workspaceUnsupported:untrustedPartial/g, ''); - } - const untrustedUnsupportedOnly = /@workspaceUnsupported:untrusted/i.test(value); - if (untrustedUnsupportedOnly) { - value = value.replace(/@workspaceUnsupported:untrusted/g, ''); - } - const virtualUnsupportedOnly = /@workspaceUnsupported:virtual/i.test(value); - if (virtualUnsupportedOnly) { - value = value.replace(/@workspaceUnsupported:virtual/g, ''); + + // shows local extensions which are restricted or disabled in the current workspace because of the extension's capability + + const inVirtualWorkspace = isVirtualWorkspace(this.workspaceService.getWorkspace()); + const inRestrictedWorkspace = !this.workspaceTrustManagementService.isWorkpaceTrusted(); + if (!inVirtualWorkspace && !inRestrictedWorkspace) { + return []; } - value = value.replace(/@workspaceUnsupported/g, '').replace(/@sort:(\w+)(-\w*)?/g, '').trim().toLowerCase(); + let queryString = query.value; // @sortby is already filtered out - const isVirtualWorkspace = getVirtualWorkspaceScheme(this.workspaceService.getWorkspace()) !== undefined; - const virtualUnsupportedExtensions = local.filter(extension => extension.local && !this.extensionManifestPropertiesService.canSupportVirtualWorkspace(extension.local.manifest) && (extension.name.toLowerCase().indexOf(value) > -1 || extension.displayName.toLowerCase().indexOf(value) > -1)); + const match = queryString.match(/^\s*@workspaceUnsupported(?::(untrusted|virtual)(Partial)?)?(?:\s+([^\s]*))?/i); + if (!match) { + return []; + } + const type = match[1]?.toLowerCase(); + const partial = !!match[2]; + const nameFilter = match[3]?.toLowerCase(); - let trustRequiringExtensions = local.filter(extension => extension.local && this.extensionManifestPropertiesService.getExtensionUntrustedWorkspaceSupportType(extension.local.manifest) !== true && (extension.name.toLowerCase().indexOf(value) > -1 || extension.displayName.toLowerCase().indexOf(value) > -1)); - if (isVirtualWorkspace) { - trustRequiringExtensions = trustRequiringExtensions.filter(extension => extension.local && this.extensionManifestPropertiesService.canSupportVirtualWorkspace(extension.local.manifest)); + if (nameFilter) { + local = local.filter(extension => extension.name.toLowerCase().indexOf(nameFilter) > -1 || extension.displayName.toLowerCase().indexOf(nameFilter) > -1); } - if (untrustedUnsupportedOnly) { - const untrustedUnsupportedExtensions = trustRequiringExtensions.filter(extension => extension.local && this.extensionManifestPropertiesService.getExtensionUntrustedWorkspaceSupportType(extension.local.manifest) === false); - return this.sortExtensions(untrustedUnsupportedExtensions, options); - } + const hasVirtualSupportType = (extension: IExtension, supportType: ExtensionVirtualWorkpaceSupportType) => extension.local && this.extensionManifestPropertiesService.getExtensionVirtualWorkspaceSupportType(extension.local.manifest) === supportType; + const hasRestrictedSupportType = (extension: IExtension, supportType: ExtensionUntrustedWorkpaceSupportType) => extension.local && this.extensionManifestPropertiesService.getExtensionUntrustedWorkspaceSupportType(extension.local.manifest) === supportType; - if (untrustedPartiallySupportedOnly) { - const untrustedPartiallySupportedExtensions = trustRequiringExtensions.filter(extension => extension.local && this.extensionManifestPropertiesService.getExtensionUntrustedWorkspaceSupportType(extension.local.manifest) === 'limited'); - return this.sortExtensions(untrustedPartiallySupportedExtensions, options); + if (type === 'virtual') { + // show limited and disabled extensions unless disabled because of a untrusted workspace + local = local.filter(extension => inVirtualWorkspace && hasVirtualSupportType(extension, partial ? 'limited' : false) && !(inRestrictedWorkspace && hasRestrictedSupportType(extension, false))); + } else if (type === 'untrusted') { + // show limited and disabled extensions unless disabled because of a virtual workspace + local = local.filter(extension => inRestrictedWorkspace && hasRestrictedSupportType(extension, partial ? 'limited' : false) && !(inVirtualWorkspace && hasVirtualSupportType(extension, false))); + } else { + // show extensions that are restricted or disabled in the current workspace + local = local.filter(extension => inVirtualWorkspace && !hasVirtualSupportType(extension, true) || inRestrictedWorkspace && !hasRestrictedSupportType(extension, true)); } - - if (virtualUnsupportedOnly) { - return this.sortExtensions(virtualUnsupportedExtensions, options); - } - - return this.sortExtensions(trustRequiringExtensions, options); + return this.sortExtensions(local, options); } @@ -980,9 +980,7 @@ export class ExtensionsListView extends ViewPane { || this.isBuiltInExtensionsQuery(query) || this.isSearchBuiltInExtensionsQuery(query) || this.isBuiltInGroupExtensionsQuery(query) - || this.isSearchWorkspaceUnsupportedExtensionsQuery(query) - || this.isWorkspaceUnsupportedExtensionsQuery(query) - || this.isWorkspaceUnsupportedGroupExtensionsQuery(query); + || this.isSearchWorkspaceUnsupportedExtensionsQuery(query); } static isSearchBuiltInExtensionsQuery(query: string): boolean { @@ -998,15 +996,7 @@ export class ExtensionsListView extends ViewPane { } static isSearchWorkspaceUnsupportedExtensionsQuery(query: string): boolean { - return /@workspaceUnsupported\s.+/i.test(query); - } - - static isWorkspaceUnsupportedExtensionsQuery(query: string): boolean { - return /^\s*@workspaceUnsupported$/i.test(query.trim()); - } - - static isWorkspaceUnsupportedGroupExtensionsQuery(query: string): boolean { - return /^\s*@workspaceUnsupported:.+$/i.test(query.trim()); + return /^\s*@workspaceUnsupported(:(untrusted|virtual)(Partial)?)?(\s|$)/i.test(query); } static isInstalledExtensionsQuery(query: string): boolean { @@ -1104,21 +1094,46 @@ export class BuiltInProgrammingLanguageExtensionsView extends ExtensionsListView } } +function toSpecificWorkspaceUnsupportedQuery(query: string, qualifier: string): string | undefined { + if (!query) { + return '@workspaceUnsupported:' + qualifier; + } + const match = query.match(new RegExp(`@workspaceUnsupported(:${qualifier})?(\\s|$)`, 'i')); + if (match) { + if (!match[1]) { + return query.replace(/@workspaceUnsupported/gi, '@workspaceUnsupported:' + qualifier); + } + return query; + } + return undefined; +} + + export class UntrustedWorkspaceUnsupportedExtensionsView extends ExtensionsListView { override async show(query: string): Promise> { - return (query && query.trim() !== '@workspaceUnsupported') ? this.showEmptyModel() : super.show('@workspaceUnsupported:untrusted'); + const updatedQuery = toSpecificWorkspaceUnsupportedQuery(query, 'untrusted'); + return updatedQuery ? super.show(updatedQuery) : this.showEmptyModel(); } } export class UntrustedWorkspacePartiallySupportedExtensionsView extends ExtensionsListView { override async show(query: string): Promise> { - return (query && query.trim() !== '@workspaceUnsupported') ? this.showEmptyModel() : super.show('@workspaceUnsupported:untrustedPartial'); + const updatedQuery = toSpecificWorkspaceUnsupportedQuery(query, 'untrustedPartial'); + return updatedQuery ? super.show(updatedQuery) : this.showEmptyModel(); } } export class VirtualWorkspaceUnsupportedExtensionsView extends ExtensionsListView { override async show(query: string): Promise> { - return (query && query.trim() !== '@workspaceUnsupported') ? this.showEmptyModel() : super.show('@workspaceUnsupported:virtual'); + const updatedQuery = toSpecificWorkspaceUnsupportedQuery(query, 'virtual'); + return updatedQuery ? super.show(updatedQuery) : this.showEmptyModel(); + } +} + +export class VirtualWorkspacePartiallySupportedExtensionsView extends ExtensionsListView { + override async show(query: string): Promise> { + const updatedQuery = toSpecificWorkspaceUnsupportedQuery(query, 'virtualPartial'); + return updatedQuery ? super.show(updatedQuery) : this.showEmptyModel(); } } diff --git a/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts b/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts index e93f8f483df..ac64051f0aa 100644 --- a/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts +++ b/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts @@ -82,6 +82,7 @@ export class ExtensionHostProfileService extends Disposable implements IExtensio if (visible) { const indicator: IStatusbarEntry = { + name: nls.localize('status.profiler', "Extension Profiler"), text: nls.localize('profilingExtensionHost', "Profiling Extension Host"), showProgress: true, ariaLabel: nls.localize('profilingExtensionHost', "Profiling Extension Host"), @@ -98,7 +99,7 @@ export class ExtensionHostProfileService extends Disposable implements IExtensio this.profilingStatusBarIndicatorLabelUpdater.value = toDisposable(() => clearInterval(handle)); if (!this.profilingStatusBarIndicator) { - this.profilingStatusBarIndicator = this._statusbarService.addEntry(indicator, 'status.profiler', nls.localize('status.profiler', "Extension Profiler"), StatusbarAlignment.RIGHT); + this.profilingStatusBarIndicator = this._statusbarService.addEntry(indicator, 'status.profiler', StatusbarAlignment.RIGHT); } else { this.profilingStatusBarIndicator.update(indicator); } diff --git a/src/vs/workbench/contrib/extensions/electron-sandbox/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/electron-sandbox/extensions.contribution.ts index cba25e85507..6b0ba3cad76 100644 --- a/src/vs/workbench/contrib/extensions/electron-sandbox/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/electron-sandbox/extensions.contribution.ts @@ -36,7 +36,7 @@ class RuntimeExtensionsInputSerializer implements IEditorInputSerializer { serialize(editorInput: EditorInput): string { return ''; } - deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput { + deserialize(instantiationService: IInstantiationService): EditorInput { return RuntimeExtensionsInput.instance; } } diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts index efedc1c28dd..9798f435ebf 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts @@ -175,6 +175,12 @@ function aGalleryExtension(name: string, properties: any = {}, galleryExtensionP return galleryExtension; } +class TestExtensionRecommendationsService extends ExtensionRecommendationsService { + protected override get workbenchRecommendationDelay() { + return 0; + } +} + suite('ExtensionRecommendationsService Test', () => { let workspaceService: IWorkspaceContextService; let instantiationService: TestInstantiationService; @@ -311,7 +317,7 @@ suite('ExtensionRecommendationsService Test', () => { function testNoPromptForValidRecommendations(recommendations: string[]) { return setUpFolderWorkspace('myFolder', recommendations).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); return testObject.activationPromise.then(() => { assert.strictEqual(Object.keys(testObject.getAllRecommendationsWithReason()).length, recommendations.length); assert.ok(!prompted); @@ -321,7 +327,7 @@ suite('ExtensionRecommendationsService Test', () => { function testNoPromptOrRecommendationsForValidRecommendations(recommendations: string[]) { return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); assert.ok(!prompted); return testObject.getWorkspaceRecommendations().then(() => { @@ -350,7 +356,7 @@ suite('ExtensionRecommendationsService Test', () => { test('ExtensionRecommendationsService: Prompt for valid workspace recommendations', async () => { await setUpFolderWorkspace('myFolder', mockTestData.recommendedExtensions); - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); await Event.toPromise(promptedEmitter.event); const recommendations = Object.keys(testObject.getAllRecommendationsWithReason()); @@ -379,7 +385,7 @@ suite('ExtensionRecommendationsService Test', () => { test('ExtensionRecommendationsService: No Prompt for valid workspace recommendations if ignoreRecommendations is set', () => { testConfigurationService.setUserConfiguration(ConfigurationKey, { ignoreRecommendations: true }); return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); return testObject.activationPromise.then(() => { assert.ok(!prompted); }); @@ -389,7 +395,7 @@ suite('ExtensionRecommendationsService Test', () => { test('ExtensionRecommendationsService: No Prompt for valid workspace recommendations if showRecommendationsOnlyOnDemand is set', () => { testConfigurationService.setUserConfiguration(ConfigurationKey, { showRecommendationsOnlyOnDemand: true }); return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); return testObject.activationPromise.then(() => { assert.ok(!prompted); }); @@ -407,7 +413,7 @@ suite('ExtensionRecommendationsService Test', () => { instantiationService.get(IStorageService).store('extensionsAssistant/ignored_recommendations', '["ms-dotnettools.csharp", "mockpublisher2.mockextension2"]', StorageScope.GLOBAL, StorageTarget.MACHINE); return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); return testObject.activationPromise.then(() => { const recommendations = testObject.getAllRecommendationsWithReason(); assert.ok(!recommendations['ms-dotnettools.csharp']); // stored recommendation that has been globally ignored @@ -425,7 +431,7 @@ suite('ExtensionRecommendationsService Test', () => { instantiationService.get(IStorageService).store('extensionsAssistant/recommendations', storedRecommendations, StorageScope.GLOBAL, StorageTarget.MACHINE); return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions, ignoredRecommendations).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); return testObject.activationPromise.then(() => { const recommendations = testObject.getAllRecommendationsWithReason(); assert.ok(!recommendations['ms-dotnettools.csharp']); // stored recommendation that has been workspace ignored @@ -447,7 +453,7 @@ suite('ExtensionRecommendationsService Test', () => { storageService.store('extensionsAssistant/ignored_recommendations', globallyIgnoredRecommendations, StorageScope.GLOBAL, StorageTarget.MACHINE); await setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions, workspaceIgnoredRecommendations); - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); await testObject.activationPromise; const recommendations = testObject.getAllRecommendationsWithReason(); @@ -467,7 +473,7 @@ suite('ExtensionRecommendationsService Test', () => { await setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions); const extensionIgnoredRecommendationsService = instantiationService.get(IExtensionIgnoredRecommendationsService); - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); await testObject.activationPromise; let recommendations = testObject.getAllRecommendationsWithReason(); @@ -499,7 +505,7 @@ suite('ExtensionRecommendationsService Test', () => { storageService.store('extensionsAssistant/ignored_recommendations', '["ms-vscode.vscode"]', StorageScope.GLOBAL, StorageTarget.MACHINE); await setUpFolderWorkspace('myFolder', []); - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); const extensionIgnoredRecommendationsService = instantiationService.get(IExtensionIgnoredRecommendationsService); extensionIgnoredRecommendationsService.onDidChangeGlobalIgnoredRecommendation(changeHandlerTarget); extensionIgnoredRecommendationsService.toggleGlobalIgnoredRecommendation(ignoredExtensionId, true); @@ -514,7 +520,7 @@ suite('ExtensionRecommendationsService Test', () => { instantiationService.get(IStorageService).store('extensionsAssistant/recommendations', storedRecommendations, StorageScope.GLOBAL, StorageTarget.MACHINE); return setUpFolderWorkspace('myFolder', []).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); return testObject.activationPromise.then(() => { const recommendations = testObject.getFileBasedRecommendations(); assert.strictEqual(recommendations.length, 2); @@ -533,7 +539,7 @@ suite('ExtensionRecommendationsService Test', () => { instantiationService.get(IStorageService).store('extensionsAssistant/recommendations', storedRecommendations, StorageScope.GLOBAL, StorageTarget.MACHINE); return setUpFolderWorkspace('myFolder', []).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); + testObject = instantiationService.createInstance(TestExtensionRecommendationsService); return testObject.activationPromise.then(() => { const recommendations = testObject.getFileBasedRecommendations(); assert.strictEqual(recommendations.length, 2); diff --git a/src/vs/workbench/contrib/feedback/browser/feedbackStatusbarItem.ts b/src/vs/workbench/contrib/feedback/browser/feedbackStatusbarItem.ts index 07b502f176f..7708f4a8f65 100644 --- a/src/vs/workbench/contrib/feedback/browser/feedbackStatusbarItem.ts +++ b/src/vs/workbench/contrib/feedback/browser/feedbackStatusbarItem.ts @@ -80,7 +80,7 @@ export class FeedbackStatusbarConribution extends Disposable implements IWorkben private createFeedbackStatusEntry(): void { // Status entry - this.entry = this._register(this.statusbarService.addEntry(this.getStatusEntry(), 'status.feedback', localize('status.feedback', "Tweet Feedback"), StatusbarAlignment.RIGHT, -100 /* towards the end of the right hand side */)); + this.entry = this._register(this.statusbarService.addEntry(this.getStatusEntry(), 'status.feedback', StatusbarAlignment.RIGHT, -100 /* towards the end of the right hand side */)); // Command to toggle CommandsRegistry.registerCommand(FeedbackStatusbarConribution.TOGGLE_FEEDBACK_COMMAND, () => this.toggleFeedback()); @@ -136,6 +136,7 @@ export class FeedbackStatusbarConribution extends Disposable implements IWorkben private getStatusEntry(showBeak?: boolean): IStatusbarEntry { return { + name: localize('status.feedback.name', "Feedback"), text: '$(feedback)', ariaLabel: localize('status.feedback', "Tweet Feedback"), tooltip: localize('status.feedback', "Tweet Feedback"), diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index f569437b2c2..df99b2a38c8 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -681,7 +681,7 @@ function trimLongName(name: string): string { return name; } -export function getWellFormedFileName(filename: string): string { +function getWellFormedFileName(filename: string): string { if (!filename) { return filename; } @@ -689,8 +689,7 @@ export function getWellFormedFileName(filename: string): string { // Trim tabs filename = trim(filename, '\t'); - // Remove trailing dots and slashes - filename = rtrim(filename, '.'); + // Remove trailing slashes filename = rtrim(filename, '/'); filename = rtrim(filename, '\\'); diff --git a/src/vs/workbench/contrib/files/browser/fileImportExport.ts b/src/vs/workbench/contrib/files/browser/fileImportExport.ts index 4730977dce3..a0a072c5cbc 100644 --- a/src/vs/workbench/contrib/files/browser/fileImportExport.ts +++ b/src/vs/workbench/contrib/files/browser/fileImportExport.ts @@ -165,7 +165,7 @@ export class BrowserFileUpload { return; } - await this.explorerService.applyBulkEdit([new ResourceFileEdit(joinPath(target.resource, entry.name), undefined, { recursive: true })], { + await this.explorerService.applyBulkEdit([new ResourceFileEdit(joinPath(target.resource, entry.name), undefined, { recursive: true, folder: target.getChild(entry.name)?.isDirectory })], { undoLabel: localize('overwrite', "Overwrite {0}", entry.name), progressLabel: localize('overwriting', "Overwriting {0}", entry.name), }); diff --git a/src/vs/workbench/contrib/files/test/browser/textFileEditorTracker.test.ts b/src/vs/workbench/contrib/files/test/browser/textFileEditorTracker.test.ts index c9082eeec1b..bd45cd731f0 100644 --- a/src/vs/workbench/contrib/files/test/browser/textFileEditorTracker.test.ts +++ b/src/vs/workbench/contrib/files/test/browser/textFileEditorTracker.test.ts @@ -25,6 +25,8 @@ import { IFilesConfigurationService } from 'vs/workbench/services/filesConfigura import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { FILE_EDITOR_INPUT_ID } from 'vs/workbench/contrib/files/common/files'; +import { IWorkspaceTrustRequestService } from 'vs/platform/workspace/common/workspaceTrust'; +import { TestWorkspaceTrustRequestService } from 'vs/workbench/services/workspaces/test/common/testWorkspaceTrustService'; suite('Files - TextFileEditorTracker', () => { @@ -57,6 +59,7 @@ suite('Files - TextFileEditorTracker', () => { const part = await createEditorPart(instantiationService, disposables); instantiationService.stub(IEditorGroupsService, part); + instantiationService.stub(IWorkspaceTrustRequestService, new TestWorkspaceTrustRequestService(false)); const editorService: EditorService = instantiationService.createInstance(EditorService); instantiationService.stub(IEditorService, editorService); diff --git a/src/vs/workbench/contrib/issue/browser/issue.web.contribution.ts b/src/vs/workbench/contrib/issue/browser/issue.web.contribution.ts index 190c892525f..51965b5220c 100644 --- a/src/vs/workbench/contrib/issue/browser/issue.web.contribution.ts +++ b/src/vs/workbench/contrib/issue/browser/issue.web.contribution.ts @@ -13,7 +13,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { CATEGORIES } from 'vs/workbench/common/actions'; import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; import { IWebIssueService, WebIssueService } from 'vs/workbench/contrib/issue/browser/issueService'; -import { OpenIssueReporterArgs, OpenIssueReporterActionId } from 'vs/workbench/contrib/issue/common/commands'; +import { OpenIssueReporterArgs, OpenIssueReporterActionId, OpenIssueReporterApiCommandId } from 'vs/workbench/contrib/issue/common/commands'; class RegisterIssueContribution implements IWorkbenchContribution { @@ -34,6 +34,53 @@ class RegisterIssueContribution implements IWorkbenchContribution { return accessor.get(IWebIssueService).openReporter({ extensionId }); }); + CommandsRegistry.registerCommand({ + id: OpenIssueReporterApiCommandId, + handler: function (accessor, args?: [string] | OpenIssueReporterArgs) { + let extensionId: string | undefined; + if (args) { + if (Array.isArray(args)) { + [extensionId] = args; + } else { + extensionId = args.extensionId; + } + } + + if (!!extensionId && typeof extensionId !== 'string') { + throw new Error(`Invalid argument when running '${OpenIssueReporterApiCommandId}: 'extensionId' must be of type string `); + } + + return accessor.get(IWebIssueService).openReporter({ extensionId }); + }, + description: { + description: 'Open the issue reporter and optionally prefill part of the form.', + args: [ + { + name: 'options', + description: 'Data to use to prefill the issue reporter with.', + isOptional: true, + schema: { + oneOf: [ + { + type: 'string', + description: 'The extension id to preselect.' + }, + { + type: 'object', + properties: { + extensionId: { + type: 'string' + }, + } + + } + ] + } + }, + ] + } + }); + const command: ICommandAction = { id: OpenIssueReporterActionId, title: { value: OpenIssueReporterActionLabel, original: 'Report Issue' }, diff --git a/src/vs/workbench/contrib/issue/common/commands.ts b/src/vs/workbench/contrib/issue/common/commands.ts index bcd6e252c88..a5a9ff998f2 100644 --- a/src/vs/workbench/contrib/issue/common/commands.ts +++ b/src/vs/workbench/contrib/issue/common/commands.ts @@ -5,6 +5,8 @@ export const OpenIssueReporterActionId = 'workbench.action.openIssueReporter'; +export const OpenIssueReporterApiCommandId = 'vscode.openIssueReporter'; + export interface OpenIssueReporterArgs { readonly extensionId?: string; readonly issueTitle?: string; diff --git a/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts b/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts index 84177acd367..c7f49c38f05 100644 --- a/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts +++ b/src/vs/workbench/contrib/issue/electron-sandbox/issue.contribution.ts @@ -14,7 +14,7 @@ import { WorkbenchIssueService } from 'vs/workbench/services/issue/electron-sand import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { IssueReporterData } from 'vs/platform/issue/common/issue'; import { IIssueService } from 'vs/platform/issue/electron-sandbox/issue'; -import { OpenIssueReporterArgs, OpenIssueReporterActionId } from 'vs/workbench/contrib/issue/common/commands'; +import { OpenIssueReporterArgs, OpenIssueReporterActionId, OpenIssueReporterApiCommandId } from 'vs/workbench/contrib/issue/common/commands'; if (!!product.reportIssueUrl) { registerAction2(ReportPerformanceIssueUsingReporterAction); @@ -27,6 +27,50 @@ if (!!product.reportIssueUrl) { return accessor.get(IWorkbenchIssueService).openReporter(data); }); + CommandsRegistry.registerCommand({ + id: OpenIssueReporterApiCommandId, + handler: function (accessor, args?: [string] | OpenIssueReporterArgs) { + const data: Partial = Array.isArray(args) + ? { extensionId: args[0] } + : args || {}; + + return accessor.get(IWorkbenchIssueService).openReporter(data); + }, + description: { + description: 'Open the issue reporter and optionally prefill part of the form.', + args: [ + { + name: 'options', + description: 'Data to use to prefill the issue reporter with.', + isOptional: true, + schema: { + oneOf: [ + { + type: 'string', + description: 'The extension id to preselect.' + }, + { + type: 'object', + properties: { + extensionId: { + type: 'string' + }, + issueTitle: { + type: 'string' + }, + issueBody: { + type: 'string' + } + } + + } + ] + } + }, + ] + } + }); + const reportIssue: ICommandAction = { id: OpenIssueReporterActionId, title: { diff --git a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts index e0d049f9a56..14b184c6cdb 100644 --- a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts +++ b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts @@ -374,7 +374,7 @@ class MarkersStatusBarContributions extends Disposable implements IWorkbenchCont @IStatusbarService private readonly statusbarService: IStatusbarService ) { super(); - this.markersStatusItem = this._register(this.statusbarService.addEntry(this.getMarkersItem(), 'status.problems', localize('status.problems', "Problems"), StatusbarAlignment.LEFT, 50 /* Medium Priority */)); + this.markersStatusItem = this._register(this.statusbarService.addEntry(this.getMarkersItem(), 'status.problems', StatusbarAlignment.LEFT, 50 /* Medium Priority */)); this.markerService.onMarkerChanged(() => this.markersStatusItem.update(this.getMarkersItem())); } @@ -382,6 +382,7 @@ class MarkersStatusBarContributions extends Disposable implements IWorkbenchCont const markersStatistics = this.markerService.getStatistics(); const tooltip = this.getMarkersTooltip(markersStatistics); return { + name: localize('status.problems', "Problems"), text: this.getMarkersText(markersStatistics), ariaLabel: tooltip, tooltip, diff --git a/src/vs/workbench/contrib/notebook/browser/constants.ts b/src/vs/workbench/contrib/notebook/browser/constants.ts deleted file mode 100644 index f38ad088c9f..00000000000 --- a/src/vs/workbench/contrib/notebook/browser/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Scrollable Element - -export const SCROLLABLE_ELEMENT_PADDING_TOP = 18; diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts b/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts index 56d92da0111..4cad4eb65fe 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts @@ -18,7 +18,7 @@ import { InputFocusedContext, InputFocusedContextKey } from 'vs/platform/context import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { IQuickInputService, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; -import { BaseCellRenderTemplate, CellEditState, CellFocusMode, EXECUTE_CELL_COMMAND_ID, EXPAND_CELL_INPUT_COMMAND_ID, getNotebookEditorFromEditorPane, IActiveNotebookEditor, ICellViewModel, NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_INPUT_COLLAPSED, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, NOTEBOOK_CELL_OUTPUT_COLLAPSED, NOTEBOOK_CELL_EXECUTION_STATE, NOTEBOOK_CELL_TYPE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_KERNEL_COUNT, NOTEBOOK_INTERRUPTIBLE_KERNEL, NOTEBOOK_HAS_RUNNING_CELL, CHANGE_CELL_LANGUAGE, QUIT_EDIT_CELL_COMMAND_ID } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { BaseCellRenderTemplate, CellEditState, CellFocusMode, EXECUTE_CELL_COMMAND_ID, EXPAND_CELL_INPUT_COMMAND_ID, getNotebookEditorFromEditorPane, IActiveNotebookEditor, ICellViewModel, NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_INPUT_COLLAPSED, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, NOTEBOOK_CELL_OUTPUT_COLLAPSED, NOTEBOOK_CELL_EXECUTION_STATE, NOTEBOOK_CELL_TYPE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_KERNEL_COUNT, NOTEBOOK_INTERRUPTIBLE_KERNEL, NOTEBOOK_HAS_RUNNING_CELL, CHANGE_CELL_LANGUAGE, QUIT_EDIT_CELL_COMMAND_ID, NOTEBOOK_USE_CONSOLIDATED_OUTPUT_BUTTON } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellEditType, CellKind, ICellEditOperation, isDocumentExcludePattern, NotebookCellMetadata, NotebookCellExecutionState, TransientCellMetadata, TransientDocumentMetadata, SelectionStateType, ICellReplaceEdit } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { ICellRange, isICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; @@ -34,6 +34,7 @@ import { NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewMod import { INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; import { Iterable } from 'vs/base/common/iterator'; import { flatten } from 'vs/base/common/arrays'; +import { Codicon } from 'vs/base/common/codicons'; // Kernel Command export const SELECT_KERNEL_ID = 'notebook.selectKernel'; @@ -684,7 +685,7 @@ registerAction2(class ExecuteNotebookAction extends NotebookAction { { id: MenuId.NotebookToolbar, order: -1, - group: 'navigation', + group: 'navigation/execute', when: ContextKeyExpr.and( executeNotebookCondition, ContextKeyExpr.or(NOTEBOOK_INTERRUPTIBLE_KERNEL.toNegated(), NOTEBOOK_HAS_RUNNING_CELL.toNegated()), @@ -755,7 +756,7 @@ registerAction2(class CancelNotebook extends NotebookAction { { id: MenuId.NotebookToolbar, order: -1, - group: 'navigation', + group: 'navigation/execute', when: ContextKeyExpr.and( NOTEBOOK_HAS_RUNNING_CELL, NOTEBOOK_INTERRUPTIBLE_KERNEL, @@ -916,7 +917,13 @@ abstract class InsertCellCommand extends NotebookAction { } async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise { - context.notebookEditor.insertNotebookCell(context.cell, this.kind, this.direction, undefined, true); + if (context.cell) { + context.notebookEditor.insertNotebookCell(context.cell, this.kind, this.direction, undefined, true); + } else { + const focusRange = context.notebookEditor.getFocus(); + const next = focusRange.end - 1; + context.notebookEditor.insertNotebookCell(context.notebookEditor.viewModel.viewCells[next], this.kind, this.direction, undefined, true); + } } } @@ -1023,6 +1030,22 @@ MenuRegistry.appendMenuItem(MenuId.NotebookCellBetween, { when: NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true) }); +MenuRegistry.appendMenuItem(MenuId.NotebookToolbar, { + command: { + id: INSERT_CODE_CELL_BELOW_COMMAND_ID, + icon: Codicon.add, + title: localize('notebookActions.menu.insertCode.ontoolbar', "Code"), + tooltip: localize('notebookActions.menu.insertCode.tooltip', "Add Code Cell") + }, + order: -5, + group: 'navigation/add', + when: ContextKeyExpr.and( + NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true), + ContextKeyExpr.notEquals('config.notebook.experimental.insertToolbarPosition', 'betweenCells'), + ContextKeyExpr.notEquals('config.notebook.experimental.insertToolbarPosition', 'hidden') + ) +}); + MenuRegistry.appendMenuItem(MenuId.NotebookCellListTop, { command: { id: INSERT_CODE_CELL_AT_TOP_COMMAND_ID, @@ -1077,6 +1100,22 @@ MenuRegistry.appendMenuItem(MenuId.NotebookCellBetween, { when: NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true) }); +MenuRegistry.appendMenuItem(MenuId.NotebookToolbar, { + command: { + id: INSERT_MARKDOWN_CELL_BELOW_COMMAND_ID, + icon: Codicon.add, + title: localize('notebookActions.menu.insertMarkdown.ontoolbar', "Markdown"), + tooltip: localize('notebookActions.menu.insertMarkdown.tooltip', "Add Markdown Cell") + }, + order: -5, + group: 'navigation/add', + when: ContextKeyExpr.and( + NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true), + ContextKeyExpr.notEquals('config.notebook.experimental.insertToolbarPosition', 'betweenCells'), + ContextKeyExpr.notEquals('config.notebook.experimental.insertToolbarPosition', 'hidden') + ) +}); + MenuRegistry.appendMenuItem(MenuId.NotebookCellListTop, { command: { id: INSERT_MARKDOWN_CELL_AT_TOP_COMMAND_ID, @@ -1147,7 +1186,9 @@ registerAction2(class QuitEditCellAction extends NotebookCellAction { weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT - 5 }, { - when: quitEditCondition, + when: ContextKeyExpr.and( + quitEditCondition, + NOTEBOOK_CELL_TYPE.isEqualTo('markdown')), primary: KeyMod.WinCtrl | KeyCode.Enter, win: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Enter @@ -1267,12 +1308,18 @@ registerAction2(class ClearCellOutputsAction extends NotebookCellAction { super({ id: CLEAR_CELL_OUTPUTS_COMMAND_ID, title: localize('clearCellOutputs', 'Clear Cell Outputs'), - menu: { - id: MenuId.NotebookCellTitle, - when: ContextKeyExpr.and(NOTEBOOK_CELL_TYPE.isEqualTo('code'), executeNotebookCondition, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_CELL_EDITABLE), - order: CellToolbarOrder.ClearCellOutput, - group: CELL_TITLE_OUTPUT_GROUP_ID - }, + menu: [ + { + id: MenuId.NotebookCellTitle, + when: ContextKeyExpr.and(NOTEBOOK_CELL_TYPE.isEqualTo('code'), executeNotebookCondition, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_CELL_EDITABLE, NOTEBOOK_USE_CONSOLIDATED_OUTPUT_BUTTON.toNegated()), + order: CellToolbarOrder.ClearCellOutput, + group: CELL_TITLE_OUTPUT_GROUP_ID + }, + { + id: MenuId.NotebookOutputToolbar, + when: ContextKeyExpr.and(NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_CELL_EDITABLE) + }, + ], keybinding: { when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey), NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_CELL_EDITABLE), primary: KeyMod.Alt | KeyCode.Delete, @@ -1501,7 +1548,7 @@ registerAction2(class ClearAllCellOutputsAction extends NotebookAction { { id: MenuId.NotebookToolbar, when: ContextKeyExpr.equals('config.notebook.experimental.globalToolbar', true), - group: 'navigation', + group: 'navigation/execute', order: 0 } ], diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/status/editorStatus.ts b/src/vs/workbench/contrib/notebook/browser/contrib/status/editorStatus.ts index 3cd8440458b..4a415850196 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/status/editorStatus.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/status/editorStatus.ts @@ -37,12 +37,26 @@ registerAction2(class extends Action2 { precondition: NOTEBOOK_IS_ACTIVE_EDITOR, icon: selectKernelIcon, f1: true, - menu: { + menu: [{ id: MenuId.EditorTitle, - when: ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_KERNEL_COUNT.notEqualsTo(0), ContextKeyExpr.equals('config.notebook.experimental.showKernelInEditorTitle', true),), + when: ContextKeyExpr.and( + NOTEBOOK_IS_ACTIVE_EDITOR, + NOTEBOOK_KERNEL_COUNT.notEqualsTo(0), + ContextKeyExpr.equals('config.notebook.experimental.showKernelInEditorTitle', true), + ContextKeyExpr.notEquals('config.notebook.experimental.globalToolbar', true) + ), group: 'navigation', order: -10 - }, + }, { + id: MenuId.NotebookToolbar, + when: ContextKeyExpr.and( + NOTEBOOK_KERNEL_COUNT.notEqualsTo(0), + ContextKeyExpr.equals('config.notebook.experimental.showKernelInEditorTitle', true), + ContextKeyExpr.equals('config.notebook.experimental.globalToolbar', true) + ), + group: 'status', + order: -10 + }], description: { description: nls.localize('notebookActions.selectKernel.args', "Notebook Kernel Args"), args: [ @@ -228,6 +242,12 @@ export class KernelStatus extends Disposable implements IWorkbenchContribution { } const updateStatus = () => { + if (activeEditor.notebookOptions.getLayoutConfiguration().globalToolbar) { + // kernel info rendered in the notebook toolbar already + this._kernelInfoElement.clear(); + return; + } + const notebook = activeEditor.viewModel?.notebookDocument; if (notebook) { this._showKernelStatus(notebook); @@ -240,6 +260,7 @@ export class KernelStatus extends Disposable implements IWorkbenchContribution { this._editorDisposables.add(this._notebookKernelService.onDidChangeNotebookKernelBinding(updateStatus)); this._editorDisposables.add(this._notebookKernelService.onDidChangeNotebookAffinity(updateStatus)); this._editorDisposables.add(activeEditor.onDidChangeModel(updateStatus)); + this._editorDisposables.add(activeEditor.notebookOptions.onDidChangeOptions(updateStatus)); updateStatus(); } @@ -268,13 +289,13 @@ export class KernelStatus extends Disposable implements IWorkbenchContribution { const tooltip = kernel.description ?? kernel.detail ?? kernel.label; this._kernelInfoElement.add(this._statusbarService.addEntry( { + name: nls.localize('notebook.info', "Notebook Kernel Info"), text: `$(notebook-kernel-select) ${kernel.label}`, ariaLabel: kernel.label, tooltip: isSuggested ? nls.localize('tooltop', "{0} (suggestion)", tooltip) : tooltip, command: SELECT_KERNEL_ID, }, 'notebook.selectKernel', - nls.localize('notebook.info', "Notebook Kernel Info"), StatusbarAlignment.RIGHT, 10 )); @@ -286,13 +307,13 @@ export class KernelStatus extends Disposable implements IWorkbenchContribution { // multiple kernels -> show selection hint this._kernelInfoElement.add(this._statusbarService.addEntry( { + name: nls.localize('notebook.select', "Notebook Kernel Selection"), text: nls.localize('kernel.select.label', "Select Kernel"), ariaLabel: nls.localize('kernel.select.label', "Select Kernel"), command: SELECT_KERNEL_ID, backgroundColor: { id: 'statusBarItem.prominentBackground' } }, 'notebook.selectKernel', - nls.localize('notebook.select', "Notebook Kernel Selection"), StatusbarAlignment.RIGHT, 10 )); @@ -340,12 +361,11 @@ export class ActiveCellStatus extends Disposable implements IWorkbenchContributi return; } - const entry = { text: newText, ariaLabel: newText }; + const entry = { name: nls.localize('notebook.activeCellStatusName', "Notebook Editor Selections"), text: newText, ariaLabel: newText }; if (!this._accessor.value) { this._accessor.value = this._statusbarService.addEntry( entry, 'notebook.activeCellStatus', - nls.localize('notebook.activeCellStatusName', "Notebook Editor Selections"), StatusbarAlignment.RIGHT, 100 ); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/contributedStatusBarItemController.ts b/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/contributedStatusBarItemController.ts index 7d855e950d0..a625c2e7330 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/contributedStatusBarItemController.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/contributedStatusBarItemController.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { flatten } from 'vs/base/common/arrays'; -import { Throttler } from 'vs/base/common/async'; +import { disposableTimeout, Throttler } from 'vs/base/common/async'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { ICellVisibilityChangeEvent, NotebookVisibleCellObserver } from 'vs/workbench/contrib/notebook/browser/contrib/statusBar/notebookVisibleCellObserver'; @@ -69,7 +69,7 @@ class CellStatusBarHelper extends Disposable { private _currentItemIds: string[] = []; private _currentItemLists: INotebookCellStatusBarItemList[] = []; - private readonly _cancelTokenSource: CancellationTokenSource; + private _activeToken: CancellationTokenSource | undefined; private readonly _updateThrottler = new Throttler(); @@ -80,9 +80,7 @@ class CellStatusBarHelper extends Disposable { ) { super(); - this._cancelTokenSource = new CancellationTokenSource(); - this._register(toDisposable(() => this._cancelTokenSource.dispose(true))); - + this._register(toDisposable(() => this._activeToken?.dispose(true))); this._updateSoon(); this._register(this._cell.model.onDidChangeContent(() => this._updateSoon())); this._register(this._cell.model.onDidChangeLanguage(() => this._updateSoon())); @@ -93,17 +91,20 @@ class CellStatusBarHelper extends Disposable { private _updateSoon(): void { // Wait a tick to make sure that the event is fired to the EH before triggering status bar providers - setTimeout(() => { + this._register(disposableTimeout(() => { this._updateThrottler.queue(() => this._update()); - }, 0); + }, 0)); } private async _update() { const cellIndex = this._notebookViewModel.getCellIndex(this._cell); const docUri = this._notebookViewModel.notebookDocument.uri; const viewType = this._notebookViewModel.notebookDocument.viewType; - const itemLists = await this._notebookCellStatusBarService.getStatusBarItemsForCell(docUri, cellIndex, viewType, this._cancelTokenSource.token); - if (this._cancelTokenSource.token.isCancellationRequested) { + + this._activeToken?.dispose(true); + const tokenSource = this._activeToken = new CancellationTokenSource(); + const itemLists = await this._notebookCellStatusBarService.getStatusBarItemsForCell(docUri, cellIndex, viewType, tokenSource.token); + if (tokenSource.token.isCancellationRequested) { itemLists.forEach(itemList => itemList.dispose && itemList.dispose()); return; } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/executionStatusBarItemController.ts b/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/executionStatusBarItemController.ts index 88092be185a..e32ba0518d5 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/executionStatusBarItemController.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/statusBar/executionStatusBarItemController.ts @@ -243,7 +243,7 @@ class KeybindingPlaceholderStatusBarHelper extends Disposable { super(); // Create a fake ContextKeyService, and look up the keybindings within this context. - this._contextKeyService = _contextKeyService.createScoped(document.createElement('div')); + this._contextKeyService = this._register(_contextKeyService.createScoped(document.createElement('div'))); InputFocusedContext.bindTo(this._contextKeyService).set(true); EditorContextKeys.editorTextFocus.bindTo(this._contextKeyService).set(true); EditorContextKeys.focus.bindTo(this._contextKeyService).set(true); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts index 9db388ec10e..9472f2455ba 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts @@ -143,10 +143,10 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD setMarkdownCellEditState(cellId: string, editState: CellEditState): void { // throw new Error('Method not implemented.'); } - markdownCellDragStart(cellId: string, position: { clientY: number }): void { + markdownCellDragStart(cellId: string, event: { dragOffsetY: number }): void { // throw new Error('Method not implemented.'); } - markdownCellDrag(cellId: string, position: { clientY: number }): void { + markdownCellDrag(cellId: string, event: { dragOffsetY: number }): void { // throw new Error('Method not implemented.'); } markdownCellDragEnd(cellId: string): void { @@ -378,7 +378,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD this._modifiedWebview.dispose(); } - this._modifiedWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions()) as BackLayerWebView; + this._modifiedWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions(), undefined) as BackLayerWebView; // attach the webview container to the DOM tree first this._list.rowsContainer.insertAdjacentElement('afterbegin', this._modifiedWebview.element); await this._modifiedWebview.createWebview(); @@ -391,7 +391,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD this._originalWebview.dispose(); } - this._originalWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions()) as BackLayerWebView; + this._originalWebview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeDiffWebviewOptions(), undefined) as BackLayerWebView; // attach the webview container to the DOM tree first this._list.rowsContainer.insertAdjacentElement('afterbegin', this._originalWebview.element); await this._originalWebview.createWebview(); diff --git a/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts b/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts index de640c7926e..e925a6fdf98 100644 --- a/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts +++ b/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts @@ -6,7 +6,7 @@ import { IJSONSchema } from 'vs/base/common/jsonSchema'; import * as nls from 'vs/nls'; import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; -import { NotebookEditorPriority } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { NotebookEditorPriority, NotebookRendererEntrypoint } from 'vs/workbench/contrib/notebook/common/notebookCommon'; namespace NotebookEditorContribution { export const viewType = 'viewType'; @@ -30,6 +30,7 @@ namespace NotebookRendererContribution { export const entrypoint = 'entrypoint'; export const hardDependencies = 'dependencies'; export const optionalDependencies = 'optionalDependencies'; + export const requiresMessaging = 'requiresMessaging'; } export interface INotebookRendererContribution { @@ -37,9 +38,10 @@ export interface INotebookRendererContribution { readonly [NotebookRendererContribution.viewType]?: string; readonly [NotebookRendererContribution.displayName]: string; readonly [NotebookRendererContribution.mimeTypes]?: readonly string[]; - readonly [NotebookRendererContribution.entrypoint]: string; + readonly [NotebookRendererContribution.entrypoint]: NotebookRendererEntrypoint; readonly [NotebookRendererContribution.hardDependencies]: readonly string[]; readonly [NotebookRendererContribution.optionalDependencies]: readonly string[]; + readonly [NotebookRendererContribution.requiresMessaging]: boolean | 'optional' | undefined; } const notebookProviderContribution: IJSONSchema = { @@ -130,8 +132,27 @@ const notebookRendererContribution: IJSONSchema = { } }, [NotebookRendererContribution.entrypoint]: { - type: 'string', description: nls.localize('contributes.notebook.renderer.entrypoint', 'File to load in the webview to render the extension.'), + oneOf: [ + { + type: 'string', + }, + // todo@connor4312 + @mjbvz: uncomment this once it's ready for external adoption + // { + // type: 'object', + // required: ['extends', 'path'], + // properties: { + // extends: { + // type: 'string', + // description: nls.localize('contributes.notebook.renderer.entrypoint.extends', 'Existing renderer that this one extends.'), + // }, + // path: { + // type: 'string', + // description: nls.localize('contributes.notebook.renderer.entrypoint', 'File to load in the webview to render the extension.'), + // }, + // } + // } + ] }, [NotebookRendererContribution.hardDependencies]: { type: 'array', @@ -145,6 +166,20 @@ const notebookRendererContribution: IJSONSchema = { items: { type: 'string' }, markdownDescription: nls.localize('contributes.notebook.renderer.optionalDependencies', 'List of soft kernel dependencies the renderer can make use of. If any of the dependencies are present in the `NotebookKernel.preloads`, the renderer will be preferred over renderers that don\'t interact with the kernel.'), }, + [NotebookRendererContribution.requiresMessaging]: { + default: false, + enum: [ + true, + false, + 'optional' + ], + enumDescriptions: [ + nls.localize('contributes.notebook.renderer.requiresMessaging.true', 'Messaging is required. The renderer will only be used when it\'s part of an extension that can be run in an extension host.'), + nls.localize('contributes.notebook.renderer.requiresMessaging.optional', 'The renderer is better with messaging available, but it\'s not requried.'), + nls.localize('contributes.notebook.renderer.requiresMessaging.false', 'The renderer does not require messaging.'), + ], + description: nls.localize('contributes.notebook.renderer.requiresMessaging', 'Defines how and if the renderer needs to communicate with an extension host, via `createRendererMessaging`. Renderers with stronger messaging requirements may not work in all environments.'), + }, } } }; diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebook.css b/src/vs/workbench/contrib/notebook/browser/media/notebook.css index 489ef010508..3534ad26e6a 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebook.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebook.css @@ -11,15 +11,14 @@ position: relative; } -.monaco-workbench .notebookOverlay .notebook-top-toolbar { +.monaco-workbench .notebookOverlay .notebook-toolbar-container { width: 100%; - display: inline-flex; - padding-left: 8px; + display: none; margin-top: 2px; margin-bottom: 2px; } -.monaco-workbench .notebookOverlay .notebook-top-toolbar .monaco-action-bar .action-item { +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar .action-item { height: 22px; display: flex; align-items: center; @@ -27,22 +26,62 @@ margin-right: 8px; } -.monaco-workbench .notebookOverlay .notebook-top-toolbar .monaco-action-bar .action-item:hover { +.monaco-workbench .notebookOverlay .notebook-toolbar-container > .monaco-scrollable-element { + flex: 1; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container > .monaco-scrollable-element .notebook-toolbar-left { + padding: 0px 8px; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container .notebook-toolbar-right { + display: flex; + padding: 0px 0px 0px 8px; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar .action-item .kernel-label { + background-size: 16px; + padding: 0px 5px 0px 3px; + border-radius: 5px; + font-size: 13px; + height: 22px; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container .notebook-toolbar-left .monaco-action-bar .action-item .action-label.separator { + margin: 5px 0px !important; + padding: 0px !important; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar .action-item:hover { background-color: var(--code-toolbarHoverBackground); } -.monaco-workbench .notebookOverlay .notebook-top-toolbar .monaco-action-bar .action-item .action-label { +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar .action-item .action-label { background-size: 16px; + padding-left: 2px; } .monaco-workbench .notebook-action-view-item .action-label { display: inline-flex; } -.monaco-workbench .notebookOverlay .notebook-top-toolbar .monaco-action-bar .action-item .notebook-label { +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar .action-item .notebook-label { background-size: 16px; - padding: 0px 5px 0px 3px; + padding: 0px 5px 0px 2px; border-radius: 5px; + background-color: unset; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar:not(.vertical) .action-item.active .action-label:not(.disabled) { + background-color: unset; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover { + background-color: unset; +} + +.monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar:not(.vertical) .action-item.active { + background-color: unset; } .monaco-workbench .cell.markdown { @@ -215,11 +254,11 @@ color: red; /*TODO@rebornix theme color*/ } -.monaco-workbench .notebookOverlay .cell-drag-image .output .multi-mimetype-output { +.monaco-workbench .notebookOverlay .cell-drag-image .output .cell-output-toolbar { display: none; } -.monaco-workbench .notebookOverlay .output .multi-mimetype-output { +.monaco-workbench .notebookOverlay .output .cell-output-toolbar { position: absolute; top: 4px; left: -30px; @@ -308,46 +347,6 @@ display: none; } -/* top and bottom borders on cells */ -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-top:before, -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-bottom:before, -.monaco-workbench .notebookOverlay .monaco-list .markdown-cell-row .cell-inner-container:before, -.monaco-workbench .notebookOverlay .monaco-list .markdown-cell-row .cell-inner-container:after { - content: ""; - position: absolute; - width: 100%; - height: 1px; -} - -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-left:before, -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-right:before { - content: ""; - position: absolute; - width: 1px; - height: 100%; - z-index: 10; -} - -/* top border */ -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-top:before { - border-top: 1px solid transparent; -} - -/* left border */ -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-left:before { - border-left: 1px solid transparent; -} - -/* bottom border */ -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-bottom:before { - border-bottom: 1px solid transparent; -} - -/* right border */ -.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-right:before { - border-right: 1px solid transparent; -} - .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-top:before { top: 0; } diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index 1ac18f58be5..c2fe1b21212 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -53,6 +53,11 @@ import { EditorOverride } from 'vs/platform/editor/common/editor'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IWorkingCopyEditorService } from 'vs/workbench/services/workingCopy/common/workingCopyEditorService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; +import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { NotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl'; +import { INotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; // Editor Contribution import 'vs/workbench/contrib/notebook/browser/contrib/clipboard/notebookClipboard'; @@ -73,15 +78,11 @@ import 'vs/workbench/contrib/notebook/browser/contrib/cellOperations/cellOperati import 'vs/workbench/contrib/notebook/browser/contrib/viewportCustomMarkdown/viewportCustomMarkdown'; import 'vs/workbench/contrib/notebook/browser/contrib/troubleshoot/layout'; - // Diff Editor Contribution import 'vs/workbench/contrib/notebook/browser/diff/notebookDiffActions'; // Output renderers registration import 'vs/workbench/contrib/notebook/browser/view/output/transforms/richTransform'; -import { ILabelService } from 'vs/platform/label/common/label'; -import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; /*--------------------------------------------------------------------------------------------- */ @@ -255,7 +256,7 @@ class CellContentProvider implements ITextModelContentProvider { } if (result) { - const once = result.onWillDispose(() => { + const once = Event.any(result.onWillDispose, ref.object.notebook.onWillDispose)(() => { once.dispose(); ref.dispose(); }); @@ -544,6 +545,7 @@ registerSingleton(INotebookEditorModelResolverService, NotebookModelResolverServ registerSingleton(INotebookCellStatusBarService, NotebookCellStatusBarService, true); registerSingleton(INotebookEditorService, NotebookEditorWidgetService, true); registerSingleton(INotebookKernelService, NotebookKernelService, true); +registerSingleton(INotebookRendererMessagingService, NotebookRendererMessagingService, true); const configurationRegistry = Registry.as(Extensions.Configuration); configurationRegistry.registerConfiguration({ @@ -591,7 +593,7 @@ configurationRegistry.registerConfiguration({ markdownDescription: nls.localize('notebook.cellToolbarVisibility.description', "Whether the cell toolbar should appear on hover or click."), type: 'string', enum: ['hover', 'click'], - default: 'hover' + default: 'click' }, } }); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts index d188dea2b16..90bdc027e00 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -49,6 +49,7 @@ export const NOTEBOOK_CELL_LIST_FOCUSED = new RawContextKey('notebookCe export const NOTEBOOK_OUTPUT_FOCUSED = new RawContextKey('notebookOutputFocused', false); export const NOTEBOOK_EDITOR_EDITABLE = new RawContextKey('notebookEditable', true); export const NOTEBOOK_HAS_RUNNING_CELL = new RawContextKey('notebookHasRunningCell', false); +export const NOTEBOOK_USE_CONSOLIDATED_OUTPUT_BUTTON = new RawContextKey('notebookUseConsolidatedOutputButton', false); // Cell keys export const NOTEBOOK_VIEW_TYPE = new RawContextKey('notebookViewType', undefined); @@ -170,16 +171,16 @@ export interface ICommonNotebookEditor { triggerScroll(event: IMouseWheelEvent): void; getCellByInfo(cellInfo: ICommonCellInfo): IGenericCellViewModel; getCellById(cellId: string): IGenericCellViewModel | undefined; - toggleNotebookCellSelection(cell: IGenericCellViewModel): void; + toggleNotebookCellSelection(cell: IGenericCellViewModel, selectFromPrevious: boolean): void; focusNotebookCell(cell: IGenericCellViewModel, focus: 'editor' | 'container' | 'output', options?: IFocusNotebookCellOptions): void; focusNextNotebookCell(cell: IGenericCellViewModel, focus: 'editor' | 'container' | 'output'): void; updateOutputHeight(cellInfo: ICommonCellInfo, output: IDisplayOutputViewModel, height: number, isInit: boolean, source?: string): void; scheduleOutputHeightAck(cellInfo: ICommonCellInfo, outputId: string, height: number): void; updateMarkdownCellHeight(cellId: string, height: number, isInit: boolean): void; setMarkdownCellEditState(cellId: string, editState: CellEditState): void; - markdownCellDragStart(cellId: string, position: { clientY: number }): void; - markdownCellDrag(cellId: string, position: { clientY: number }): void; - markdownCellDrop(cellId: string, position: { clientY: number, ctrlKey: boolean, altKey: boolean }): void; + markdownCellDragStart(cellId: string, event: { dragOffsetY: number }): void; + markdownCellDrag(cellId: string, event: { dragOffsetY: number }): void; + markdownCellDrop(cellId: string, event: { dragOffsetY: number, ctrlKey: boolean, altKey: boolean }): void; markdownCellDragEnd(cellId: string): void; } diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorToolbar.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorToolbar.ts new file mode 100644 index 00000000000..0e2fd14c3cd --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorToolbar.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from 'vs/base/browser/dom'; +import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; +import { IAction, Separator } from 'vs/base/common/actions'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { ScrollbarVisibility } from 'vs/base/common/scrollable'; +import { IMenu, MenuItemAction, SubmenuItemAction } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService, optional } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { toolbarActiveBackground } from 'vs/platform/theme/common/colorRegistry'; +import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { SELECT_KERNEL_ID } from 'vs/workbench/contrib/notebook/browser/contrib/coreActions'; +import { INotebookEditor, NOTEBOOK_EDITOR_ID } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { NotebooKernelActionViewItem } from 'vs/workbench/contrib/notebook/browser/notebookKernelActionViewItem'; +import { ActionViewWithLabel } from 'vs/workbench/contrib/notebook/browser/view/renderers/cellActionView'; +import { CellMenus } from 'vs/workbench/contrib/notebook/browser/view/renderers/cellMenus'; +import { ExperimentalGlobalToolbar } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { ITASExperimentService } from 'vs/workbench/services/experiment/common/experimentService'; + +export class NotebookEditorToolbar extends Disposable { + // private _editorToolbarContainer!: HTMLElement; + private _leftToolbarScrollable!: DomScrollableElement; + private _notebookTopLeftToolbarContainer!: HTMLElement; + private _notebookTopRightToolbarContainer!: HTMLElement; + private _notebookGlobalActionsMenu!: IMenu; + private _notebookLeftToolbar!: ToolBar; + private _notebookRightToolbar!: ToolBar; + private _useGlobalToolbar: boolean = false; + + private readonly _onDidChangeState = this._register(new Emitter()); + onDidChangeState: Event = this._onDidChangeState.event; + + get useGlobalToolbar(): boolean { + return this._useGlobalToolbar; + } + + private _pendingLayout: IDisposable | undefined; + + constructor( + readonly notebookEditor: INotebookEditor, + readonly contextKeyService: IContextKeyService, + readonly domNode: HTMLElement, + @IInstantiationService readonly instantiationService: IInstantiationService, + @IConfigurationService readonly configurationService: IConfigurationService, + @IContextMenuService readonly contextMenuService: IContextMenuService, + @IEditorService private readonly editorService: IEditorService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @optional(ITASExperimentService) private readonly experimentService: ITASExperimentService + ) { + super(); + + this._buildBody(); + + this._register(this.editorService.onDidActiveEditorChange(() => { + if (this.editorService.activeEditorPane?.getId() === NOTEBOOK_EDITOR_ID) { + const notebookEditor = this.editorService.activeEditorPane.getControl() as INotebookEditor; + if (notebookEditor === this.notebookEditor) { + // this is the active editor + this._showNotebookActionsinEditorToolbar(); + return; + } + } + })); + + this._reigsterNotebookActionsToolbar(); + } + + private _buildBody() { + this._notebookTopLeftToolbarContainer = document.createElement('div'); + this._notebookTopLeftToolbarContainer.classList.add('notebook-toolbar-left'); + this._leftToolbarScrollable = new DomScrollableElement(this._notebookTopLeftToolbarContainer, { + vertical: ScrollbarVisibility.Hidden, + horizontal: ScrollbarVisibility.Auto, + horizontalScrollbarSize: 3, + useShadows: false, + scrollYToX: true + }); + this._register(this._leftToolbarScrollable); + + DOM.append(this.domNode, this._leftToolbarScrollable.getDomNode()); + this._notebookTopRightToolbarContainer = document.createElement('div'); + this._notebookTopRightToolbarContainer.classList.add('notebook-toolbar-right'); + DOM.append(this.domNode, this._notebookTopRightToolbarContainer); + } + + private _reigsterNotebookActionsToolbar() { + const cellMenu = this.instantiationService.createInstance(CellMenus); + this._notebookGlobalActionsMenu = this._register(cellMenu.getNotebookToolbar(this.contextKeyService)); + this._register(this._notebookGlobalActionsMenu); + + this._useGlobalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar) ?? false; + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ExperimentalGlobalToolbar)) { + this._useGlobalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar); + this._showNotebookActionsinEditorToolbar(); + } + })); + + const context = { + ui: true, + notebookEditor: this.notebookEditor + }; + + const actionProvider = (action: IAction) => { + if (action.id === SELECT_KERNEL_ID) { + // // this is being disposed by the consumer + return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor); + } + + return action instanceof MenuItemAction ? this.instantiationService.createInstance(ActionViewWithLabel, action) : undefined; + }; + + this._notebookLeftToolbar = new ToolBar(this._notebookTopLeftToolbarContainer, this.contextMenuService, { + getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), + actionViewItemProvider: actionProvider, + renderDropdownAsChildElement: true + }); + this._register(this._notebookLeftToolbar); + this._notebookLeftToolbar.context = context; + + this._notebookRightToolbar = new ToolBar(this._notebookTopRightToolbarContainer, this.contextMenuService, { + getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), + actionViewItemProvider: actionProvider, + renderDropdownAsChildElement: true + }); + this._register(this._notebookRightToolbar); + this._notebookRightToolbar.context = context; + + this._showNotebookActionsinEditorToolbar(); + this._register(this._notebookGlobalActionsMenu.onDidChange(() => { + this._showNotebookActionsinEditorToolbar(); + })); + + if (this.experimentService) { + this.experimentService.getTreatment('nbtoolbarineditor').then(treatment => { + if (treatment === undefined) { + return; + } + if (this._useGlobalToolbar !== treatment) { + this._useGlobalToolbar = treatment; + this._showNotebookActionsinEditorToolbar(); + } + }); + } + } + + private _showNotebookActionsinEditorToolbar() { + // when there is no view model, just ignore. + if (!this.notebookEditor.hasModel()) { + return; + } + + if (!this._useGlobalToolbar) { + this.domNode.style.display = 'none'; + } else { + this._notebookLeftToolbar.setActions([], []); + const groups = this._notebookGlobalActionsMenu.getActions({ shouldForwardArgs: true, renderShortTitle: true }); + this.domNode.style.display = 'flex'; + const primaryLeftGroups = groups.filter(group => /^navigation/.test(group[0])); + let primaryActions: IAction[] = []; + primaryLeftGroups.sort((a, b) => { + if (a[0] === 'navigation') { + return 1; + } + + if (b[0] === 'navigation') { + return -1; + } + + return 0; + }).forEach((group, index) => { + primaryActions.push(...group[1]); + if (index < primaryLeftGroups.length - 1) { + primaryActions.push(new Separator()); + } + }); + const primaryRightGroup = groups.find(group => /^status/.test(group[0])); + const primaryRightActions = primaryRightGroup ? primaryRightGroup[1] : []; + const secondaryActions = groups.filter(group => /^navigation/.test(group[0]) && /^status/.test(group[0])).reduce((prev: (MenuItemAction | SubmenuItemAction)[], curr) => { prev.push(...curr[1]); return prev; }, []); + + this._notebookLeftToolbar.setActions(primaryActions, secondaryActions); + this._notebookRightToolbar.setActions(primaryRightActions, []); + this._updateScrollbar(); + } + + this._onDidChangeState.fire(); + } + + layout() { + this._updateScrollbar(); + } + + private _updateScrollbar() { + this._pendingLayout?.dispose(); + + this._pendingLayout = DOM.measure(() => { + DOM.measure(() => { // double RAF + this._leftToolbarScrollable.setRevealOnScroll(false); + this._leftToolbarScrollable.scanDomNode(); + this._leftToolbarScrollable.setRevealOnScroll(true); + }); + }); + } + + override dispose() { + this._pendingLayout?.dispose(); + super.dispose(); + } +} + +registerThemingParticipant((theme, collector) => { + const toolbarActiveBackgroundColor = theme.getColor(toolbarActiveBackground); + if (toolbarActiveBackgroundColor) { + collector.addRule(` + .monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar:not(.vertical) .action-item.active { + background-color: ${toolbarActiveBackgroundColor}; + } + `); + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index bf5eb6e6228..6204eeaf2d6 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -26,11 +26,11 @@ import { IEditor } from 'vs/editor/common/editorCommon'; import { IModeService } from 'vs/editor/common/services/modeService'; import * as nls from 'vs/nls'; import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; -import { IMenu, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from 'vs/platform/actions/common/actions'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { IInstantiationService, optional } from 'vs/platform/instantiation/common/instantiation'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; @@ -42,7 +42,7 @@ import { IEditorMemento } from 'vs/workbench/common/editor'; import { Memento, MementoObject } from 'vs/workbench/common/memento'; import { PANEL_BORDER } from 'vs/workbench/common/theme'; import { debugIconStartForeground } from 'vs/workbench/contrib/debug/browser/debugColors'; -import { CellEditState, CellFocusMode, IActiveNotebookEditor, ICellOutputViewModel, ICellViewModel, ICommonCellInfo, IDisplayOutputLayoutUpdateRequest, IFocusNotebookCellOptions, IGenericCellViewModel, IInsetRenderOutput, INotebookCellList, INotebookCellOutputLayoutInfo, INotebookDeltaDecoration, INotebookEditor, INotebookEditorContribution, INotebookEditorContributionDescription, INotebookEditorCreationOptions, INotebookEditorMouseEvent, NotebookEditorOptions, NotebookLayoutInfo, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_EDITOR_ID, NOTEBOOK_OUTPUT_FOCUSED } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { CellEditState, CellFocusMode, IActiveNotebookEditor, ICellOutputViewModel, ICellViewModel, ICommonCellInfo, IDisplayOutputLayoutUpdateRequest, IFocusNotebookCellOptions, IGenericCellViewModel, IInsetRenderOutput, INotebookCellList, INotebookCellOutputLayoutInfo, INotebookDeltaDecoration, INotebookEditor, INotebookEditorContribution, INotebookEditorContributionDescription, INotebookEditorCreationOptions, INotebookEditorMouseEvent, NotebookEditorOptions, NotebookLayoutInfo, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_EDITOR_ID, NOTEBOOK_OUTPUT_FOCUSED, RenderOutputType } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { NotebookDecorationCSSRules, NotebookRefCountedStyleSheet } from 'vs/workbench/contrib/notebook/browser/notebookEditorDecorations'; import { NotebookEditorExtensionsRegistry } from 'vs/workbench/contrib/notebook/browser/notebookEditorExtensions'; import { NotebookEditorKernelManager } from 'vs/workbench/contrib/notebook/browser/notebookEditorKernelManager'; @@ -64,20 +64,15 @@ import { editorGutterModifiedBackground } from 'vs/workbench/contrib/scm/browser import { Webview } from 'vs/workbench/contrib/webview/browser/webview'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; -import { CellMenus } from 'vs/workbench/contrib/notebook/browser/view/renderers/cellMenus'; -import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; -import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { ITASExperimentService } from 'vs/workbench/services/experiment/common/experimentService'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { isWeb } from 'vs/base/common/platform'; import { mark } from 'vs/workbench/contrib/notebook/common/notebookPerformance'; import { readFontInfo } from 'vs/editor/browser/config/configuration'; import { INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; import { NotebookEditorContextKeys } from 'vs/workbench/contrib/notebook/browser/notebookEditorWidgetContextKeys'; import { NotebookOptions } from 'vs/workbench/contrib/notebook/common/notebookOptions'; -import { SCROLLABLE_ELEMENT_PADDING_TOP } from 'vs/workbench/contrib/notebook/browser/constants'; import { ViewContext } from 'vs/workbench/contrib/notebook/browser/viewModel/viewContext'; -import { ActionViewWithLabel } from 'vs/workbench/contrib/notebook/browser/view/renderers/cellActionView'; +import { NotebookEditorToolbar } from 'vs/workbench/contrib/notebook/browser/notebookEditorToolbar'; +import { INotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; const $ = DOM.$; @@ -205,6 +200,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor private static readonly EDITOR_MEMENTOS = new Map>(); private _overlayContainer!: HTMLElement; private _notebookTopToolbarContainer!: HTMLElement; + private _notebookTopToolbar!: NotebookEditorToolbar; private _body!: HTMLElement; private _styleElement!: HTMLStyleElement; private _overflowContainer!: HTMLElement; @@ -217,7 +213,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor private _dndController: CellDragAndDropController | null = null; private _listTopCellToolbar: ListTopCellToolbar | null = null; private _renderedEditors: Map = new Map(); - private _viewContext: ViewContext | undefined; + private _viewContext: ViewContext; private _notebookViewModel: NotebookViewModel | undefined; private _localStore: DisposableStore = this._register(new DisposableStore()); private _localCellStateListeners: DisposableStore[] = []; @@ -321,9 +317,9 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor @IInstantiationService instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @IAccessibilityService accessibilityService: IAccessibilityService, + @INotebookRendererMessagingService private readonly notebookRendererMessaging: INotebookRendererMessagingService, @INotebookEditorService private readonly notebookEditorService: INotebookEditorService, @INotebookKernelService private readonly notebookKernelService: INotebookKernelService, - @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @ILayoutService private readonly layoutService: ILayoutService, @@ -331,9 +327,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor @IMenuService private readonly menuService: IMenuService, @IThemeService private readonly themeService: IThemeService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IModeService private readonly modeService: IModeService, - @IKeybindingService private readonly keybindingService: IKeybindingService, - @optional(ITASExperimentService) private readonly experimentService: ITASExperimentService + @IModeService private readonly modeService: IModeService ) { super(); this.isEmbedded = creationOptions.isEmbedded || false; @@ -341,6 +335,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this.useRenderer = !isWeb && !!this.configurationService.getValue(ExperimentalUseMarkdownRenderer) && !accessibilityService.isScreenReaderOptimized(); this._notebookOptions = new NotebookOptions(this.configurationService); this._register(this._notebookOptions); + this._viewContext = new ViewContext(this._notebookOptions, new NotebookEventDispatcher()); this._overlayContainer = document.createElement('div'); this.scopedContextKeyService = contextKeyService.createScoped(this._overlayContainer); @@ -374,11 +369,15 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this._updateForNotebookConfiguration(); } - if (e.compactView) { + if (e.compactView || e.focusIndicator || e.insertToolbarPosition || e.cellToolbarLocation) { this._styleElement?.remove(); this._createLayoutStyles(); this._webview?.updateOptions(this.notebookOptions.computeWebviewOptions()); } + + if (this._dimension && this._isVisible) { + this.layout(this._dimension); + } })); this.notebookEditorService.addNotebookEditor(this); @@ -535,7 +534,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor private _createBody(parent: HTMLElement): void { this._notebookTopToolbarContainer = document.createElement('div'); - this._notebookTopToolbarContainer.classList.add('notebook-top-toolbar'); + this._notebookTopToolbarContainer.classList.add('notebook-toolbar-container'); this._notebookTopToolbarContainer.style.display = 'none'; DOM.append(parent, this._notebookTopToolbarContainer); this._body = document.createElement('div'); @@ -559,10 +558,12 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor codeCellLeftMargin, markdownCellBottomMargin, markdownCellTopMargin, - bottomCellToolbarGap, - bottomCellToolbarHeight, + bottomToolbarGap: bottomCellToolbarGap, + bottomToolbarHeight: bottomCellToolbarHeight, collapsedIndicatorHeight, - compactView + compactView, + focusIndicator, + insertToolbarPosition } = this._notebookOptions.getLayoutConfiguration(); const styleSheets: string[] = []; @@ -573,6 +574,103 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor styleSheets.push(`.notebookOverlay .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .markdown-cell-row div.cell.code { margin-left: ${codeCellLeftMargin}px; }`); } + // focus indicator + if (focusIndicator === 'border') { + styleSheets.push(` + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-top:before, + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-bottom:before, + .monaco-workbench .notebookOverlay .monaco-list .markdown-cell-row .cell-inner-container:before, + .monaco-workbench .notebookOverlay .monaco-list .markdown-cell-row .cell-inner-container:after { + content: ""; + position: absolute; + width: 100%; + height: 1px; + } + + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-left:before, + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-right:before { + content: ""; + position: absolute; + width: 1px; + height: 100%; + z-index: 10; + } + + /* top border */ + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-top:before { + border-top: 1px solid transparent; + } + + /* left border */ + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-left:before { + border-left: 1px solid transparent; + } + + /* bottom border */ + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-bottom:before { + border-bottom: 1px solid transparent; + } + + /* right border */ + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-right:before { + border-right: 1px solid transparent; + } + `); + + // left and right border margins + styleSheets.push(` + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.focused .cell-focus-indicator-left:before, + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.focused .cell-focus-indicator-right:before, + .monaco-workbench .notebookOverlay .monaco-list.selection-multiple .monaco-list-row.code-cell-row.selected .cell-focus-indicator-left:before, + .monaco-workbench .notebookOverlay .monaco-list.selection-multiple .monaco-list-row.code-cell-row.selected .cell-focus-indicator-right:before { + top: -${cellTopMargin}px; height: calc(100% + ${cellTopMargin + cellBottomMargin}px) + }`); + } else { + styleSheets.push(` + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-left:before, + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-right:before { + content: ""; + position: absolute; + width: 0px; + height: 100%; + z-index: 10; + } + + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-focus-indicator-left:before { + border-left: 1px solid transparent; + border-right: 1px solid transparent; + border-radius: 2px; + } + `); + + // left and right border margins + styleSheets.push(` + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.focused .cell-focus-indicator-left:before, + .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.focused .cell-focus-indicator-right:before, + .monaco-workbench .notebookOverlay .monaco-list.selection-multiple .monaco-list-row.code-cell-row.selected .cell-focus-indicator-left:before, + .monaco-workbench .notebookOverlay .monaco-list.selection-multiple .monaco-list-row.code-cell-row.selected .cell-focus-indicator-right:before { + top: 0px; height: 100%px; + }`); + } + + // between cell insert toolbar + if (insertToolbarPosition === 'betweenCells' || insertToolbarPosition === 'both') { + styleSheets.push(`.monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row .cell-bottom-toolbar-container { display: flex; }`); + styleSheets.push(`.monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .cell-list-top-cell-toolbar-container { display: flex; }`); + } else { + styleSheets.push(`.monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row .cell-bottom-toolbar-container { display: none; }`); + styleSheets.push(`.monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .cell-list-top-cell-toolbar-container { display: none; }`); + } + + // top insert toolbar + const topInsertToolbarHeight = this._notebookOptions.computeTopInserToolbarHeight(this.viewModel?.viewType); + styleSheets.push(`.notebookOverlay .cell-list-top-cell-toolbar-container { top: -${topInsertToolbarHeight}px }`); + styleSheets.push(`.notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element, + .notebookOverlay > .cell-list-container > .notebook-gutter > .monaco-list > .monaco-scrollable-element { + padding-top: ${topInsertToolbarHeight}px; + box-sizing: border-box; + }`); + styleSheets.push(`.notebookOverlay .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .code-cell-row div.cell.code { margin-left: ${codeCellLeftMargin + cellRunGutter}px; }`); styleSheets.push(`.notebookOverlay .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row div.cell { margin-right: ${cellRightMargin}px; }`); styleSheets.push(`.notebookOverlay .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row > .cell-inner-container { padding-top: ${cellTopMargin}px; }`); @@ -598,7 +696,6 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor styleSheets.push(`.notebookOverlay .monaco-list .monaco-list-row .cell-shadow-container-bottom { top: ${cellBottomMargin}px; }`); styleSheets.push(`.monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row .cell-collapsed-part { margin-left: ${codeCellLeftMargin + cellRunGutter}px; height: ${collapsedIndicatorHeight}px; }`); - styleSheets.push(`.notebookOverlay .cell-list-top-cell-toolbar-container { top: -${SCROLLABLE_ELEMENT_PADDING_TOP}px }`); styleSheets.push(`.monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row .cell-bottom-toolbar-container { height: ${bottomCellToolbarHeight}px }`); styleSheets.push(`.monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .cell-list-top-cell-toolbar-container { height: ${bottomCellToolbarHeight}px }`); @@ -614,15 +711,6 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor display: none; }`); - // left and right border margins - styleSheets.push(` - .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.focused .cell-focus-indicator-left:before, - .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.focused .cell-focus-indicator-right:before, - .monaco-workbench .notebookOverlay .monaco-list.selection-multiple .monaco-list-row.code-cell-row.selected .cell-focus-indicator-left:before, - .monaco-workbench .notebookOverlay .monaco-list.selection-multiple .monaco-list-row.code-cell-row.selected .cell-focus-indicator-right:before { - top: -${cellTopMargin}px; height: calc(100% + ${cellTopMargin + cellBottomMargin}px) - }`); - this._styleElement.textContent = styleSheets.join('\n'); } @@ -648,6 +736,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor 'NotebookCellList', this._overlayContainer, this._body, + this._viewContext, this._listDelegate, renderers, this.scopedContextKeyService, @@ -770,18 +859,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this._register(widgetFocusTracker.onDidBlur(() => this._onDidBlurEmitter.fire())); this._reigsterNotebookActionsToolbar(); - this._register(this.editorService.onDidActiveEditorChange(() => { - if (this.editorService.activeEditorPane?.getId() === NOTEBOOK_EDITOR_ID) { - const notebookEditor = this.editorService.activeEditorPane.getControl() as INotebookEditor; - if (notebookEditor === this) { - // this is the active editor - this._showNotebookActionsinEditorToolbar(); - return; - } - } - this._toolbarActionDisposable.clear(); - })); } private showListContextMenu(e: IListContextMenuEvent) { @@ -797,81 +875,13 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor }); } - private _notebookGlobalActionsMenu!: IMenu; - private _toolbarActionDisposable = this._register(new DisposableStore()); - private _topToolbar!: ToolBar; - private _useGlobalToolbar: boolean = false; private _reigsterNotebookActionsToolbar() { - const cellMenu = this.instantiationService.createInstance(CellMenus); - this._notebookGlobalActionsMenu = this._register(cellMenu.getNotebookToolbar(this.scopedContextKeyService)); - this._register(this._notebookGlobalActionsMenu); - - this._useGlobalToolbar = this.configurationService.getValue('notebook.experimental.globalToolbar') ?? false; - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('notebook.experimental.globalToolbar')) { - this._useGlobalToolbar = this.configurationService.getValue('notebook.experimental.globalToolbar'); - this._showNotebookActionsinEditorToolbar(); + this._notebookTopToolbar = this._register(this.instantiationService.createInstance(NotebookEditorToolbar, this, this.scopedContextKeyService, this._notebookTopToolbarContainer)); + this._register(this._notebookTopToolbar.onDidChangeState(() => { + if (this._dimension && this._isVisible) { + this.layout(this._dimension); } })); - - this._topToolbar = new ToolBar(this._notebookTopToolbarContainer, this.contextMenuService, { - getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), - actionViewItemProvider: action => { - if (action instanceof MenuItemAction) { - return this.instantiationService.createInstance(ActionViewWithLabel, action); - } else { - return undefined; - } - }, - renderDropdownAsChildElement: true - }); - this._register(this._topToolbar); - this._topToolbar.context = { - ui: true, - notebookEditor: this - }; - - this._showNotebookActionsinEditorToolbar(); - this._register(this._notebookGlobalActionsMenu.onDidChange(() => { - this._showNotebookActionsinEditorToolbar(); - })); - - if (this.experimentService) { - this.experimentService.getTreatment('nbtoolbarineditor').then(treatment => { - if (treatment === undefined) { - return; - } - if (this._useGlobalToolbar !== treatment) { - this._useGlobalToolbar = treatment; - this._showNotebookActionsinEditorToolbar(); - } - }); - } - } - - private _showNotebookActionsinEditorToolbar() { - // when there is no view model, just ignore. - if (!this.viewModel) { - return; - } - - if (!this._useGlobalToolbar) { - this._notebookTopToolbarContainer.style.display = 'none'; - } else { - this._toolbarActionDisposable.clear(); - this._topToolbar.setActions([], []); - const groups = this._notebookGlobalActionsMenu.getActions({ shouldForwardArgs: true }); - this._notebookTopToolbarContainer.style.display = 'flex'; - const primaryGroup = groups.find(group => group[0] === 'navigation'); - const primaryActions = primaryGroup ? primaryGroup[1] : []; - const secondaryActions = groups.filter(group => group[0] !== 'navigation').reduce((prev: (MenuItemAction | SubmenuItemAction)[], curr) => { prev.push(...curr[1]); return prev; }, []); - - this._topToolbar.setActions(primaryActions, secondaryActions); - } - - if (this._dimension && this._isVisible) { - this.layout(this._dimension); - } } private _updateForCursorNavigationMode(applyFocusChange: () => void): void { @@ -909,9 +919,16 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor async setModel(textModel: NotebookTextModel, viewState: INotebookEditorViewState | undefined): Promise { if (this.viewModel === undefined || !this.viewModel.equal(textModel)) { + const oldTopInsertToolbarHeight = this._notebookOptions.computeTopInserToolbarHeight(this.viewModel?.viewType); this._detachModel(); await this._attachModel(textModel, viewState); + const newTopInsertToolbarHeight = this._notebookOptions.computeTopInserToolbarHeight(this.viewModel?.viewType); + if (oldTopInsertToolbarHeight !== newTopInsertToolbarHeight) { + this._styleElement?.remove(); + this._createLayoutStyles(); + this._webview?.updateOptions(this.notebookOptions.computeWebviewOptions()); + } type WorkbenchNotebookOpenClassification = { scheme: { classification: 'SystemMetaData', purpose: 'FeatureInsight'; }; ext: { classification: 'SystemMetaData', purpose: 'FeatureInsight'; }; @@ -1100,7 +1117,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor } private async _createWebview(id: string, resource: URI): Promise { - this._webview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeWebviewOptions()); + this._webview = this.instantiationService.createInstance(BackLayerWebView, this, id, resource, this._notebookOptions.computeWebviewOptions(), this.notebookRendererMessaging.getScoped(this._uuid)); this._webview.element.style.width = '100%'; // attach the webview container to the DOM tree first @@ -1109,8 +1126,6 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor private async _attachModel(textModel: NotebookTextModel, viewState: INotebookEditorViewState | undefined) { await this._createWebview(this.getId(), textModel.uri); - - this._viewContext = new ViewContext(this._notebookOptions, new NotebookEventDispatcher()); this.viewModel = this.instantiationService.createInstance(NotebookViewModel, textModel.viewType, textModel, this._viewContext, this.getLayoutInfo()); this._viewContext.eventDispatcher.emit([new NotebookLayoutChangedEvent({ width: true, fontInfo: true }, this.getLayoutInfo())]); @@ -1211,7 +1226,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor })); if (this._dimension) { - this._list.layout(this._dimension.height - SCROLLABLE_ELEMENT_PADDING_TOP, this._dimension.width); + const topInserToolbarHeight = this._notebookOptions.computeTopInserToolbarHeight(this.viewModel?.viewType); + this._list.layout(this._dimension.height - topInserToolbarHeight, this._dimension.width); } else { this._list.layout(); } @@ -1439,16 +1455,18 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor }; } + const topInserToolbarHeight = this._notebookOptions.computeTopInserToolbarHeight(this.viewModel?.viewType); + this._dimension = new DOM.Dimension(dimension.width, dimension.height); - DOM.size(this._body, dimension.width, dimension.height - (this._useGlobalToolbar ? /** Toolbar height */ 26 : 0)); - if (this._list.getRenderHeight() < dimension.height - SCROLLABLE_ELEMENT_PADDING_TOP) { + DOM.size(this._body, dimension.width, dimension.height - (this._notebookTopToolbar?.useGlobalToolbar ? /** Toolbar height */ 26 : 0)); + if (this._list.getRenderHeight() < dimension.height - topInserToolbarHeight) { // the new dimension is larger than the list viewport, update its additional height first, otherwise the list view will move down a bit (as the `scrollBottom` will move down) - this._list.updateOptions({ additionalScrollHeight: this._scrollBeyondLastLine ? Math.max(0, (dimension.height - SCROLLABLE_ELEMENT_PADDING_TOP - 50)) : SCROLLABLE_ELEMENT_PADDING_TOP }); - this._list.layout(dimension.height - SCROLLABLE_ELEMENT_PADDING_TOP, dimension.width); + this._list.updateOptions({ additionalScrollHeight: this._scrollBeyondLastLine ? Math.max(0, (dimension.height - topInserToolbarHeight - 50)) : topInserToolbarHeight }); + this._list.layout(dimension.height - topInserToolbarHeight, dimension.width); } else { // the new dimension is smaller than the list viewport, if we update the additional height, the `scrollBottom` will move up, which moves the whole list view upwards a bit. So we run a layout first. - this._list.layout(dimension.height - SCROLLABLE_ELEMENT_PADDING_TOP, dimension.width); - this._list.updateOptions({ additionalScrollHeight: this._scrollBeyondLastLine ? Math.max(0, (dimension.height - SCROLLABLE_ELEMENT_PADDING_TOP - 50)) : SCROLLABLE_ELEMENT_PADDING_TOP }); + this._list.layout(dimension.height - topInserToolbarHeight, dimension.width); + this._list.updateOptions({ additionalScrollHeight: this._scrollBeyondLastLine ? Math.max(0, (dimension.height - topInserToolbarHeight - 50)) : topInserToolbarHeight }); } this._overlayContainer.style.visibility = 'visible'; @@ -1466,6 +1484,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this._webviewTransparentCover.style.width = `${dimension.width}px`; } + this._notebookTopToolbar.layout(); + this._viewContext?.eventDispatcher.emit([new NotebookLayoutChangedEvent({ width: true, fontInfo: true }, this.getLayoutInfo())]); } //#endregion @@ -1509,10 +1529,6 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor const focused = DOM.isAncestor(document.activeElement, this._overlayContainer); this._editorFocus.set(focused); this.viewModel?.setFocus(focused); - - if (!focused) { - this._toolbarActionDisposable.clear(); - } } hasFocus() { @@ -2041,19 +2057,38 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor } } - toggleNotebookCellSelection(cell: ICellViewModel): void { + toggleNotebookCellSelection(selectedCell: ICellViewModel, selectFromPrevious: boolean): void { const currentSelections = this._list.getSelectedElements(); + const isSelected = currentSelections.includes(selectedCell); - const isSelected = currentSelections.includes(cell); + const previousSelection = selectFromPrevious ? currentSelections[currentSelections.length - 1] ?? selectedCell : selectedCell; + const selectedIndex = this._list.getViewIndex(selectedCell)!; + const previousIndex = this._list.getViewIndex(previousSelection)!; + + const cellsInSelectionRange = this.getCellsInRange(selectedIndex, previousIndex); if (isSelected) { // Deselect - this._list.selectElements(currentSelections.filter(current => current !== cell)); + this._list.selectElements(currentSelections.filter(current => !cellsInSelectionRange.includes(current))); } else { // Add to selection - this._list.selectElements([...currentSelections, cell]); + this.focusElement(selectedCell); + this._list.selectElements([...currentSelections.filter(current => !cellsInSelectionRange.includes(current)), ...cellsInSelectionRange]); } } + private getCellsInRange(fromInclusive: number, toInclusive: number): ICellViewModel[] { + const selectedCellsInRange: ICellViewModel[] = []; + for (let index = 0; index < this._list.length; ++index) { + const cell = this._list.element(index); + if (cell) { + if ((index >= fromInclusive && index <= toInclusive) || (index >= toInclusive && index <= fromInclusive)) { + selectedCellsInRange.push(cell); + } + } + } + return selectedCellsInRange; + } + focusNotebookCell(cell: ICellViewModel, focusItem: 'editor' | 'container' | 'output', options?: IFocusNotebookCellOptions) { if (this._isDisposed) { return; @@ -2252,6 +2287,10 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor return; } + if (output.type === RenderOutputType.Extension) { + this.notebookRendererMessaging.prepare(output.renderer.id); + } + const cellTop = this._list.getAbsoluteTopOfElement(cell); if (!this._webview.insetMapping.has(output.source)) { await this._webview.createOutput({ cellId: cell.id, cellHandle: cell.handle, cellUri: cell.uri }, output, cellTop, offset); @@ -2403,8 +2442,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor const cell = this.getCellById(cellId); const layoutConfiguration = this._notebookOptions.getLayoutConfiguration(); if (cell && cell instanceof MarkdownCellViewModel) { - if (height + layoutConfiguration.bottomCellToolbarGap !== cell.layoutInfo.totalHeight) { - this._debug('updateMarkdownCellHeight', cell.handle, height + layoutConfiguration.bottomCellToolbarGap, isInit); + if (height + layoutConfiguration.bottomToolbarGap !== cell.layoutInfo.totalHeight) { + this._debug('updateMarkdownCellHeight', cell.handle, height + layoutConfiguration.bottomToolbarGap, isInit); cell.renderedMarkdownHeight = height; } } @@ -2417,24 +2456,24 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor } } - markdownCellDragStart(cellId: string, ctx: { clientY: number }): void { + markdownCellDragStart(cellId: string, event: { dragOffsetY: number }): void { const cell = this.getCellById(cellId); if (cell instanceof MarkdownCellViewModel) { - this._dndController?.startExplicitDrag(cell, ctx); + this._dndController?.startExplicitDrag(cell, event.dragOffsetY); } } - markdownCellDrag(cellId: string, ctx: { clientY: number }): void { + markdownCellDrag(cellId: string, event: { dragOffsetY: number }): void { const cell = this.getCellById(cellId); if (cell instanceof MarkdownCellViewModel) { - this._dndController?.explicitDrag(cell, ctx); + this._dndController?.explicitDrag(cell, event.dragOffsetY); } } - markdownCellDrop(cellId: string, ctx: { clientY: number, ctrlKey: boolean, altKey: boolean }): void { + markdownCellDrop(cellId: string, event: { dragOffsetY: number, ctrlKey: boolean, altKey: boolean }): void { const cell = this.getCellById(cellId); if (cell instanceof MarkdownCellViewModel) { - this._dndController?.explicitDrop(cell, ctx); + this._dndController?.explicitDrop(cell, event); } } @@ -2478,7 +2517,6 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this._webviewTransparentCover = null; this._dndController = null; this._listTopCellToolbar = null; - this._viewContext = undefined; this._notebookViewModel = undefined; this._cellContextKeyManager = null; this._renderedEditors.clear(); @@ -2624,25 +2662,16 @@ export const cellEditorBackground = registerColor('notebook.cellEditorBackground }, nls.localize('notebook.cellEditorBackground', "Cell editor background color.")); registerThemingParticipant((theme, collector) => { - collector.addRule(`.notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element, - .notebookOverlay > .cell-list-container > .notebook-gutter > .monaco-list > .monaco-scrollable-element { - padding-top: ${SCROLLABLE_ELEMENT_PADDING_TOP}px; - box-sizing: border-box; - }`); - const link = theme.getColor(textLinkForeground); if (link) { - collector.addRule(`.notebookOverlay .output a, - .notebookOverlay .cell.markdown a, + collector.addRule(`.notebookOverlay .cell.markdown a, .notebookOverlay .output-show-more-container a { color: ${link};} `); } const activeLink = theme.getColor(textLinkActiveForeground); if (activeLink) { - collector.addRule(`.notebookOverlay .output a:hover, - .notebookOverlay .cell .output a:active, - .notebookOverlay .output-show-more-container a:active + collector.addRule(`.notebookOverlay .output-show-more-container a:active { color: ${activeLink}; }`); } const shortcut = theme.getColor(textPreformatForeground); @@ -2697,8 +2726,8 @@ registerThemingParticipant((theme, collector) => { const focusedCellBackgroundColor = theme.getColor(focusedCellBackground); if (focusedCellBackgroundColor) { - collector.addRule(`.notebookOverlay .code-cell-row.focused .cell-focus-indicator, - .notebookOverlay .markdown-cell-row.focused { background-color: ${focusedCellBackgroundColor} !important; }`); + collector.addRule(`.notebookOverlay .code-cell-row.focused .cell-focus-indicator { background-color: ${focusedCellBackgroundColor} !important; }`); + collector.addRule(`.notebookOverlay .markdown-cell-row.focused { background-color: ${focusedCellBackgroundColor} !important; }`); collector.addRule(`.notebookOverlay .code-cell-row.focused .cell-collapsed-part { background-color: ${focusedCellBackgroundColor} !important; }`); } diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidgetContextKeys.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidgetContextKeys.ts index 8df4ae49e95..d51c5574af7 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidgetContextKeys.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidgetContextKeys.ts @@ -5,7 +5,7 @@ import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { ICellViewModel, NOTEBOOK_HAS_RUNNING_CELL, NOTEBOOK_INTERRUPTIBLE_KERNEL, NOTEBOOK_KERNEL_COUNT, INotebookEditor, NOTEBOOK_KERNEL_SELECTED, NOTEBOOK_VIEW_TYPE } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { ICellViewModel, INotebookEditor, NOTEBOOK_HAS_RUNNING_CELL, NOTEBOOK_INTERRUPTIBLE_KERNEL, NOTEBOOK_KERNEL_COUNT, NOTEBOOK_KERNEL_SELECTED, NOTEBOOK_USE_CONSOLIDATED_OUTPUT_BUTTON, NOTEBOOK_VIEW_TYPE } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel'; import { NotebookCellExecutionState } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; @@ -16,6 +16,7 @@ export class NotebookEditorContextKeys { private readonly _notebookKernelSelected: IContextKey; private readonly _interruptibleKernel: IContextKey; private readonly _someCellRunning: IContextKey; + private readonly _useConsolidatedOutputButton: IContextKey; private _viewType!: IContextKey; private readonly _disposables = new DisposableStore(); @@ -31,12 +32,18 @@ export class NotebookEditorContextKeys { this._notebookKernelSelected = NOTEBOOK_KERNEL_SELECTED.bindTo(contextKeyService); this._interruptibleKernel = NOTEBOOK_INTERRUPTIBLE_KERNEL.bindTo(contextKeyService); this._someCellRunning = NOTEBOOK_HAS_RUNNING_CELL.bindTo(contextKeyService); + this._useConsolidatedOutputButton = NOTEBOOK_USE_CONSOLIDATED_OUTPUT_BUTTON.bindTo(contextKeyService); this._viewType = NOTEBOOK_VIEW_TYPE.bindTo(contextKeyService); + this._handleDidChangeModel(); + this._updateForNotebookOptions(); + this._disposables.add(_editor.onDidChangeModel(this._handleDidChangeModel, this)); this._disposables.add(_notebookKernelService.onDidAddKernel(this._updateKernelContext, this)); this._disposables.add(_notebookKernelService.onDidChangeNotebookKernelBinding(this._updateKernelContext, this)); - this._handleDidChangeModel(); + this._disposables.add(_editor.notebookOptions.onDidChangeOptions(() => { + this._updateForNotebookOptions(); + })); } dispose(): void { @@ -104,4 +111,8 @@ export class NotebookEditorContextKeys { this._interruptibleKernel.set(selected?.implementsInterrupt ?? false); this._notebookKernelSelected.set(Boolean(selected)); } + + private _updateForNotebookOptions(): void { + this._useConsolidatedOutputButton.set(this._editor.notebookOptions.getLayoutConfiguration().consolidatedOutputButton); + } } diff --git a/src/vs/workbench/contrib/notebook/browser/notebookKernelActionViewItem.ts b/src/vs/workbench/contrib/notebook/browser/notebookKernelActionViewItem.ts index b473b71f80d..9a70372f350 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookKernelActionViewItem.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookKernelActionViewItem.ts @@ -12,6 +12,7 @@ import { NotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookEd import { selectKernelIcon } from 'vs/workbench/contrib/notebook/browser/notebookIcons'; import { INotebookKernelMatchResult, INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; import { toolbarHoverBackground } from 'vs/platform/theme/common/colorRegistry'; +import { INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; registerThemingParticipant((theme, collector) => { const value = theme.getColor(toolbarHoverBackground); @@ -26,7 +27,7 @@ export class NotebooKernelActionViewItem extends ActionViewItem { constructor( actualAction: IAction, - private readonly _editor: NotebookEditor, + private readonly _editor: NotebookEditor | INotebookEditor, @INotebookKernelService private readonly _notebookKernelService: INotebookKernelService, ) { super( @@ -56,13 +57,14 @@ export class NotebooKernelActionViewItem extends ActionViewItem { } } - private _update(): void { - const widget = this._editor.getControl(); - if (!widget || !widget.hasModel()) { + protected _update(): void { + const notebook = this._editor.viewModel?.notebookDocument; + + if (!notebook) { this._resetAction(); return; } - const notebook = widget.viewModel.notebookDocument; + const info = this._notebookKernelService.getMatchingKernel(notebook); this._updateActionFromKernelInfo(info); } diff --git a/src/vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl.ts new file mode 100644 index 00000000000..0d816f42fb5 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { INotebookRendererMessagingService, IScopedRendererMessaging } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + +type MessageToSend = { editorId: string; rendererId: string; message: unknown }; + +export class NotebookRendererMessagingService implements INotebookRendererMessagingService { + declare _serviceBrand: undefined; + /** + * Activation promises. Maps renderer IDs to a queue of messages that should + * be sent once activation finishes, or undefined if activation is complete. + */ + private readonly activations = new Map(); + private readonly receiveMessageEmitter = new Emitter<{ editorId: string; rendererId: string, message: unknown }>(); + public readonly onDidReceiveMessage = this.receiveMessageEmitter.event; + private readonly postMessageEmitter = new Emitter(); + public readonly onShouldPostMessage = this.postMessageEmitter.event; + + constructor(@IExtensionService private readonly extensionService: IExtensionService) { } + + /** @inheritdoc */ + public fireDidReceiveMessage(editorId: string, rendererId: string, message: unknown): void { + this.receiveMessageEmitter.fire({ editorId, rendererId, message }); + } + + /** @inheritdoc */ + public prepare(rendererId: string) { + if (this.activations.has(rendererId)) { + return; + } + + const queue: MessageToSend[] = []; + this.activations.set(rendererId, queue); + + this.extensionService.activateByEvent(`onRenderer:${rendererId}`).then(() => { + for (const message of queue) { + this.postMessageEmitter.fire(message); + } + + this.activations.set(rendererId, undefined); + }); + } + + /** @inheritdoc */ + public getScoped(editorId: string): IScopedRendererMessaging { + return { + onDidReceiveMessage: Event.filter(this.onDidReceiveMessage, e => e.editorId === editorId), + postMessage: (rendererId, message) => this.postMessage(editorId, rendererId, message), + }; + } + + private postMessage(editorId: string, rendererId: string, message: unknown): void { + if (!this.activations.has(rendererId)) { + this.prepare(rendererId); + } + + const activation = this.activations.get(rendererId); + const toSend = { rendererId, editorId, message }; + if (activation === undefined) { + this.postMessageEmitter.fire(toSend); + } else { + activation.push(toSend); + } + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts index 05db784c920..90c4f8bc43e 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookServiceImpl.ts @@ -359,7 +359,7 @@ export class NotebookService extends Disposable implements INotebookService { continue; } - this._notebookRenderersInfoStore.add(new NotebookOutputRendererInfo({ + this._notebookRenderersInfoStore.add(this._instantiationService.createInstance(NotebookOutputRendererInfo, { id, extension: extension.description, entrypoint: notebookContribution.entrypoint, @@ -367,6 +367,7 @@ export class NotebookService extends Disposable implements INotebookService { mimeTypes: notebookContribution.mimeTypes || [], dependencies: notebookContribution.dependencies, optionalDependencies: notebookContribution.optionalDependencies, + requiresMessaging: notebookContribution.requiresMessaging, })); } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts index f14f3fcca8d..5584ff93977 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts @@ -24,8 +24,8 @@ import { CellViewModel, NotebookViewModel } from 'vs/workbench/contrib/notebook/ import { diff, NOTEBOOK_EDITOR_CURSOR_BOUNDARY, CellKind, SelectionStateType } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { ICellRange, cellRangesToIndexes } from 'vs/workbench/contrib/notebook/common/notebookRange'; import { clamp } from 'vs/base/common/numbers'; -import { SCROLLABLE_ELEMENT_PADDING_TOP } from 'vs/workbench/contrib/notebook/browser/constants'; import { ISplice } from 'vs/base/common/sequence'; +import { ViewContext } from 'vs/workbench/contrib/notebook/browser/viewModel/viewContext'; export interface IFocusNextPreviousDelegate { onFocusNext(applyFocusNext: () => void): void; @@ -91,10 +91,13 @@ export class NotebookCellList extends WorkbenchList implements ID private readonly _focusNextPreviousDelegate: IFocusNextPreviousDelegate; + private readonly _viewContext: ViewContext; + constructor( private listUser: string, parentContainer: HTMLElement, container: HTMLElement, + viewContext: ViewContext, delegate: IListVirtualDelegate, renderers: IListRenderer[], contextKeyService: IContextKeyService, @@ -106,6 +109,7 @@ export class NotebookCellList extends WorkbenchList implements ID ) { super(listUser, container, delegate, renderers, options, contextKeyService, listService, themeService, configurationService, keybindingService); NOTEBOOK_CELL_LIST_FOCUSED.bindTo(this.contextKeyService).set(true); + this._viewContext = viewContext; this._focusNextPreviousDelegate = options.focusNextPreviousDelegate; this._previousFocusedElements = this.getFocusedElements(); this._localDisposableStore.add(this.onDidChangeFocus((e) => { @@ -900,7 +904,8 @@ export class NotebookCellList extends WorkbenchList implements ID } getViewScrollBottom() { - return this.getViewScrollTop() + this.view.renderHeight - SCROLLABLE_ELEMENT_PADDING_TOP; + const topInsertToolbarHeight = this._viewContext.notebookOptions.computeTopInserToolbarHeight(this.viewModel?.viewType); + return this.getViewScrollTop() + this.view.renderHeight - topInsertToolbarHeight; } private _revealRange(viewIndex: number, range: Range, revealType: CellRevealType, newlyCreated: boolean, alignToBottom: boolean) { diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts index 0a0ce408a70..c00f3c1f24a 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -26,10 +26,11 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { asWebviewUri } from 'vs/workbench/api/common/shared/webview'; import { CellEditState, ICellOutputViewModel, ICommonCellInfo, ICommonNotebookEditor, IDisplayOutputLayoutUpdateRequest, IDisplayOutputViewModel, IGenericCellViewModel, IInsetRenderOutput, RenderOutputType } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; -import { preloadsScriptStr, WebviewPreloadRenderer } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads'; +import { preloadsScriptStr, RendererMetadata } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads'; import { transformWebviewThemeVars } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewThemeMapping'; import { MarkdownCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel'; -import { INotebookKernel, INotebookRendererInfo, NotebookRendererMatch } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { INotebookKernel, INotebookRendererInfo } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IScopedRendererMessaging } from 'vs/workbench/contrib/notebook/common/notebookRendererMessagingService'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; import { IWebviewService, WebviewContentPurpose, WebviewElement } from 'vs/workbench/contrib/webview/browser/webview'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; @@ -131,17 +132,13 @@ export interface IToggleMarkdownPreviewMessage extends BaseToWebviewMessage { export interface ICellDragStartMessage extends BaseToWebviewMessage { type: 'cell-drag-start'; readonly cellId: string; - readonly position: { - readonly clientY: number; - }; + readonly dragOffsetY: number; } export interface ICellDragMessage extends BaseToWebviewMessage { type: 'cell-drag'; readonly cellId: string; - readonly position: { - readonly clientY: number; - }; + readonly dragOffsetY: number; } export interface ICellDropMessage extends BaseToWebviewMessage { @@ -149,9 +146,7 @@ export interface ICellDropMessage extends BaseToWebviewMessage { readonly cellId: string; readonly ctrlKey: boolean readonly altKey: boolean; - readonly position: { - readonly clientY: number; - }; + readonly dragOffsetY: number; } export interface ICellDragEndMessage extends BaseToWebviewMessage { @@ -203,7 +198,7 @@ export interface ICreationRequestMessage { cellTop: number; outputOffset: number; left: number; - requiredPreloads: ReadonlyArray; + requiredPreloads: ReadonlyArray; readonly initiallyHidden?: boolean; rendererId?: string | undefined; } @@ -263,17 +258,15 @@ export interface IAckOutputHeightMessage { height: number; } -export type PreloadSource = 'kernel' | { rendererId: string }; -export interface IPreloadResource { +export interface IControllerPreload { originalUri: string; uri: string; - source: PreloadSource; } -export interface IUpdatePreloadResourceMessage { +export interface IUpdateControllerPreloadsMessage { type: 'preload'; - resources: IPreloadResource[]; + resources: IControllerPreload[]; } export interface IUpdateDecorationsMessage { @@ -288,6 +281,12 @@ export interface ICustomKernelMessage extends BaseToWebviewMessage { message: unknown; } +export interface ICustomRendererMessage extends BaseToWebviewMessage { + type: 'customRendererMessage'; + rendererId: string; + message: unknown; +} + export interface ICreateMarkdownMessage { type: 'createMarkdownPreview', cell: IMarkdownCellInitialization; @@ -351,6 +350,7 @@ export type FromWebviewMessage = | IScrollAckMessage | IBlurOutputMessage | ICustomKernelMessage + | ICustomRendererMessage | IClickedDataUrlMessage | IClickMarkdownPreviewMessage | IContextMenuMarkdownPreviewMessage @@ -376,9 +376,10 @@ export type ToWebviewMessage = | IClearOutputRequestMessage | IHideOutputMessage | IShowOutputMessage - | IUpdatePreloadResourceMessage + | IUpdateControllerPreloadsMessage | IUpdateDecorationsMessage | ICustomKernelMessage + | ICustomRendererMessage | ICreateMarkdownMessage | IDeleteMarkdownMessage | IShowMarkdownMessage @@ -430,9 +431,9 @@ export class BackLayerWebView extends Disposable { private _currentKernel?: INotebookKernel; constructor( - public notebookEditor: ICommonNotebookEditor, - public id: string, - public documentUri: URI, + public readonly notebookEditor: ICommonNotebookEditor, + public readonly id: string, + public readonly documentUri: URI, public options: { outputNodePadding: number, outputNodeLeftPadding: number, @@ -442,6 +443,7 @@ export class BackLayerWebView extends Disposable { rightMargin: number, runGutter: number, }, + private readonly rendererMessaging: IScopedRendererMessaging | undefined, @IWebviewService readonly webviewService: IWebviewService, @IOpenerService readonly openerService: IOpenerService, @INotebookService private readonly notebookService: INotebookService, @@ -460,6 +462,17 @@ export class BackLayerWebView extends Disposable { this.element.style.height = '1400px'; this.element.style.position = 'absolute'; + + if (rendererMessaging) { + this._register(rendererMessaging.onDidReceiveMessage(evt => { + this._sendMessageToWebview({ + __vscode_notebook_message: true, + type: 'customRendererMessage', + rendererId: evt.rendererId, + message: evt.message + }); + })); + } } updateOptions(options: { @@ -496,7 +509,7 @@ export class BackLayerWebView extends Disposable { } private generateContent(coreDependencies: string, baseUrl: string) { - const markupRenderer = this.getMarkdownRenderer(); + const renderersData = this.getRendererData(); return html` @@ -755,48 +768,26 @@ export class BackLayerWebView extends Disposable { ${coreDependencies}
- + `; } - private getMarkdownRenderer(): WebviewPreloadRenderer[] { - const markdownMimeType = 'text/markdown'; - const allRenderers = this.notebookService.getRenderers() - .filter(renderer => renderer.matchesWithoutKernel(markdownMimeType) !== NotebookRendererMatch.Never); - - const topLevelMarkdownRenderers = allRenderers - .filter(renderer => renderer.dependencies.length === 0); - - const subRenderers = new Map>(); - for (const renderer of allRenderers) { - for (const dep of renderer.dependencies) { - if (!subRenderers.has(dep)) { - subRenderers.set(dep, []); - } - const entryPoint = this.asWebviewUri(renderer.entrypoint, renderer.extensionLocation); - subRenderers.get(dep)!.push({ entrypoint: entryPoint.toString(true) }); - } - } - - return topLevelMarkdownRenderers.map((renderer): WebviewPreloadRenderer => { - const src = this.asWebviewUri(renderer.entrypoint, renderer.extensionLocation); + private getRendererData(): RendererMetadata[] { + return this.notebookService.getRenderers().map((renderer): RendererMetadata => { + const entrypoint = this.asWebviewUri(renderer.entrypoint, renderer.extensionLocation).toString(); return { - entrypoint: src.toString(), + id: renderer.id, + entrypoint, mimeTypes: renderer.mimeTypes, - dependencies: subRenderers.get(renderer.id) || [], + extends: renderer.extends, + messaging: !!renderer.messaging, }; }); } private asWebviewUri(uri: URI, fromExtension: URI | undefined) { - const remoteAuthority = fromExtension?.scheme === Schemas.vscodeRemote ? fromExtension.authority : undefined; - return asWebviewUri({ - isExtensionDevelopmentDebug: this.environmentService.isExtensionDevelopment, - webviewCspSource: this.environmentService.webviewCspSource, - webviewResourceRoot: this.environmentService.webviewResourceRoot, - remote: { authority: remoteAuthority } - }, this.id, uri); + return asWebviewUri(uri, fromExtension?.scheme === Schemas.vscodeRemote ? { isRemote: true, authority: fromExtension.authority } : undefined); } postKernelMessage(message: any) { @@ -896,6 +887,10 @@ var requirejs = (function() { return; } + if (matchesScheme(link, Schemas.command)) { + console.warn('Command links are deprecated and will be removed, use messag passing instead: https://github.com/microsoft/vscode/issues/123601'); + } + if (matchesScheme(link, Schemas.http) || matchesScheme(link, Schemas.https) || matchesScheme(link, Schemas.mailto) || matchesScheme(link, Schemas.command)) { this.openerService.open(link, { fromUserGesture: true, allowContributedOpeners: true, allowCommands: true }); @@ -1020,13 +1015,18 @@ var requirejs = (function() { this._onMessage.fire({ message: data.message }); break; } + case 'customRendererMessage': + { + this.rendererMessaging?.postMessage(data.rendererId, data.message); + break; + } case 'clickMarkdownPreview': { const cell = this.notebookEditor.getCellById(data.cellId); if (cell) { if (data.shiftKey || (isMacintosh ? data.metaKey : data.ctrlKey)) { - // Add to selection - this.notebookEditor.toggleNotebookCellSelection(cell); + // Modify selection + this.notebookEditor.toggleNotebookCellSelection(cell, /* fromPrevious */ data.shiftKey); } else { // Normal click this.notebookEditor.focusNotebookCell(cell, 'container', { skipReveal: true }); @@ -1086,18 +1086,18 @@ var requirejs = (function() { } case 'cell-drag-start': { - this.notebookEditor.markdownCellDragStart(data.cellId, data.position); + this.notebookEditor.markdownCellDragStart(data.cellId, data); break; } case 'cell-drag': { - this.notebookEditor.markdownCellDrag(data.cellId, data.position); + this.notebookEditor.markdownCellDrag(data.cellId, data); break; } case 'cell-drop': { this.notebookEditor.markdownCellDrop(data.cellId, { - clientY: data.position.clientY, + dragOffsetY: data.dragOffsetY, ctrlKey: data.ctrlKey, altKey: data.altKey, }); @@ -1188,7 +1188,6 @@ var requirejs = (function() { purpose: WebviewContentPurpose.NotebookRenderer, enableFindWidget: false, transformCssVariables: transformWebviewThemeVars, - serviceWorkerFetchIgnoreSubdomain: true }, { allowMultipleAPIAcquire: true, allowScripts: true, @@ -1211,7 +1210,6 @@ var requirejs = (function() { if (this._currentKernel) { this._updatePreloadsFromKernel(this._currentKernel); } - this.updateRendererPreloads(renderers); for (const [output, inset] of this.insetMapping.entries()) { this._sendMessageToWebview({ ...inset.cachedCreation, initiallyHidden: this.hiddenInsetMapping.has(output) }); @@ -1486,7 +1484,6 @@ var requirejs = (function() { ...messageBase, outputId: output.outputId, rendererId: content.renderer.id, - requiredPreloads: await this.updateRendererPreloads([content.renderer]), content: { type: RenderOutputType.Extension, outputId: output.outputId, @@ -1617,13 +1614,13 @@ var requirejs = (function() { } private _updatePreloadsFromKernel(kernel: INotebookKernel) { - const resources: IPreloadResource[] = []; + const resources: IControllerPreload[] = []; for (const preload of kernel.preloadUris) { const uri = this.environmentService.isExtensionDevelopment && (preload.scheme === 'http' || preload.scheme === 'https') ? preload : this.asWebviewUri(preload, undefined); if (!this._preloadsCache.has(uri.toString())) { - resources.push({ uri: uri.toString(), originalUri: preload.toString(), source: 'kernel' }); + resources.push({ uri: uri.toString(), originalUri: preload.toString() }); this._preloadsCache.add(uri.toString()); } } @@ -1635,43 +1632,7 @@ var requirejs = (function() { this._updatePreloads(resources); } - async updateRendererPreloads(renderers: Iterable) { - if (this._disposed) { - return []; - } - - const requiredPreloads: IPreloadResource[] = []; - const resources: IPreloadResource[] = []; - const extensionLocations: URI[] = []; - for (const rendererInfo of renderers) { - extensionLocations.push(rendererInfo.extensionLocation); - for (const preload of [rendererInfo.entrypoint, ...rendererInfo.preloads]) { - const uri = this.asWebviewUri(preload, rendererInfo.extensionLocation); - const resource: IPreloadResource = { - uri: uri.toString(), - originalUri: preload.toString(), - source: { rendererId: rendererInfo.id }, - }; - - requiredPreloads.push(resource); - - if (!this._preloadsCache.has(uri.toString())) { - resources.push(resource); - this._preloadsCache.add(uri.toString()); - } - } - } - - if (!resources.length) { - return requiredPreloads; - } - - this.rendererRootsCache = extensionLocations; - this._updatePreloads(resources); - return requiredPreloads; - } - - private _updatePreloads(resources: IPreloadResource[]) { + private _updatePreloads(resources: IControllerPreload[]) { if (!this.webview) { return; } diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts index fca5bd43ce6..03eea9d8ec5 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts @@ -157,7 +157,7 @@ export class CellDragAndDropController extends Disposable { private updateInsertIndicator(dropDirection: string, insertionIndicatorAbsolutePos: number) { const layoutInfo = this.notebookEditor.notebookOptions.getLayoutConfiguration(); - const insertionIndicatorTop = insertionIndicatorAbsolutePos - this.list.scrollTop + layoutInfo.bottomCellToolbarGap / 2; + const insertionIndicatorTop = insertionIndicatorAbsolutePos - this.list.scrollTop + layoutInfo.bottomToolbarGap / 2; if (insertionIndicatorTop >= 0) { this.listInsertionIndicator.style.top = `${insertionIndicatorTop}px`; this.setInsertIndicatorVisibility(true); @@ -195,12 +195,12 @@ export class CellDragAndDropController extends Disposable { } } - private _dropImpl(draggedCell: ICellViewModel, dropDirection: 'above' | 'below', ctx: { clientY: number, ctrlKey: boolean, altKey: boolean }, draggedOverCell: ICellViewModel) { + private _dropImpl(draggedCell: ICellViewModel, dropDirection: 'above' | 'below', ctx: { ctrlKey: boolean, altKey: boolean }, draggedOverCell: ICellViewModel) { const cellTop = this.list.getAbsoluteTopOfElement(draggedOverCell); const cellHeight = this.list.elementHeight(draggedOverCell); const insertionIndicatorAbsolutePos = dropDirection === 'above' ? cellTop : cellTop + cellHeight; const layoutInfo = this.notebookEditor.notebookOptions.getLayoutConfiguration(); - const insertionIndicatorTop = insertionIndicatorAbsolutePos - this.list.scrollTop + layoutInfo.bottomCellToolbarGap / 2; + const insertionIndicatorTop = insertionIndicatorAbsolutePos - this.list.scrollTop + layoutInfo.bottomToolbarGap / 2; const editorHeight = this.notebookEditor.getDomNode().getBoundingClientRect().height; if (insertionIndicatorTop < 0 || insertionIndicatorTop > editorHeight) { // Ignore drop, insertion point is off-screen @@ -323,18 +323,18 @@ export class CellDragAndDropController extends Disposable { })); } - public startExplicitDrag(cell: ICellViewModel, position: { clientY: number }) { + public startExplicitDrag(cell: ICellViewModel, _dragOffsetY: number) { this.currentDraggedCell = cell; this.setInsertIndicatorVisibility(true); } - public explicitDrag(cell: ICellViewModel, position: { clientY: number }) { - const target = this.list.elementAt(position.clientY); + public explicitDrag(cell: ICellViewModel, dragOffsetY: number) { + const target = this.list.elementAt(dragOffsetY); if (target && target !== cell) { const cellTop = this.list.getAbsoluteTopOfElement(target); const cellHeight = this.list.elementHeight(target); - const dropDirection = this.getExplicitDragDropDirection(position.clientY, cellTop, cellHeight); + const dropDirection = this.getExplicitDragDropDirection(dragOffsetY, cellTop, cellHeight); const insertionIndicatorAbsolutePos = dropDirection === 'above' ? cellTop : cellTop + cellHeight; this.updateInsertIndicator(dropDirection, insertionIndicatorAbsolutePos); } @@ -344,16 +344,19 @@ export class CellDragAndDropController extends Disposable { return; } - const viewRect = this.notebookEditor.getDomNode().getBoundingClientRect(); - const eventPositionInView = position.clientY - this.list.scrollTop; + const notebookViewRect = this.notebookEditor.getDomNode().getBoundingClientRect(); + const eventPositionInView = dragOffsetY - this.list.scrollTop; - const scrollMargin = 0.2; - const maxScrollPerFrame = 20; - const eventPositionRatio = eventPositionInView / viewRect.height; - if (eventPositionRatio < scrollMargin) { - this.list.scrollTop -= maxScrollPerFrame * (1 - eventPositionRatio / scrollMargin); - } else if (eventPositionRatio > 1 - scrollMargin) { - this.list.scrollTop += maxScrollPerFrame * (1 - ((1 - eventPositionRatio) / scrollMargin)); + // Percentage from the top/bottom of the screen where we start scrolling while dragging + const notebookViewScrollMargins = 0.2; + + const maxScrollDeltaPerFrame = 20; + + const eventPositionRatio = eventPositionInView / notebookViewRect.height; + if (eventPositionRatio < notebookViewScrollMargins) { + this.list.scrollTop -= maxScrollDeltaPerFrame * (1 - eventPositionRatio / notebookViewScrollMargins); + } else if (eventPositionRatio > 1 - notebookViewScrollMargins) { + this.list.scrollTop += maxScrollDeltaPerFrame * (1 - ((1 - eventPositionRatio) / notebookViewScrollMargins)); } } @@ -361,24 +364,23 @@ export class CellDragAndDropController extends Disposable { this.setInsertIndicatorVisibility(false); } - public explicitDrop(cell: ICellViewModel, ctx: { clientY: number, ctrlKey: boolean, altKey: boolean }) { + public explicitDrop(cell: ICellViewModel, ctx: { dragOffsetY: number, ctrlKey: boolean, altKey: boolean }) { this.currentDraggedCell = undefined; this.setInsertIndicatorVisibility(false); - const target = this.list.elementAt(ctx.clientY); + const target = this.list.elementAt(ctx.dragOffsetY); if (!target || target === cell) { return; } const cellTop = this.list.getAbsoluteTopOfElement(target); const cellHeight = this.list.elementHeight(target); - const dropDirection = this.getExplicitDragDropDirection(ctx.clientY, cellTop, cellHeight); + const dropDirection = this.getExplicitDragDropDirection(ctx.dragOffsetY, cellTop, cellHeight); this._dropImpl(cell, dropDirection, ctx, target); } private getExplicitDragDropDirection(clientY: number, cellTop: number, cellHeight: number) { - const dragOffset = this.list.scrollTop + clientY; - const dragPosInElement = dragOffset - cellTop; + const dragPosInElement = clientY - cellTop; const dragPosRatio = dragPosInElement / cellHeight; return this.getDropInsertDirection(dragPosRatio); diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellMenus.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellMenus.ts index 7b4e7e7204d..54febbfebe3 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellMenus.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellMenus.ts @@ -16,6 +16,10 @@ export class CellMenus { return this.getMenu(MenuId.NotebookToolbar, contextKeyService); } + getNotebookRightToolbar(contextKeyService: IContextKeyService): IMenu { + return this.getMenu(MenuId.NotebookRightToolbar, contextKeyService); + } + getCellTitleMenu(contextKeyService: IContextKeyService): IMenu { return this.getMenu(MenuId.NotebookCellTitle, contextKeyService); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellOutput.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellOutput.ts index 421cbba176b..da239a7d247 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellOutput.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellOutput.ts @@ -3,24 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Schemas } from 'vs/base/common/network'; import * as DOM from 'vs/base/browser/dom'; +import { renderMarkdown } from 'vs/base/browser/markdownRenderer'; +import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; +import { Action, IAction } from 'vs/base/common/actions'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; +import { Schemas } from 'vs/base/common/network'; import * as nls from 'vs/nls'; -import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { KeyCode } from 'vs/base/common/keyCodes'; -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { IQuickPickItem, IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { ThemeIcon } from 'vs/platform/theme/common/themeService'; import { CodeCellRenderTemplate, ICellOutputViewModel, IInsetRenderOutput, INotebookEditor, IRenderOutput, RenderOutputType } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { mimetypeIcon } from 'vs/workbench/contrib/notebook/browser/notebookIcons'; import { getResizesObserver } from 'vs/workbench/contrib/notebook/browser/view/renderers/cellWidgets'; import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; -import { BUILTIN_RENDERER_ID, CellUri, NotebookCellOutputsSplice, IOrderedMimeType, INotebookKernel } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; -import { IMarkdownString } from 'vs/base/common/htmlContent'; -import { renderMarkdown } from 'vs/base/browser/markdownRenderer'; -import { IOpenerService } from 'vs/platform/opener/common/opener'; -import { ThemeIcon } from 'vs/platform/theme/common/themeService'; -import { mimetypeIcon } from 'vs/workbench/contrib/notebook/browser/notebookIcons'; import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; +import { BUILTIN_RENDERER_ID, CellUri, INotebookKernel, IOrderedMimeType, NotebookCellOutputsSplice } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; const OUTPUT_COUNT_LIMIT = 500; @@ -33,7 +39,9 @@ interface IRenderResult { } export class CellOutputElement extends Disposable { - readonly localDisposableStore = new DisposableStore(); + private readonly _renderDisposableStore = this._register(new DisposableStore()); + private readonly _actionsDisposable = this._register(new MutableDisposable()); + domNode!: HTMLElement; renderResult?: IRenderOutput; @@ -47,21 +55,27 @@ export class CellOutputElement extends Disposable { } } + private readonly contextKeyService: IContextKeyService; + constructor( private notebookEditor: INotebookEditor, - private notebookService: INotebookService, - private quickInputService: IQuickInputService, private viewCell: CodeCellViewModel, private outputContainer: HTMLElement, - readonly output: ICellOutputViewModel + readonly output: ICellOutputViewModel, + @INotebookService private readonly notebookService: INotebookService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IContextKeyService parentContextKeyService: IContextKeyService, + @IMenuService private readonly menuService: IMenuService, ) { super(); + this.contextKeyService = this._register(parentContextKeyService.createScoped(this.outputContainer)); + this._register(this.output.model.onDidChangeData(() => { this.updateOutputRendering(); })); - - this._register(this.localDisposableStore); } detach() { @@ -86,7 +100,7 @@ export class CellOutputElement extends Disposable { // user chooses another mimetype const index = this.viewCell.outputsViewModels.indexOf(this.output); const nextElement = this.domNode.nextElementSibling; - this.localDisposableStore.clear(); + this._renderDisposableStore.clear(); const element = this.domNode; if (element) { element.parentElement?.removeChild(element); @@ -118,9 +132,7 @@ export class CellOutputElement extends Disposable { this.domNode = this.useDedicatedDOM ? DOM.$('.output-inner-container') : this.outputContainer.lastChild as HTMLElement; this.domNode.setAttribute('output-mime-type', pickedMimeTypeRenderer.mimeType); - if (mimeTypes.filter(mimeType => mimeType.isTrusted).length > 1) { - this.attachMimetypeSwitcher(this.domNode, notebookTextModel, this.notebookEditor.activeKernel, mimeTypes); - } + this.attachToolbar(this.domNode, notebookTextModel, this.notebookEditor.activeKernel, index, mimeTypes); const notebookUri = CellUri.parse(this.viewCell.uri)?.notebook; if (!notebookUri) { @@ -207,7 +219,7 @@ export class CellOutputElement extends Disposable { }); elementSizeObserver.startObserving(); - this.localDisposableStore.add(elementSizeObserver); + this._renderDisposableStore.add(elementSizeObserver); } private previousDivSupportAppend(mimeType: string) { @@ -220,29 +232,42 @@ export class CellOutputElement extends Disposable { return false; } - private async attachMimetypeSwitcher(outputItemDiv: HTMLElement, notebookTextModel: NotebookTextModel, kernel: INotebookKernel | undefined, mimeTypes: readonly IOrderedMimeType[]) { + private async attachToolbar(outputItemDiv: HTMLElement, notebookTextModel: NotebookTextModel, kernel: INotebookKernel | undefined, index: number, mimeTypes: readonly IOrderedMimeType[]) { + const hasMultipleMimeTypes = mimeTypes.filter(mimeType => mimeType.isTrusted).length <= 1; + if (index > 0 && hasMultipleMimeTypes) { + return; + } + + const useConsolidatedButton = this.notebookEditor.notebookOptions.getLayoutConfiguration().consolidatedOutputButton; + outputItemDiv.style.position = 'relative'; - const mimeTypePicker = DOM.$('.multi-mimetype-output'); - mimeTypePicker.classList.add(...ThemeIcon.asClassNameArray(mimetypeIcon)); - mimeTypePicker.tabIndex = 0; - mimeTypePicker.title = nls.localize('mimeTypePicker', "Choose a different output mimetype, available mimetypes: {0}", mimeTypes.map(mimeType => mimeType.mimeType).join(', ')); + const mimeTypePicker = DOM.$('.cell-output-toolbar'); + outputItemDiv.appendChild(mimeTypePicker); - this.localDisposableStore.add(DOM.addStandardDisposableListener(mimeTypePicker, 'mousedown', async e => { - if (e.leftButton) { - e.preventDefault(); - e.stopPropagation(); - await this.pickActiveMimeTypeRenderer(notebookTextModel, kernel, this.output); - } + + const toolbar = this._renderDisposableStore.add(new ToolBar(mimeTypePicker, this.contextMenuService, { + getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), + renderDropdownAsChildElement: true })); - this.localDisposableStore.add((DOM.addDisposableListener(mimeTypePicker, DOM.EventType.KEY_DOWN, async e => { - const event = new StandardKeyboardEvent(e); - if ((event.equals(KeyCode.Enter) || event.equals(KeyCode.Space))) { - e.preventDefault(); - e.stopPropagation(); - await this.pickActiveMimeTypeRenderer(notebookTextModel, kernel, this.output); - } - }))); + // TODO: This could probably be a real registered action, but it has to talk to this output element + const pickAction = new Action('notebook.output.pickMimetype', nls.localize('pickMimeType', "Choose a different output mimetype"), ThemeIcon.asClassName(mimetypeIcon), undefined, + async _context => this.pickActiveMimeTypeRenderer(notebookTextModel, kernel, this.output)); + if (index === 0 && useConsolidatedButton) { + const menu = this.menuService.createMenu(MenuId.NotebookOutputToolbar, this.contextKeyService); + const updateMenuToolbar = () => { + const primary: IAction[] = []; + const secondary: IAction[] = []; + const result = { primary, secondary }; + + this._actionsDisposable.value = createAndFillInActionBarActions(menu, { shouldForwardArgs: true }, result, () => false); + toolbar.setActions([], [pickAction, ...secondary]); + }; + updateMenuToolbar(); + this._renderDisposableStore.add(menu.onDidChange(updateMenuToolbar)); + } else { + toolbar.setActions([pickAction]); + } } private async pickActiveMimeTypeRenderer(notebookTextModel: NotebookTextModel, kernel: INotebookKernel | undefined, viewModel: ICellOutputViewModel) { @@ -284,7 +309,7 @@ export class CellOutputElement extends Disposable { // user chooses another mimetype const index = this.viewCell.outputsViewModels.indexOf(viewModel); const nextElement = this.domNode.nextElementSibling; - this.localDisposableStore.clear(); + this._renderDisposableStore.clear(); const element = this.domNode; if (element) { element.parentElement?.removeChild(element); @@ -356,9 +381,8 @@ export class CellOutputContainer extends Disposable { private notebookEditor: INotebookEditor, private viewCell: CodeCellViewModel, private templateData: CodeCellRenderTemplate, - @INotebookService private readonly notebookService: INotebookService, - @IQuickInputService private readonly quickInputService: IQuickInputService, - @IOpenerService private readonly openerService: IOpenerService + @IOpenerService private readonly openerService: IOpenerService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -546,7 +570,7 @@ export class CellOutputContainer extends Disposable { private _renderOutput(currOutput: ICellOutputViewModel, index: number, beforeElement?: HTMLElement) { if (!this.outputEntries.has(currOutput)) { - this.outputEntries.set(currOutput, new CellOutputElement(this.notebookEditor, this.notebookService, this.quickInputService, this.viewCell, this.templateData.outputContainer, currOutput)); + this.outputEntries.set(currOutput, this.instantiationService.createInstance(CellOutputElement, this.notebookEditor, this.viewCell, this.templateData.outputContainer, currOutput)); } return this.outputEntries.get(currOutput)!.render(index, beforeElement); diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts index 23f94176202..555c743706c 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts @@ -881,10 +881,10 @@ export class CodeCellRenderer extends AbstractCellRenderer implements IListRende const layoutInfo = this.notebookEditor.notebookOptions.getLayoutConfiguration(); templateData.focusIndicatorLeft.style.height = `${element.layoutInfo.indicatorHeight}px`; templateData.focusIndicatorRight.style.height = `${element.layoutInfo.indicatorHeight}px`; - templateData.focusIndicatorBottom.style.top = `${element.layoutInfo.totalHeight - layoutInfo.bottomCellToolbarGap - layoutInfo.cellBottomMargin}px`; + templateData.focusIndicatorBottom.style.top = `${element.layoutInfo.totalHeight - layoutInfo.bottomToolbarGap - layoutInfo.cellBottomMargin}px`; templateData.outputContainer.style.top = `${element.layoutInfo.outputContainerOffset}px`; templateData.outputShowMoreContainer.style.top = `${element.layoutInfo.outputShowMoreContainerOffset}px`; - templateData.dragHandle.style.height = `${element.layoutInfo.totalHeight - layoutInfo.bottomCellToolbarGap}px`; + templateData.dragHandle.style.height = `${element.layoutInfo.totalHeight - layoutInfo.bottomToolbarGap}px`; } renderElement(element: CodeCellViewModel, index: number, templateData: CodeCellRenderTemplate, height: number | undefined): void { diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index 282608bfea7..40f70ec98f8 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -41,7 +41,7 @@ interface PreloadStyles { declare function __import(path: string): Promise; -async function webviewPreloads(style: PreloadStyles, rendererData: readonly WebviewPreloadRenderer[]) { +async function webviewPreloads(style: PreloadStyles, rendererData: readonly RendererMetadata[]) { const acquireVsCodeApi = globalThis.acquireVsCodeApi; const vscode = acquireVsCodeApi(); delete (globalThis as any).acquireVsCodeApi; @@ -111,32 +111,89 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv } }; - const runScript = async (url: string, originalUri: string, globals: { [name: string]: unknown } = {}): Promise<() => (PreloadResult)> => { - let text: string; - try { - const res = await fetch(url); - text = await res.text(); - if (!res.ok) { - throw new Error(`Unexpected ${res.status} requesting ${originalUri}: ${text || res.statusText}`); - } - - globals.scriptUrl = url; - } catch (e) { - return () => ({ state: PreloadState.Error, error: e.message }); + async function loadScriptSource(url: string, originalUri = url): Promise { + const res = await fetch(url); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Unexpected ${res.status} requesting ${originalUri}: ${text || res.statusText}`); } + return text; + } + + interface RendererContext { + getState(): T | undefined; + setState(newState: T): void; + getRenderer(id: string): any | undefined; + postMessage?(message: unknown): void; + onDidReceiveMessage?: Event; + } + + interface ScriptModule { + activate: (ctx?: RendererContext) => any; + } + + const invokeSourceWithGlobals = (functionSrc: string, globals: { [name: string]: unknown }) => { const args = Object.entries(globals); - return () => { - try { - new Function(...args.map(([k]) => k), text)(...args.map(([, v]) => v)); - return { state: PreloadState.Ok }; - } catch (e) { - console.error(e); - return { state: PreloadState.Error, error: e.message }; + return new Function(...args.map(([k]) => k), functionSrc)(...args.map(([, v]) => v)); + }; + + const runPreload = async (url: string, originalUri: string): Promise => { + const text = await loadScriptSource(url, originalUri); + return { + activate: () => { + try { + return invokeSourceWithGlobals(text, { ...kernelPreloadGlobals, scriptUrl: url }); + } catch (e) { + console.error(e); + throw e; + } } }; }; + const runRenderScript = async (url: string, rendererId: string): Promise => { + const text = await loadScriptSource(url); + // TODO: Support both the new module based renderers and the old style global renderers + const isModule = /\bexport\b.*\bactivate\b/.test(text); + if (isModule) { + return __import(url); + } else { + return createBackCompatModule(rendererId, url, text); + } + }; + + const createBackCompatModule = (rendererId: string, scriptUrl: string, scriptText: string): ScriptModule => ({ + activate: (): RendererApi => { + const onDidCreateOutput = createEmitter(); + const onWillDestroyOutput = createEmitter(); + + const globals = { + scriptUrl, + acquireNotebookRendererApi: (): GlobalNotebookRendererApi => ({ + onDidCreateOutput: onDidCreateOutput.event, + onWillDestroyOutput: onWillDestroyOutput.event, + setState: newState => vscode.setState({ ...vscode.getState(), [rendererId]: newState }), + getState: () => { + const state = vscode.getState(); + return typeof state === 'object' && state ? state[rendererId] as T : undefined; + }, + }), + }; + + invokeSourceWithGlobals(scriptText, globals); + + return { + renderCell(id, context) { + onDidCreateOutput.fire({ ...context, outputId: id }); + }, + destroyCell(id) { + onWillDestroyOutput.fire(id ? { outputId: id } : undefined); + } + }; + } + }); + const dimensionUpdater = new class { private readonly pending = new Map(); @@ -352,8 +409,6 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv focusTrackers.set(outputId, new FocusTracker(element, outputId)); } - const dontEmit = Symbol('dontEmit'); - function createEmitter(listenerChange: (listeners: Set>) => void = () => undefined): EmitterLike { const listeners = new Set>(); return { @@ -385,29 +440,21 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv }; } - // Maps the events in the given emitter, invoking mapFn on each one. mapFn can return - // the dontEmit symbol to skip emission. - function mapEmitter(emitter: EmitterLike, mapFn: (data: T) => R | typeof dontEmit) { - let listener: IDisposable; - const mapped = createEmitter(listeners => { - if (listeners.size && !listener) { - listener = emitter.event(data => { - const v = mapFn(data); - if (v !== dontEmit) { - mapped.fire(v); - } - }); - } else if (listener && !listeners.size) { - listener.dispose(); - } - }); - - return mapped.event; + function showPreloadErrors(outputNode: HTMLElement, ...errors: readonly Error[]) { + outputNode.innerText = `Error loading preloads:`; + const errList = document.createElement('ul'); + for (const result of errors) { + console.error(result); + const item = document.createElement('li'); + item.innerText = result.message; + errList.appendChild(item); + } + outputNode.appendChild(errList); } interface ICreateCellInfo { element: HTMLElement; - outputId: string; + outputId?: string; mime: string; value: unknown; @@ -418,26 +465,15 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv outputId: string; } - const onWillDestroyOutput = createEmitter<'all' | { rendererId: string, info: IDestroyCellInfo }>(); - const onDidCreateOutput = createEmitter<{ rendererId: string, info: ICreateCellInfo }>(); const onDidReceiveKernelMessage = createEmitter(); - const acquireNotebookRendererApi = (id: string) => ({ - setState(newState: T) { - vscode.setState({ ...vscode.getState(), [id]: newState }); - }, - getState(): T | undefined { - const state = vscode.getState(); - return typeof state === 'object' && state ? state[id] as T : undefined; - }, - onWillDestroyOutput: mapEmitter(onWillDestroyOutput, (evt) => { - if (evt === 'all') { - return undefined; - } - return evt.rendererId === id ? evt.info : dontEmit; - }), - onDidCreateOutput: mapEmitter(onDidCreateOutput, ({ rendererId, info }) => rendererId === id ? info : dontEmit), - }); + /** @deprecated */ + interface GlobalNotebookRendererApi { + setState: (newState: T) => void; + getState(): T | undefined; + readonly onWillDestroyOutput: Event; + readonly onDidCreateOutput: Event; + } const kernelPreloadGlobals = { acquireVsCodeApi, @@ -445,42 +481,6 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv postKernelMessage: (data: unknown) => postNotebookMessage('customKernelMessage', { message: data }), }; - const enum PreloadState { - Ok, - Error - } - - type PreloadResult = { state: PreloadState.Ok } | { state: PreloadState.Error, error: string }; - - /** - * Map of preload resource URIs to promises that resolve one the resource - * loads or errors. - */ - const preloadPromises = new Map>(); - const queuedOuputActions = new Map>(); - - /** - * Enqueues an action that affects a output. This blocks behind renderer load - * requests that affect the same output. This should be called whenever you - * do something that affects output to ensure it runs in - * the correct order. - */ - const enqueueOutputAction = (event: T, fn: (event: T) => Promise | void) => { - const queued = queuedOuputActions.get(event.outputId); - const maybePromise = queued ? queued.then(() => fn(event)) : fn(event); - if (typeof maybePromise === 'undefined') { - return; // a synchonrously-called function, we're done - } - - const promise = maybePromise.then(() => { - if (queuedOuputActions.get(event.outputId) === promise) { - queuedOuputActions.delete(event.outputId); - } - }); - - queuedOuputActions.set(event.outputId, promise); - }; - const ttPolicy = window.trustedTypes?.createPolicy('notebookOutputRenderer', { createHTML: value => value, createScript: value => value, @@ -562,10 +562,15 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv } } break; - case 'html': - enqueueOutputAction(event.data, async data => { - const preloadResults = await Promise.all(data.requiredPreloads.map(p => preloadPromises.get(p.uri))); - if (!queuedOuputActions.has(data.outputId)) { // output was cleared while loading + case 'html': { + const data = event.data; + outputs.enqueue(event.data.outputId, async (state) => { + const preloadsAndErrors = await Promise.all([ + data.rendererId ? renderers.load(data.rendererId) : undefined, + ...data.requiredPreloads.map(p => kernelPreloads.waitFor(p.uri)), + ].map(p => p?.catch(err => err))); + + if (state.cancelled) { return; } @@ -615,39 +620,32 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv if (content.type === RenderOutputType.Html) { const trustedHtml = ttPolicy?.createHTML(content.htmlContent) ?? content.htmlContent; outputNode.innerHTML = trustedHtml as string; - cellOutputContainer.appendChild(outputContainer); - outputContainer.appendChild(outputNode); domEval(outputNode); - } else if (preloadResults.some(e => e?.state === PreloadState.Error)) { - outputNode.innerText = `Error loading preloads:`; - const errList = document.createElement('ul'); - for (const result of preloadResults) { - if (result?.state === PreloadState.Error) { - const item = document.createElement('li'); - item.innerText = result.error; - errList.appendChild(item); - } - } - outputNode.appendChild(errList); - cellOutputContainer.appendChild(outputContainer); - outputContainer.appendChild(outputNode); + } else if (preloadsAndErrors.some(e => e instanceof Error)) { + const errors = preloadsAndErrors.filter((e): e is Error => e instanceof Error); + showPreloadErrors(outputNode, ...errors); } else { - onDidCreateOutput.fire({ - rendererId: data.rendererId!, - info: { + const rendererApi = preloadsAndErrors[0] as RendererApi; + try { + rendererApi.renderCell(outputId, { element: outputNode, - outputId, mime: content.mimeType, value: content.value, metadata: content.metadata, - } - }); - cellOutputContainer.appendChild(outputContainer); - outputContainer.appendChild(outputNode); + }); + } catch (e) { + showPreloadErrors(outputNode, e); + } } + cellOutputContainer.appendChild(outputContainer); + outputContainer.appendChild(outputNode); resizeObserver.observe(outputNode, outputId, true); + if (content.type === RenderOutputType.Html) { + domEval(outputNode); + } + const clientHeight = outputNode.clientHeight; const cps = document.defaultView!.getComputedStyle(outputNode); if (clientHeight !== 0 && cps.padding === '0px') { @@ -670,6 +668,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv cellOutputContainer.style.visibility = data.initiallyHidden ? 'hidden' : 'visible'; }); break; + } case 'view-scroll': { // const date = new Date(); @@ -696,8 +695,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv break; } case 'clear': - queuedOuputActions.clear(); // stop all loading outputs - onWillDestroyOutput.fire('all'); + renderers.clearAll(); document.getElementById('container')!.innerText = ''; focusTrackers.forEach(ft => { @@ -709,26 +707,29 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv const output = document.getElementById(event.data.outputId); const { rendererId, outputId } = event.data; - queuedOuputActions.delete(outputId); // stop any in-progress rendering + outputs.cancelOutput(outputId); if (output && output.parentNode) { if (rendererId) { - onWillDestroyOutput.fire({ rendererId, info: { outputId } }); + renderers.clearOutput(rendererId, outputId); } output.parentNode.removeChild(output); } break; } - case 'hideOutput': - enqueueOutputAction(event.data, ({ outputId }) => { + case 'hideOutput': { + const { outputId } = event.data; + outputs.enqueue(event.data.outputId, () => { const container = document.getElementById(outputId)?.parentElement?.parentElement; if (container) { container.style.visibility = 'hidden'; } }); break; - case 'showOutput': - enqueueOutputAction(event.data, ({ outputId, cellTop: top, }) => { + } + case 'showOutput': { + const { outputId, cellTop: top } = event.data; + outputs.enqueue(event.data.outputId, () => { const output = document.getElementById(outputId); if (output) { output.parentElement!.parentElement!.style.visibility = 'visible'; @@ -740,6 +741,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv } }); break; + } case 'ack-dimension': { const { outputId, height } = event.data; @@ -752,24 +754,8 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv } case 'preload': const resources = event.data.resources; - let queue: Promise = Promise.resolve({ state: PreloadState.Ok }); - for (const { uri, originalUri, source } of resources) { - const globals = source === 'kernel' - ? kernelPreloadGlobals - : { acquireNotebookRendererApi: () => acquireNotebookRendererApi(source.rendererId) }; - - // create the promise so that the scripts download in parallel, but - // only invoke them in series within the queue - const promise = runScript(uri, originalUri, globals); - queue = queue.then(() => promise.then(fn => { - const result = fn(); - if (result.state === PreloadState.Error) { - console.error(result.error); - } - - return result; - })); - preloadPromises.set(uri, queue); + for (const { uri, originalUri } of resources) { + kernelPreloads.load(uri, originalUri); } break; case 'focus-output': @@ -786,6 +772,9 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv case 'customKernelMessage': onDidReceiveKernelMessage.fire(event.data.message); break; + case 'customRendererMessage': + renderers.getRenderer(event.data.rendererId)?.receiveMessage(event.data.message); + break; case 'notebookStyles': const documentStyle = document.documentElement.style; @@ -806,51 +795,217 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv } }); - interface MarkupRenderer { - renderMarkup: (context: { element: HTMLElement, content: string }) => void; + interface RendererApi { + renderCell: (id: string, context: ICreateCellInfo) => void; + destroyCell?: (id?: string) => void; } - const markupRenderers = new class { + class Renderer { + constructor( + public readonly data: RendererMetadata, + private readonly loadExtension: (id: string) => Promise, + ) { } - private readonly mimeTypesToRenderers = new Map Promise; - }>(); + private _onMessageEvent = createEmitter(); + private _loadPromise: Promise | undefined; + private _api: RendererApi | undefined; + + public get api() { return this._api; } + + public load(): Promise { + if (!this._loadPromise) { + this._loadPromise = this._load(); + } + + return this._loadPromise; + } + + public receiveMessage(message: unknown) { + this._onMessageEvent.fire(message); + } + + private createRendererContext(): RendererContext { + const { id, messaging } = this.data; + const context: RendererContext = { + setState: newState => vscode.setState({ ...vscode.getState(), [id]: newState }), + getState: () => { + const state = vscode.getState(); + return typeof state === 'object' && state ? state[id] as T : undefined; + }, + getRenderer: (id: string) => renderers.getRenderer(id)?.api, + }; + + if (messaging) { + context.onDidReceiveMessage = this._onMessageEvent.event; + context.postMessage = message => postNotebookMessage('customRendererMessage', { rendererId: id, message }); + } + + return context; + } + + /** Inner function cached in the _loadPromise(). */ + private async _load() { + const module = await runRenderScript(this.data.entrypoint, this.data.id); + if (!module) { + return; + } + + const api = module.activate(this.createRendererContext()); + this._api = api; + + // Squash any errors extends errors. They won't prevent the renderer + // itself from working, so just log them. + await Promise.all(rendererData + .filter(d => d.extends === this.data.id) + .map(d => this.loadExtension(d.id).catch(console.error)), + ); + + return api; + } + } + + const kernelPreloads = new class { + private readonly preloads = new Map>(); + + /** + * Returns a promise that resolves when the given preload is activated. + */ + public waitFor(uri: string) { + return this.preloads.get(uri) || Promise.resolve(new Error(`Preload not ready: ${uri}`)); + } + + /** + * Loads a preload. + * @param uri URI to load from + * @param originalUri URI to show in an error message if the preload is invalid. + */ + public load(uri: string, originalUri: string) { + const promise = Promise.all([ + runPreload(uri, originalUri), + this.waitForAllCurrent(), + ]).then(([module]) => module.activate()); + + this.preloads.set(uri, promise); + return promise; + } + + /** + * Returns a promise that waits for all currently-registered preloads to + * activate before resolving. + */ + private waitForAllCurrent() { + return Promise.all([...this.preloads.values()].map(p => p.catch(err => err))); + } + }; + + const outputs = new class { + private outputs = new Map }>(); + /** + * Pushes the action onto the list of actions for the given output ID, + * ensuring that it's run in-order. + */ + public enqueue(outputId: string, action: (record: { cancelled: boolean }) => unknown) { + const record = this.outputs.get(outputId); + if (!record) { + this.outputs.set(outputId, { cancelled: false, queue: new Promise(r => r(action({ cancelled: false }))) }); + } else { + record.queue = record.queue.then(r => !record.cancelled && action(record)); + } + } + + /** + * Cancells the rendering of all outputs. + */ + public cancelAll() { + for (const record of this.outputs.values()) { + record.cancelled = true; + } + this.outputs.clear(); + } + + /** + * Cancels any ongoing rendering out an output. + */ + public cancelOutput(outputId: string) { + const output = this.outputs.get(outputId); + if (output) { + output.cancelled = true; + this.outputs.delete(outputId); + } + } + }; + + const renderers = new class { + private readonly _renderers = new Map(); constructor() { for (const renderer of rendererData) { - let loadPromise: Promise | undefined; - - const entry = { - load: () => { - if (!loadPromise) { - loadPromise = __import(renderer.entrypoint).then(module => { - return module.activate({ dependencies: renderer.dependencies }); - }); - } - return loadPromise; - }, - renderer: undefined, - }; - - for (const mime of renderer.mimeTypes || []) { - if (!this.mimeTypesToRenderers.has(mime)) { - this.mimeTypesToRenderers.set(mime, entry); + this._renderers.set(renderer.id, new Renderer(renderer, async (extensionId) => { + const ext = this._renderers.get(extensionId); + if (!ext) { + throw new Error(`Could not find extending renderer: ${extensionId}`); } - } + + await ext.load(); + })); } } - async renderMarkdown(element: HTMLElement, content: string): Promise { - const entry = this.mimeTypesToRenderers.get('text/markdown'); - if (!entry) { + public getRenderer(id: string) { + return this._renderers.get(id); + } + + public async load(id: string) { + const renderer = this._renderers.get(id); + if (!renderer) { throw new Error('Could not find renderer'); } - const renderer = await entry.load(); - renderer.renderMarkup({ element, content }); + + return renderer.load(); + } + + + public clearAll() { + outputs.cancelAll(); + for (const renderer of this._renderers.values()) { + renderer.api?.destroyCell?.(); + } + } + + public clearOutput(rendererId: string, outputId: string) { + outputs.cancelOutput(outputId); + this._renderers.get(rendererId)?.api?.destroyCell?.(outputId); + } + + public async renderCustom(rendererId: string, outputId: string, info: ICreateCellInfo) { + const api = await this.load(rendererId); + if (!api) { + throw new Error(`renderer ${rendererId} did not return an API`); + } + + api.renderCell(outputId, info); + } + + public async renderMarkdown(id: string, element: HTMLElement, content: string): Promise { + const markdownRenderers = Array.from(this._renderers.values()) + .filter(renderer => renderer.data.mimeTypes.includes('text/markdown') && !renderer.data.extends); + + if (!markdownRenderers.length) { + throw new Error('Could not find renderer'); + } + + await Promise.all(markdownRenderers.map(x => x.load())); + + markdownRenderers[0].api?.renderCell(id, { + element, + value: content, + mime: 'text/markdown', + metadata: undefined, + outputId: undefined, + }); } }(); - vscode.postMessage({ __vscode_notebook_message: true, type: 'initialized' @@ -978,7 +1133,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv previewNode.innerText = ''; } else { previewContainerNode.classList.remove('emptyMarkdownCell'); - await markupRenderers.renderMarkdown(previewNode, content); + await renderers.renderMarkdown(cellId, previewNode, content); if (!hasPostedRenderedMathTelemetry) { const hasRenderedMath = previewNode.querySelector('.katex'); @@ -1025,7 +1180,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv cellId: drag.cellId, ctrlKey: e.ctrlKey, altKey: e.altKey, - position: { clientY: e.clientY }, + dragOffsetY: e.clientY, }); }); } @@ -1041,7 +1196,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv postNotebookMessage('cell-drag-start', { cellId: cellId, - position: { clientY: e.clientY }, + dragOffsetY: e.clientY, }); // Continuously send updates while dragging instead of relying on `updateDrag`. @@ -1053,7 +1208,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv postNotebookMessage('cell-drag', { cellId: cellId, - position: { clientY: this.currentDrag.clientY }, + dragOffsetY: this.currentDrag.clientY, }); requestAnimationFrame(trySendDragUpdate); }; @@ -1077,13 +1232,15 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv }(); } -export interface WebviewPreloadRenderer { +export interface RendererMetadata { + readonly id: string; readonly entrypoint: string; readonly mimeTypes: readonly string[]; - readonly dependencies: ReadonlyArray<{ entrypoint: string }>; + readonly extends: string | undefined; + readonly messaging: boolean; } -export function preloadsScriptStr(styleValues: PreloadStyles, renderers: readonly WebviewPreloadRenderer[]) { +export function preloadsScriptStr(styleValues: PreloadStyles, renderers: readonly RendererMetadata[]) { // TS will try compiling `import()` in webviePreloads, so use an helper function instead // of using `import(...)` directly return ` diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts index 57ed3710937..c1ebfdb4c06 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts @@ -147,7 +147,7 @@ export abstract class BaseCellViewModel extends Disposable { })); this._register(this._viewContext.notebookOptions.onDidChangeOptions(e => { - if (e.cellStatusBarVisibility) { + if (e.cellStatusBarVisibility || e.insertToolbarPosition) { this.layoutChange({}); } })); diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts index 3049c90f3b3..479bb337226 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts @@ -179,8 +179,8 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod + editorHeight + statusbarHeight; const outputShowMoreContainerOffset = totalHeight - - notebookLayoutConfiguration.bottomCellToolbarGap - - notebookLayoutConfiguration.bottomCellToolbarHeight / 2 + - notebookLayoutConfiguration.bottomToolbarGap + - notebookLayoutConfiguration.bottomToolbarHeight / 2 - outputShowMoreContainerHeight; const bottomToolbarOffset = this.viewContext.notebookOptions.computeBottomToolbarOffset(totalHeight); const editorWidth = state.outerWidth !== undefined @@ -209,11 +209,11 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod notebookLayoutConfiguration.cellTopMargin + notebookLayoutConfiguration.collapsedIndicatorHeight + notebookLayoutConfiguration.cellBottomMargin //CELL_BOTTOM_MARGIN - + notebookLayoutConfiguration.bottomCellToolbarGap //BOTTOM_CELL_TOOLBAR_GAP + + notebookLayoutConfiguration.bottomToolbarGap //BOTTOM_CELL_TOOLBAR_GAP + outputTotalHeight + outputShowMoreContainerHeight; const outputShowMoreContainerOffset = totalHeight - - notebookLayoutConfiguration.bottomCellToolbarGap - - notebookLayoutConfiguration.bottomCellToolbarHeight / 2 + - notebookLayoutConfiguration.bottomToolbarGap + - notebookLayoutConfiguration.bottomToolbarHeight / 2 - outputShowMoreContainerHeight; const bottomToolbarOffset = this.viewContext.notebookOptions.computeBottomToolbarOffset(totalHeight); const editorWidth = state.outerWidth !== undefined @@ -314,7 +314,7 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod + this.getEditorStatusbarHeight() + outputsTotalHeight + outputShowMoreContainerHeight - + layoutConfiguration.bottomCellToolbarGap //BOTTOM_CELL_TOOLBAR_GAP + + layoutConfiguration.bottomToolbarGap //BOTTOM_CELL_TOOLBAR_GAP + layoutConfiguration.cellBottomMargin; // CELL_BOTTOM_MARGIN; } @@ -411,6 +411,5 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod this._outputCollection = []; this._outputsTop = null; - this._outputViewModels = []; } } diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts index 1703faa5359..c0b94acf079 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts @@ -29,7 +29,7 @@ export class MarkdownCellViewModel extends BaseCellViewModel implements ICellVie set renderedMarkdownHeight(newHeight: number) { if (this.getEditState() === CellEditState.Preview) { - const newTotalHeight = newHeight + this.viewContext.notebookOptions.getLayoutConfiguration().bottomCellToolbarGap; // BOTTOM_CELL_TOOLBAR_GAP; + const newTotalHeight = newHeight + this.viewContext.notebookOptions.getLayoutConfiguration().bottomToolbarGap; // BOTTOM_CELL_TOOLBAR_GAP; this.totalHeight = newTotalHeight; } } @@ -52,7 +52,7 @@ export class MarkdownCellViewModel extends BaseCellViewModel implements ICellVie this.totalHeight = this._editorHeight + layoutConfiguration.markdownCellTopMargin // MARKDOWN_CELL_TOP_MARGIN + layoutConfiguration.markdownCellBottomMargin // MARKDOWN_CELL_BOTTOM_MARGIN - + layoutConfiguration.bottomCellToolbarGap // BOTTOM_CELL_TOOLBAR_GAP + + layoutConfiguration.bottomToolbarGap // BOTTOM_CELL_TOOLBAR_GAP + this.viewContext.notebookOptions.computeStatusBarHeight(); } @@ -120,7 +120,7 @@ export class MarkdownCellViewModel extends BaseCellViewModel implements ICellVie editorWidth: initialNotebookLayoutInfo?.width ? this.viewContext.notebookOptions.computeMarkdownCellEditorWidth(initialNotebookLayoutInfo.width) : 0, - bottomToolbarOffset: this.viewContext.notebookOptions.getLayoutConfiguration().bottomCellToolbarGap, // BOTTOM_CELL_TOOLBAR_GAP, + bottomToolbarOffset: this.viewContext.notebookOptions.getLayoutConfiguration().bottomToolbarGap, // BOTTOM_CELL_TOOLBAR_GAP, totalHeight: 0 }; diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel.ts index 840b8621878..4e8b53f69a1 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModel.ts @@ -3,40 +3,40 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { groupBy } from 'vs/base/common/collections'; import { onUnexpectedError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { clamp } from 'vs/base/common/numbers'; +import { dirname } from 'vs/base/common/resources'; import * as strings from 'vs/base/common/strings'; import { URI } from 'vs/base/common/uri'; +import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer'; import { IBulkEditService, ResourceEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService'; +import { IPosition, Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import * as editorCommon from 'vs/editor/common/editorCommon'; -import { IModelDecorationOptions, IModelDeltaDecoration, TrackedRangeStickiness, IReadonlyTextBuffer, EndOfLinePreference } from 'vs/editor/common/model'; +import { EndOfLinePreference, IModelDecorationOptions, IModelDeltaDecoration, IReadonlyTextBuffer, TrackedRangeStickiness } from 'vs/editor/common/model'; +import { MultiModelEditStackElement, SingleModelEditStackElement } from 'vs/editor/common/model/editStack'; import { IntervalNode, IntervalTree } from 'vs/editor/common/model/intervalTree'; import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; import { WorkspaceTextEdit } from 'vs/editor/common/modes'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; +import { FoldingRegions } from 'vs/editor/contrib/folding/foldingRanges'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; -import { CellEditState, CellFindMatch, ICellViewModel, NotebookLayoutInfo, INotebookDeltaDecoration, INotebookDeltaCellStatusBarItems, CellFocusMode, CellFindMatchWithIndex } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { ResourceNotebookCellEdit } from 'vs/workbench/contrib/bulkEdit/browser/bulkCellEdits'; +import { CellFoldingState, EditorFoldingStateDelegate } from 'vs/workbench/contrib/notebook/browser/contrib/fold/foldingModel'; +import { CellEditState, CellFindMatch, CellFindMatchWithIndex, CellFocusMode, ICellViewModel, INotebookDeltaCellStatusBarItems, INotebookDeltaDecoration, NotebookLayoutInfo } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { NotebookCellSelectionCollection } from 'vs/workbench/contrib/notebook/browser/viewModel/cellSelectionCollection'; import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; import { NotebookMetadataChangedEvent } from 'vs/workbench/contrib/notebook/browser/viewModel/eventDispatcher'; -import { CellFoldingState, EditorFoldingStateDelegate } from 'vs/workbench/contrib/notebook/browser/contrib/fold/foldingModel'; import { MarkdownCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel'; -import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; -import { CellKind, NotebookCellMetadata, INotebookSearchOptions, NotebookCellsChangeType, ICell, NotebookCellTextModelSplice, CellEditType, IOutputDto, SelectionStateType, ISelectionState } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { ICellRange, cellIndexesToRanges, cellRangesToIndexes, reduceRanges } from 'vs/workbench/contrib/notebook/common/notebookRange'; -import { FoldingRegions } from 'vs/editor/contrib/folding/foldingRanges'; -import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; -import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer'; -import { dirname } from 'vs/base/common/resources'; -import { IPosition, Position } from 'vs/editor/common/core/position'; -import { MultiModelEditStackElement, SingleModelEditStackElement } from 'vs/editor/common/model/editStack'; -import { ResourceNotebookCellEdit } from 'vs/workbench/contrib/bulkEdit/browser/bulkCellEdits'; -import { NotebookCellSelectionCollection } from 'vs/workbench/contrib/notebook/browser/viewModel/cellSelectionCollection'; -import { ITextModelService } from 'vs/editor/common/services/resolverService'; -import { groupByNumber } from 'vs/base/common/collections'; import { ViewContext } from 'vs/workbench/contrib/notebook/browser/viewModel/viewContext'; +import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; +import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; +import { CellEditType, CellKind, ICell, INotebookSearchOptions, IOutputDto, ISelectionState, NotebookCellMetadata, NotebookCellsChangeType, NotebookCellTextModelSplice, SelectionStateType } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { cellIndexesToRanges, cellRangesToIndexes, ICellRange, reduceRanges } from 'vs/workbench/contrib/notebook/common/notebookRange'; export interface INotebookEditorViewState { editingCells: { [key: number]: boolean }; @@ -730,13 +730,13 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD } deltaCellStatusBarItems(oldItems: string[], newItems: INotebookDeltaCellStatusBarItems[]): string[] { - const deletesByHandle = groupByNumber(oldItems, id => this._statusBarItemIdToCellMap.get(id) ?? -1); + const deletesByHandle = groupBy(oldItems, id => this._statusBarItemIdToCellMap.get(id) ?? -1); const result: string[] = []; newItems.forEach(itemDelta => { const cell = this.getCellByHandle(itemDelta.handle); - const deleted = deletesByHandle.get(itemDelta.handle) ?? []; - deletesByHandle.delete(itemDelta.handle); + const deleted = deletesByHandle[itemDelta.handle] ?? []; + delete deletesByHandle[itemDelta.handle]; const ret = cell?.deltaCellStatusBarItems(deleted, itemDelta.items) || []; ret.forEach(id => { this._statusBarItemIdToCellMap.set(id, itemDelta.handle); @@ -745,10 +745,12 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD result.push(...ret); }); - deletesByHandle.forEach((ids, handle) => { + for (let _handle in deletesByHandle) { + const handle = parseInt(_handle); + const ids = deletesByHandle[handle]; const cell = this.getCellByHandle(handle); cell?.deltaCellStatusBarItems(ids, []); - }); + } return result; } diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index 816f3dbaf20..560db334bc9 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -53,6 +53,8 @@ export const ACCESSIBLE_NOTEBOOK_DISPLAY_ORDER = [ export const BUILTIN_RENDERER_ID = '_builtin'; export const RENDERER_NOT_AVAILABLE = '_notAvailable'; +export type NotebookRendererEntrypoint = string | { extends: string; path: string }; + export enum NotebookRunState { Running = 1, Idle = 2 @@ -129,13 +131,17 @@ export const enum NotebookRendererMatch { Never = 3, } +export type RendererMessagingSpec = true | false | 'optional'; + export interface INotebookRendererInfo { id: string; displayName: string; + extends?: string; entrypoint: URI; preloads: ReadonlyArray; extensionLocation: URI; extensionId: ExtensionIdentifier; + messaging: RendererMessagingSpec; readonly mimeTypes: readonly string[]; @@ -894,7 +900,11 @@ export const ShowCellStatusBarKey = 'notebook.showCellStatusBar'; export const NotebookTextDiffEditorPreview = 'notebook.diff.enablePreview'; export const ExperimentalUseMarkdownRenderer = 'notebook.experimental.useMarkdownRenderer'; export const ExperimentalCompactView = 'notebook.experimental.compactView'; +export const ExperimentalFocusIndicator = 'notebook.experimental.cellFocusIndicator'; +export const ExperimentalInsertToolbarPosition = 'notebook.experimental.insertToolbarPosition'; +export const ExperimentalGlobalToolbar = 'notebook.experimental.globalToolbar'; export const ExperimentalUndoRedoPerCell = 'notebook.experimental.undoRedoPerCell'; +export const ExperimentalConsolidatedOutputButton = 'notebook.experimental.consolidatedOutputButton'; export const enum CellStatusbarAlignment { Left = 1, @@ -912,7 +922,7 @@ export class NotebookWorkingCopyTypeIdentifier { private static _prefix = 'notebook/'; static create(viewType: string): string { - return `${NotebookWorkingCopyTypeIdentifier._prefix}/${viewType}`; + return `${NotebookWorkingCopyTypeIdentifier._prefix}${viewType}`; } static parse(candidate: string): string | undefined { diff --git a/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts index 511261f2ada..b0684bbf13f 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts @@ -23,13 +23,14 @@ import { TaskSequentializer } from 'vs/base/common/async'; import { bufferToStream, streamToBuffer, VSBuffer, VSBufferReadableStream } from 'vs/base/common/buffer'; import { assertType } from 'vs/base/common/types'; import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; -import { FileWorkingCopyState, IFileWorkingCopyModel, IFileWorkingCopyModelContentChangedEvent, IFileWorkingCopyModelFactory, IResolvedFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { StoredFileWorkingCopy, StoredFileWorkingCopyState, IStoredFileWorkingCopy, IStoredFileWorkingCopyModel, IStoredFileWorkingCopyModelContentChangedEvent, IStoredFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { canceled } from 'vs/base/common/errors'; import { NotebookEditorInput } from 'vs/workbench/contrib/notebook/common/notebookEditorInput'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IFileWorkingCopyManager, IFileWorkingCopySaveAsOptions } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; import { filter } from 'vs/base/common/objects'; +import { IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; +import { IUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelContentChangedEvent, IUntitledFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; //#region --- complex content provider @@ -425,13 +426,13 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE readonly onDidChangeOrphaned: Event = this._onDidChangeOrphaned.event; readonly onDidChangeReadonly: Event = this._onDidChangeReadonly.event; - private _workingCopy?: IResolvedFileWorkingCopy; + private _workingCopy?: IStoredFileWorkingCopy | IUntitledFileWorkingCopy; private readonly _workingCopyListeners = new DisposableStore(); constructor( readonly resource: URI, readonly viewType: string, - private readonly _workingCopyManager: IFileWorkingCopyManager, + private readonly _workingCopyManager: IFileWorkingCopyManager, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IFileService private readonly _fileService: IFileService ) { @@ -449,7 +450,7 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE } get notebook(): NotebookTextModel | undefined { - return this._workingCopy?.model.notebookModel; + return this._workingCopy?.model?.notebookModel; } override isResolved(): this is IResolvedNotebookEditorModel { @@ -461,11 +462,17 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE } isOrphaned(): boolean { - return this._workingCopy?.hasState(FileWorkingCopyState.ORPHAN) ?? false; + return SimpleNotebookEditorModel._isStoredFileWorkingCopy(this._workingCopy) && this._workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN); } isReadonly(): boolean { - return this._workingCopy?.isReadonly() || this._fileService.hasCapability(this.resource, FileSystemProviderCapabilities.Readonly); + if (SimpleNotebookEditorModel._isStoredFileWorkingCopy(this._workingCopy)) { + return this._workingCopy.isReadonly(); + } else if (this._fileService.hasCapability(this.resource, FileSystemProviderCapabilities.Readonly)) { + return true; + } else { + return false; + } } revert(options?: IRevertOptions): Promise { @@ -479,33 +486,46 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE } async load(options?: INotebookLoadOptions): Promise { - const workingCopy = await this._workingCopyManager.resolve(this.resource, { reload: { async: !options?.forceReadFromFile } }); + if (!this._workingCopy) { - this._workingCopy = >workingCopy; - this._workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(), this._workingCopyListeners); - this._workingCopy.onDidSave(() => this._onDidSave.fire(), this._workingCopyListeners); - this._workingCopy.onDidChangeOrphaned(() => this._onDidChangeOrphaned.fire(), this._workingCopyListeners); - this._workingCopy.onDidChangeReadonly(() => this._onDidChangeReadonly.fire(), this._workingCopyListeners); + if (this.resource.scheme === Schemas.untitled) { + this._workingCopy = await this._workingCopyManager.resolve({ untitledResource: this.resource }); + } else { + this._workingCopy = await this._workingCopyManager.resolve(this.resource, { forceReadFromFile: options?.forceReadFromFile }); + this._workingCopyListeners.add(this._workingCopy.onDidSave(() => this._onDidSave.fire())); + this._workingCopyListeners.add(this._workingCopy.onDidChangeOrphaned(() => this._onDidChangeOrphaned.fire())); + this._workingCopyListeners.add(this._workingCopy.onDidChangeReadonly(() => this._onDidChangeReadonly.fire())); + } + this._workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(), undefined, this._workingCopyListeners); + + this._workingCopyListeners.add(this._workingCopy.onWillDispose(() => { + this._workingCopyListeners.clear(); + this._workingCopy?.model?.dispose(); + })); } + assertType(this.isResolved()); return this; } - async saveAs(target: URI, options?: IFileWorkingCopySaveAsOptions): Promise { - const newWorkingCopy = await this._workingCopyManager.saveAs(this.resource, target, options); + async saveAs(target: URI): Promise { + const newWorkingCopy = await this._workingCopyManager.saveAs(this.resource, target); if (!newWorkingCopy) { return undefined; } - assertType(newWorkingCopy.isResolved()); // this is a little hacky because we leave the new working copy alone. BUT // the newly created editor input will pick it up and claim ownership of it. return this._instantiationService.createInstance(NotebookEditorInput, newWorkingCopy.resource, this.viewType, {}); } + + private static _isStoredFileWorkingCopy(candidate?: unknown): candidate is IStoredFileWorkingCopy { + return candidate instanceof StoredFileWorkingCopy; + } } -export class NotebookFileWorkingCopyModel implements IFileWorkingCopyModel { +export class NotebookFileWorkingCopyModel implements IStoredFileWorkingCopyModel, IUntitledFileWorkingCopyModel { - private readonly _onDidChangeContent = new Emitter(); + private readonly _onDidChangeContent = new Emitter(); private readonly _changeListener: IDisposable; readonly onDidChangeContent = this._onDidChangeContent.event; @@ -525,10 +545,10 @@ export class NotebookFileWorkingCopyModel implements IFileWorkingCopyModel { if (rawEvent.transient) { continue; } - //todo@jrieken,@rebornix forward this information from notebook model this._onDidChangeContent.fire({ - isRedoing: false, - isUndoing: false + isRedoing: false, //todo@rebornix forward this information from notebook model + isUndoing: false, + isEmpty: false, //_notebookModel.cells.length === 0 // todo@jrieken non transient metadata? }); break; } @@ -585,14 +605,16 @@ export class NotebookFileWorkingCopyModel implements IFileWorkingCopyModel { this._notebookModel.reset(data.cells, data.metadata, this._notebookSerializer.options); } - get versionId() { return this._notebookModel.alternativeVersionId; } + get versionId() { + return this._notebookModel.alternativeVersionId; + } pushStackElement(): void { this._notebookModel.pushStackElement('save', undefined, undefined); } } -export class NotebookFileWorkingCopyModelFactory implements IFileWorkingCopyModelFactory{ +export class NotebookFileWorkingCopyModelFactory implements IStoredFileWorkingCopyModelFactory, IUntitledFileWorkingCopyModelFactory{ constructor( private readonly _viewType: string, @@ -606,7 +628,8 @@ export class NotebookFileWorkingCopyModelFactory implements IFileWorkingCopyMode throw new Error('CANNOT open file notebook with this provider'); } - const data = await info.serializer.dataToNotebook(await streamToBuffer(stream)); + const bytes = await streamToBuffer(stream); + const data = await info.serializer.dataToNotebook(bytes); if (token.isCancellationRequested) { throw canceled(); diff --git a/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts b/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts index b4dcb5c68c9..17babc5a8f1 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts @@ -11,16 +11,16 @@ import { combinedDisposable, DisposableStore, dispose, IDisposable, IReference, import { ComplexNotebookProviderInfo, INotebookService, SimpleNotebookProviderInfo } from 'vs/workbench/contrib/notebook/common/notebookService'; import { ILogService } from 'vs/platform/log/common/log'; import { Emitter, Event } from 'vs/base/common/event'; -import { FileWorkingCopyManager, IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { INotebookEditorModelResolverService } from 'vs/workbench/contrib/notebook/common/notebookEditorModelResolverService'; import { ResourceMap } from 'vs/base/common/map'; +import { FileWorkingCopyManager, IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; class NotebookModelReferenceCollection extends ReferenceCollection> { private readonly _disposables = new DisposableStore(); - private readonly _workingCopyManagers = new Map>(); + private readonly _workingCopyManagers = new Map>(); private readonly _modelListener = new Map(); private readonly _onDidSaveNotebook = new Emitter(); @@ -70,10 +70,12 @@ class NotebookModelReferenceCollection extends ReferenceCollection>this._instantiationService.createInstance( + const factory = new NotebookFileWorkingCopyModelFactory(viewType, this._notebookService); + workingCopyManager = >this._instantiationService.createInstance( FileWorkingCopyManager, workingCopyTypeId, - new NotebookFileWorkingCopyModelFactory(viewType, this._notebookService) + factory, + factory, ); this._workingCopyManagers.set(workingCopyTypeId, workingCopyManager); } diff --git a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts index bd4cd916a29..b953078c681 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts @@ -6,7 +6,9 @@ import { Emitter } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { CellToolbarLocKey, CellToolbarVisibility, ExperimentalCompactView, ShowCellStatusBarKey } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellToolbarLocKey, CellToolbarVisibility, ExperimentalCompactView, ExperimentalConsolidatedOutputButton, ExperimentalFocusIndicator, ExperimentalGlobalToolbar, ExperimentalInsertToolbarPosition, ShowCellStatusBarKey } from 'vs/workbench/contrib/notebook/common/notebookCommon'; + +const SCROLLABLE_ELEMENT_PADDING_TOP = 18; let EDITOR_TOP_PADDING = 12; const editorTopPaddingChangeEmitter = new Emitter(); @@ -33,8 +35,8 @@ export interface NotebookLayoutConfiguration { markdownCellTopMargin: number; markdownCellBottomMargin: number; markdownPreviewPadding: number; - bottomCellToolbarGap: number; - bottomCellToolbarHeight: number; + bottomToolbarGap: number; + bottomToolbarHeight: number; editorToolbarHeight: number; editorTopPadding: number; editorBottomPadding: number; @@ -45,6 +47,10 @@ export interface NotebookLayoutConfiguration { cellToolbarLocation: string | { [key: string]: string }; cellToolbarInteraction: string; compactView: boolean; + focusIndicator: 'border' | 'gutter'; + insertToolbarPosition: 'betweenCells' | 'notebookToolbar' | 'both' | 'hidden'; + globalToolbar: boolean; + consolidatedOutputButton: boolean; } interface NotebookOptionsChangeEvent { @@ -53,6 +59,9 @@ interface NotebookOptionsChangeEvent { cellToolbarInteraction?: boolean; editorTopPadding?: boolean; compactView?: boolean; + focusIndicator?: boolean; + insertToolbarPosition?: boolean; + globalToolbar?: boolean; } const defaultConfigConstants = { @@ -61,7 +70,6 @@ const defaultConfigConstants = { markdownCellTopMargin: 8, markdownCellBottomMargin: 8, markdownCellLeftMargin: 32, - bottomCellToolbarGap: 18, }; const compactConfigConstants = { @@ -70,7 +78,6 @@ const compactConfigConstants = { markdownCellTopMargin: 6, markdownCellBottomMargin: 6, markdownCellLeftMargin: 32, - bottomCellToolbarGap: 12, }; export class NotebookOptions { @@ -79,11 +86,16 @@ export class NotebookOptions { readonly onDidChangeOptions = this._onDidChangeOptions.event; private _disposables: IDisposable[]; - constructor(readonly configurationService: IConfigurationService) { + constructor(private readonly configurationService: IConfigurationService) { const showCellStatusBar = this.configurationService.getValue(ShowCellStatusBarKey); + const globalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar) ?? false; + const consolidatedOutputButton = this.configurationService.getValue(ExperimentalConsolidatedOutputButton) ?? true; const cellToolbarLocation = this.configurationService.getValue(CellToolbarLocKey); const cellToolbarInteraction = this.configurationService.getValue(CellToolbarVisibility); const compactView = this.configurationService.getValue(ExperimentalCompactView); + const focusIndicator = this.configurationService.getValue<'border' | 'gutter'>(ExperimentalFocusIndicator) ?? 'border'; + const insertToolbarPosition = this.configurationService.getValue<'betweenCells' | 'notebookToolbar' | 'both' | 'hidden'>(ExperimentalInsertToolbarPosition) ?? 'both'; + const { bottomToolbarGap, bottomToolbarHeight } = this._computeBottomToolbarDimensions(compactView, insertToolbarPosition); this._disposables = []; this._layoutConfiguration = { @@ -94,16 +106,21 @@ export class NotebookOptions { cellStatusBarHeight: 22, cellOutputPadding: 14, markdownPreviewPadding: 8, - bottomCellToolbarHeight: 22, + bottomToolbarHeight: bottomToolbarHeight, + bottomToolbarGap: bottomToolbarGap, editorToolbarHeight: 0, editorTopPadding: EDITOR_TOP_PADDING, editorBottomPadding: 4, editorBottomPaddingWithoutStatusBar: 12, collapsedIndicatorHeight: 24, showCellStatusBar, + globalToolbar, + consolidatedOutputButton, cellToolbarLocation, cellToolbarInteraction, - compactView + compactView, + focusIndicator, + insertToolbarPosition }; this._disposables.push(this.configurationService.onDidChangeConfiguration(e => { @@ -111,8 +128,12 @@ export class NotebookOptions { let cellToolbarLocation = e.affectsConfiguration(CellToolbarLocKey); let cellToolbarInteraction = e.affectsConfiguration(CellToolbarVisibility); let compactView = e.affectsConfiguration(ExperimentalCompactView); + let focusIndicator = e.affectsConfiguration(ExperimentalFocusIndicator); + let insertToolbarPosition = e.affectsConfiguration(ExperimentalInsertToolbarPosition); + let globalToolbar = e.affectsConfiguration(ExperimentalGlobalToolbar); + let consolidatedOutputButton = e.affectsConfiguration(ExperimentalConsolidatedOutputButton); - if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView) { + if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView && !focusIndicator && !insertToolbarPosition && !globalToolbar && !consolidatedOutputButton) { return; } @@ -130,6 +151,10 @@ export class NotebookOptions { configuration.cellToolbarInteraction = this.configurationService.getValue(CellToolbarVisibility); } + if (focusIndicator) { + configuration.focusIndicator = this.configurationService.getValue<'border' | 'gutter'>(ExperimentalFocusIndicator) ?? 'border'; + } + if (compactView) { const compactViewValue = this.configurationService.getValue('notebook.experimental.compactView'); configuration = Object.assign(configuration, { @@ -138,6 +163,21 @@ export class NotebookOptions { configuration.compactView = compactViewValue; } + if (insertToolbarPosition) { + configuration.insertToolbarPosition = this.configurationService.getValue<'betweenCells' | 'notebookToolbar' | 'both' | 'hidden'>(ExperimentalInsertToolbarPosition) ?? 'both'; + const { bottomToolbarGap, bottomToolbarHeight } = this._computeBottomToolbarDimensions(configuration.compactView, configuration.insertToolbarPosition); + configuration.bottomToolbarHeight = bottomToolbarHeight; + configuration.bottomToolbarGap = bottomToolbarGap; + } + + if (globalToolbar) { + configuration.globalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar) ?? false; + } + + if (consolidatedOutputButton) { + configuration.consolidatedOutputButton = this.configurationService.getValue(ExperimentalConsolidatedOutputButton) ?? true; + } + this._layoutConfiguration = configuration; // trigger event @@ -145,7 +185,10 @@ export class NotebookOptions { cellStatusBarVisibility: cellStatusBarVisibility, cellToolbarLocation: cellToolbarLocation, cellToolbarInteraction: cellToolbarInteraction, - compactView: compactView + compactView: compactView, + focusIndicator: focusIndicator, + insertToolbarPosition: insertToolbarPosition, + globalToolbar: globalToolbar }); })); @@ -157,6 +200,23 @@ export class NotebookOptions { })); } + private _computeBottomToolbarDimensions(compactView: boolean, insertToolbarPosition: 'betweenCells' | 'notebookToolbar' | 'both' | 'hidden'): { bottomToolbarGap: number, bottomToolbarHeight: number } { + if (insertToolbarPosition === 'betweenCells' || insertToolbarPosition === 'both') { + return compactView ? { + bottomToolbarGap: 12, + bottomToolbarHeight: 22 + } : { + bottomToolbarGap: 18, + bottomToolbarHeight: 22 + }; + } else { + return { + bottomToolbarGap: 0, + bottomToolbarHeight: 0 + }; + } + } + getLayoutConfiguration(): NotebookLayoutConfiguration { return this._layoutConfiguration; } @@ -164,14 +224,14 @@ export class NotebookOptions { computeCollapsedMarkdownCellHeight(): number { return this._layoutConfiguration.markdownCellTopMargin + this._layoutConfiguration.collapsedIndicatorHeight - + this._layoutConfiguration.bottomCellToolbarGap + + this._layoutConfiguration.bottomToolbarGap + this._layoutConfiguration.markdownCellBottomMargin; } computeBottomToolbarOffset(totalHeight: number) { return totalHeight - - this._layoutConfiguration.bottomCellToolbarGap - - this._layoutConfiguration.bottomCellToolbarHeight / 2; + - this._layoutConfiguration.bottomToolbarGap + - this._layoutConfiguration.bottomToolbarHeight / 2; } computeCodeCellEditorWidth(outerWidth: number): number { @@ -265,11 +325,25 @@ export class NotebookOptions { computeIndicatorPosition(totalHeight: number) { return { - bottomIndicatorTop: totalHeight - this._layoutConfiguration.bottomCellToolbarGap - this._layoutConfiguration.cellBottomMargin, - verticalIndicatorHeight: totalHeight - this._layoutConfiguration.bottomCellToolbarGap + bottomIndicatorTop: totalHeight - this._layoutConfiguration.bottomToolbarGap - this._layoutConfiguration.cellBottomMargin, + verticalIndicatorHeight: totalHeight - this._layoutConfiguration.bottomToolbarGap }; } + computeTopInserToolbarHeight(viewType?: string): number { + if (this._layoutConfiguration.insertToolbarPosition === 'betweenCells' || this._layoutConfiguration.insertToolbarPosition === 'both') { + return SCROLLABLE_ELEMENT_PADDING_TOP; + } + + const cellToolbarLocation = this.computeCellToolbarLocation(viewType); + + if (cellToolbarLocation === 'left' || cellToolbarLocation === 'right') { + return SCROLLABLE_ELEMENT_PADDING_TOP; + } + + return 0; + } + dispose() { this._disposables.forEach(d => d.dispose()); this._disposables = []; diff --git a/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts index b698cd5b3ee..3ce2880b1d6 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts @@ -8,7 +8,8 @@ import { Iterable } from 'vs/base/common/iterator'; import { joinPath } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; -import { INotebookRendererInfo, NotebookRendererMatch } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { INotebookRendererInfo, NotebookRendererEntrypoint, NotebookRendererMatch, RendererMessagingSpec } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; class DependencyList { private readonly value: ReadonlySet; @@ -34,12 +35,14 @@ class DependencyList { export class NotebookOutputRendererInfo implements INotebookRendererInfo { readonly id: string; + readonly extends?: string; readonly entrypoint: URI; readonly displayName: string; readonly extensionLocation: URI; readonly extensionId: ExtensionIdentifier; readonly hardDependencies: DependencyList; readonly optionalDependencies: DependencyList; + readonly messaging: RendererMessagingSpec; // todo: re-add preloads in pure renderer API readonly preloads: ReadonlyArray = []; @@ -49,21 +52,30 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { constructor(descriptor: { readonly id: string; readonly displayName: string; - readonly entrypoint: string; + readonly entrypoint: NotebookRendererEntrypoint; readonly mimeTypes: readonly string[]; readonly extension: IExtensionDescription; readonly dependencies: readonly string[] | undefined; readonly optionalDependencies: readonly string[] | undefined; - }) { + readonly requiresMessaging: RendererMessagingSpec | undefined; + }, @IExtensionService public readonly extensions: IExtensionService) { this.id = descriptor.id; this.extensionId = descriptor.extension.identifier; this.extensionLocation = descriptor.extension.extensionLocation; - this.entrypoint = joinPath(this.extensionLocation, descriptor.entrypoint); + + if (typeof descriptor.entrypoint === 'string') { + this.entrypoint = joinPath(this.extensionLocation, descriptor.entrypoint); + } else { + this.extends = descriptor.entrypoint.extends; + this.entrypoint = joinPath(this.extensionLocation, descriptor.entrypoint.path); + } + this.displayName = descriptor.displayName; this.mimeTypes = descriptor.mimeTypes; this.mimeTypeGlobs = this.mimeTypes.map(pattern => glob.parse(pattern)); this.hardDependencies = new DependencyList(descriptor.dependencies ?? Iterable.empty()); this.optionalDependencies = new DependencyList(descriptor.optionalDependencies ?? Iterable.empty()); + this.messaging = descriptor.requiresMessaging ?? false; } get dependencies(): string[] { @@ -75,6 +87,12 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { return NotebookRendererMatch.Never; } + // todo@connor4312 this a no-op since extensions that can't run are never + // shared as a contribution + if (this.messaging === true && !this.extensions.getExtensionsStatus()[this.extensionId.value]) { + return NotebookRendererMatch.Never; + } + if (this.hardDependencies.defined) { return NotebookRendererMatch.WithHardKernelDependency; } @@ -103,6 +121,10 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { } private matchesMimeTypeOnly(mimeType: string) { + if (this.extends !== undefined) { + return false; + } + return this.mimeTypeGlobs.some(pattern => pattern(mimeType)) || this.mimeTypes.some(pattern => pattern === mimeType); } } diff --git a/src/vs/workbench/contrib/notebook/common/notebookRendererMessagingService.ts b/src/vs/workbench/contrib/notebook/common/notebookRendererMessagingService.ts new file mode 100644 index 00000000000..2264baa168f --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/notebookRendererMessagingService.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export const INotebookRendererMessagingService = createDecorator('INotebookRendererMessagingService'); + +export interface INotebookRendererMessagingService { + readonly _serviceBrand: undefined; + + /** + * Event that fires when a message should be posted to extension hosts. + */ + onShouldPostMessage: Event<{ editorId: string; rendererId: string; message: unknown }>; + + /** + * Prepares messaging for the given renderer ID. + */ + prepare(rendererId: string): void; + /** + * Gets messaging scoped for a specific editor. + */ + getScoped(editorId: string): IScopedRendererMessaging; + + /** + * Called when the main thread gets a message for a renderer. + */ + fireDidReceiveMessage(editorId: string, rendererId: string, message: unknown): void; +} + +export interface IScopedRendererMessaging { + /** + * Event that fires when a message is received. + */ + onDidReceiveMessage: Event<{ rendererId: string; message: unknown }>; + + /** + * Sends a message to an extension from a renderer. + */ + postMessage(rendererId: string, message: unknown): void; +} diff --git a/src/vs/workbench/contrib/notebook/test/notebookCellList.test.ts b/src/vs/workbench/contrib/notebook/test/notebookCellList.test.ts index 84e2e5aad25..122ffb94bc9 100644 --- a/src/vs/workbench/contrib/notebook/test/notebookCellList.test.ts +++ b/src/vs/workbench/contrib/notebook/test/notebookCellList.test.ts @@ -4,12 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { SCROLLABLE_ELEMENT_PADDING_TOP } from 'vs/workbench/contrib/notebook/browser/constants'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { NotebookOptions } from 'vs/workbench/contrib/notebook/common/notebookOptions'; import { createNotebookCellList, setupInstantiationService, withTestNotebook } from 'vs/workbench/contrib/notebook/test/testNotebookEditor'; suite('NotebookCellList', () => { const instantiationService = setupInstantiationService(); + const notebookDefaultOptions = new NotebookOptions(instantiationService.get(IConfigurationService)); + const topInsertToolbarHeight = notebookDefaultOptions.computeTopInserToolbarHeight(); test('revealElementsInView: reveal fully visible cell should not scroll', async function () { await withTestNotebook( @@ -32,7 +35,7 @@ suite('NotebookCellList', () => { cellList.attachViewModel(viewModel); // render height 210, it can render 3 full cells and 1 partial cell - cellList.layout(210 + SCROLLABLE_ELEMENT_PADDING_TOP, 100); + cellList.layout(210 + topInsertToolbarHeight, 100); // scroll a bit, scrollTop to bottom: 5, 215 cellList.scrollTop = 5; @@ -77,7 +80,7 @@ suite('NotebookCellList', () => { cellList.attachViewModel(viewModel); // render height 210, it can render 3 full cells and 1 partial cell - cellList.layout(210 + SCROLLABLE_ELEMENT_PADDING_TOP, 100); + cellList.layout(210 + topInsertToolbarHeight, 100); // init scrollTop and scrollBottom assert.deepStrictEqual(cellList.scrollTop, 0); @@ -116,12 +119,12 @@ suite('NotebookCellList', () => { }); const cellList = createNotebookCellList(instantiationService); - // without additionalscrollheight, the last 20 px will always be hidden due to `SCROLLABLE_ELEMENT_PADDING_TOP` + // without additionalscrollheight, the last 20 px will always be hidden due to `topInsertToolbarHeight` cellList.updateOptions({ additionalScrollHeight: 100 }); cellList.attachViewModel(viewModel); // render height 210, it can render 3 full cells and 1 partial cell - cellList.layout(210 + SCROLLABLE_ELEMENT_PADDING_TOP, 100); + cellList.layout(210 + topInsertToolbarHeight, 100); // init scrollTop and scrollBottom assert.deepStrictEqual(cellList.scrollTop, 0); @@ -154,7 +157,7 @@ suite('NotebookCellList', () => { cellList.attachViewModel(viewModel); // render height 210, it can render 3 full cells and 1 partial cell - cellList.layout(210 + SCROLLABLE_ELEMENT_PADDING_TOP, 100); + cellList.layout(210 + topInsertToolbarHeight, 100); // init scrollTop and scrollBottom assert.deepStrictEqual(cellList.scrollTop, 0); @@ -196,7 +199,7 @@ suite('NotebookCellList', () => { cellList.attachViewModel(viewModel); // render height 210, it can render 3 full cells and 1 partial cell - cellList.layout(210 + SCROLLABLE_ELEMENT_PADDING_TOP, 100); + cellList.layout(210 + topInsertToolbarHeight, 100); // init scrollTop and scrollBottom assert.deepStrictEqual(cellList.scrollTop, 0); @@ -249,7 +252,7 @@ suite('NotebookCellList', () => { cellList.attachViewModel(viewModel); // render height 210, it can render 3 full cells and 1 partial cell - cellList.layout(210 + SCROLLABLE_ELEMENT_PADDING_TOP, 100); + cellList.layout(210 + topInsertToolbarHeight, 100); // init scrollTop and scrollBottom assert.deepStrictEqual(cellList.scrollTop, 0); @@ -283,7 +286,7 @@ suite('NotebookCellList', () => { cellList.attachViewModel(viewModel); // render height 210, it can render 3 full cells and 1 partial cell - cellList.layout(210 + SCROLLABLE_ELEMENT_PADDING_TOP, 100); + cellList.layout(210 + topInsertToolbarHeight, 100); // init scrollTop and scrollBottom assert.deepStrictEqual(cellList.scrollTop, 0); diff --git a/src/vs/workbench/contrib/notebook/test/notebookCommon.test.ts b/src/vs/workbench/contrib/notebook/test/notebookCommon.test.ts index f431f6cd4dd..88da6bd5e79 100644 --- a/src/vs/workbench/contrib/notebook/test/notebookCommon.test.ts +++ b/src/vs/workbench/contrib/notebook/test/notebookCommon.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { NOTEBOOK_DISPLAY_ORDER, sortMimeTypes, CellKind, diff, CellUri } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { NOTEBOOK_DISPLAY_ORDER, sortMimeTypes, CellKind, diff, CellUri, NotebookWorkingCopyTypeIdentifier } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { cellRangesToIndexes, cellIndexesToRanges } from 'vs/workbench/contrib/notebook/common/notebookRange'; import { TestCell, setupInstantiationService } from 'vs/workbench/contrib/notebook/test/testNotebookEditor'; import { URI } from 'vs/base/common/uri'; @@ -323,3 +323,13 @@ suite('CellRange', function () { assert.deepStrictEqual(cellIndexesToRanges([10, 9]), [{ start: 9, end: 11 }]); }); }); + +suite('NotebookWorkingCopyTypeIdentifier', function () { + + test('works', function () { + const viewType = 'testViewType'; + const type = NotebookWorkingCopyTypeIdentifier.create('testViewType'); + assert.strictEqual(NotebookWorkingCopyTypeIdentifier.parse(type), viewType); + assert.strictEqual(NotebookWorkingCopyTypeIdentifier.parse('something'), undefined); + }); +}); diff --git a/src/vs/workbench/contrib/notebook/test/notebookRendererMessagingService.test.ts b/src/vs/workbench/contrib/notebook/test/notebookRendererMessagingService.test.ts new file mode 100644 index 00000000000..b5c2774c766 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/test/notebookRendererMessagingService.test.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { NullExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { stub } from 'sinon'; +import { NotebookRendererMessagingService } from 'vs/workbench/contrib/notebook/browser/notebookRendererMessagingServiceImpl'; +import * as assert from 'assert'; +import { timeout } from 'vs/base/common/async'; + +suite('NotebookRendererMessaging', () => { + let extService: NullExtensionService; + let m: NotebookRendererMessagingService; + let sent: unknown[] = []; + let received: unknown[] = []; + + setup(() => { + sent = []; + extService = new NullExtensionService(); + m = new NotebookRendererMessagingService(extService); + m.onShouldPostMessage(e => sent.push(e)); + m.onDidReceiveMessage(e => received.push(e)); + }); + + test('activates on prepare', () => { + const activate = stub(extService, 'activateByEvent').returns(Promise.resolve()); + m.prepare('foo'); + m.prepare('foo'); + m.prepare('foo'); + + assert.deepStrictEqual(activate.args, [['onRenderer:foo']]); + }); + + test('buffers and then plays events', async () => { + stub(extService, 'activateByEvent').returns(Promise.resolve()); + + const scoped = m.getScoped('some-editor'); + scoped.postMessage('foo', 1); + scoped.postMessage('foo', 2); + assert.deepStrictEqual(sent, []); + + await timeout(0); + + const expected = [ + { editorId: 'some-editor', rendererId: 'foo', message: 1 }, + { editorId: 'some-editor', rendererId: 'foo', message: 2 } + ]; + + assert.deepStrictEqual(sent, expected); + + scoped.postMessage('foo', 3); + + assert.deepStrictEqual(sent, [ + ...expected, + { editorId: 'some-editor', rendererId: 'foo', message: 3 } + ]); + }); +}); diff --git a/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts index 04da8ba66b7..7f474ee7440 100644 --- a/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts +++ b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts @@ -177,7 +177,7 @@ function _createTestNotebookEditor(instantiationService: TestInstantiationServic const viewContext = new ViewContext(new NotebookOptions(instantiationService.get(IConfigurationService)), new NotebookEventDispatcher()); const viewModel: NotebookViewModel = instantiationService.createInstance(NotebookViewModel, viewType, model.notebook, viewContext, null); - const cellList = createNotebookCellList(instantiationService); + const cellList = createNotebookCellList(instantiationService, viewContext); cellList.attachViewModel(viewModel); const listViewInfoAccessor = new ListViewInfoAccessor(cellList); @@ -275,7 +275,7 @@ export async function withTestNotebook(cells: [source: string, lang: st return res; } -export function createNotebookCellList(instantiationService: TestInstantiationService) { +export function createNotebookCellList(instantiationService: TestInstantiationService, viewContext?: ViewContext) { const delegate: IListVirtualDelegate = { getHeight(element: CellViewModel) { return element.getHeight(17); }, getTemplateId() { return 'template'; } @@ -293,6 +293,7 @@ export function createNotebookCellList(instantiationService: TestInstantiationSe 'NotebookCellList', DOM.$('container'), DOM.$('body'), + viewContext ?? new ViewContext(new NotebookOptions(instantiationService.get(IConfigurationService)), new NotebookEventDispatcher()), delegate, [renderer], instantiationService.get(IContextKeyService), diff --git a/src/vs/workbench/contrib/outline/browser/outlinePane.ts b/src/vs/workbench/contrib/outline/browser/outlinePane.ts index 8b16ac9dc58..230d10664b6 100644 --- a/src/vs/workbench/contrib/outline/browser/outlinePane.ts +++ b/src/vs/workbench/contrib/outline/browser/outlinePane.ts @@ -37,6 +37,9 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; import { ITreeSorter } from 'vs/base/browser/ui/tree/tree'; import { URI } from 'vs/base/common/uri'; +import { EditorOverride } from 'vs/platform/editor/common/editor'; +import { NotebookEditorInput } from 'vs/workbench/contrib/notebook/common/notebookEditorInput'; +import { CustomEditorInput } from 'vs/workbench/contrib/customEditor/browser/customEditorInput'; const _ctxFollowsCursor = new RawContextKey('outlineFollowsCursor', false); const _ctxFilterOnType = new RawContextKey('outlineFiltersOnType', false); @@ -284,8 +287,13 @@ export class OutlinePane extends ViewPane { // feature: reveal outline selection in editor // on change -> reveal/select defining range - this._editorDisposables.add(tree.onDidOpen(e => newOutline.reveal(e.element, e.editorOptions, e.sideBySide))); - + this._editorDisposables.add(tree.onDidOpen(e => { + let override: EditorOverride | string = EditorOverride.DISABLED; + if (this._editorService.activeEditor instanceof NotebookEditorInput || this._editorService.activeEditor instanceof CustomEditorInput) { + override = this._editorService.activeEditor.viewType; + } + newOutline.reveal(e.element, { ...e.editorOptions, override }, e.sideBySide); + })); // feature: reveal editor selection in outline const revealActiveElement = () => { if (!this._outlineViewState.followCursor || !newOutline.activeElement) { diff --git a/src/vs/workbench/contrib/output/browser/outputView.ts b/src/vs/workbench/contrib/output/browser/outputView.ts index 852f3659252..54e4474aa16 100644 --- a/src/vs/workbench/contrib/output/browser/outputView.ts +++ b/src/vs/workbench/contrib/output/browser/outputView.ts @@ -12,7 +12,7 @@ import { IStorageService } from 'vs/platform/storage/common/storage'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfigurationService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { EditorInput, EditorOptions, IEditorOpenContext } from 'vs/workbench/common/editor'; +import { EditorOptions, IEditorOpenContext } from 'vs/workbench/common/editor'; import { AbstractTextResourceEditor } from 'vs/workbench/browser/parts/editor/textResourceEditor'; import { OUTPUT_VIEW_ID, IOutputService, CONTEXT_IN_OUTPUT, IOutputChannel, CONTEXT_ACTIVE_LOG_OUTPUT, CONTEXT_OUTPUT_SCROLL_LOCK } from 'vs/workbench/contrib/output/common/output'; import { IThemeService, registerThemingParticipant, IColorTheme, ICssStyleCollector } from 'vs/platform/theme/common/themeService'; @@ -221,7 +221,7 @@ export class OutputEditor extends AbstractTextResourceEditor { return channel ? nls.localize('outputViewWithInputAriaLabel', "{0}, Output panel", channel.label) : nls.localize('outputViewAriaLabel', "Output panel"); } - override async setInput(input: EditorInput, options: EditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + override async setInput(input: TextResourceEditorInput, options: EditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise {// const focus = !(options && options.preserveFocus); if (input.matches(this.input)) { return; diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts index 26c4b58ff4e..21f20c9720e 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts @@ -7,7 +7,7 @@ import 'vs/css!./media/keybindingsEditor'; import { localize } from 'vs/nls'; import { Delayer } from 'vs/base/common/async'; import * as DOM from 'vs/base/browser/dom'; -import { OS } from 'vs/base/common/platform'; +import { isIOS, OS } from 'vs/base/common/platform'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { CheckboxActionViewItem } from 'vs/base/browser/ui/checkbox/checkbox'; import { HighlightedLabel } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel'; @@ -174,7 +174,7 @@ export class KeybindingsEditor extends EditorPane implements IKeybindingsEditorP const activeKeybindingEntry = this.activeKeybindingEntry; if (activeKeybindingEntry) { this.selectEntry(activeKeybindingEntry); - } else { + } else if (!isIOS) { this.searchWidget.focus(); } } diff --git a/src/vs/workbench/contrib/preferences/browser/keyboardLayoutPicker.ts b/src/vs/workbench/contrib/preferences/browser/keyboardLayoutPicker.ts index d3a90dcfcc2..9b789932749 100644 --- a/src/vs/workbench/contrib/preferences/browser/keyboardLayoutPicker.ts +++ b/src/vs/workbench/contrib/preferences/browser/keyboardLayoutPicker.ts @@ -32,6 +32,8 @@ export class KeyboardLayoutPickerContribution extends Disposable implements IWor ) { super(); + const name = nls.localize('status.workbench.keyboardLayout', "Keyboard Layout"); + let layout = this.keyboardLayoutService.getCurrentKeyboardLayout(); if (layout) { let layoutInfo = parseKeyboardLayoutDescription(layout); @@ -39,12 +41,12 @@ export class KeyboardLayoutPickerContribution extends Disposable implements IWor this.pickerElement.value = this.statusbarService.addEntry( { + name, text, ariaLabel: text, command: KEYBOARD_LAYOUT_OPEN_PICKER }, 'status.workbench.keyboardLayout', - nls.localize('status.workbench.keyboardLayout', "Keyboard Layout"), StatusbarAlignment.RIGHT ); } @@ -56,6 +58,7 @@ export class KeyboardLayoutPickerContribution extends Disposable implements IWor if (this.pickerElement.value) { const text = nls.localize('keyboardLayout', "Layout: {0}", layoutInfo.label); this.pickerElement.value.update({ + name, text, ariaLabel: text, command: KEYBOARD_LAYOUT_OPEN_PICKER @@ -64,12 +67,12 @@ export class KeyboardLayoutPickerContribution extends Disposable implements IWor const text = nls.localize('keyboardLayout', "Layout: {0}", layoutInfo.label); this.pickerElement.value = this.statusbarService.addEntry( { + name, text, ariaLabel: text, command: KEYBOARD_LAYOUT_OPEN_PICKER }, 'status.workbench.keyboardLayout', - nls.localize('status.workbench.keyboardLayout', "Keyboard Layout"), StatusbarAlignment.RIGHT ); } diff --git a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts index 372b4a853e2..c0109b78a12 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts @@ -110,14 +110,10 @@ class KeybindingsEditorInputSerializer implements IEditorInputSerializer { } serialize(editorInput: EditorInput): string { - const input = editorInput; - return JSON.stringify({ - name: input.getName(), - typeId: input.typeId - }); + return ''; } - deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput { + deserialize(instantiationService: IInstantiationService): EditorInput { return instantiationService.createInstance(KeybindingsEditorInput); } } @@ -129,10 +125,10 @@ class SettingsEditor2InputSerializer implements IEditorInputSerializer { } serialize(input: SettingsEditor2Input): string { - return '{}'; + return ''; } - deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): SettingsEditor2Input { + deserialize(instantiationService: IInstantiationService): SettingsEditor2Input { return instantiationService.createInstance(SettingsEditor2Input); } } diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesEditor.ts b/src/vs/workbench/contrib/preferences/browser/preferencesEditor.ts index 3b0f9656d56..71a30eac276 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesEditor.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesEditor.ts @@ -151,14 +151,14 @@ export class PreferencesEditor extends EditorPane { this.preferencesRenderers.editFocusedPreference(); } - override setInput(newInput: EditorInput, options: SettingsEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + override setInput(input: PreferencesEditorInput, options: SettingsEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise {// this.defaultSettingsEditorContextKey.set(true); this.defaultSettingsJSONEditorContextKey.set(true); if (options && options.query) { this.focusSearch(options.query); } - return super.setInput(newInput, options, context, token).then(() => this.updateInput(newInput as PreferencesEditorInput, options, context, token)); + return super.setInput(input, options, context, token).then(() => this.updateInput(input, options, context, token)); } layout(dimension: DOM.Dimension): void { @@ -205,7 +205,7 @@ export class PreferencesEditor extends EditorPane { } private updateInput(newInput: PreferencesEditorInput, options: EditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { - return this.sideBySidePreferencesWidget.setInput(newInput.secondary, newInput.primary, options, context, token).then(({ defaultPreferencesRenderer, editablePreferencesRenderer }) => { + return this.sideBySidePreferencesWidget.setInput(newInput.secondary, newInput.primary, options, context, token).then(({ defaultPreferencesRenderer, editablePreferencesRenderer }) => { if (token.isCancellationRequested) { return; } diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts index 06e6fba025e..be7329df4c1 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts @@ -822,7 +822,7 @@ class EditSettingRenderer extends Disposable { private onEditSettingClicked(editPreferenceWidget: EditPreferenceWidget, e: IEditorMouseEvent): void { EventHelper.stop(e.event, true); - const anchor = { x: e.event.posx, y: e.event.posy + 10 }; + const anchor = { x: e.event.posx, y: e.event.posy }; const actions = this.getSettings(editPreferenceWidget.getLine()).length === 1 ? this.getActions(editPreferenceWidget.preferences[0], this.getConfigurationsMap()[editPreferenceWidget.preferences[0].key]) : editPreferenceWidget.preferences.map(setting => new SubmenuAction(`preferences.submenu.${setting.key}`, setting.key, this.getActions(setting, this.getConfigurationsMap()[setting.key]))); this.contextMenuService.showContextMenu({ diff --git a/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts b/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts index 88049494784..33a5e367bc0 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts @@ -6,7 +6,7 @@ import * as nls from 'vs/nls'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { Extensions, IViewContainersRegistry, IViewsRegistry, IViewsService, ViewContainer, ViewContainerLocation } from 'vs/workbench/common/views'; -import { IRemoteExplorerService, makeAddress, mapHasAddressLocalhostOrAllInterfaces, OnPortForward, PORT_AUTO_FORWARD_SETTING, PORT_AUTO_SOURCE_SETTING, PORT_AUTO_SOURCE_SETTING_OUTPUT, PORT_AUTO_SOURCE_SETTING_PROCESS, TUNNEL_VIEW_CONTAINER_ID, TUNNEL_VIEW_ID } from 'vs/workbench/services/remote/common/remoteExplorerService'; +import { Attributes, IRemoteExplorerService, makeAddress, mapHasAddressLocalhostOrAllInterfaces, OnPortForward, PORT_AUTO_FORWARD_SETTING, PORT_AUTO_SOURCE_SETTING, PORT_AUTO_SOURCE_SETTING_OUTPUT, PORT_AUTO_SOURCE_SETTING_PROCESS, TUNNEL_VIEW_CONTAINER_ID, TUNNEL_VIEW_ID } from 'vs/workbench/services/remote/common/remoteExplorerService'; import { forwardedPortsViewEnabled, ForwardPortAction, OpenPortInBrowserAction, TunnelPanel, TunnelPanelDescriptor, TunnelViewModel, OpenPortInPreviewAction } from 'vs/workbench/contrib/remote/browser/tunnelView'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; @@ -123,7 +123,7 @@ export class ForwardedPortsView extends Disposable implements IWorkbenchContribu private updateStatusBar() { if (!this.entryAccessor) { - this._register(this.entryAccessor = this.statusbarService.addEntry(this.entry, 'status.forwardedPorts', nls.localize('status.forwardedPorts', "Forwarded Ports"), StatusbarAlignment.LEFT, 40)); + this._register(this.entryAccessor = this.statusbarService.addEntry(this.entry, 'status.forwardedPorts', StatusbarAlignment.LEFT, 40)); } else { this.entryAccessor.update(this.entry); } @@ -143,6 +143,7 @@ export class ForwardedPortsView extends Disposable implements IWorkbenchContribu allTunnels.map(forwarded => forwarded.remotePort).join(', ')); } return { + name: nls.localize('status.forwardedPorts', "Forwarded Ports"), text: `$(radio-tower) ${text}`, ariaLabel: tooltip, tooltip, @@ -443,13 +444,14 @@ class OutputAutomaticPortForwarding extends Disposable { if (mapHasAddressLocalhostOrAllInterfaces(this.remoteExplorerService.tunnelModel.detected, localUrl.host, localUrl.port)) { return; } - if ((await this.remoteExplorerService.tunnelModel.getAttributes([localUrl.port]))?.get(localUrl.port)?.onAutoForward === OnPortForward.Ignore) { + const attributes = (await this.remoteExplorerService.tunnelModel.getAttributes([localUrl.port]))?.get(localUrl.port); + if (attributes?.onAutoForward === OnPortForward.Ignore) { return; } if (this.privilegedOnly() && !isPortPrivileged(localUrl.port, (await this.remoteAgentService.getEnvironment())?.os)) { return; } - const forwarded = await this.remoteExplorerService.forward(localUrl, undefined, undefined, undefined, undefined, undefined, false); + const forwarded = await this.remoteExplorerService.forward(localUrl, undefined, undefined, undefined, undefined, undefined, false, attributes ?? null); if (forwarded) { this.notifier.doAction([forwarded]); } @@ -543,7 +545,7 @@ class ProcAutomaticPortForwarding extends Disposable { } private async forwardCandidates(): Promise { - const attributes = await this.remoteExplorerService.tunnelModel.getAttributes(this.remoteExplorerService.tunnelModel.candidates.map(candidate => candidate.port)); + let attributes: Map | undefined; const allTunnels: RemoteTunnel[] = []; for (const value of this.remoteExplorerService.tunnelModel.candidates) { if (!value.detail) { @@ -561,10 +563,16 @@ class ProcAutomaticPortForwarding extends Disposable { if (mapHasAddressLocalhostOrAllInterfaces(this.remoteExplorerService.tunnelModel.detected, value.host, value.port)) { continue; } - if (attributes?.get(value.port)?.onAutoForward === OnPortForward.Ignore) { + + if (!attributes) { + attributes = await this.remoteExplorerService.tunnelModel.getAttributes(this.remoteExplorerService.tunnelModel.candidates.map(candidate => candidate.port)); + } + + const portAttributes = attributes?.get(value.port); + if (portAttributes?.onAutoForward === OnPortForward.Ignore) { continue; } - const forwarded = await this.remoteExplorerService.forward(value, undefined, undefined, undefined, undefined, undefined, false); + const forwarded = await this.remoteExplorerService.forward(value, undefined, undefined, undefined, undefined, undefined, false, portAttributes ?? null); if (!alreadyForwarded && forwarded) { this.autoForwarded.add(address); } else if (forwarded) { diff --git a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts index 99ba6ad9d14..342ce7934eb 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts @@ -317,6 +317,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr const ariaLabel = getCodiconAriaLabel(text); const properties: IStatusbarEntry = { + name, backgroundColor: themeColorFromId(STATUS_BAR_HOST_NAME_BACKGROUND), color: themeColorFromId(STATUS_BAR_HOST_NAME_FOREGROUND), ariaLabel, @@ -329,7 +330,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr if (this.remoteStatusEntry) { this.remoteStatusEntry.update(properties); } else { - this.remoteStatusEntry = this.statusbarService.addEntry(properties, 'status.host', name, StatusbarAlignment.LEFT, Number.MAX_VALUE /* first entry */); + this.remoteStatusEntry = this.statusbarService.addEntry(properties, 'status.host', StatusbarAlignment.LEFT, Number.MAX_VALUE /* first entry */); } } diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index adb05e76587..16a420a7ce3 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -23,7 +23,7 @@ import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; import { ActionRunner, IAction } from 'vs/base/common/actions'; import { IMenuService, MenuId, MenuRegistry, ILocalizedString } from 'vs/platform/actions/common/actions'; import { createAndFillInContextMenuActions, createAndFillInActionBarActions, createActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; -import { IRemoteExplorerService, TunnelModel, makeAddress, TunnelType, ITunnelItem, Tunnel, TUNNEL_VIEW_ID, parseAddress, CandidatePort, TunnelPrivacy, TunnelEditId, mapHasAddressLocalhostOrAllInterfaces } from 'vs/workbench/services/remote/common/remoteExplorerService'; +import { IRemoteExplorerService, TunnelModel, makeAddress, TunnelType, ITunnelItem, Tunnel, TUNNEL_VIEW_ID, parseAddress, CandidatePort, TunnelPrivacy, TunnelEditId, mapHasAddressLocalhostOrAllInterfaces, TunnelProtocol, Attributes } from 'vs/workbench/services/remote/common/remoteExplorerService'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; @@ -65,6 +65,13 @@ class TunnelTreeVirtualDelegate implements ITableVirtualDelegate { } } +function toTunnelProtocol(value: string | undefined): TunnelProtocol { + if (value === TunnelProtocol.Https) { + return TunnelProtocol.Https; + } + return TunnelProtocol.Http; +} + export interface ITunnelViewModel { readonly onForwardedPortsChanged: Event; readonly all: TunnelItem[]; @@ -422,7 +429,8 @@ class ActionBarRenderer extends Disposable implements ITableRenderer('tunnelType', TunnelType.Add, true); export const TunnelCloseableContextKey = new RawContextKey('tunnelCloseable', false, true); const TunnelPrivacyContextKey = new RawContextKey('tunnelPrivacy', undefined, true); +const TunnelProtocolContextKey = new RawContextKey('tunnelProtocol', TunnelProtocol.Http, true); const TunnelViewFocusContextKey = new RawContextKey('tunnelViewFocus', false, nls.localize('tunnel.focusContext', "Whether the Ports view has focus.")); const TunnelViewSelectionKeyName = 'tunnelViewSelection'; const TunnelViewSelectionContextKey = new RawContextKey(TunnelViewSelectionKeyName, undefined, true); @@ -681,6 +690,7 @@ export class TunnelPanel extends ViewPane { private tunnelTypeContext: IContextKey; private tunnelCloseableContext: IContextKey; private tunnelPrivacyContext: IContextKey; + private tunnelProtocolContext: IContextKey; private tunnelViewFocusContext: IContextKey; private tunnelViewSelectionContext: IContextKey; private tunnelViewMultiSelectionContext: IContextKey; @@ -714,6 +724,7 @@ export class TunnelPanel extends ViewPane { this.tunnelTypeContext = TunnelTypeContextKey.bindTo(contextKeyService); this.tunnelCloseableContext = TunnelCloseableContextKey.bindTo(contextKeyService); this.tunnelPrivacyContext = TunnelPrivacyContextKey.bindTo(contextKeyService); + this.tunnelProtocolContext = TunnelProtocolContextKey.bindTo(contextKeyService); this.tunnelViewFocusContext = TunnelViewFocusContextKey.bindTo(contextKeyService); this.tunnelViewSelectionContext = TunnelViewSelectionContextKey.bindTo(contextKeyService); this.tunnelViewMultiSelectionContext = TunnelViewMultiSelectionContextKey.bindTo(contextKeyService); @@ -876,12 +887,14 @@ export class TunnelPanel extends ViewPane { this.tunnelTypeContext.set(item.tunnelType); this.tunnelCloseableContext.set(!!item.closeable); this.tunnelPrivacyContext.set(item.privacy); + this.tunnelProtocolContext.set(toTunnelProtocol(item.localUri?.scheme)); this.portChangableContextKey.set(!!item.localPort); } else { this.tunnelTypeContext.reset(); this.tunnelViewSelectionContext.reset(); this.tunnelCloseableContext.reset(); this.tunnelPrivacyContext.reset(); + this.tunnelProtocolContext.reset(); this.portChangableContextKey.reset(); } } @@ -910,11 +923,13 @@ export class TunnelPanel extends ViewPane { this.tunnelTypeContext.set(node.tunnelType); this.tunnelCloseableContext.set(!!node.closeable); this.tunnelPrivacyContext.set(node.privacy); + this.tunnelProtocolContext.set(toTunnelProtocol(node.localUri?.scheme)); this.portChangableContextKey.set(!!node.localPort); } else { this.tunnelTypeContext.set(TunnelType.Add); this.tunnelCloseableContext.set(false); this.tunnelPrivacyContext.set(undefined); + this.tunnelProtocolContext.set(undefined); this.portChangableContextKey.set(false); } @@ -1365,6 +1380,34 @@ namespace MakePortPrivateAction { } } +namespace SetTunnelProtocolAction { + export const ID_HTTP = 'remote.tunnel.setProtocolHttp'; + export const ID_HTTPS = 'remote.tunnel.setProtocolHttps'; + export const LABEL_HTTP = nls.localize('remote.tunnel.protocolHttp', "HTTP"); + export const LABEL_HTTPS = nls.localize('remote.tunnel.protocolHttps', "HTTPS"); + + async function handler(arg: any, protocol: TunnelProtocol, remoteExplorerService: IRemoteExplorerService) { + if (arg instanceof TunnelItem) { + const attributes: Partial = { + protocol + }; + return remoteExplorerService.tunnelModel.configPortsAttributes.addAttributes(arg.remotePort, attributes); + } + } + + export function handlerHttp(): ICommandHandler { + return async (accessor, arg) => { + return handler(arg, TunnelProtocol.Http, accessor.get(IRemoteExplorerService)); + }; + } + + export function handlerHttps(): ICommandHandler { + return async (accessor, arg) => { + return handler(arg, TunnelProtocol.Https, accessor.get(IRemoteExplorerService)); + }; + } +} + const tunnelViewCommandsWeightBonus = 10; // give our commands a little bit more weight over other default list/tree commands const isForwardedExpr = TunnelTypeContextKey.isEqualTo(TunnelType.Forwarded); @@ -1410,6 +1453,8 @@ CommandsRegistry.registerCommand(CopyAddressAction.COMMANDPALETTE_ID, CopyAddres CommandsRegistry.registerCommand(ChangeLocalPortAction.ID, ChangeLocalPortAction.handler()); CommandsRegistry.registerCommand(MakePortPublicAction.ID, MakePortPublicAction.handler()); CommandsRegistry.registerCommand(MakePortPrivateAction.ID, MakePortPrivateAction.handler()); +CommandsRegistry.registerCommand(SetTunnelProtocolAction.ID_HTTP, SetTunnelProtocolAction.handlerHttp()); +CommandsRegistry.registerCommand(SetTunnelProtocolAction.ID_HTTPS, SetTunnelProtocolAction.handlerHttps()); MenuRegistry.appendMenuItem(MenuId.CommandPalette, ({ command: { @@ -1508,6 +1553,13 @@ MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ }, when: ContextKeyExpr.and(TunnelPrivacyContextKey.isEqualTo(TunnelPrivacy.Public), isNotMultiSelectionExpr) })); +MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ + group: '2_localaddress', + order: 3, + submenu: MenuId.TunnelProtocol, + title: nls.localize('tunnelContext.protocolMenu', "Change Port Protocol"), + when: ContextKeyExpr.and(isForwardedExpr, isNotMultiSelectionExpr) +})); MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ group: '3_forward', order: 0, @@ -1526,6 +1578,23 @@ MenuRegistry.appendMenuItem(MenuId.TunnelContext, ({ }, })); +MenuRegistry.appendMenuItem(MenuId.TunnelProtocol, ({ + order: 0, + command: { + id: SetTunnelProtocolAction.ID_HTTP, + title: SetTunnelProtocolAction.LABEL_HTTP, + toggled: TunnelProtocolContextKey.isEqualTo(TunnelProtocol.Http) + } +})); +MenuRegistry.appendMenuItem(MenuId.TunnelProtocol, ({ + order: 1, + command: { + id: SetTunnelProtocolAction.ID_HTTPS, + title: SetTunnelProtocolAction.LABEL_HTTPS, + toggled: TunnelProtocolContextKey.isEqualTo(TunnelProtocol.Https) + } +})); + MenuRegistry.appendMenuItem(MenuId.TunnelPortInline, ({ group: '0_manage', diff --git a/src/vs/workbench/contrib/remote/common/tunnelFactory.ts b/src/vs/workbench/contrib/remote/common/tunnelFactory.ts index 36524ca4b7c..36a3cf1f914 100644 --- a/src/vs/workbench/contrib/remote/common/tunnelFactory.ts +++ b/src/vs/workbench/contrib/remote/common/tunnelFactory.ts @@ -13,10 +13,11 @@ import { IRemoteExplorerService } from 'vs/workbench/services/remote/common/remo import { ILogService } from 'vs/platform/log/common/log'; export class TunnelFactoryContribution extends Disposable implements IWorkbenchContribution { + constructor( @ITunnelService tunnelService: ITunnelService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, - @IOpenerService openerService: IOpenerService, + @IOpenerService private openerService: IOpenerService, @IRemoteExplorerService remoteExplorerService: IRemoteExplorerService, @ILogService logService: ILogService ) { @@ -51,7 +52,7 @@ export class TunnelFactoryContribution extends Disposable implements IWorkbenchC tunnelRemoteHost: tunnel.remoteAddress.host, // The tunnel factory may give us an inaccessible local address. // To make sure this doesn't happen, resolve the uri immediately. - localAddress: (await openerService.resolveExternalUri(URI.parse(localAddress))).resolved.toString(), + localAddress: await this.resolveExternalUri(localAddress), public: !!tunnel.public, dispose: async () => { await tunnel.dispose(); } }; @@ -62,4 +63,12 @@ export class TunnelFactoryContribution extends Disposable implements IWorkbenchC remoteExplorerService.setTunnelInformation(undefined); } } + + private async resolveExternalUri(uri: string): Promise { + try { + return (await this.openerService.resolveExternalUri(URI.parse(uri))).resolved.toString(); + } catch { + return uri; + } + } } diff --git a/src/vs/workbench/contrib/scm/browser/activity.ts b/src/vs/workbench/contrib/scm/browser/activity.ts index fcfe819b884..8e4de79194b 100644 --- a/src/vs/workbench/contrib/scm/browser/activity.ts +++ b/src/vs/workbench/contrib/scm/browser/activity.ts @@ -154,11 +154,12 @@ export class SCMStatusController implements IWorkbenchContribution { ariaLabel = ariaLabel ? `${ariaLabel}, ${label}` : label; disposables.add(this.statusbarService.addEntry({ + name: localize('status.scm', "Source Control"), text: command.title, ariaLabel: `${ariaLabel}${command.tooltip ? ` - ${command.tooltip}` : ''}`, tooltip, command: command.id ? command : undefined - }, 'status.scm', localize('status.scm', "Source Control"), MainThreadStatusBarAlignment.LEFT, 10000)); + }, 'status.scm', MainThreadStatusBarAlignment.LEFT, 10000)); } this.statusBarDisposable = disposables; diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index dfc17b3e07d..6eee9af184f 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -1239,7 +1239,7 @@ class ViewModel { } focus() { - if (this.tree.getFocus().length === 0) { + if (this.tree.getFocus().length === 0 && !platform.isIOS) { for (const repository of this.scmViewService.visibleRepositories) { const widget = this.inputRenderer.getRenderedInputWidget(repository.input); diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index 61197043ff4..e54b8b2a8fb 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -17,7 +17,7 @@ import { Action2, ICommandAction, MenuId, MenuRegistry, registerAction2, SyncAct import { CommandsRegistry, ICommandHandler, ICommandService } from 'vs/platform/commands/common/commands'; import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; -import { ContextKeyEqualsExpr, ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ContextKeyEqualsExpr, ContextKeyExpr, ContextKeyRegexExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IFileService } from 'vs/platform/files/common/files'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; @@ -636,6 +636,8 @@ const viewDescriptor: IViewDescriptor = { mnemonicTitle: nls.localize({ key: 'miViewSearch', comment: ['&& denotes a mnemonic'] }, "&&Search"), keybindings: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KEY_F, + // Yes, this is weird. See #116188, #115556, #115511, and now #124146, for examples of what can go wrong here. + when: ContextKeyRegexExpr.create('neverMatch', /doesNotMatch/) }, order: 1 } @@ -709,8 +711,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ ] }, id: Constants.FindInFilesActionId, - // Give more weightage to this keybinding than of `View: Show Search` keybinding. See #116188, #115556, #115511 - weight: KeybindingWeight.WorkbenchContrib + 1, + weight: KeybindingWeight.WorkbenchContrib, when: null, primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KEY_F, handler: FindInFilesCommand diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts index f20048620be..c15d93d7249 100644 --- a/src/vs/workbench/contrib/search/browser/searchView.ts +++ b/src/vs/workbench/contrib/search/browser/searchView.ts @@ -902,7 +902,7 @@ export class SearchView extends ViewPane { override focus(): void { super.focus(); - if (this.lastFocusState === 'input' || !this.hasSearchResults()) { + if (!env.isIOS && (this.lastFocusState === 'input' || !this.hasSearchResults())) { const updatedText = this.searchConfig.seedOnFocus ? this.updateTextFromSelection({ allowSearchOnType: false }) : false; this.searchWidget.focus(undefined, undefined, updatedText); } else { diff --git a/src/vs/workbench/contrib/searchEditor/browser/searchEditorActions.ts b/src/vs/workbench/contrib/searchEditor/browser/searchEditorActions.ts index 41c415ed5ce..4a6ca12b468 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/searchEditorActions.ts +++ b/src/vs/workbench/contrib/searchEditor/browser/searchEditorActions.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { Schemas } from 'vs/base/common/network'; -import { assertIsDefined, withNullAsUndefined } from 'vs/base/common/types'; +import { withNullAsUndefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/searchEditor'; import { ICodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { EditorOverride } from 'vs/platform/editor/common/editor'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; @@ -23,7 +24,6 @@ import { OpenSearchEditorArgs } from 'vs/workbench/contrib/searchEditor/browser/ import { getOrMakeSearchEditorInput, SearchEditorInput } from 'vs/workbench/contrib/searchEditor/browser/searchEditorInput'; import { serializeSearchResultForEditor } from 'vs/workbench/contrib/searchEditor/browser/searchEditorSerialization'; import { IConfigurationResolverService } from 'vs/workbench/services/configurationResolver/common/configurationResolver'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; import { ISearchConfigurationProperties } from 'vs/workbench/services/search/common/search'; @@ -99,7 +99,6 @@ export async function openSearchEditor(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); - const editorGroupsService = accessor.get(IEditorGroupsService); const telemetryService = accessor.get(ITelemetryService); const instantiationService = accessor.get(IInstantiationService); const configurationService = accessor.get(IConfigurationService); @@ -148,7 +147,7 @@ export const openNewSearchEditor = let editor: SearchEditor; if (existing && args.location === 'reuse') { const input = existing.editor as SearchEditorInput; - editor = assertIsDefined(await assertIsDefined(editorGroupsService.getGroup(existing.groupId)).openEditor(input)) as SearchEditor; + editor = (await editorService.openEditor(input, { override: EditorOverride.DISABLED }, existing.groupId)) as SearchEditor; if (selected) { editor.setQuery(selected); } else { editor.selectQuery(); } editor.setSearchConfig(args); diff --git a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts index e1f1eb3aa5f..b3bceff3270 100644 --- a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts +++ b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts @@ -130,6 +130,7 @@ export class TaskStatusBarContributions extends Disposable implements IWorkbench } } else { const itemProps: IStatusbarEntry = { + name: nls.localize('status.runningTasks', "Running Tasks"), text: `$(tools) ${tasks.length}`, ariaLabel: nls.localize('numberOfRunningTasks', "{0} running tasks", tasks.length), tooltip: nls.localize('runningTasks', "Show Running Tasks"), @@ -137,7 +138,7 @@ export class TaskStatusBarContributions extends Disposable implements IWorkbench }; if (!this.runningTasksStatusItem) { - this.runningTasksStatusItem = this.statusbarService.addEntry(itemProps, 'status.runningTasks', nls.localize('status.runningTasks', "Running Tasks"), StatusbarAlignment.LEFT, 49 /* Medium Priority, next to Markers */); + this.runningTasksStatusItem = this.statusbarService.addEntry(itemProps, 'status.runningTasks', StatusbarAlignment.LEFT, 49 /* Medium Priority, next to Markers */); } else { this.runningTasksStatusItem.update(itemProps); } diff --git a/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts b/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts index b491ab10216..05e62f5dd7a 100644 --- a/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts +++ b/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts @@ -22,7 +22,9 @@ interface TerminalData { const TASK_TERMINAL_STATUS_ID = 'task_terminal_status'; const ACTIVE_TASK_STATUS: ITerminalStatus = { id: TASK_TERMINAL_STATUS_ID, icon: Codicon.play, severity: Severity.Info, tooltip: nls.localize('taskTerminalStatus.active', "Task is running") }; const SUCCEEDED_TASK_STATUS: ITerminalStatus = { id: TASK_TERMINAL_STATUS_ID, icon: Codicon.check, severity: Severity.Info, tooltip: nls.localize('taskTerminalStatus.succeeded', "Task succeeded") }; +const SUCCEEDED_INACTIVE_TASK_STATUS: ITerminalStatus = { id: TASK_TERMINAL_STATUS_ID, icon: Codicon.check, severity: Severity.Info, tooltip: nls.localize('taskTerminalStatus.succeededInactive', "Task succeeded and waiting...") }; const FAILED_TASK_STATUS: ITerminalStatus = { id: TASK_TERMINAL_STATUS_ID, icon: Codicon.error, severity: Severity.Error, tooltip: nls.localize('taskTerminalStatus.errors', "Task has errors") }; +const FAILED_INACTIVE_TASK_STATUS: ITerminalStatus = { id: TASK_TERMINAL_STATUS_ID, icon: Codicon.error, severity: Severity.Error, tooltip: nls.localize('taskTerminalStatus.errorsInactive', "Task has errors and is waiting...") }; export class TaskTerminalStatus extends Disposable { private terminalMap: Map = new Map(); @@ -76,9 +78,9 @@ export class TaskTerminalStatus extends Disposable { } terminalData.terminal.statusList.remove(terminalData.status); if (terminalData.problemMatcher.numberOfMatches === 0) { - terminalData.terminal.statusList.add(SUCCEEDED_TASK_STATUS); + terminalData.terminal.statusList.add(SUCCEEDED_INACTIVE_TASK_STATUS); } else { - terminalData.terminal.statusList.add(FAILED_TASK_STATUS); + terminalData.terminal.statusList.add(FAILED_INACTIVE_TASK_STATUS); } } diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 0ee3da84157..09697c78813 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -1209,7 +1209,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { } } if (this.currentTask.shellLaunchConfig) { - this.currentTask.shellLaunchConfig.icon = 'tools'; + this.currentTask.shellLaunchConfig.icon = { id: 'tools' }; } let prefersSameTerminal = presentationOptions.panel === PanelKind.Dedicated; diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminal.css b/src/vs/workbench/contrib/terminal/browser/media/terminal.css index 00f31fc01a0..58cd1e99f25 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminal.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminal.css @@ -35,10 +35,10 @@ position: absolute; top: 0; } -.monaco-workbench .pane-body.integrated-terminal .monaco-split-view2.horizontal .split-view-view:first-child .terminal-wrapper { +.monaco-workbench .pane-body.integrated-terminal .terminal-group .monaco-split-view2.horizontal .split-view-view:first-child .terminal-wrapper { margin-left: 20px; } -.monaco-workbench .pane-body.integrated-terminal .monaco-split-view2.horizontal .split-view-view:last-child .terminal-wrapper { +.monaco-workbench .pane-body.integrated-terminal .terminal-group .monaco-split-view2.horizontal .split-view-view:last-child .terminal-wrapper { margin-right: 20px; } @@ -289,3 +289,39 @@ margin-left: 4px; color: inherit; } + +.monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .tabs-list .terminal-tabs-entry .uri-icon { + background-repeat: no-repeat; + background-size: contain; + margin-right: 4px; + height: 100%; +} + +.monaco-workbench .terminal-uri-icon .monaco-highlighted-label .codicon, +.monaco-action-bar .terminal-uri-icon.single-terminal-tab.action-label .codicon { + background-size: 16px; +} +.monaco-workbench .terminal-uri-icon .monaco-highlighted-label .codicon::before, +.monaco-action-bar .terminal-uri-icon.single-terminal-tab.action-label:not(.alt-command) .codicon::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; +} + +.monaco-workbench .pane-body.integrated-terminal .terminal-drop-overlay { + display: block; + position: absolute; + left: 0; + right: 0; + height: 100%; + pointer-events: none; + opacity: 0; /* hidden initially */ + transition: left 70ms ease-out, right 70ms ease-out, opacity 150ms ease-out; +} +.monaco-workbench .pane-body.integrated-terminal .terminal-drop-overlay.drop-left { + right: 50%; +} +.monaco-workbench .pane-body.integrated-terminal .terminal-drop-overlay.drop-right { + left: 50% +} diff --git a/src/vs/workbench/contrib/terminal/browser/remoteTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/remoteTerminalService.ts index 9495b9f928f..619b98e9212 100644 --- a/src/vs/workbench/contrib/terminal/browser/remoteTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/remoteTerminalService.ts @@ -16,12 +16,13 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { ILogService } from 'vs/platform/log/common/log'; import { INotificationHandle, INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; import { IRemoteAuthorityResolverService } from 'vs/platform/remote/common/remoteAuthorityResolver'; -import { IRequestResolveVariablesEvent, IShellLaunchConfig, IShellLaunchConfigDto, ITerminalChildProcess, ITerminalProfile, ITerminalsLayoutInfo, ITerminalsLayoutInfoById } from 'vs/platform/terminal/common/terminal'; +import { IRequestResolveVariablesEvent, IShellLaunchConfig, IShellLaunchConfigDto, ITerminalChildProcess, ITerminalProfile, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, TerminalIcon } from 'vs/platform/terminal/common/terminal'; +import { IProcessDetails } from 'vs/platform/terminal/common/terminalProcess'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { RemotePty } from 'vs/workbench/contrib/terminal/browser/remotePty'; import { IRemoteTerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ICompleteTerminalConfiguration, RemoteTerminalChannelClient, REMOTE_TERMINAL_CHANNEL_NAME } from 'vs/workbench/contrib/terminal/common/remoteTerminalChannel'; -import { IRemoteTerminalAttachTarget, ITerminalConfigHelper } from 'vs/workbench/contrib/terminal/common/terminal'; +import { ITerminalConfigHelper } from 'vs/workbench/contrib/terminal/common/terminal'; import { IConfigurationResolverService } from 'vs/workbench/services/configurationResolver/common/configurationResolver'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; @@ -195,18 +196,20 @@ export class RemoteTerminalService extends Disposable implements IRemoteTerminal return undefined; } - async listProcesses(): Promise { + async listProcesses(): Promise { const terms = this._remoteTerminalChannel ? await this._remoteTerminalChannel.listProcesses() : []; return terms.map(termDto => { - return { + return { id: termDto.id, pid: termDto.pid, title: termDto.title, + titleSource: termDto.titleSource, cwd: termDto.cwd, workspaceId: termDto.workspaceId, workspaceName: termDto.workspaceName, icon: termDto.icon, - color: termDto.color + color: termDto.color, + isOrphan: termDto.isOrphan }; }); } @@ -215,7 +218,7 @@ export class RemoteTerminalService extends Disposable implements IRemoteTerminal await this._remoteTerminalChannel?.updateTitle(id, title); } - async updateIcon(id: number, icon: string, color?: string): Promise { + async updateIcon(id: number, icon: TerminalIcon, color?: string): Promise { await this._remoteTerminalChannel?.updateIcon(id, icon, color); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 495bbcffcdb..0268f12c9bc 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -8,8 +8,8 @@ import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { FindReplaceState } from 'vs/editor/contrib/find/findState'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IOffProcessTerminalService, IShellLaunchConfig, ITerminalChildProcess, ITerminalDimensions, ITerminalLaunchError, ITerminalProfile, ITerminalTabLayoutInfoById, TerminalShellType } from 'vs/platform/terminal/common/terminal'; -import { ICommandTracker, INavigationMode, IRemoteTerminalAttachTarget, IStartExtensionTerminalRequest, ITerminalConfigHelper, ITerminalProcessExtHostProxy, TitleEventSource } from 'vs/workbench/contrib/terminal/common/terminal'; +import { IOffProcessTerminalService, IShellLaunchConfig, ITerminalChildProcess, ITerminalDimensions, ITerminalLaunchError, ITerminalProfile, ITerminalTabLayoutInfoById, TerminalIcon, TitleEventSource, TerminalShellType } from 'vs/platform/terminal/common/terminal'; +import { ICommandTracker, INavigationMode, IRemoteTerminalAttachTarget, IStartExtensionTerminalRequest, ITerminalConfigHelper, ITerminalProcessExtHostProxy } from 'vs/workbench/contrib/terminal/common/terminal'; import type { Terminal as XTermTerminal } from 'xterm'; import type { SearchAddon as XTermSearchAddon } from 'xterm-addon-search'; import type { Unicode11Addon as XTermUnicode11Addon } from 'xterm-addon-unicode11'; @@ -17,7 +17,6 @@ import type { WebglAddon as XTermWebglAddon } from 'xterm-addon-webgl'; import { ITerminalStatusList } from 'vs/workbench/contrib/terminal/browser/terminalStatusList'; import { ICompleteTerminalConfiguration } from 'vs/workbench/contrib/terminal/common/remoteTerminalChannel'; import { Orientation } from 'vs/base/browser/ui/splitview/splitview'; -import { ThemeIcon } from 'vs/platform/theme/common/themeService'; export const ITerminalService = createDecorator('terminalService'); export const ITerminalInstanceService = createDecorator('terminalInstanceService'); @@ -64,15 +63,20 @@ export interface ITerminalGroup { activeInstance: ITerminalInstance | null; terminalInstances: ITerminalInstance[]; title: string; - onDisposed: Event; - onInstancesChanged: Event; + + readonly onDisposed: Event; + readonly onInstancesChanged: Event; + readonly onPanelOrientationChanged: Event; + focusPreviousPane(): void; focusNextPane(): void; resizePane(direction: Direction): void; resizePanes(relativeSizes: number[]): void; setActiveInstanceByIndex(index: number): void; attachToElement(element: HTMLElement): void; + addInstance(instance: ITerminalInstance): void; removeInstance(instance: ITerminalInstance): void; + moveInstance(instance: ITerminalInstance, index: number): void; setVisible(visible: boolean): void; layout(width: number, height: number): void; addDisposable(disposable: IDisposable): void; @@ -145,6 +149,12 @@ export interface ITerminalService { splitInstance(instance: ITerminalInstance, shell?: IShellLaunchConfig, cwd?: string | URI): ITerminalInstance | null; splitInstance(instance: ITerminalInstance, profile: ITerminalProfile): ITerminalInstance | null; unsplitInstance(instance: ITerminalInstance): void; + joinInstances(instances: ITerminalInstance[]): void; + /** + * Moves a terminal instance's group to the target instance group's position. + */ + moveGroup(source: ITerminalInstance, target: ITerminalInstance): void; + moveInstance(source: ITerminalInstance, target: ITerminalInstance, side: 'left' | 'right'): void; /** * Perform an action with the active terminal instance, if the terminal does @@ -256,7 +266,7 @@ export interface ITerminalInstance { readonly rows: number; readonly maxCols: number; readonly maxRows: number; - readonly icon?: ThemeIcon; + readonly icon?: TerminalIcon; readonly color?: string; readonly statusList: ITerminalStatusList; @@ -312,6 +322,11 @@ export interface ITerminalInstance { onFocus: Event; + /** + * An event that fires when a terminal is dropped on this instance via drag and drop. + */ + onRequestAddInstanceToGroup: Event; + /** * Attach a listener to the raw data stream coming from the pty, including ANSI escape * sequences. @@ -363,6 +378,11 @@ export interface ITerminalInstance { */ readonly title: string; + /** + * How the current title was set. + */ + readonly titleSource: TitleEventSource; + /** * The shell type of the terminal. */ @@ -590,6 +610,11 @@ export interface ITerminalInstance { changeColor(): Promise; } +export interface IRequestAddInstanceToGroupEvent { + uri: URI; + side: 'left' | 'right' +} + export const enum LinuxDistro { Unknown = 1, Fedora = 2, diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index 6c1b44b85ba..a3def71891b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -26,13 +26,13 @@ import { IListService } from 'vs/platform/list/browser/listService'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IPickOptions, IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; -import { ILocalTerminalService, ITerminalProfile, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; +import { ILocalTerminalService, ITerminalProfile, TerminalSettingId, TitleEventSource } from 'vs/platform/terminal/common/terminal'; import { IWorkspaceContextService, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { PICK_WORKSPACE_FOLDER_COMMAND_ID } from 'vs/workbench/browser/actions/workspaceCommands'; import { FindInFilesCommand, IFindInFilesArgs } from 'vs/workbench/contrib/search/browser/searchActions'; import { Direction, IRemoteTerminalService, ITerminalInstance, ITerminalInstanceService, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalQuickAccessProvider } from 'vs/workbench/contrib/terminal/browser/terminalQuickAccess'; -import { IRemoteTerminalAttachTarget, ITerminalConfigHelper, KEYBINDING_CONTEXT_TERMINAL_A11Y_TREE_FOCUS, KEYBINDING_CONTEXT_TERMINAL_ALT_BUFFER_ACTIVE, KEYBINDING_CONTEXT_TERMINAL_FIND_FOCUSED, KEYBINDING_CONTEXT_TERMINAL_FIND_NOT_VISIBLE, KEYBINDING_CONTEXT_TERMINAL_FIND_VISIBLE, KEYBINDING_CONTEXT_TERMINAL_FOCUS, KEYBINDING_CONTEXT_TERMINAL_IS_OPEN, KEYBINDING_CONTEXT_TERMINAL_PROCESS_SUPPORTED, KEYBINDING_CONTEXT_TERMINAL_TABS_FOCUS, KEYBINDING_CONTEXT_TERMINAL_TABS_SINGULAR_SELECTION, KEYBINDING_CONTEXT_TERMINAL_TEXT_SELECTED, TERMINAL_ACTION_CATEGORY, TerminalCommandId, TERMINAL_VIEW_ID, TitleEventSource } from 'vs/workbench/contrib/terminal/common/terminal'; +import { IRemoteTerminalAttachTarget, ITerminalConfigHelper, KEYBINDING_CONTEXT_TERMINAL_A11Y_TREE_FOCUS, KEYBINDING_CONTEXT_TERMINAL_ALT_BUFFER_ACTIVE, KEYBINDING_CONTEXT_TERMINAL_FIND_FOCUSED, KEYBINDING_CONTEXT_TERMINAL_FIND_NOT_VISIBLE, KEYBINDING_CONTEXT_TERMINAL_FIND_VISIBLE, KEYBINDING_CONTEXT_TERMINAL_FOCUS, KEYBINDING_CONTEXT_TERMINAL_IS_OPEN, KEYBINDING_CONTEXT_TERMINAL_PROCESS_SUPPORTED, KEYBINDING_CONTEXT_TERMINAL_TABS_FOCUS, KEYBINDING_CONTEXT_TERMINAL_TABS_SINGULAR_SELECTION, KEYBINDING_CONTEXT_TERMINAL_TEXT_SELECTED, TERMINAL_ACTION_CATEGORY, TerminalCommandId, TERMINAL_VIEW_ID } from 'vs/workbench/contrib/terminal/common/terminal'; import { ITerminalContributionService } from 'vs/workbench/contrib/terminal/common/terminalExtensionPoints'; import { IConfigurationResolverService } from 'vs/workbench/services/configurationResolver/common/configurationResolver'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; @@ -1392,7 +1392,6 @@ export function registerTerminalActions() { }); } } - return undefined; } }); registerAction2(class extends Action2 { @@ -1410,6 +1409,23 @@ export function registerTerminalActions() { await terminalService.doWithActiveInstance(async t => terminalService.unsplitInstance(t)); } }); + registerAction2(class extends Action2 { + constructor() { + super({ + id: TerminalCommandId.JoinInstance, + title: { value: localize('workbench.action.terminal.joinInstance', "Join Terminals"), original: 'Join Terminals' }, + category, + precondition: ContextKeyExpr.and(KEYBINDING_CONTEXT_TERMINAL_PROCESS_SUPPORTED, KEYBINDING_CONTEXT_TERMINAL_TABS_SINGULAR_SELECTION.toNegated()) + }); + } + async run(accessor: ServicesAccessor) { + const terminalService = accessor.get(ITerminalService); + const instances = getSelectedInstances(accessor); + if (instances) { + terminalService.joinInstances(instances); + } + } + }); registerAction2(class extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts index d91b2448225..b7b915f93c9 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts @@ -43,7 +43,7 @@ class SplitPaneContainer extends Disposable { this._splitViewDisposables.add(this._splitView.onDidSashReset(() => this._splitView.distributeViewSizes())); } - split(instance: ITerminalInstance, index: number = this._children.length): void { + split(instance: ITerminalInstance, index: number): void { this._addChild(instance, index); } @@ -248,6 +248,7 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { readonly onInstancesChanged: Event = this._onInstancesChanged.event; private readonly _onPanelOrientationChanged = new Emitter(); get onPanelOrientationChanged(): Event { return this._onPanelOrientationChanged.event; } + constructor( private _container: HTMLElement | undefined, shellLaunchConfigOrInstance: IShellLaunchConfig | ITerminalInstance | undefined, @@ -274,13 +275,19 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { } else { instance = this._terminalService.createInstance(shellLaunchConfigOrInstance); } - this._terminalInstances.push(instance); + if (this._terminalInstances.length === 0) { + this._terminalInstances.push(instance); + } else { + this._terminalInstances.splice(this._activeInstanceIndex + 1, 0, instance); + } this._initInstanceListeners(instance); if (this._splitPaneContainer) { - this._splitPaneContainer!.split(instance); + this._splitPaneContainer!.split(instance, this._activeInstanceIndex + 1); } + instance.setVisible(this._isVisible); + this._onInstancesChanged.fire(); } @@ -317,7 +324,7 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { }; } - private _initInstanceListeners(instance: ITerminalInstance): void { + private _initInstanceListeners(instance: ITerminalInstance) { this._instanceDisposables.set(instance.instanceId, [ instance.onDisposed(instance => this._onInstanceDisposed(instance)), instance.onFocused(instance => this._setActiveInstance(instance)) @@ -328,7 +335,7 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { this._removeInstance(instance); } - removeInstance(instance: ITerminalInstance): void { + removeInstance(instance: ITerminalInstance) { this._removeInstance(instance); // Dispose instance event listeners @@ -372,7 +379,21 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { } } - private _setActiveInstance(instance: ITerminalInstance): void { + moveInstance(instance: ITerminalInstance, index: number): void { + const sourceIndex = this.terminalInstances.indexOf(instance); + if (sourceIndex === -1) { + return; + } + this._terminalInstances.splice(sourceIndex, 1); + this._terminalInstances.splice(index, 0, instance); + if (this._splitPaneContainer) { + this._splitPaneContainer.remove(instance); + this._splitPaneContainer.split(instance, sourceIndex < index ? index - 1 : index); + } + this._onInstancesChanged.fire(); + } + + private _setActiveInstance(instance: ITerminalInstance) { this.setActiveInstanceByIndex(this._getIndexFromId(instance.instanceId)); } @@ -419,7 +440,7 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { const orientation = this._terminalLocation === ViewContainerLocation.Panel && this._panelPosition === Position.BOTTOM ? Orientation.HORIZONTAL : Orientation.VERTICAL; const newLocal = this._instantiationService.createInstance(SplitPaneContainer, this._groupElement, orientation); this._splitPaneContainer = newLocal; - this.terminalInstances.forEach(instance => this._splitPaneContainer!.split(instance)); + this.terminalInstances.forEach(instance => this._splitPaneContainer!.split(instance, this._activeInstanceIndex + 1)); } this.setVisible(this._isVisible); } @@ -456,14 +477,8 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { split(shellLaunchConfig: IShellLaunchConfig): ITerminalInstance { const instance = this._terminalService.createInstance(shellLaunchConfig); - this._terminalInstances.splice(this._activeInstanceIndex + 1, 0, instance); - this._initInstanceListeners(instance); + this.addInstance(instance); this._setActiveInstance(instance); - - if (this._splitPaneContainer) { - this._splitPaneContainer.split(instance, this._activeInstanceIndex); - } - return instance; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalIcon.ts b/src/vs/workbench/contrib/terminal/browser/terminalIcon.ts new file mode 100644 index 00000000000..b4b18a7b9bd --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalIcon.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from 'vs/base/common/codicons'; +import { hash } from 'vs/base/common/hash'; +import { URI } from 'vs/base/common/uri'; +import { ColorScheme } from 'vs/platform/theme/common/theme'; +import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; + + +export function getColorClass(terminal: ITerminalInstance): string | undefined { + let color = undefined; + if (terminal.color) { + color = terminal.color; + } else if (ThemeIcon.isThemeIcon(terminal.icon) && terminal.icon.color) { + color = terminal.icon.color.id.replace('.', '_'); + } + if (color) { + return `terminal-icon-${color}`; + } + return undefined; +} + +export function getUriClasses(terminal: ITerminalInstance, colorScheme: ColorScheme): string[] | undefined { + const icon = terminal.icon; + if (!icon) { + return undefined; + } + const iconClasses: string[] = []; + let uri = undefined; + if (icon instanceof URI) { + uri = icon; + } else if (icon instanceof Object && 'light' in icon && 'dark' in icon) { + uri = colorScheme === ColorScheme.LIGHT ? icon.light : icon.dark; + } + if (uri instanceof URI) { + const uriIconKey = hash(uri.path).toString(36); + const className = `terminal-uri-icon-${uriIconKey}`; + iconClasses.push(className); + iconClasses.push(`terminal-uri-icon`); + } + return iconClasses; +} + +export function getIconId(terminal: ITerminalInstance): string { + if (!terminal.icon || (terminal.icon instanceof Object && !('id' in terminal.icon))) { + return Codicon.terminal.id; + } + return terminal.icon.id; +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 9d47112572d..f715d7b2029 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -9,7 +9,7 @@ import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { debounce } from 'vs/base/common/decorators'; import { Emitter, Event } from 'vs/base/common/event'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { IDisposable, dispose, Disposable } from 'vs/base/common/lifecycle'; +import { IDisposable, dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { TabFocus } from 'vs/editor/common/config/commonEditorConfig'; import * as nls from 'vs/nls'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; @@ -21,15 +21,15 @@ import { ILogService } from 'vs/platform/log/common/log'; import { INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { activeContrastBorder, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground } from 'vs/platform/theme/common/colorRegistry'; -import { ICssStyleCollector, IColorTheme, IThemeService, registerThemingParticipant, ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { ICssStyleCollector, IColorTheme, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager'; -import { ITerminalProcessManager, KEYBINDING_CONTEXT_TERMINAL_TEXT_SELECTED, NEVER_MEASURE_RENDER_TIME_STORAGE_KEY, ProcessState, TERMINAL_VIEW_ID, KEYBINDING_CONTEXT_TERMINAL_A11Y_TREE_FOCUS, INavigationMode, TitleEventSource, DEFAULT_COMMANDS_TO_SKIP_SHELL, TERMINAL_CREATION_COMMANDS, KEYBINDING_CONTEXT_TERMINAL_ALT_BUFFER_ACTIVE, SUGGESTED_RENDERER_TYPE, ITerminalProfileResolverService } from 'vs/workbench/contrib/terminal/common/terminal'; +import { ITerminalProcessManager, KEYBINDING_CONTEXT_TERMINAL_TEXT_SELECTED, NEVER_MEASURE_RENDER_TIME_STORAGE_KEY, ProcessState, TERMINAL_VIEW_ID, KEYBINDING_CONTEXT_TERMINAL_A11Y_TREE_FOCUS, INavigationMode, DEFAULT_COMMANDS_TO_SKIP_SHELL, TERMINAL_CREATION_COMMANDS, KEYBINDING_CONTEXT_TERMINAL_ALT_BUFFER_ACTIVE, SUGGESTED_RENDERER_TYPE, ITerminalProfileResolverService } from 'vs/workbench/contrib/terminal/common/terminal'; import { ansiColorIdentifiers, ansiColorMap, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_FOREGROUND_COLOR, TERMINAL_SELECTION_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; import { TerminalLinkManager } from 'vs/workbench/contrib/terminal/browser/links/terminalLinkManager'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; -import { ITerminalInstanceService, ITerminalInstance, ITerminalExternalLinkProvider } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalInstanceService, ITerminalInstance, ITerminalExternalLinkProvider, IRequestAddInstanceToGroupEvent } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalProcessManager } from 'vs/workbench/contrib/terminal/browser/terminalProcessManager'; import type { Terminal as XTermTerminal, IBuffer, ITerminalAddon, RendererType, ITheme } from 'xterm'; import type { SearchAddon, ISearchOptions } from 'xterm-addon-search'; @@ -46,7 +46,7 @@ import { TypeAheadAddon } from 'vs/workbench/contrib/terminal/browser/terminalTy import { BrowserFeatures } from 'vs/base/browser/canIUse'; import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences'; import { IEnvironmentVariableInfo } from 'vs/workbench/contrib/terminal/common/environmentVariable'; -import { IProcessDataEvent, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, TerminalShellType, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; +import { IProcessDataEvent, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, TerminalShellType, TerminalSettingId, TitleEventSource, TerminalIcon } from 'vs/platform/terminal/common/terminal'; import { IProductService } from 'vs/platform/product/common/productService'; import { formatMessageForTerminal } from 'vs/workbench/contrib/terminal/common/terminalStrings'; import { AutoOpenBarrier } from 'vs/base/common/async'; @@ -58,6 +58,7 @@ import { isMacintosh, isWindows, OperatingSystem, OS } from 'vs/base/common/plat import { URI } from 'vs/base/common/uri'; import { Schemas } from 'vs/base/common/network'; import { DataTransfers } from 'vs/base/browser/dnd'; +import { DragAndDropObserver, IDragAndDropObserverCallbacks } from 'vs/workbench/browser/dnd'; // How long in milliseconds should an average frame take to render for a notification to appear // which suggests the fallback DOM-based renderer @@ -104,6 +105,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _skipTerminalCommands: string[]; private _shellType: TerminalShellType; private _title: string = ''; + private _titleSource: TitleEventSource = TitleEventSource.Process; private _container: HTMLElement | undefined; private _wrapperElement: (HTMLElement & { xterm?: XTermTerminal }) | undefined; private _xterm: XTermTerminal | undefined; @@ -132,6 +134,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _webglAddon: WebglAddon | undefined; private _commandTrackerAddon: CommandTrackerAddon | undefined; private _navigationModeAddon: INavigationMode & ITerminalAddon | undefined; + private _dndObserver: IDisposable | undefined; private _lastLayoutDimensions: dom.Dimension | undefined; @@ -184,8 +187,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { get navigationMode(): INavigationMode | undefined { return this._navigationModeAddon; } get isDisconnected(): boolean { return this._processManager.isDisconnected; } get isRemote(): boolean { return this._processManager.remoteAuthority !== undefined; } - get title(): string { return this._getTitle(); } - get icon(): ThemeIcon | undefined { return this._getIcon(); } + get title(): string { return this._title; } + get titleSource(): TitleEventSource { return this._titleSource; } + get icon(): TerminalIcon | undefined { return this._getIcon(); } get color(): string | undefined { return this._getColor(); } private readonly _onExit = new Emitter(); @@ -216,6 +220,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { get onMaximumDimensionsChanged(): Event { return this._onMaximumDimensionsChanged.event; } private readonly _onFocus = new Emitter(); get onFocus(): Event { return this._onFocus.event; } + private readonly _onRequestAddInstanceToGroup = new Emitter(); + get onRequestAddInstanceToGroup(): Event { return this._onRequestAddInstanceToGroup.event; } constructor( private readonly _terminalFocusContextKey: IContextKey, @@ -273,12 +279,20 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._initDimensions(); this._createProcessManager(); + + this._register(toDisposable(() => this._dndObserver?.dispose())); + this._containerReadyBarrier = new AutoOpenBarrier(Constants.WaitForContainerThreshold); this._xtermReadyPromise = this._createXterm(); this._xtermReadyPromise.then(async () => { // Wait for a period to allow a container to be ready await this._containerReadyBarrier.wait(); await this._createProcess(); + + // Re-establish the title after reconnect + if (this.shellLaunchConfig.attachPersistentProcess) { + this.setTitle(this.shellLaunchConfig.attachPersistentProcess.title, this.shellLaunchConfig.attachPersistentProcess.titleSource); + } }); this.addDisposable(this._configurationService.onDidChangeConfiguration(e => { @@ -306,26 +320,19 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { initialDataEventsTimeout = undefined; this._initialDataEvents = undefined; }, 10000); - this._register({ - dispose: () => { - if (initialDataEventsTimeout) { - window.clearTimeout(initialDataEventsTimeout); - } + this._register(toDisposable(() => { + if (initialDataEventsTimeout) { + window.clearTimeout(initialDataEventsTimeout); } - }); + })); } - private _getIcon(): Codicon | undefined { - if (this.shellLaunchConfig.icon) { - return iconRegistry.get(this.shellLaunchConfig.icon); + private _getIcon(): TerminalIcon | undefined { + const icon = this._shellLaunchConfig.icon || this._shellLaunchConfig.attachPersistentProcess?.icon; + if (!icon) { + return this._processManager.processState >= ProcessState.Launching ? Codicon.terminal : undefined; } - if (this.shellLaunchConfig?.attachPersistentProcess?.icon) { - return iconRegistry.get(this.shellLaunchConfig.attachPersistentProcess.icon); - } - if (this._processManager.processState >= ProcessState.Launching) { - return Codicon.terminal; - } - return undefined; + return icon; } private _getColor(): string | undefined { @@ -341,13 +348,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { return undefined; } - private _getTitle(): string { - if (this.shellLaunchConfig.attachPersistentProcess?.title) { - return this.shellLaunchConfig.attachPersistentProcess.title; - } - return this._title; - } - addDisposable(disposable: IDisposable): void { this._register(disposable); } @@ -496,7 +496,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { letterSpacing: font.letterSpacing, lineHeight: font.lineHeight, minimumContrastRatio: config.minimumContrastRatio, - bellStyle: config.enableBell ? 'sound' : 'none', + bellStyle: 'none', macOptionIsMeta: config.macOptionIsMeta, macOptionClickForcesSelection: config.macOptionClickForcesSelection, rightClickSelectsWord: config.rightClickBehavior === 'selectWord', @@ -602,6 +602,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._container?.removeChild(this._wrapperElement); this._container = container; this._container.appendChild(this._wrapperElement); + setTimeout(() => this._initDragAndDrop(container)); } private async _attachToElement(container: HTMLElement): Promise { @@ -749,30 +750,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._refreshSelectionContextKey(); })); - this._register(dom.addDisposableListener(xterm.element, dom.EventType.DROP, async (dragEvent: DragEvent) => { - if (!dragEvent.dataTransfer) { - return; - } - - // Check if files were dragged from the tree explorer - let path: string | undefined; - const resources = dragEvent.dataTransfer.getData(DataTransfers.RESOURCES); - if (resources) { - path = URI.parse(JSON.parse(resources)[0]).fsPath; - } else if (dragEvent.dataTransfer.files?.[0].path /* Electron only */) { - // Check if the file was dragged from the filesystem - path = URI.file(dragEvent.dataTransfer.files[0].path).fsPath; - } - - if (!path) { - return; - } - - const preparedPath = await this._terminalInstanceService.preparePathForTerminalAsync(path, this.shellLaunchConfig.executable, this.title, this.shellType, this.isRemote); - - this.sendText(preparedPath, false); - this.focus(); - })); + this._initDragAndDrop(container); this._widgetManager.attachToElement(xterm.element); this._processManager.onProcessReady(() => this._linkManager?.setWidgetManager(this._widgetManager)); @@ -794,6 +772,18 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } } + private _initDragAndDrop(container: HTMLElement) { + this._dndObserver?.dispose(); + const dndController = new TerminalInstanceDropAndDropController(container); + dndController.onDropTerminal(e => this._onRequestAddInstanceToGroup.fire(e)); + dndController.onDropFile(async path => { + const preparedPath = await this._terminalInstanceService.preparePathForTerminalAsync(path, this.shellLaunchConfig.executable, this.title, this.shellType, this.isRemote); + this.sendText(preparedPath, false); + this.focus(); + }); + this._dndObserver = new DragAndDropObserver(container.parentElement!, dndController); + } + private async _measureRenderTime(): Promise { await this._xtermReadyPromise; const frameTimes: number[] = []; @@ -1058,7 +1048,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.setTitle(this._shellLaunchConfig.name, TitleEventSource.Api); } else { // Only listen for process title changes when a name is not provided - if (this._configHelper.config.experimentalUseTitleEvent) { + if (this._configHelper.config.titleMode === 'sequence') { // Set the title to the first event if the sequence hasn't set it yet Event.once(this._processManager.onProcessTitle)(e => { if (!this._title) { @@ -1610,10 +1600,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } switch (eventSource) { case TitleEventSource.Process: - if (isWindows) { - // Remove the .exe extension - title = path.basename(title); - title = title.split('.exe')[0]; + + if (this._processManager.os === OperatingSystem.Windows) { + // Extract the file name without extension + title = path.win32.parse(title).name; } else { const firstSpaceIndex = title.indexOf(' '); if (title.startsWith('/')) { @@ -1629,9 +1619,18 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { dispose(this._messageTitleDisposable); this._messageTitleDisposable = undefined; break; + case TitleEventSource.Sequence: + // On Windows, some shells will fire this with the full path which we want to trim + // to show just the file name. This should only happen if the title looks like an + // absolute Windows file path + if (this._processManager.os === OperatingSystem.Windows && title.match(/^[a-zA-Z]:\\.+\.[a-zA-Z]{1,3}/)) { + title = path.win32.parse(title).name; + } + break; } const didTitleChange = title !== this._title; this._title = title; + this._titleSource = eventSource; if (didTitleChange) { this._setAriaLabel(this._xterm, this._instanceId, this._title); @@ -1808,8 +1807,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { title: nls.localize('changeTerminalIcon', "Change Icon"), matchOnDescription: true }); - if (result) { - this.shellLaunchConfig.icon = result.description; + if (result && result.description) { + this.shellLaunchConfig.icon = iconRegistry.get(result.description); this._onIconChanged.fire(this); } } @@ -1837,6 +1836,116 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } } +class TerminalInstanceDropAndDropController extends Disposable implements IDragAndDropObserverCallbacks { + private _dropOverlay?: HTMLElement; + + + private readonly _onDropFile = new Emitter(); + get onDropFile(): Event { return this._onDropFile.event; } + private readonly _onDropTerminal = new Emitter(); + get onDropTerminal(): Event { return this._onDropTerminal.event; } + + constructor( + private readonly _container: HTMLElement + ) { + super(); + this._register(toDisposable(() => this._clearDropOverlay())); + } + + private _clearDropOverlay() { + if (this._dropOverlay && this._dropOverlay.parentElement) { + this._dropOverlay.parentElement.removeChild(this._dropOverlay); + } + this._dropOverlay = undefined; + } + + onDragEnter(e: DragEvent) { + if (!this._dropOverlay) { + this._dropOverlay = document.createElement('div'); + this._dropOverlay.classList.add('terminal-drop-overlay'); + } + + const types = e.dataTransfer?.types || []; + + // Dragging terminals + if (types.includes('terminals')) { + const side = this._getDropSide(e); + this._dropOverlay.classList.toggle('drop-left', side === 'left'); + this._dropOverlay.classList.toggle('drop-right', side === 'right'); + } + + if (!this._dropOverlay.parentElement) { + this._container.appendChild(this._dropOverlay); + } + } + onDragLeave(e: DragEvent) { + this._clearDropOverlay(); + } + + onDragEnd(e: DragEvent) { + this._clearDropOverlay(); + } + + onDragOver(e: DragEvent) { + if (!e.dataTransfer || !this._dropOverlay) { + return; + } + + const types = e.dataTransfer?.types || []; + + // Dragging terminals + if (types.includes('terminals')) { + const side = this._getDropSide(e); + this._dropOverlay.classList.toggle('drop-left', side === 'left'); + this._dropOverlay.classList.toggle('drop-right', side === 'right'); + } + + this._dropOverlay.style.opacity = '1'; + } + + async onDrop(e: DragEvent) { + this._clearDropOverlay(); + + if (!e.dataTransfer) { + return; + } + + // Check if files were dragged from the tree explorer + let path: string | undefined; + const resources = e.dataTransfer.getData(DataTransfers.RESOURCES); + if (resources) { + const uri = URI.parse(JSON.parse(resources)[0]); + if (uri.scheme === Schemas.vscodeTerminal) { + this._onDropTerminal.fire({ + uri, + side: this._getDropSide(e) + }); + return; + } else { + path = uri.fsPath; + } + } else if (e.dataTransfer.files?.[0].path /* Electron only */) { + // Check if the file was dragged from the filesystem + path = URI.file(e.dataTransfer.files[0].path).fsPath; + } + + if (!path) { + return; + } + + this._onDropFile.fire(path); + } + + private _getDropSide(e: DragEvent): 'left' | 'right' { + const target = this._container; + if (!target) { + return 'right'; + } + const rect = target.getBoundingClientRect(); + return e.clientX - rect.left < rect.width / 2 ? 'left' : 'right'; + } +} + let colors: string[] = []; registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { // add icon colors diff --git a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts index 1a0caeb3c89..6003a55d239 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts @@ -7,7 +7,7 @@ import { localize } from 'vs/nls'; import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; import { ContextKeyAndExpr, ContextKeyEqualsExpr, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { TerminalSettingId } from 'vs/platform/terminal/common/terminal'; -import { KEYBINDING_CONTEXT_TERMINAL_PROCESS_SUPPORTED, TerminalCommandId, TERMINAL_VIEW_ID } from 'vs/workbench/contrib/terminal/common/terminal'; +import { KEYBINDING_CONTEXT_TERMINAL_PROCESS_SUPPORTED, KEYBINDING_CONTEXT_TERMINAL_TABS_SINGULAR_SELECTION, TerminalCommandId, TERMINAL_VIEW_ID } from 'vs/workbench/contrib/terminal/common/terminal'; const enum ContextMenuGroup { Create = '1_create', @@ -228,6 +228,15 @@ export function setupTerminalMenus(): void { MenuRegistry.appendMenuItems( [ + { + id: MenuId.TerminalInlineTabContext, item: { + group: ContextMenuGroup.Create, + command: { + id: TerminalCommandId.Split, + title: localize('workbench.action.terminal.split', "Split Terminal") + } + } + }, { id: MenuId.TerminalInlineTabContext, item: { command: { @@ -257,15 +266,6 @@ export function setupTerminalMenus(): void { group: ContextMenuGroup.Edit } }, - { - id: MenuId.TerminalInlineTabContext, item: { - group: ContextMenuGroup.Create, - command: { - id: TerminalCommandId.Split, - title: localize('workbench.action.terminal.split', "Split Terminal") - } - } - }, { id: MenuId.TerminalInlineTabContext, item: { command: { @@ -280,6 +280,15 @@ export function setupTerminalMenus(): void { MenuRegistry.appendMenuItems( [ + { + id: MenuId.TerminalTabContext, item: { + command: { + id: TerminalCommandId.SplitInstance, + title: localize('workbench.action.terminal.splitInstance', "Split Terminal"), + }, + group: ContextMenuGroup.Create + } + }, { id: MenuId.TerminalTabContext, item: { command: { @@ -309,11 +318,12 @@ export function setupTerminalMenus(): void { }, { id: MenuId.TerminalTabContext, item: { + group: ContextMenuGroup.Config, command: { - id: TerminalCommandId.SplitInstance, - title: localize('workbench.action.terminal.splitInstance', "Split Terminal"), + id: TerminalCommandId.JoinInstance, + title: localize('workbench.action.terminal.joinInstance', "Join Terminals") }, - group: ContextMenuGroup.Create + when: KEYBINDING_CONTEXT_TERMINAL_TABS_SINGULAR_SELECTION.toNegated() } }, { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts index 399cf6b2f63..71a5445705e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts @@ -13,12 +13,14 @@ import { IRemoteTerminalService, ITerminalService } from 'vs/workbench/contrib/t import { IConfigurationResolverService } from 'vs/workbench/services/configurationResolver/common/configurationResolver'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; import { IProcessEnvironment, OperatingSystem, OS } from 'vs/base/common/platform'; -import { IShellLaunchConfig, ITerminalProfile, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; +import { IShellLaunchConfig, ITerminalProfile, TerminalIcon, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; import { IShellLaunchConfigResolveOptions, ITerminalProfileResolverService } from 'vs/workbench/contrib/terminal/common/terminal'; import * as path from 'vs/base/common/path'; import { Codicon, iconRegistry } from 'vs/base/common/codicons'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { debounce } from 'vs/base/common/decorators'; +import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { URI, UriComponents } from 'vs/base/common/uri'; export interface IProfileContextProvider { getDefaultSystemShell: (remoteAuthority: string | undefined, os: OperatingSystem) => Promise; @@ -43,7 +45,7 @@ export abstract class BaseTerminalProfileResolverService implements ITerminalPro private readonly _logService: ILogService, private readonly _terminalService: ITerminalService, private readonly _workspaceContextService: IWorkspaceContextService, - private readonly _remoteAgentService: IRemoteAgentService, + private readonly _remoteAgentService: IRemoteAgentService ) { if (this._remoteAgentService.getConnection()) { this._remoteAgentService.getEnvironment().then(env => this._primaryBackendOs = env?.os || OS); @@ -71,10 +73,14 @@ export abstract class BaseTerminalProfileResolverService implements ITerminalPro } resolveIcon(shellLaunchConfig: IShellLaunchConfig, os: OperatingSystem): void { - if (shellLaunchConfig.executable) { + if (shellLaunchConfig.icon) { + shellLaunchConfig.icon = this._getCustomIcon(shellLaunchConfig.icon) || Codicon.terminal; return; } + if (shellLaunchConfig.executable) { + return; + } const defaultProfile = this._getUnresolvedRealDefaultProfile(os); if (defaultProfile) { shellLaunchConfig.icon = defaultProfile.icon; @@ -106,8 +112,7 @@ export abstract class BaseTerminalProfileResolverService implements ITerminalPro // Verify the icon is valid, and fallback correctly to the generic terminal id if there is // an issue - shellLaunchConfig.icon = this._verifyIcon(shellLaunchConfig.icon) || this._verifyIcon(resolvedProfile.icon) || Codicon.terminal.id; - + shellLaunchConfig.icon = this._getCustomIcon(shellLaunchConfig.icon) || this._getCustomIcon(resolvedProfile.icon) || Codicon.terminal; // Override the name if specified if (resolvedProfile.overrideName) { shellLaunchConfig.name = resolvedProfile.profileName; @@ -119,12 +124,6 @@ export abstract class BaseTerminalProfileResolverService implements ITerminalPro } } - private _verifyIcon(iconId?: string): string | undefined { - if (!iconId || !iconRegistry.get(iconId)) { - return undefined; - } - return iconId; - } async getDefaultShell(options: IShellLaunchConfigResolveOptions): Promise { return (await this.getDefaultProfile(options)).path; @@ -142,6 +141,36 @@ export abstract class BaseTerminalProfileResolverService implements ITerminalPro return this._context.getEnvironment(remoteAuthority); } + private _getCustomIcon(icon?: unknown): TerminalIcon | undefined { + if (!icon) { + return undefined; + } + if (typeof icon === 'string') { + return iconRegistry.get(icon); + } + if (ThemeIcon.isThemeIcon(icon)) { + return icon; + } + if (URI.isUri(icon) || this._isUriComponents(icon)) { + return URI.revive(icon); + } + if (typeof icon === 'object' && icon && 'light' in icon && 'dark' in icon) { + const castedIcon = (icon as { light: unknown, dark: unknown }); + if ((URI.isUri(castedIcon.light) || this._isUriComponents(castedIcon.light)) && (URI.isUri(castedIcon.dark) || this._isUriComponents(castedIcon.dark))) { + return { light: URI.revive(castedIcon.light), dark: URI.revive(castedIcon.dark) }; + } + } + return undefined; + } + + private _isUriComponents(thing: unknown): thing is UriComponents { + if (!thing) { + return false; + } + return typeof (thing).path === 'string' && + typeof (thing).scheme === 'string'; + } + private async _getUnresolvedDefaultProfile(options: IShellLaunchConfigResolveOptions): Promise { // If automation shell is allowed, prefer that if (options.allowAutomationShell) { @@ -315,18 +344,18 @@ export abstract class BaseTerminalProfileResolverService implements ITerminalPro } } - private _guessProfileIcon(shell: string): string | undefined { + private _guessProfileIcon(shell: string): ThemeIcon | undefined { const file = path.parse(shell).name; switch (file) { case 'bash': - return Codicon.terminalBash.id; + return Codicon.terminalBash; case 'pwsh': case 'powershell': - return Codicon.terminalPowershell.id; + return Codicon.terminalPowershell; case 'tmux': - return Codicon.terminalTmux.id; + return Codicon.terminalTmux; case 'cmd': - return Codicon.terminalCmd.id; + return Codicon.terminalCmd; default: return undefined; } @@ -389,7 +418,7 @@ export class BrowserTerminalProfileResolverService extends BaseTerminalProfileRe @IRemoteTerminalService remoteTerminalService: IRemoteTerminalService, @ITerminalService terminalService: ITerminalService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, - @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService ) { super( { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts b/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts index d5fd6d9db0a..d62c06f238c 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts @@ -10,8 +10,9 @@ import { matchesFuzzy } from 'vs/base/common/filters'; import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; -import { ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { IThemeService, ThemeIcon } from 'vs/platform/theme/common/themeService'; import { killTerminalIcon, renameTerminalIcon } from 'vs/workbench/contrib/terminal/browser/terminalIcons'; +import { getColorClass, getIconId, getUriClasses } from 'vs/workbench/contrib/terminal/browser/terminalIcon'; export class TerminalQuickAccessProvider extends PickerQuickAccessProvider { @@ -20,6 +21,7 @@ export class TerminalQuickAccessProvider extends PickerQuickAccessProvider { switch (buttonIndex) { case 0: diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 86e6c28143e..e2e19ad7fbb 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -294,12 +294,8 @@ export class TerminalService implements ITerminalService { // The state must be updated when the terminal is relaunched, otherwise the persistent // terminal ID will be stale and the process will be leaked. this.onInstanceProcessIdReady(() => this._saveState()); - this.onInstanceTitleChanged(instance => { - this._updateTitle(instance); - }); - this.onInstanceIconChanged(instance => { - this._updateIcon(instance); - }); + this.onInstanceTitleChanged(instance => this._updateTitle(instance)); + this.onInstanceIconChanged(instance => this._updateIcon(instance)); } private _handleInstanceContextKeys(): void { @@ -410,7 +406,7 @@ export class TerminalService implements ITerminalService { if (!this.configHelper.config.enablePersistentSessions || !instance || !instance.persistentProcessId || !instance.title) { return; } - this._offProcessTerminalService?.updateTitle(instance.persistentProcessId, instance.title); + this._offProcessTerminalService?.updateTitle(instance.persistentProcessId, instance.title, instance.titleSource); } @debounce(500) @@ -418,7 +414,7 @@ export class TerminalService implements ITerminalService { if (!this.configHelper.config.enablePersistentSessions || !instance || !instance.persistentProcessId || !instance.icon) { return; } - this._offProcessTerminalService?.updateIcon(instance.persistentProcessId, instance.icon.id, instance.color); + this._offProcessTerminalService?.updateIcon(instance.persistentProcessId, instance.icon, instance.color); } private _removeGroup(group: ITerminalGroup): void { @@ -638,6 +634,87 @@ export class TerminalService implements ITerminalService { this._onInstancesChanged.fire(); } + joinInstances(instances: ITerminalInstance[]): void { + // Find the group of the first instance that is the only instance in the group, if one exists + let candidateInstance: ITerminalInstance | undefined = undefined; + let candidateGroup: ITerminalGroup | undefined = undefined; + for (const instance of instances) { + const group = this.getGroupForInstance(instance); + if (group?.terminalInstances.length === 1) { + candidateInstance = instance; + candidateGroup = group; + break; + } + } + + // Create a new group if needed + if (!candidateGroup) { + candidateGroup = this._instantiationService.createInstance(TerminalGroup, this._terminalContainer, undefined); + candidateGroup.onPanelOrientationChanged((orientation) => this._onPanelOrientationChanged.fire(orientation)); + this._terminalGroups.push(candidateGroup); + candidateGroup.addDisposable(candidateGroup.onDisposed(this._onGroupDisposed.fire, this._onGroupDisposed)); + candidateGroup.addDisposable(candidateGroup.onInstancesChanged(this._onInstancesChanged.fire, this._onInstancesChanged)); + } + + const wasActiveGroup = this.getActiveGroup() === candidateGroup; + + // Unsplit all other instances and add them to the new group + for (const instance of instances) { + if (instance === candidateInstance) { + continue; + } + + const oldGroup = this.getGroupForInstance(instance); + if (!oldGroup) { + // Something went wrong, don't join this one + continue; + } + oldGroup.removeInstance(instance); + candidateGroup.addInstance(instance); + } + + // Set the active terminal + this.setActiveInstance(instances[0]); + + // Fire events + this._onInstancesChanged.fire(); + if (!wasActiveGroup) { + this._onActiveGroupChanged.fire(); + } + } + + moveGroup(source: ITerminalInstance, target: ITerminalInstance): void { + const sourceGroup = this.getGroupForInstance(source); + const targetGroup = this.getGroupForInstance(target); + if (!sourceGroup || !targetGroup) { + return; + } + const sourceGroupIndex = this._terminalGroups.indexOf(sourceGroup); + const targetGroupIndex = this._terminalGroups.indexOf(targetGroup); + this._terminalGroups.splice(sourceGroupIndex, 1); + this._terminalGroups.splice(targetGroupIndex, 0, sourceGroup); + this._onInstancesChanged.fire(); + } + + moveInstance(source: ITerminalInstance, target: ITerminalInstance, side: 'left' | 'right'): void { + const sourceGroup = this.getGroupForInstance(source); + const targetGroup = this.getGroupForInstance(target); + if (!sourceGroup || !targetGroup) { + return; + } + + // Move from the source group to the target group + if (sourceGroup !== targetGroup) { + // Move groups + sourceGroup.removeInstance(source); + targetGroup.addInstance(source); + } + + // Rearrange within the target group + const index = targetGroup.terminalInstances.indexOf(target) + (side === 'right' ? 1 : 0); + targetGroup.moveInstance(source, index); + } + protected _initInstanceListeners(instance: ITerminalInstance): void { instance.addDisposable(instance.onDisposed(this._onInstanceDisposed.fire, this._onInstanceDisposed)); instance.addDisposable(instance.onTitleChanged(this._onInstanceTitleChanged.fire, this._onInstanceTitleChanged)); @@ -654,6 +731,12 @@ export class TerminalService implements ITerminalService { })); instance.addDisposable(instance.onMaximumDimensionsChanged(() => this._onInstanceMaximumDimensionsChanged.fire(instance))); instance.addDisposable(instance.onFocus(this._onActiveInstanceChanged.fire, this._onActiveInstanceChanged)); + instance.addDisposable(instance.onRequestAddInstanceToGroup(e => { + const sourceInstance = this.getInstanceFromId(parseInt(e.uri.path)); + if (sourceInstance) { + this.moveInstance(sourceInstance, instance, e.side); + } + })); } registerProcessSupport(isSupported: boolean): void { @@ -877,7 +960,7 @@ export class TerminalService implements ITerminalService { iconClass: ThemeIcon.asClassName(configureTerminalProfileIcon), tooltip: nls.localize('createQuickLaunchProfile', "Configure Terminal Profile") }]; - const icon = profile.icon ? (iconRegistry.get(profile.icon) || Codicon.terminal) : Codicon.terminal; + const icon = (profile.icon && ThemeIcon.isThemeIcon(profile.icon)) ? profile.icon : Codicon.terminal; const label = `$(${icon.id}) ${profile.profileName}`; if (profile.args) { if (typeof profile.args === 'string') { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts index c033e468a47..70238c70884 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts @@ -27,11 +27,13 @@ import { IDecorationsService } from 'vs/workbench/services/decorations/browser/d import { IHoverAction, IHoverService } from 'vs/workbench/services/hover/browser/hover'; import Severity from 'vs/base/common/severity'; import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { IListDragAndDrop, IListRenderer } from 'vs/base/browser/ui/list/list'; +import { IListDragAndDrop, IListDragOverReaction, IListRenderer, ListDragOverEffect } from 'vs/base/browser/ui/list/list'; import { DataTransfers, IDragAndDropData } from 'vs/base/browser/dnd'; import { disposableTimeout } from 'vs/base/common/async'; import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; import { URI } from 'vs/base/common/uri'; +import { getColorClass, getIconId, getUriClasses } from 'vs/workbench/contrib/terminal/browser/terminalIcon'; +import { Schemas } from 'vs/base/common/network'; const $ = DOM.$; @@ -59,9 +61,10 @@ export class TerminalTabList extends WorkbenchList { @ITerminalService private _terminalService: ITerminalService, @ITerminalInstanceService _terminalInstanceService: ITerminalInstanceService, @IInstantiationService instantiationService: IInstantiationService, - @IDecorationsService _decorationsService: IDecorationsService + @IDecorationsService _decorationsService: IDecorationsService, + @IThemeService private readonly _themeService: IThemeService ) { - super('TerminalTabsTree', container, + super('TerminalTabsList', container, { getHeight: () => TerminalTabsListSizes.TabHeight, getTemplateId: () => 'terminal.tabs' @@ -70,6 +73,7 @@ export class TerminalTabList extends WorkbenchList { { horizontalScrolling: false, supportDynamicHeights: false, + selectionNavigation: true, identityProvider: { getId: e => e?.instanceId }, @@ -77,7 +81,7 @@ export class TerminalTabList extends WorkbenchList { smoothScrolling: configurationService.getValue('workbench.list.smoothScrolling'), multipleSelectionSupport: true, additionalScrollHeight: TerminalTabsListSizes.TabHeight, - dnd: new TerminalTabsDragAndDrop(_terminalService, _terminalInstanceService) + dnd: instantiationService.createInstance(TerminalTabsDragAndDrop) }, contextKeyService, listService, @@ -89,6 +93,8 @@ export class TerminalTabList extends WorkbenchList { this._terminalService.onInstanceTitleChanged(() => this.render()); this._terminalService.onInstanceIconChanged(() => this.render()); this._terminalService.onInstancePrimaryStatusChanged(() => this.render()); + this._terminalService.onDidChangeConnectionState(() => this.render()); + this._themeService.onDidColorThemeChange(() => this.render()); this._terminalService.onActiveInstanceChanged(e => { if (e) { const i = this._terminalService.terminalInstances.indexOf(e); @@ -133,13 +139,8 @@ export class TerminalTabList extends WorkbenchList { this._terminalTabsSingleSelectedContextKey = KEYBINDING_CONTEXT_TERMINAL_TABS_SINGULAR_SELECTION.bindTo(contextKeyService); - this.onDidChangeSelection(e => { - this._terminalTabsSingleSelectedContextKey.set(e.elements.length === 1); - }); - - this.onDidChangeFocus(e => { - this._terminalTabsSingleSelectedContextKey.set(e.elements.length === 1); - }); + this.onDidChangeSelection(e => this._updateContextKey()); + this.onDidChangeFocus(() => this._updateContextKey()); this.onDidOpen(async e => { const instance = e.element; @@ -161,6 +162,10 @@ export class TerminalTabList extends WorkbenchList { render(): void { this.splice(0, this.length, this._terminalService.terminalInstances); } + + private _updateContextKey() { + this._terminalTabsSingleSelectedContextKey.set(this.getSelectedElements().length === 1); + } } class TerminalTabsRenderer implements IListRenderer { @@ -175,7 +180,8 @@ class TerminalTabsRenderer implements IListRenderer Severity.Ignore) { - label = `${prefix}$(${primaryStatus.icon?.id || instance.icon?.id})`; + label = `${prefix}$(${primaryStatus.icon?.id || iconId})`; } else { - label = `${prefix}$(${instance.icon?.id})`; + label = `${prefix}$(${iconId})`; } } else { this.fillActionBar(instance, template); - label = `${prefix}$(${instance.icon?.id})`; + label = `${prefix}$(${iconId})`; // Only add the title if the icon is set, this prevents the title jumping around for // example when launching with a ShellLaunchConfig.name and no icon if (instance.icon) { @@ -274,12 +280,6 @@ class TerminalTabsRenderer implements IListRenderer('.codicon'); - if (codicon) { - codicon.style.color = instance?.icon?.color?.id || ''; - } - - if (!hasActionbar) { template.actionBar.clear(); } @@ -295,6 +295,16 @@ class TerminalTabsRenderer implements IListRenderer { private _autoFocusDisposable: IDisposable = Disposable.None; constructor( - private _terminalService: ITerminalService, - private _terminalInstanceService: ITerminalInstanceService + @ITerminalService private _terminalService: ITerminalService, + @ITerminalInstanceService private _terminalInstanceService: ITerminalInstanceService ) { } getDragURI(instance: ITerminalInstance): string | null { - return null; + return URI.from({ + scheme: Schemas.vscodeTerminal, + path: instance.instanceId.toString() + }).toString(); } - onDragOver(data: IDragAndDropData, targetInstance: ITerminalInstance | undefined, targetIndex: number | undefined, originalEvent: DragEvent): boolean { + onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void { + if (!originalEvent.dataTransfer) { + return; + } + const dndData: unknown = data.getData(); + if (!Array.isArray(dndData)) { + return; + } + // Attach terminals type to event + const terminals: ITerminalInstance[] = dndData.filter(e => 'instanceId' in (e as any)); + if (terminals.length > 0) { + originalEvent.dataTransfer.setData('terminals', JSON.stringify(terminals.map(e => e.instanceId))); + } + } + + onDragOver(data: IDragAndDropData, targetInstance: ITerminalInstance | undefined, targetIndex: number | undefined, originalEvent: DragEvent): boolean | IListDragOverReaction { let result = true; const didChangeAutoFocusInstance = this._autoFocusInstance !== targetInstance; @@ -424,24 +452,55 @@ class TerminalTabsDragAndDrop implements IListDragAndDrop { return result; } - const isExternalDragOver = !(data instanceof ElementsDragAndDropData); - if (didChangeAutoFocusInstance && isExternalDragOver) { + if (didChangeAutoFocusInstance) { this._autoFocusDisposable = disposableTimeout(() => { this._terminalService.setActiveInstance(targetInstance); this._autoFocusInstance = undefined; }, 500); } - return result; + return { + feedback: targetIndex ? [targetIndex] : undefined, + accept: true, + effect: ListDragOverEffect.Move + }; } drop(data: IDragAndDropData, targetInstance: ITerminalInstance | undefined, targetIndex: number | undefined, originalEvent: DragEvent): void { this._autoFocusDisposable.dispose(); this._autoFocusInstance = undefined; - const isExternalDrop = !(data instanceof ElementsDragAndDropData); - if (isExternalDrop) { + if (!(data instanceof ElementsDragAndDropData)) { this._handleExternalDrop(targetInstance, originalEvent); + return; + } + + const draggedElement = data.getData(); + if (!draggedElement || !Array.isArray(draggedElement)) { + return; + } + let focused = false; + + let sourceInstances: ITerminalInstance[] = []; + for (const e of draggedElement) { + if ('instanceId' in e) { + sourceInstances.push(e as ITerminalInstance); + } + } + + if (!targetInstance) { + for (const instance of sourceInstances) { + this._terminalService.unsplitInstance(instance); + } + return; + } + + for (const instance of sourceInstances) { + this._terminalService.moveGroup(instance, targetInstance); + if (!focused) { + this._terminalService.setActiveInstance(instance); + focused = true; + } } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 6bcd9c5aa5e..a5f1b725b63 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -10,9 +10,9 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IThemeService, IColorTheme, registerThemingParticipant, ICssStyleCollector, ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { IThemeService, IColorTheme, registerThemingParticipant, ICssStyleCollector, ThemeIcon, Themable } from 'vs/platform/theme/common/themeService'; import { switchTerminalActionViewItemSeparator, switchTerminalShowTabsTitle } from 'vs/workbench/contrib/terminal/browser/terminalActions'; -import { TERMINAL_BACKGROUND_COLOR, TERMINAL_BORDER_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; +import { TERMINAL_BACKGROUND_COLOR, TERMINAL_BORDER_COLOR, TERMINAL_DRAG_AND_DROP_BACKGROUND } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; import { INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; import { ITerminalInstance, ITerminalService, TerminalConnectionState } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPane'; @@ -20,7 +20,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IViewDescriptorService } from 'vs/workbench/common/views'; import { IOpenerService } from 'vs/platform/opener/common/opener'; -import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; +import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND, EDITOR_DRAG_AND_DROP_BACKGROUND } from 'vs/workbench/common/theme'; import { IMenu, IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; import { ITerminalProfileResolverService, TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; import { TerminalSettingId, ITerminalProfile } from 'vs/platform/terminal/common/terminal'; @@ -39,6 +39,9 @@ import { createAndFillInContextMenuActions, MenuEntryActionViewItem } from 'vs/p import { TerminalTabContextMenuGroup } from 'vs/workbench/contrib/terminal/browser/terminalMenus'; import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem'; import { dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { ColorScheme } from 'vs/platform/theme/common/theme'; +import { getColorClass, getUriClasses } from 'vs/workbench/contrib/terminal/browser/terminalIcon'; export class TerminalViewPane extends ViewPane { private _actions: IAction[] | undefined; @@ -104,6 +107,7 @@ export class TerminalViewPane extends ViewPane { this._parentDomElement = container; this._parentDomElement.classList.add('integrated-terminal'); this._fontStyleElement = document.createElement('style'); + this._instantiationService.createInstance(TerminalThemeIconStyle, this._parentDomElement); if (!this.shouldShowWelcome()) { this._createTabsView(); @@ -296,6 +300,11 @@ registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) = collector.addRule(`.monaco-workbench .pane-body.integrated-terminal .split-view-view:not(:first-child) { border-color: ${borderColor.toString()}; }`); collector.addRule(`.monaco-workbench .pane-body.integrated-terminal .tabs-container { border-color: ${borderColor.toString()}; }`); } + + const dndBackgroundColor = theme.getColor(TERMINAL_DRAG_AND_DROP_BACKGROUND) || theme.getColor(EDITOR_DRAG_AND_DROP_BACKGROUND); + if (dndBackgroundColor) { + collector.addRule(`.monaco-workbench .pane-body.integrated-terminal .terminal-drop-overlay { background-color: ${dndBackgroundColor.toString()}; }`); + } }); @@ -347,6 +356,8 @@ function getTerminalSelectOpenItems(terminalService: ITerminalService): ISelectO class SingleTerminalTabActionViewItem extends MenuEntryActionViewItem { private _color: string | undefined; + private _altCommand: string | undefined; + private _class: string | undefined; private readonly _elementDisposables: IDisposable[] = []; constructor( action: IAction, @@ -427,13 +438,33 @@ class SingleTerminalTabActionViewItem extends MenuEntryActionViewItem { } label.style.color = colorStyle; dom.reset(label, ...renderLabelWithIcons(getSingleTabLabel(instance, ThemeIcon.isThemeIcon(this._commandAction.item.icon) ? this._commandAction.item.icon : undefined))); + + if (this._altCommand) { + label.classList.remove(this._altCommand); + this._altCommand = undefined; + } if (this._color) { label.classList.remove(this._color); this._color = undefined; } - if (instance?.color) { - this._color = `terminal-icon-${instance.color}`; - label.classList.add(this._color); + if (this._class) { + label.classList.remove(this._class); + label.classList.remove('terminal-uri-icon'); + this._class = undefined; + } + const colorClass = getColorClass(instance); + if (colorClass) { + this._color = colorClass; + label.classList.add(colorClass); + } + const uriClasses = getUriClasses(instance, this._themeService.getColorTheme().type); + if (uriClasses) { + this._class = uriClasses?.[0]; + label.classList.add(...uriClasses); + } + if (this._commandAction.item.icon) { + this._altCommand = `alt-command`; + label.classList.add(this._altCommand); } this.updateTooltip(); } @@ -454,7 +485,9 @@ function getSingleTabLabel(instance: ITerminalInstance | null, icon?: ThemeIcon) if (!instance || !instance.title) { return ''; } - const label = `$(${icon?.id || instance.icon?.id}) ${getSingleTabTooltip(instance)}`; + let iconClass = ThemeIcon.isThemeIcon(instance.icon) ? instance.icon?.id : Codicon.terminal.id; + const label = `$(${icon?.id || iconClass}) ${getSingleTabTooltip(instance)}`; + const primaryStatus = instance.statusList.primary; if (!primaryStatus?.icon) { return label; @@ -471,3 +504,48 @@ function getSingleTabTooltip(instance: ITerminalInstance | null): string { } return `${instance.title} ${instance.shellLaunchConfig.description}`; } + +class TerminalThemeIconStyle extends Themable { + private _styleElement: HTMLElement; + constructor( + container: HTMLElement, + @IThemeService private readonly _themeService: IThemeService, + @ITerminalService private readonly _terminalService: ITerminalService + ) { + super(_themeService); + this._registerListeners(); + this._styleElement = document.createElement('style'); + container.appendChild(this._styleElement); + this._register(toDisposable(() => container.removeChild(this._styleElement))); + this.updateStyles(); + } + + private _registerListeners(): void { + this._register(this._terminalService.onInstanceIconChanged(() => this.updateStyles())); + this._register(this._terminalService.onInstancesChanged(() => this.updateStyles())); + } + + override updateStyles(): void { + super.updateStyles(); + let css = ''; + // TODO add a rule collector to avoid duplication + for (const instance of this._terminalService.terminalInstances) { + const icon = instance.icon; + if (!icon) { + return; + } + let uri = undefined; + if (icon instanceof URI) { + uri = icon; + } else if (icon instanceof Object && 'light' in icon && 'dark' in icon) { + uri = this._themeService.getColorTheme().type === ColorScheme.LIGHT ? icon.light : icon.dark; + } + const iconClasses = getUriClasses(instance, this._themeService.getColorTheme().type); + if (uri instanceof URI && iconClasses && iconClasses.length > 1) { + css += `.monaco-workbench .${iconClasses[0]} .monaco-highlighted-label .codicon, .monaco-action-bar .terminal-uri-icon.single-terminal-tab.action-label:not(.alt-command) .codicon {`; + css += `background-image: ${dom.asCSSUrl(uri)};}`; + } + } + this._styleElement.textContent = css; + } +} diff --git a/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts b/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts index 3c9c2b9e797..633d8c00770 100644 --- a/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts +++ b/src/vs/workbench/contrib/terminal/common/remoteTerminalChannel.ts @@ -18,7 +18,7 @@ import { IEditorService } from 'vs/workbench/services/editor/common/editorServic import { Schemas } from 'vs/base/common/network'; import { ILabelService } from 'vs/platform/label/common/label'; import { IEnvironmentVariableService, ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable'; -import { IProcessDataEvent, IRequestResolveVariablesEvent, IShellLaunchConfig, IShellLaunchConfigDto, ITerminalDimensionsOverride, ITerminalEnvironment, ITerminalLaunchError, ITerminalProfile, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, TerminalShellType } from 'vs/platform/terminal/common/terminal'; +import { IProcessDataEvent, IRequestResolveVariablesEvent, IShellLaunchConfig, IShellLaunchConfigDto, ITerminalDimensionsOverride, ITerminalEnvironment, ITerminalLaunchError, ITerminalProfile, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, TerminalIcon, TerminalShellType } from 'vs/platform/terminal/common/terminal'; import { IGetTerminalLayoutInfoArgs, IProcessDetails, IPtyHostProcessReplayEvent, ISetTerminalLayoutInfoArgs } from 'vs/platform/terminal/common/terminalProcess'; import { IProcessEnvironment, OperatingSystem } from 'vs/base/common/platform'; @@ -266,7 +266,7 @@ export class RemoteTerminalChannelClient { return this._channel.call('$updateTitle', [id, title]); } - updateIcon(id: number, icon: string, color?: string): Promise { + updateIcon(id: number, icon: TerminalIcon, color?: string): Promise { return this._channel.call('$updateIcon', [id, icon, color]); } diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 6ec4d1f8ab7..14571020725 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -9,9 +9,10 @@ import { IDisposable } from 'vs/base/common/lifecycle'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IProcessEnvironment, OperatingSystem } from 'vs/base/common/platform'; import { IExtensionPointDescriptor } from 'vs/workbench/services/extensions/common/extensionsRegistry'; -import { IProcessDataEvent, IProcessReadyEvent, IShellLaunchConfig, ITerminalDimensions, ITerminalDimensionsOverride, ITerminalLaunchError, ITerminalProfile, ITerminalProfileObject, TerminalShellType } from 'vs/platform/terminal/common/terminal'; +import { IProcessDataEvent, IProcessReadyEvent, IShellLaunchConfig, ITerminalDimensions, ITerminalDimensionsOverride, ITerminalLaunchError, ITerminalProfile, ITerminalProfileObject, TerminalShellType, TitleEventSource } from 'vs/platform/terminal/common/terminal'; import { IEnvironmentVariableInfo } from 'vs/workbench/contrib/terminal/common/environmentVariable'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { URI } from 'vs/base/common/uri'; export const TERMINAL_VIEW_ID = 'terminal'; @@ -188,7 +189,7 @@ export interface ITerminalConfiguration { splitCwd: 'workspaceRoot' | 'initial' | 'inherited'; windowsEnableConpty: boolean; wordSeparators: string; - experimentalUseTitleEvent: boolean; + titleMode: 'executable' | 'sequence'; enableFileLinks: boolean; unicodeVersion: '6' | '11'; experimentalLinkProvider: boolean; @@ -230,11 +231,12 @@ export interface IRemoteTerminalAttachTarget { id: number; pid: number; title: string; + titleSource: TitleEventSource; cwd: string; workspaceId: string; workspaceName: string; isOrphan: boolean; - icon: string | undefined; + icon: URI | { light: URI; dark: URI } | { id: string, color?: { id: string } } | undefined; color: string | undefined; } @@ -363,15 +365,6 @@ export interface IDefaultShellAndArgsRequest { callback: (shell: string, args: string[] | string | undefined) => void; } -export enum TitleEventSource { - /** From the API or the rename command that overrides any other type */ - Api, - /** From the process name property*/ - Process, - /** From the VT sequence */ - Sequence -} - export const QUICK_LAUNCH_PROFILE_CHOICE = 'workbench.action.terminal.profile.choice'; export const enum TerminalCommandId { @@ -398,6 +391,7 @@ export const enum TerminalCommandId { SplitInstance = 'workbench.action.terminal.splitInstance', SplitInActiveWorkspace = 'workbench.action.terminal.splitInActiveWorkspace', Unsplit = 'workbench.action.terminal.unsplit', + JoinInstance = 'workbench.action.terminal.joinInstance', Relaunch = 'workbench.action.terminal.relaunch', FocusPreviousPane = 'workbench.action.terminal.focusPreviousPane', ShowTabs = 'workbench.action.terminal.showTabs', diff --git a/src/vs/workbench/contrib/terminal/common/terminalColorRegistry.ts b/src/vs/workbench/contrib/terminal/common/terminalColorRegistry.ts index 30c7c55d9e4..e9847286a0b 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalColorRegistry.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalColorRegistry.ts @@ -6,7 +6,7 @@ import * as nls from 'vs/nls'; import { registerColor, ColorIdentifier, ColorDefaults } from 'vs/platform/theme/common/colorRegistry'; -import { PANEL_BORDER } from 'vs/workbench/common/theme'; +import { EDITOR_DRAG_AND_DROP_BACKGROUND, PANEL_BORDER } from 'vs/workbench/common/theme'; /** * The color identifiers for the terminal's ansi colors. The index in the array corresponds to the index @@ -32,6 +32,11 @@ export const TERMINAL_BORDER_COLOR = registerColor('terminal.border', { light: PANEL_BORDER, hc: PANEL_BORDER }, nls.localize('terminal.border', 'The color of the border that separates split panes within the terminal. This defaults to panel.border.')); +export const TERMINAL_DRAG_AND_DROP_BACKGROUND = registerColor('terminal.dropBackground', { + dark: EDITOR_DRAG_AND_DROP_BACKGROUND, + light: EDITOR_DRAG_AND_DROP_BACKGROUND, + hc: EDITOR_DRAG_AND_DROP_BACKGROUND +}, nls.localize('terminal.dragAndDropBackground', "Background color when dragging on top of terminals. The color should have transparency so that the terminal contents can still shine through.")); export const ansiColorMap: { [key: string]: { index: number, defaults: ColorDefaults } } = { 'terminal.ansiBlack': { diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 22e6521dafd..a9d58c95824 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -333,10 +333,15 @@ const terminalConfiguration: IConfigurationNode = { type: 'string', default: ' ()[]{}\',"`─' }, - [TerminalSettingId.ExperimentalUseTitleEvent]: { - description: localize('terminal.integrated.experimentalUseTitleEvent', "An experimental setting that will use the terminal title event for the dropdown title. This setting will only apply to new terminals."), - type: 'boolean', - default: false + [TerminalSettingId.TitleMode]: { + description: localize('terminal.integrated.titleMode', "Determines how the terminal's title is set, this shows up in the terminal's tab or dropdown entry."), + type: 'string', + enum: ['executable', 'sequence'], + markdownEnumDescriptions: [ + localize('titleMode.executable', "The title is set by the _terminal_, the name of the detected foreground process will be used."), + localize('titleMode.sequence', "The title is set by the _process_ via an escape sequence, this is useful if your shell dynamically sets the title.") + ], + default: 'executable' }, [TerminalSettingId.EnableFileLinks]: { description: localize('terminal.integrated.enableFileLinks', "Whether to enable file links in the terminal. Links can be slow when working on a network drive in particular because each file link is verified against the file system. Changing this will take effect only in new terminals."), diff --git a/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalService.ts b/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalService.ts index 7bdaa59e580..18feaabdacd 100644 --- a/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalService.ts @@ -8,12 +8,13 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; import { IProcessEnvironment, OperatingSystem } from 'vs/base/common/platform'; import { withNullAsUndefined } from 'vs/base/common/types'; +import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { ILogService } from 'vs/platform/log/common/log'; import { INotificationHandle, INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; -import { ILocalTerminalService, IShellLaunchConfig, ITerminalChildProcess, ITerminalsLayoutInfo, ITerminalsLayoutInfoById } from 'vs/platform/terminal/common/terminal'; +import { ILocalTerminalService, IShellLaunchConfig, ITerminalChildProcess, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, TitleEventSource } from 'vs/platform/terminal/common/terminal'; import { IGetTerminalLayoutInfoArgs, IProcessDetails, ISetTerminalLayoutInfoArgs } from 'vs/platform/terminal/common/terminalProcess'; import { ILocalPtyService } from 'vs/platform/terminal/electron-sandbox/terminal'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; @@ -114,11 +115,11 @@ export class LocalTerminalService extends Disposable implements ILocalTerminalSe })); } } - async updateTitle(id: number, title: string): Promise { - await this._localPtyService.updateTitle(id, title); + async updateTitle(id: number, title: string, titleSource: TitleEventSource): Promise { + await this._localPtyService.updateTitle(id, title, titleSource); } - async updateIcon(id: number, icon: string, color?: string): Promise { + async updateIcon(id: number, icon: URI | { light: URI; dark: URI } | { id: string, color?: { id: string } }, color?: string): Promise { await this._localPtyService.updateIcon(id, icon, color); } diff --git a/src/vs/workbench/contrib/terminal/electron-sandbox/terminalProfileResolverService.ts b/src/vs/workbench/contrib/terminal/electron-sandbox/terminalProfileResolverService.ts index 8c61df06052..93363863f9e 100644 --- a/src/vs/workbench/contrib/terminal/electron-sandbox/terminalProfileResolverService.ts +++ b/src/vs/workbench/contrib/terminal/electron-sandbox/terminalProfileResolverService.ts @@ -24,7 +24,7 @@ export class ElectronTerminalProfileResolverService extends BaseTerminalProfileR @ILocalTerminalService localTerminalService: ILocalTerminalService, @IRemoteTerminalService remoteTerminalService: IRemoteTerminalService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, - @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService ) { super( { diff --git a/src/vs/workbench/contrib/terminal/test/node/terminalProfiles.test.ts b/src/vs/workbench/contrib/terminal/test/node/terminalProfiles.test.ts index c282f94cbf0..5e521205775 100644 --- a/src/vs/workbench/contrib/terminal/test/node/terminalProfiles.test.ts +++ b/src/vs/workbench/contrib/terminal/test/node/terminalProfiles.test.ts @@ -58,7 +58,7 @@ suite('Workbench - TerminalProfiles', () => { }, useWslProfiles: false }; - const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(config), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(config), fsProvider, undefined, undefined, undefined); const expected = [ { profileName: 'Git Bash', path: 'C:\\Program Files\\Git\\bin\\bash.exe', args: ['--login'], isDefault: true } ]; @@ -78,7 +78,7 @@ suite('Workbench - TerminalProfiles', () => { }, useWslProfiles: false }; - const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(config), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(config), fsProvider, undefined, undefined, undefined); const expected = [ { profileName: 'PowerShell', path: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', overrideName: true, args: ['-NoProfile'], isDefault: true } ]; @@ -98,7 +98,7 @@ suite('Workbench - TerminalProfiles', () => { }, useWslProfiles: false }; - const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(config), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(config), fsProvider, undefined, undefined, undefined); const expected = [{ profileName: 'Git Bash', path: 'C:\\Program Files\\Git\\bin\\bash.exe', args: [], isAutoDetected: undefined, overrideName: undefined, isDefault: true }]; profilesEqual(profiles, expected); }); @@ -120,7 +120,7 @@ suite('Workbench - TerminalProfiles', () => { 'C:\\Sysnative\\WindowsPowerShell\\v1.0\\powershell.exe', 'C:\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' ]); - const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(pwshSourceConfig), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(pwshSourceConfig), fsProvider, undefined, undefined, undefined); const expected = [ { profileName: 'PowerShell', path: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', isDefault: true } ]; @@ -133,7 +133,7 @@ suite('Workbench - TerminalProfiles', () => { 'C:\\Sysnative\\WindowsPowerShell\\v1.0\\powershell.exe', 'C:\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' ]); - const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(pwshSourceConfig), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(pwshSourceConfig), fsProvider, undefined, undefined, undefined); const expected = [ { profileName: 'PowerShell', path: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', isDefault: true } ]; @@ -144,7 +144,7 @@ suite('Workbench - TerminalProfiles', () => { 'C:\\Windows\\Sysnative\\WindowsPowerShell\\v1.0\\powershell.exe', 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' ]); - const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(pwshSourceConfig), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(pwshSourceConfig), fsProvider, undefined, undefined, undefined); strictEqual(profiles.length, 1); strictEqual(profiles[0].profileName, 'PowerShell'); }); @@ -188,7 +188,7 @@ suite('Workbench - TerminalProfiles', () => { '/bin/fakeshell1', '/bin/fakeshell3' ]); - const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(absoluteConfig), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(absoluteConfig), fsProvider, undefined, undefined, undefined); const expected: ITerminalProfile[] = [ { profileName: 'fakeshell1', path: '/bin/fakeshell1', isDefault: true }, { profileName: 'fakeshell3', path: '/bin/fakeshell3', isDefault: true } @@ -200,7 +200,7 @@ suite('Workbench - TerminalProfiles', () => { '/bin/fakeshell1', '/bin/fakeshell3' ], '/bin/fakeshell1\n/bin/fakeshell3'); - const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(onPathConfig), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(onPathConfig), fsProvider, undefined, undefined, undefined); const expected: ITerminalProfile[] = [ { profileName: 'fakeshell1', path: 'fakeshell1', isDefault: true }, { profileName: 'fakeshell3', path: 'fakeshell3', isDefault: true } @@ -212,7 +212,7 @@ suite('Workbench - TerminalProfiles', () => { const fsProvider = createFsProvider([ '/bin/fakeshell1' ], '/bin/fakeshell1\n/bin/fakeshell3'); - const profiles = await detectAvailableProfiles(false, buildTestSafeConfigProvider(onPathConfig), fsProvider, undefined, undefined, undefined); + const profiles = await detectAvailableProfiles(true, buildTestSafeConfigProvider(onPathConfig), fsProvider, undefined, undefined, undefined); const expected: ITerminalProfile[] = [ { profileName: 'fakeshell1', path: 'fakeshell1', isDefault: true } ]; @@ -226,11 +226,11 @@ suite('Workbench - TerminalProfiles', () => { async existsFile(path: string): Promise { return expectedPaths.includes(path); }, - async readFile(path: string, options: { encoding: BufferEncoding, flag?: string | number } | BufferEncoding): Promise { + async readFile(path: string): Promise { if (path !== '/etc/shells') { fail('Unexected path'); } - return etcShellsContent; + return Buffer.from(etcShellsContent); } }; return provider; diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts index 4e7eda0020c..31f26f29aa9 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts @@ -4,25 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { ObjectTree } from 'vs/base/browser/ui/tree/objectTree'; +import { mapFind } from 'vs/base/common/arrays'; import { Emitter } from 'vs/base/common/event'; import { FuzzyScore } from 'vs/base/common/filters'; +import { Iterable } from 'vs/base/common/iterator'; import { Disposable } from 'vs/base/common/lifecycle'; import { IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; -import { TestItemTreeElement, ITestTreeProjection, IActionableTestTreeElement, TestExplorerTreeElement, TestTreeErrorMessage, isActionableTestTreeElement } from 'vs/workbench/contrib/testing/browser/explorerProjections/index'; -import { ByLocationTestItemElement, ByLocationFolderElement } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes'; +import { ByLocationFolderElement, ByLocationTestItemElement } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes'; +import { IActionableTestTreeElement, isActionableTestTreeElement, ITestTreeProjection, TestExplorerTreeElement, TestItemTreeElement, TestTreeErrorMessage } from 'vs/workbench/contrib/testing/browser/explorerProjections/index'; import { NodeChangeList, NodeRenderDirective, NodeRenderFn, peersHaveChildren } from 'vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper'; -import { IComputedStateAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; +import { IComputedStateAndDurationAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; import { InternalTestItem, TestDiffOpType, TestItemExpandState, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { TestSubscriptionListener } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; -import { mapFind } from 'vs/base/common/arrays'; -import { Iterable } from 'vs/base/common/iterator'; -const computedStateAccessor: IComputedStateAccessor = { +const computedStateAccessor: IComputedStateAndDurationAccessor = { getOwnState: i => i instanceof TestItemTreeElement ? i.ownState : TestResultState.Unset, getCurrentComputedState: i => i.state, setComputedState: (i, s) => i.state = s, + + getCurrentComputedDuration: i => i.duration, + getOwnDuration: i => i instanceof TestItemTreeElement ? i.ownDuration : undefined, + setComputedDuration: (i, d) => i.duration = d, + getChildren: i => Iterable.filter(i.children.values(), isActionableTestTreeElement), *getParents(i) { for (let parent = i.parent; parent; parent = parent.parent) { @@ -72,7 +77,13 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes for (const { items } of this.folders.values()) { for (const inTree of [...items.values()].sort((a, b) => b.depth - a.depth)) { const lookup = this.results.getStateById(inTree.test.item.extId)?.[1]; - const computed = lookup?.computedState ?? TestResultState.Unset; + let computed = TestResultState.Unset; + let ownDuration: number | undefined; + let updated = false; + if (lookup) { + computed = lookup.computedState; + ownDuration = lookup.ownDuration; + } if (lookup) { inTree.ownState = lookup.ownComputedState; @@ -80,6 +91,15 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes if (computed !== inTree.state) { inTree.state = computed; + updated = true; + } + + if (ownDuration !== inTree.ownDuration) { + inTree.ownDuration = ownDuration; + updated = true; + } + + if (updated) { this.addUpdated(inTree); } } @@ -90,12 +110,14 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes // when test states change, reflect in the tree // todo: optimize this to avoid needing to iterate - this._register(results.onTestChanged(({ item: result }) => { + this._register(results.onTestChanged(({ item: result, reason }) => { for (const { items } of this.folders.values()) { const item = items.get(result.item.extId); if (item) { item.retired = result.retired; - refreshComputedState(computedStateAccessor, item, this.addUpdated, result.computedState); + item.ownState = result.ownComputedState; + item.ownDuration = result.ownDuration; + refreshComputedState(computedStateAccessor, item).forEach(this.addUpdated); this.addUpdated(item); this.updateEmitter.fire(); } @@ -265,8 +287,13 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes }; protected unstoreItem(items: Map, treeElement: ByLocationTestItemElement) { - treeElement.parent.children.delete(treeElement); + const parent = treeElement.parent; + parent.children.delete(treeElement); items.delete(treeElement.test.item.extId); + if (parent instanceof ByLocationTestItemElement) { + refreshComputedState(computedStateAccessor, parent).forEach(this.addUpdated); + } + return treeElement.children; } @@ -282,7 +309,9 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes const prevState = this.results.getStateById(treeElement.test.item.extId)?.[1]; if (prevState) { treeElement.retired = prevState.retired; - refreshComputedState(computedStateAccessor, treeElement, this.addUpdated, prevState.computedState); + treeElement.ownState = prevState.computedState; + treeElement.ownDuration = prevState.ownDuration; + refreshComputedState(computedStateAccessor, treeElement).forEach(this.addUpdated); } } } diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts index 2b3d2d871d1..eb66e315e25 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts @@ -87,6 +87,11 @@ export interface IActionableTestTreeElement { */ state: TestResultState; + /** + * Time it took this test/item to run. + */ + duration: number | undefined; + /** * Label for the item. */ @@ -118,6 +123,11 @@ export class TestTreeWorkspaceFolder implements IActionableTestTreeElement { */ public readonly depth = 0; + /** + * Time it took this test/item to run. + */ + public duration: number | undefined; + /** * @inheritdoc */ @@ -207,6 +217,16 @@ export class TestItemTreeElement implements IActionableTestTreeElement { */ public ownState = TestResultState.Unset; + /** + * Own, non-computed duration. + */ + public ownDuration: number | undefined; + + /** + * Time it took this test/item to run. + */ + public duration: number | undefined; + /** * @inheritdoc */ diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index 6cba69714ed..75ed69346af 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -60,10 +60,10 @@ const enum ActionOrder { } export class HideTestAction extends Action2 { - constructor( - ) { + public static readonly ID = 'testing.hideTest'; + constructor() { super({ - id: 'testing.hideTest', + id: HideTestAction.ID, title: localize('hideTest', 'Hide Test'), f1: false, menu: { @@ -85,9 +85,10 @@ export class HideTestAction extends Action2 { } export class UnhideTestAction extends Action2 { + public static readonly ID = 'testing.unhideTest'; constructor() { super({ - id: 'testing.unhideTest', + id: UnhideTestAction.ID, title: localize('unhideTest', 'Unhide Test'), f1: false, menu: { @@ -109,9 +110,10 @@ export class UnhideTestAction extends Action2 { } export class DebugAction extends Action2 { + public static readonly ID = 'testing.debug'; constructor() { super({ - id: 'testing.debug', + id: DebugAction.ID, title: localize('debug test', 'Debug Test'), icon: icons.testingDebugIcon, f1: false, @@ -134,9 +136,10 @@ export class DebugAction extends Action2 { export class RunAction extends Action2 { + public static readonly ID = 'testing.run'; constructor() { super({ - id: 'testing.run', + id: RunAction.ID, title: localize('run test', 'Run Test'), icon: icons.testingRunIcon, f1: false, @@ -211,10 +214,11 @@ abstract class RunOrDebugSelectedAction extends ViewAction } export class RunSelectedAction extends RunOrDebugSelectedAction { - constructor( - ) { + public static readonly ID = 'testing.runSelected'; + + constructor() { super( - 'testing.runSelected', + RunSelectedAction.ID, localize('runSelectedTests', 'Run Selected Tests'), icons.testingRunIcon, false, @@ -230,9 +234,10 @@ export class RunSelectedAction extends RunOrDebugSelectedAction { } export class DebugSelectedAction extends RunOrDebugSelectedAction { + public static readonly ID = 'testing.debugSelected'; constructor() { super( - 'testing.debugSelected', + DebugSelectedAction.ID, localize('debugSelectedTests', 'Debug Selected Tests'), icons.testingDebugIcon, true, @@ -314,9 +319,10 @@ abstract class RunOrDebugAllAllAction extends Action2 { } export class RunAllAction extends RunOrDebugAllAllAction { + public static readonly ID = 'testing.runAll'; constructor() { super( - 'testing.runAll', + RunAllAction.ID, localize('runAllTests', 'Run All Tests'), icons.testingRunAllIcon, false, @@ -326,9 +332,10 @@ export class RunAllAction extends RunOrDebugAllAllAction { } export class DebugAllAction extends RunOrDebugAllAllAction { + public static readonly ID = 'testing.debugAll'; constructor() { super( - 'testing.debugAll', + DebugAllAction.ID, localize('debugAllTests', 'Debug All Tests'), icons.testingDebugIcon, true, @@ -338,9 +345,10 @@ export class DebugAllAction extends RunOrDebugAllAllAction { } export class CancelTestRunAction extends Action2 { + public static readonly ID = 'testing.cancelRun'; constructor() { super({ - id: 'testing.cancelRun', + id: CancelTestRunAction.ID, title: localize('testing.cancelRun', "Cancel Test Run"), icon: icons.testingCancelIcon, menu: { @@ -367,9 +375,10 @@ export class CancelTestRunAction extends Action2 { } export class TestingViewAsListAction extends ViewAction { + public static readonly ID = 'testing.viewAsList'; constructor() { super({ - id: 'testing.viewAsList', + id: TestingViewAsListAction.ID, viewId: Testing.ExplorerViewId, title: localize('testing.viewAsList', "View as List"), f1: false, @@ -392,9 +401,10 @@ export class TestingViewAsListAction extends ViewAction { } export class TestingViewAsTreeAction extends ViewAction { + public static readonly ID = 'testing.viewAsTree'; constructor() { super({ - id: 'testing.viewAsTree', + id: TestingViewAsTreeAction.ID, viewId: Testing.ExplorerViewId, title: localize('testing.viewAsTree', "View as Tree"), f1: false, @@ -418,9 +428,10 @@ export class TestingViewAsTreeAction extends ViewAction { export class TestingSortByNameAction extends ViewAction { + public static readonly ID = 'testing.sortByName'; constructor() { super({ - id: 'testing.sortByName', + id: TestingSortByNameAction.ID, viewId: Testing.ExplorerViewId, title: localize('testing.sortByName', "Sort by Name"), f1: false, @@ -443,9 +454,10 @@ export class TestingSortByNameAction extends ViewAction { } export class TestingSortByLocationAction extends ViewAction { + public static readonly ID = 'testing.sortByLocation'; constructor() { super({ - id: 'testing.sortByLocation', + id: TestingSortByLocationAction.ID, viewId: Testing.ExplorerViewId, title: localize('testing.sortByLocation', "Sort by Location"), f1: false, @@ -468,9 +480,10 @@ export class TestingSortByLocationAction extends ViewAction } export class ShowMostRecentOutputAction extends Action2 { + public static readonly ID = 'testing.showMostRecentOutput'; constructor() { super({ - id: 'testing.showMostRecentOutput', + id: ShowMostRecentOutputAction.ID, title: localize('testing.showMostRecentOutput', "Show Output"), f1: true, category, @@ -492,9 +505,10 @@ export class ShowMostRecentOutputAction extends Action2 { export class CollapseAllAction extends ViewAction { + public static readonly ID = 'testing.collapseAll'; constructor() { super({ - id: 'testing.collapseAll', + id: CollapseAllAction.ID, viewId: Testing.ExplorerViewId, title: localize('testing.collapseAll', "Collapse All Tests"), f1: false, @@ -517,9 +531,10 @@ export class CollapseAllAction extends ViewAction { } export class RefreshTestsAction extends Action2 { + public static readonly ID = 'testing.refreshTests'; constructor() { super({ - id: 'testing.refreshTests', + id: RefreshTestsAction.ID, title: localize('testing.refresh', "Refresh Tests"), category, f1: true, @@ -541,9 +556,10 @@ export class RefreshTestsAction extends Action2 { } export class ClearTestResultsAction extends Action2 { + public static readonly ID = 'testing.clearTestResults'; constructor() { super({ - id: 'testing.clearTestResults', + id: ClearTestResultsAction.ID, title: localize('testing.clearResults', "Clear All Results"), category, f1: true @@ -559,9 +575,10 @@ export class ClearTestResultsAction extends Action2 { } export class GoToTest extends Action2 { + public static readonly ID = 'testing.editFocusedTest'; constructor() { super({ - id: 'testing.editFocusedTest', + id: GoToTest.ID, title: localize('testing.editFocusedTest', "Go to Test"), f1: false, menu: { @@ -676,9 +693,11 @@ export class GoToTest extends Action2 { } abstract class ToggleAutoRun extends Action2 { + public static readonly ID = 'testing.toggleautoRun'; + constructor(title: string, whenToggleIs: boolean) { super({ - id: 'testing.toggleautoRun', + id: ToggleAutoRun.ID, title, f1: true, icon: icons.testingAutorunIcon, @@ -767,9 +786,10 @@ abstract class RunOrDebugAtCursor extends Action2 { } export class RunAtCursor extends RunOrDebugAtCursor { + public static readonly ID = 'testing.runAtCursor'; constructor() { super({ - id: 'testing.runAtCursor', + id: RunAtCursor.ID, title: localize('testing.runAtCursor', "Run Test at Cursor"), f1: true, category, @@ -789,9 +809,10 @@ export class RunAtCursor extends RunOrDebugAtCursor { } export class DebugAtCursor extends RunOrDebugAtCursor { + public static readonly ID = 'testing.debugAtCursor'; constructor() { super({ - id: 'testing.debugAtCursor', + id: DebugAtCursor.ID, title: localize('testing.debugAtCursor', "Debug Test at Cursor"), f1: true, category, @@ -847,9 +868,10 @@ abstract class RunOrDebugCurrentFile extends Action2 { } export class RunCurrentFile extends RunOrDebugCurrentFile { + public static readonly ID = 'testing.runCurrentFile'; constructor() { super({ - id: 'testing.runCurrentFile', + id: RunCurrentFile.ID, title: localize('testing.runCurrentFile', "Run Tests in Current File"), f1: true, category, @@ -869,9 +891,10 @@ export class RunCurrentFile extends RunOrDebugCurrentFile { } export class DebugCurrentFile extends RunOrDebugCurrentFile { + public static readonly ID = 'testing.debugCurrentFile'; constructor() { super({ - id: 'testing.debugCurrentFile', + id: DebugCurrentFile.ID, title: localize('testing.debugCurrentFile', "Debug Tests in Current File"), f1: true, category, @@ -967,9 +990,10 @@ abstract class RunOrDebugLastRun extends RunOrDebugExtsById { } export class ReRunFailedTests extends RunOrDebugFailedTests { + public static readonly ID = 'testing.reRunFailTests'; constructor() { super({ - id: 'testing.reRunFailTests', + id: ReRunFailedTests.ID, title: localize('testing.reRunFailTests', "Rerun Failed Tests"), f1: true, category, @@ -989,9 +1013,10 @@ export class ReRunFailedTests extends RunOrDebugFailedTests { } export class DebugFailedTests extends RunOrDebugFailedTests { + public static readonly ID = 'testing.debugFailTests'; constructor() { super({ - id: 'testing.debugFailTests', + id: DebugFailedTests.ID, title: localize('testing.debugFailTests', "Debug Failed Tests"), f1: true, category, @@ -1011,9 +1036,10 @@ export class DebugFailedTests extends RunOrDebugFailedTests { } export class ReRunLastRun extends RunOrDebugLastRun { + public static readonly ID = 'testing.reRunLastRun'; constructor() { super({ - id: 'testing.reRunLastRun', + id: ReRunLastRun.ID, title: localize('testing.reRunLastRun', "Rerun Last Run"), f1: true, category, @@ -1033,9 +1059,10 @@ export class ReRunLastRun extends RunOrDebugLastRun { } export class DebugLastRun extends RunOrDebugLastRun { + public static readonly ID = 'testing.debugLastRun'; constructor() { super({ - id: 'testing.debugLastRun', + id: DebugLastRun.ID, title: localize('testing.debugLastRun', "Debug Last Run"), f1: true, category, @@ -1055,9 +1082,10 @@ export class DebugLastRun extends RunOrDebugLastRun { } export class SearchForTestExtension extends Action2 { + public static readonly ID = 'testing.searchForTestExtension'; constructor() { super({ - id: 'testing.searchForTestExtension', + id: SearchForTestExtension.ID, title: localize('testing.searchForTestExtension', "Search for Test Extension"), f1: false, }); @@ -1070,3 +1098,37 @@ export class SearchForTestExtension extends Action2 { viewlet.focus(); } } + +export const allTestActions = [ + AutoRunOffAction, + AutoRunOnAction, + CancelTestRunAction, + ClearTestResultsAction, + CollapseAllAction, + DebugAction, + DebugAllAction, + DebugAtCursor, + DebugCurrentFile, + DebugFailedTests, + DebugLastRun, + DebugSelectedAction, + GoToTest, + HideTestAction, + RefreshTestsAction, + ReRunFailedTests, + ReRunLastRun, + RunAction, + RunAllAction, + RunAtCursor, + RunCurrentFile, + RunSelectedAction, + SearchForTestExtension, + ShowMostRecentOutputAction, + TestingSortByLocationAction, + TestingSortByNameAction, + TestingViewAsListAction, + TestingViewAsTreeAction, + UnhideTestAction, +]; + +export const internalTestActionIds = new Set(allTestActions.map(a => a.ID)); diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts index a3587ce2d74..15721c66065 100644 --- a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -35,7 +35,7 @@ import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { TestService } from 'vs/workbench/contrib/testing/common/testServiceImpl'; import { IWorkspaceTestCollectionService, WorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import * as Action from './testExplorerActions'; +import { allTestActions } from './testExplorerActions'; registerSingleton(ITestService, TestService); registerSingleton(ITestResultStorage, TestResultStorage); @@ -89,35 +89,7 @@ viewsRegistry.registerViews([{ when: ContextKeyExpr.greater(TestingContextKeys.providerCount.key, 0), }], viewContainer); -registerAction2(Action.AutoRunOffAction); -registerAction2(Action.AutoRunOnAction); -registerAction2(Action.CancelTestRunAction); -registerAction2(Action.ClearTestResultsAction); -registerAction2(Action.CollapseAllAction); -registerAction2(Action.DebugAction); -registerAction2(Action.DebugAllAction); -registerAction2(Action.DebugAtCursor); -registerAction2(Action.DebugCurrentFile); -registerAction2(Action.DebugFailedTests); -registerAction2(Action.DebugLastRun); -registerAction2(Action.DebugSelectedAction); -registerAction2(Action.GoToTest); -registerAction2(Action.HideTestAction); -registerAction2(Action.RefreshTestsAction); -registerAction2(Action.ReRunFailedTests); -registerAction2(Action.ReRunLastRun); -registerAction2(Action.RunAction); -registerAction2(Action.RunAllAction); -registerAction2(Action.RunAtCursor); -registerAction2(Action.RunCurrentFile); -registerAction2(Action.RunSelectedAction); -registerAction2(Action.SearchForTestExtension); -registerAction2(Action.ShowMostRecentOutputAction); -registerAction2(Action.TestingSortByLocationAction); -registerAction2(Action.TestingSortByNameAction); -registerAction2(Action.TestingViewAsListAction); -registerAction2(Action.TestingViewAsTreeAction); -registerAction2(Action.UnhideTestAction); +allTestActions.forEach(registerAction2); registerAction2(CloseTestPeek); Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingContentProvider, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts index aaafab933c9..9d98c778a2e 100644 --- a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { renderStringAsPlaintext } from 'vs/base/browser/markdownRenderer'; import { Action, IAction, Separator } from 'vs/base/common/actions'; import { Event } from 'vs/base/common/event'; import { MarkdownString } from 'vs/base/common/htmlContent'; @@ -393,7 +394,7 @@ class TestMessageDecoration implements ITestDecoration { const colorTheme = themeService.getColorTheme(); editorService.registerDecorationType(this.decorationId, { after: { - contentText: message.toString(), + contentText: renderStringAsPlaintext(message), color: `${colorTheme.getColor(testMessageSeverityColors[severity].decorationForeground)}`, fontSize: `${editor.getOption(EditorOption.fontSize)}px`, fontFamily: `var(${FONT_FAMILY_VAR})`, diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index d289bc892d6..8d7fff00645 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -63,7 +63,7 @@ import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResu import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { IWorkspaceTestCollectionService, TestSubscriptionListener } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { GoToTest } from './testExplorerActions'; +import { GoToTest, internalTestActionIds } from './testExplorerActions'; export class TestingExplorerView extends ViewPane { public viewModel!: TestingExplorerViewModel; @@ -811,7 +811,14 @@ class TestExplorerActionRunner extends ActionRunner { const selection = this.getSelectedTests(); const contextIsSelected = selection.some(s => s === context); const actualContext = contextIsSelected ? selection : [context]; - await action.run(...actualContext.filter(isActionableTestTreeElement)); + const actionable = actualContext.filter(isActionableTestTreeElement); + + // Is there a better way to do this? + if (internalTestActionIds.has(action.id)) { + await action.run(...actionable); + } else { + await action.run(...actionable.map(a => a instanceof TestItemTreeElement ? a.test.item.extId : a.folder.uri)); + } } } @@ -821,11 +828,20 @@ const getLabelForTestTreeElement = (element: IActionableTestTreeElement) => { comment: ['label then the unit tests state, for example "Addition Tests (Running)"'], }, '{0} ({1})', element.label, testStateNames[element.state]); - if (element instanceof TestItemTreeElement && element.retired) { - label = localize({ - key: 'testing.treeElementLabelOutdated', - comment: ['{0} is the original label in testing.treeElementLabel'], - }, '{0}, outdated result', label, testStateNames[element.state]); + if (element instanceof TestItemTreeElement) { + if (element.duration !== undefined) { + label = localize({ + key: 'testing.treeElementLabelDuration', + comment: ['{0} is the original label in testing.treeElementLabel, {1} is a duration'], + }, '{0}, in {1}', label, formatDuration(element.duration)); + } + + if (element.retired) { + label = localize({ + key: 'testing.treeElementLabelOutdated', + comment: ['{0} is the original label in testing.treeElementLabel'], + }, '{0}, outdated result', label, testStateNames[element.state]); + } } return label; @@ -1036,10 +1052,27 @@ class TestItemRenderer extends ActionableItemTemplateData { options.title = getLabelForTestTreeElement(node.element); options.fileKind = FileKind.FILE; label.description = node.element.description || undefined; + + if (node.element.duration) { + label.description = label.description + ? `${label.description}: ${formatDuration(node.element.duration)}` + : formatDuration(node.element.duration); + } + data.label.setResource(label, options); } } +const formatDuration = (ms: number) => { + if (ms < 10) { + return `${ms.toPrecision(2)}ms`; + } else if (ms < 1000) { + return `${ms.toPrecision(3)}ms`; + } else { + return `${(ms / 1000).toPrecision(3)}s`; + } +}; + class WorkspaceFolderRenderer extends ActionableItemTemplateData { public static readonly ID = 'workspaceFolder'; diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index 03b6cadad54..9b930df9f76 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -4,11 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; -import { renderMarkdownAsPlaintext } from 'vs/base/browser/markdownRenderer'; +import { renderStringAsPlaintext } from 'vs/base/browser/markdownRenderer'; import { alert } from 'vs/base/browser/ui/aria/aria'; import { Codicon } from 'vs/base/common/codicons'; import { Color } from 'vs/base/common/color'; -import { IMarkdownString } from 'vs/base/common/htmlContent'; import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable, IReference, MutableDisposable } from 'vs/base/common/lifecycle'; import { clamp } from 'vs/base/common/numbers'; @@ -249,7 +248,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo this.peek.value!.create(); } - alert(toPlainText(message.message)); + alert(renderStringAsPlaintext(message.message)); this.peek.value!.setModel(dto); this.currentPeekUri = uri; } @@ -416,7 +415,7 @@ class TestingDiffOutputPeek extends TestingOutputPeek { this.test = test; this.show(message.location.range, hintDiffPeekHeight(message)); - this.setTitle(firstLine(toPlainText(message.message)), test.label); + this.setTitle(firstLine(renderStringAsPlaintext(message.message)), test.label); const [original, modified] = await Promise.all([ this.modelService.createModelReference(expectedUri), @@ -488,7 +487,7 @@ class TestingMessageOutputPeek extends TestingOutputPeek { this.test = test; - const messageStr = toPlainText(message.message); + const messageStr = renderStringAsPlaintext(message.message); this.show(message.location.range, hintPeekStrHeight(messageStr)); this.setTitle(firstLine(messageStr), test.label); @@ -526,10 +525,6 @@ class TestingMessageOutputPeek extends TestingOutputPeek { const hintDiffPeekHeight = (message: ITestMessage) => Math.max(hintPeekStrHeight(message.actualOutput), hintPeekStrHeight(message.expectedOutput)); -const toPlainText = (message: IMarkdownString | string) => typeof message === 'string' - ? message - : renderMarkdownAsPlaintext(message); - const firstLine = (str: string) => { const index = str.indexOf('\n'); return index === -1 ? str : str.slice(0, index); diff --git a/src/vs/workbench/contrib/testing/common/getComputedState.ts b/src/vs/workbench/contrib/testing/common/getComputedState.ts index e7400f3f700..34042e3a71a 100644 --- a/src/vs/workbench/contrib/testing/common/getComputedState.ts +++ b/src/vs/workbench/contrib/testing/common/getComputedState.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Iterable } from 'vs/base/common/iterator'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; import { maxPriority, statePriority } from 'vs/workbench/contrib/testing/common/testingStates'; @@ -17,6 +18,14 @@ export interface IComputedStateAccessor { getParents(item: T): Iterable; } +export interface IComputedStateAndDurationAccessor extends IComputedStateAccessor { + getOwnDuration(item: T): number | undefined; + getCurrentComputedDuration(item: T): number | undefined; + setComputedDuration(item: T, duration: number): void; +} + +export const isDurationAccessor = (accessor: IComputedStateAccessor): accessor is IComputedStateAndDurationAccessor => 'getOwnDuration' in accessor; + /** * Gets the computed state for the node. * @param force whether to refresh the computed state for this node, even @@ -36,49 +45,82 @@ export const getComputedState = (accessor: IComputedStateAccessor, node: T return computed; }; + +export const getComputedDuration = (accessor: IComputedStateAndDurationAccessor, node: T, force = false): number => { + let computed = accessor.getCurrentComputedDuration(node); + if (computed === undefined || force) { + const own = accessor.getOwnDuration(node); + if (own !== undefined) { + computed = own; + } else { + computed = 0; + for (const child of accessor.getChildren(node)) { + computed += getComputedDuration(accessor, child); + } + } + + accessor.setComputedDuration(node, computed); + } + + return computed; +}; + /** * Refreshes the computed state for the node and its parents. Any changes * elements cause `addUpdated` to be called. */ - export const refreshComputedState = ( accessor: IComputedStateAccessor, node: T, - addUpdated: (node: T) => void, explicitNewComputedState?: TestResultState, ) => { const oldState = accessor.getCurrentComputedState(node); const oldPriority = statePriority[oldState]; const newState = explicitNewComputedState ?? getComputedState(accessor, node, true); const newPriority = statePriority[newState]; - if (newPriority === oldPriority) { - return; + const toUpdate = new Set(); + + if (newPriority !== oldPriority) { + accessor.setComputedState(node, newState); + toUpdate.add(node); + + if (newPriority > oldPriority) { + // Update all parents to ensure they're at least this priority. + for (const parent of accessor.getParents(node)) { + const prev = accessor.getCurrentComputedState(parent); + if (prev !== undefined && statePriority[prev] >= newPriority) { + break; + } + + accessor.setComputedState(parent, newState); + toUpdate.add(parent); + } + } else if (newPriority < oldPriority) { + // Re-render all parents of this node whose computed priority might have come from this node + for (const parent of accessor.getParents(node)) { + const prev = accessor.getCurrentComputedState(parent); + if (prev === undefined || statePriority[prev] > oldPriority) { + break; + } + + accessor.setComputedState(parent, getComputedState(accessor, parent, true)); + toUpdate.add(parent); + } + } } - accessor.setComputedState(node, newState); - addUpdated(node); - - if (newPriority > oldPriority) { - // Update all parents to ensure they're at least this priority. - for (const parent of accessor.getParents(node)) { - const prev = accessor.getCurrentComputedState(parent); - if (prev !== undefined && statePriority[prev] >= newPriority) { + if (isDurationAccessor(accessor)) { + for (const parent of Iterable.concat(Iterable.single(node), accessor.getParents(node))) { + const oldDuration = accessor.getCurrentComputedDuration(node); + const newDuration = getComputedDuration(accessor, node, true); + if (oldDuration === newDuration) { break; } - accessor.setComputedState(parent, newState); - addUpdated(parent); - } - } else if (newPriority < oldPriority) { - // Re-render all parents of this node whose computed priority might have come from this node - for (const parent of accessor.getParents(node)) { - const prev = accessor.getCurrentComputedState(parent); - if (prev === undefined || statePriority[prev] > oldPriority) { - break; - } - - accessor.setComputedState(parent, getComputedState(accessor, parent, true)); - addUpdated(parent); + accessor.setComputedDuration(parent, newState); + toUpdate.add(parent); } } + + return toUpdate; }; diff --git a/src/vs/workbench/contrib/testing/common/testCollection.ts b/src/vs/workbench/contrib/testing/common/testCollection.ts index e08df28a267..55c05ea8fd1 100644 --- a/src/vs/workbench/contrib/testing/common/testCollection.ts +++ b/src/vs/workbench/contrib/testing/common/testCollection.ts @@ -148,6 +148,8 @@ export interface TestResultItem { computedState: TestResultState; /** True if the test is outdated */ retired: boolean; + /** Max duration of the item's tasks (if run directly) */ + ownDuration?: number; /** True if the test was directly requested by the run (is not a child or parent) */ direct?: boolean; } diff --git a/src/vs/workbench/contrib/testing/common/testResult.ts b/src/vs/workbench/contrib/testing/common/testResult.ts index 2bd41541af5..bfb75fcd54a 100644 --- a/src/vs/workbench/contrib/testing/common/testResult.ts +++ b/src/vs/workbench/contrib/testing/common/testResult.ts @@ -344,6 +344,7 @@ export class LiveTestResult implements ITestResult { const index = this.mustGetTaskIndex(taskId); if (duration !== undefined) { entry.tasks[index].duration = duration; + entry.ownDuration = Math.max(entry.ownDuration || 0, duration); } this.fireUpdateAndRefresh(entry, index, state); @@ -463,7 +464,7 @@ export class LiveTestResult implements ITestResult { entry.ownComputedState = newOwnComputed; this.counts[previousOwnComputed]--; this.counts[newOwnComputed]++; - refreshComputedState(this.computedStateAccessor, entry, t => + refreshComputedState(this.computedStateAccessor, entry).forEach(t => this.changeEmitter.fire( t === entry ? { item: entry, result: this, reason: TestResultItemChangeReason.OwnStateChange, previous: previousOwnComputed } diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index e294db7c023..473dbf7f0d9 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -275,10 +275,18 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu if (state.type === StateType.AvailableForDownload || state.type === StateType.Downloaded || state.type === StateType.Ready) { badge = new NumberBadge(1, () => nls.localize('updateIsReady', "New {0} update available.", this.productService.nameShort)); - } else if (state.type === StateType.CheckingForUpdates || state.type === StateType.Downloading || state.type === StateType.Updating) { + } else if (state.type === StateType.CheckingForUpdates) { badge = new ProgressBadge(() => nls.localize('checkingForUpdates', "Checking for Updates...")); clazz = 'progress-badge'; priority = 1; + } else if (state.type === StateType.Downloading) { + badge = new ProgressBadge(() => nls.localize('downloading', "Downloading...")); + clazz = 'progress-badge'; + priority = 1; + } else if (state.type === StateType.Updating) { + badge = new ProgressBadge(() => nls.localize('updating', "Updating...")); + clazz = 'progress-badge'; + priority = 1; } this.badgeDisposable.clear(); diff --git a/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts b/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts index 414c0799974..c3e8a1eedde 100644 --- a/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts +++ b/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts @@ -22,6 +22,7 @@ import { IdleValue } from 'vs/base/common/async'; import { IAuthenticationService } from 'vs/workbench/services/authentication/browser/authenticationService'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { testUrlMatchesGlob } from 'vs/workbench/contrib/url/common/urlGlob'; +import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; type TrustedDomainsDialogActionClassification = { action: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; @@ -44,6 +45,7 @@ export class OpenerValidatorContributions implements IWorkbenchContribution { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustService: IWorkspaceTrustManagementService, ) { this._openerService.registerValidator({ shouldOpen: r => this.validateLink(r) }); @@ -69,6 +71,10 @@ export class OpenerValidatorContributions implements IWorkbenchContribution { return true; } + if (this._workspaceTrustService.isWorkpaceTrusted()) { + return true; + } + const originalResource = resource; if (typeof resource === 'string') { resource = URI.parse(resource); diff --git a/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts b/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts index 7fabfa93909..82de2459325 100644 --- a/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts @@ -23,9 +23,9 @@ import { ILogService } from 'vs/platform/log/common/log'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IRemoteAuthorityResolverService } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { ITunnelService } from 'vs/platform/remote/common/tunnel'; -import { IRequestService } from 'vs/platform/request/common/request'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { WebviewPortMappingManager } from 'vs/platform/webview/common/webviewPortMapping'; +import { asWebviewUri, webviewGenericCspSource, webviewRootResourceAuthority } from 'vs/workbench/api/common/shared/webview'; import { loadLocalResource, WebviewResourceResponse } from 'vs/workbench/contrib/webview/browser/resourceLoading'; import { WebviewThemeDataProvider } from 'vs/workbench/contrib/webview/browser/themeing'; import { areWebviewContentOptionsEqual, WebviewContentOptions, WebviewExtensionDescription, WebviewMessageReceivedEvent, WebviewOptions } from 'vs/workbench/contrib/webview/browser/webview'; @@ -100,7 +100,6 @@ export abstract class BaseWebview extends Disposable { private readonly _fileService: IFileService; private readonly _logService: ILogService; private readonly _remoteAuthorityResolverService: IRemoteAuthorityResolverService; - private readonly _requestService: IRequestService; private readonly _telemetryService: ITelemetryService; private readonly _tunnelService: ITunnelService; protected readonly _environmentService: IWorkbenchEnvironmentService; @@ -122,7 +121,6 @@ export abstract class BaseWebview extends Disposable { menuService: IMenuService, notificationService: INotificationService, remoteAuthorityResolverService: IRemoteAuthorityResolverService, - requestService: IRequestService, telemetryService: ITelemetryService, tunnelService: ITunnelService, } @@ -133,7 +131,6 @@ export abstract class BaseWebview extends Disposable { this._fileService = services.fileService; this._logService = services.logService; this._remoteAuthorityResolverService = services.remoteAuthorityResolverService; - this._requestService = services.requestService; this._telemetryService = services.telemetryService; this._tunnelService = services.tunnelService; @@ -242,21 +239,22 @@ export abstract class BaseWebview extends Disposable { }); })); - this._register(this.on(WebviewMessageChannels.loadResource, (entry: { id: number, path: string, query: string, ifNoneMatch?: string }) => { - const rawPath = entry.path; - // ext-authority / scheme / path-authority / ...path - const match = rawPath.match(/^\/([^\/]*)\/([^\/]*)\/([^\/]*)(\/.+)$/); - if (!match) { - throw new Error('Could not parse resource url'); + this._register(this.on(WebviewMessageChannels.loadResource, (entry: { id: number, path: string, query: string, scheme: string, authority: string, ifNoneMatch?: string }) => { + try { + const uri = URI.from({ + scheme: entry.scheme, + authority: entry.authority, + path: entry.path, + query: entry.query, + }); + this.loadResource(entry.id, uri, entry.ifNoneMatch); + } catch (e) { + this._send('did-load-resource', { + id, + status: 404, + path: entry.path, + }); } - - const [_, remoteAuthority, scheme, pathAuthority, paths] = match; - - const uri = URI.parse(`${scheme}://${decodeURIComponent(pathAuthority)}${paths}`).with({ - query: decodeURIComponent(entry.query), - }); - - this.loadResource(entry.id, rawPath, uri, decodeURIComponent(remoteAuthority), entry.ifNoneMatch); })); this._register(this.on(WebviewMessageChannels.loadLocalhost, (entry: any) => { @@ -377,16 +375,29 @@ export abstract class BaseWebview extends Disposable { }); } - protected abstract get webviewResourceEndpoint(): string; + protected get webviewRootResourceAuthority(): string { + return webviewRootResourceAuthority; + } private rewriteVsCodeResourceUrls(value: string): string { - const remoteAuthority = this.extension?.location.scheme === Schemas.vscodeRemote ? this.extension.location.authority : ''; + const isRemote = this.extension?.location.scheme === Schemas.vscodeRemote; + const remoteAuthority = this.extension?.location.scheme === Schemas.vscodeRemote ? this.extension.location.authority : undefined; return value .replace(/(["'])(?:vscode-resource):(\/\/([^\s\/'"]+?)(?=\/))?([^\s'"]+?)(["'])/gi, (_match, startQuote, _1, scheme, path, endQuote) => { - return `${startQuote}${this.webviewResourceEndpoint}/vscode-resource/${remoteAuthority}/${scheme ?? 'file'}/${path}${endQuote}`; + const uri = URI.from({ + scheme: scheme || 'file', + path: path, + }); + const webviewUri = asWebviewUri(uri, { isRemote, authority: remoteAuthority }).toString(); + return `${startQuote}${webviewUri}${endQuote}`; }) .replace(/(["'])(?:vscode-webview-resource):(\/\/[^\s\/'"]+\/([^\s\/'"]+?)(?=\/))?([^\s'"]+?)(["'])/gi, (_match, startQuote, _1, scheme, path, endQuote) => { - return `${startQuote}${this.webviewResourceEndpoint}/vscode-resource/${remoteAuthority}/${scheme ?? 'file'}/${path}${endQuote}`; + const uri = URI.from({ + scheme: scheme || 'file', + path: path, + }); + const webviewUri = asWebviewUri(uri, { isRemote, authority: remoteAuthority }).toString(); + return `${startQuote}${webviewUri}${endQuote}`; }); } @@ -433,7 +444,7 @@ export abstract class BaseWebview extends Disposable { contents: this.content.html, options: this.content.options, state: this.content.state, - resourceEndpoint: this.webviewResourceEndpoint, + cspSource: webviewGenericCspSource, ...this.extraContentOptions }); } @@ -512,15 +523,12 @@ export abstract class BaseWebview extends Disposable { } } - private async loadResource(id: number, requestPath: string, uri: URI, remoteAuthority: string | undefined, ifNoneMatch: string | undefined) { + private async loadResource(id: number, uri: URI, ifNoneMatch: string | undefined) { try { - const remoteConnectionData = remoteAuthority ? this._remoteAuthorityResolverService.getConnectionData(remoteAuthority) : null; - - const result = await loadLocalResource(uri, ifNoneMatch, { + const result = await loadLocalResource(uri, { + ifNoneMatch, roots: this.content.options.localResourceRoots || [], - remoteConnectionData, - remoteAuthority: remoteAuthority, - }, this._fileService, this._requestService, this._logService, this._resourceLoadingCts.token); + }, this._fileService, this._logService, this._resourceLoadingCts.token); switch (result.type) { case WebviewResourceResponse.Type.Success: @@ -529,7 +537,7 @@ export abstract class BaseWebview extends Disposable { return this._send('did-load-resource', { id, status: 200, - path: requestPath, + path: uri.path, mime: result.mimeType, data: buffer, etag: result.etag, @@ -540,7 +548,7 @@ export abstract class BaseWebview extends Disposable { return this._send('did-load-resource', { id, status: 304, // not modified - path: requestPath, + path: uri.path, mime: result.mimeType, }); } @@ -549,7 +557,7 @@ export abstract class BaseWebview extends Disposable { return this._send('did-load-resource', { id, status: 401, // unauthorized - path: requestPath, + path: uri.path, }); } } @@ -560,7 +568,7 @@ export abstract class BaseWebview extends Disposable { return this._send('did-load-resource', { id, status: 404, - path: requestPath + path: uri.path, }); } diff --git a/src/vs/workbench/contrib/webview/browser/pre/host.js b/src/vs/workbench/contrib/webview/browser/pre/host.js index 8310f417ca0..7e773adefab 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/host.js +++ b/src/vs/workbench/contrib/webview/browser/pre/host.js @@ -6,8 +6,13 @@ import { createWebviewManager } from './main.js'; -const id = document.location.search.match(/\bid=([\w-]+)/)[1]; -const onElectron = /platform=electron/.test(document.location.search); +const searchParams = new URL(location.toString()).searchParams; +const id = searchParams.get('id'); +if (!id) { + throw new Error('Could not resolve webview id. Webview will not work.\nThis is usually caused by incorrectly trying to navigate in a webview'); +} + +const onElectron = searchParams.get('platform') === 'electron'; const hostMessaging = new class HostMessaging { constructor() { diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js index a31f57f26ce..6e7e3273000 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/main.js +++ b/src/vs/workbench/contrib/webview/browser/pre/main.js @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ // @ts-check +/// + /** * @typedef {{ * postMessage: (channel: string, data?: any) => void, - * onMessage: (channel: string, handler: any) => void, + * onMessage: (channel: string, handler: (event: MessageEvent, data: any) => void) => void, * focusIframeOnCreate?: boolean, * ready?: Promise, * onIframeLoaded?: (iframe: HTMLIFrameElement) => void, @@ -56,6 +58,18 @@ const getPendingFrame = () => { return /** @type {HTMLIFrameElement} */ (document.getElementById('pending-frame')); }; +/** + * @template T + * @param {T | undefined | null} obj + * @return {T} + */ +function assertIsDefined(obj) { + if (typeof obj === 'undefined' || obj === null) { + throw new Error('Found unexpected null'); + } + return obj; +} + const vscodePostMessageFuncName = '__vscode_post_message__'; const defaultStyles = document.createElement('style'); @@ -202,6 +216,9 @@ const workerReady = new Promise(async (resolve, reject) => { async registration => { await navigator.serviceWorker.ready; + /** + * @param {MessageEvent} event + */ const versionHandler = (event) => { if (event.data.channel !== 'version') { return; @@ -218,7 +235,7 @@ const workerReady = new Promise(async (resolve, reject) => { } }; navigator.serviceWorker.addEventListener('message', versionHandler); - registration.active.postMessage({ channel: 'version' }); + assertIsDefined(registration.active).postMessage({ channel: 'version' }); }, error => { reject(new Error(`Could not register service workers: ${error}.`)); @@ -231,6 +248,7 @@ const workerReady = new Promise(async (resolve, reject) => { export async function createWebviewManager(host) { // state let firstLoad = true; + /** @type {any} */ let loadTimeout; let styleVersion = 0; @@ -241,7 +259,7 @@ export async function createWebviewManager(host) { /** @type {number | undefined} */ initialScrollProgress: undefined, - /** @type {{ [key: string]: string }} */ + /** @type {{ [key: string]: string } | undefined} */ styles: undefined, /** @type {string | undefined} */ @@ -253,13 +271,13 @@ export async function createWebviewManager(host) { host.onMessage('did-load-resource', (_event, data) => { navigator.serviceWorker.ready.then(registration => { - registration.active.postMessage({ channel: 'did-load-resource', data }, data.data?.buffer ? [data.data.buffer] : []); + assertIsDefined(registration.active).postMessage({ channel: 'did-load-resource', data }, data.data?.buffer ? [data.data.buffer] : []); }); }); host.onMessage('did-load-localhost', (_event, data) => { navigator.serviceWorker.ready.then(registration => { - registration.active.postMessage({ channel: 'did-load-localhost', data }); + assertIsDefined(registration.active).postMessage({ channel: 'did-load-localhost', data }); }); }); @@ -282,7 +300,9 @@ export async function createWebviewManager(host) { if (body) { body.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast'); - body.classList.add(initData.activeTheme); + if (initData.activeTheme) { + body.classList.add(initData.activeTheme); + } body.dataset.vscodeThemeKind = initData.activeTheme; body.dataset.vscodeThemeName = initData.themeName || ''; @@ -435,6 +455,9 @@ export async function createWebviewManager(host) { let isHandlingScroll = false; + /** + * @param {WheelEvent} event + */ const handleWheel = (event) => { if (isHandlingScroll) { return; @@ -450,15 +473,21 @@ export async function createWebviewManager(host) { }); }; + /** + * @param {Event} event + */ const handleInnerScroll = (event) => { - if (!event.target || !event.target.body) { - return; - } if (isHandlingScroll) { return; } - const progress = event.currentTarget.scrollY / event.target.body.clientHeight; + const target = /** @type {HTMLDocument | null} */ (event.target); + const currentTarget = /** @type {Window | null} */ (event.currentTarget); + if (!target || !currentTarget || !target.body) { + return; + } + + const progress = currentTarget.scrollY / target.body.clientHeight; if (isNaN(progress)) { return; } @@ -475,6 +504,19 @@ export async function createWebviewManager(host) { }; /** + * @typedef {{ + * contents: string; + * options: { + * readonly allowScripts: boolean; + * readonly allowMultipleAPIAcquire: boolean; + * } + * state: any; + * cspSource: string; + * }} ContentUpdateData + */ + + /** + * @param {ContentUpdateData} data * @return {string} */ function toContentHtml(data) { @@ -484,7 +526,10 @@ export async function createWebviewManager(host) { newDocument.querySelectorAll('a').forEach(a => { if (!a.title) { - a.title = a.getAttribute('href'); + const href = a.getAttribute('href'); + if (typeof href === 'string') { + a.title = href; + } } }); @@ -508,9 +553,11 @@ export async function createWebviewManager(host) { } else { try { // Attempt to rewrite CSPs that hardcode old-style resource endpoint - const endpointUrl = new URL(data.resourceEndpoint); - const newCsp = csp.getAttribute('content').replace(/(vscode-webview-resource|vscode-resource):(?=(\s|;|$))/g, endpointUrl.origin); - csp.setAttribute('content', newCsp); + const cspContent = csp.getAttribute('content'); + if (cspContent) { + const newCsp = cspContent.replace(/(vscode-webview-resource|vscode-resource):(?=(\s|;|$))/g, data.cspSource); + csp.setAttribute('content', newCsp); + } } catch (e) { console.error(`Could not rewrite csp: ${e}`); } @@ -563,7 +610,7 @@ export async function createWebviewManager(host) { // update iframe-contents let updateId = 0; - host.onMessage('content', async (_event, data) => { + host.onMessage('content', async (_event, /** @type {ContentUpdateData} */ data) => { const currentUpdateId = ++updateId; try { @@ -586,18 +633,19 @@ export async function createWebviewManager(host) { const frame = getActiveFrame(); const wasFirstLoad = firstLoad; // keep current scrollY around and use later + /** @type {(body: HTMLElement, window: Window) => void} */ let setInitialScrollPosition; if (firstLoad) { firstLoad = false; setInitialScrollPosition = (body, window) => { - if (!isNaN(initData.initialScrollProgress)) { + if (typeof initData.initialScrollProgress === 'number' && !isNaN(initData.initialScrollProgress)) { if (window.scrollY === 0) { window.scroll(0, body.clientHeight * initData.initialScrollProgress); } } }; } else { - const scrollY = frame && frame.contentDocument && frame.contentDocument.body ? frame.contentWindow.scrollY : 0; + const scrollY = frame && frame.contentDocument && frame.contentDocument.body ? assertIsDefined(frame.contentWindow).scrollY : 0; setInitialScrollPosition = (body, window) => { if (window.scrollY === 0) { window.scroll(0, scrollY); @@ -656,15 +704,16 @@ export async function createWebviewManager(host) { return; } - if (newFrame.contentDocument.readyState !== 'loading') { + const contentDocument = assertIsDefined(newFrame.contentDocument); + if (contentDocument.readyState !== 'loading') { clearInterval(interval); - onFrameLoaded(newFrame.contentDocument); + onFrameLoaded(contentDocument); } }, 10); } else { - newFrame.contentWindow.addEventListener('DOMContentLoaded', e => { + assertIsDefined(newFrame.contentWindow).addEventListener('DOMContentLoaded', e => { const contentDocument = e.target ? (/** @type {HTMLDocument} */ (e.target)) : undefined; - onFrameLoaded(contentDocument); + onFrameLoaded(assertIsDefined(contentDocument)); }); } @@ -692,7 +741,7 @@ export async function createWebviewManager(host) { newFrame.setAttribute('id', 'active-frame'); newFrame.style.visibility = 'visible'; if (host.focusIframeOnCreate) { - newFrame.contentWindow.focus(); + assertIsDefined(newFrame.contentWindow).focus(); } contentWindow.addEventListener('scroll', handleInnerScroll); @@ -720,10 +769,12 @@ export async function createWebviewManager(host) { loadTimeout = setTimeout(() => { clearTimeout(loadTimeout); loadTimeout = undefined; - onLoad(newFrame.contentDocument, newFrame.contentWindow); + onLoad(assertIsDefined(newFrame.contentDocument), assertIsDefined(newFrame.contentWindow)); }, 200); - newFrame.contentWindow.addEventListener('load', function (e) { + const contentWindow = assertIsDefined(newFrame.contentWindow); + + contentWindow.addEventListener('load', function (e) { const contentDocument = /** @type {Document} */ (e.target); if (loadTimeout) { @@ -734,11 +785,16 @@ export async function createWebviewManager(host) { }); // Bubble out various events - newFrame.contentWindow.addEventListener('click', handleInnerClick); - newFrame.contentWindow.addEventListener('auxclick', handleAuxClick); - newFrame.contentWindow.addEventListener('keydown', handleInnerKeydown); - newFrame.contentWindow.addEventListener('keyup', handleInnerUp); - newFrame.contentWindow.addEventListener('contextmenu', e => { + contentWindow.addEventListener('click', handleInnerClick); + contentWindow.addEventListener('auxclick', handleAuxClick); + contentWindow.addEventListener('keydown', handleInnerKeydown); + contentWindow.addEventListener('keyup', handleInnerUp); + contentWindow.addEventListener('contextmenu', e => { + if (e.defaultPrevented) { + // Extension code has already handled this event + return; + } + e.preventDefault(); host.postMessage('did-context-menu', { clientX: e.clientX, @@ -760,7 +816,7 @@ export async function createWebviewManager(host) { if (!pending) { const target = getActiveFrame(); if (target) { - target.contentWindow.postMessage(data.message, '*', data.transfer); + assertIsDefined(target.contentWindow).postMessage(data.message, '*', data.transfer); return; } } @@ -776,7 +832,7 @@ export async function createWebviewManager(host) { if (!target) { return; } - target.contentDocument.execCommand(data); + assertIsDefined(target.contentDocument).execCommand(data); }); trackFocus({ @@ -784,7 +840,7 @@ export async function createWebviewManager(host) { onBlur: () => host.postMessage('did-blur') }); - (/** @type {any} */ (window))[vscodePostMessageFuncName] = (command, data, transfer) => { + (/** @type {any} */ (window))[vscodePostMessageFuncName] = (/** @type {string} */ command, /** @type {any} */ data) => { switch (command) { case 'onmessage': case 'do-update-state': diff --git a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js index e61fc59ad84..c109d848455 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -17,17 +17,11 @@ const rootPath = sw.location.pathname.replace(/\/service-worker.js$/, ''); const searchParams = new URL(location.toString()).searchParams; + /** * Origin used for resources */ -const resourceOrigin = searchParams.get('vscode-resource-origin') ?? sw.origin; - -/** - * Root path for resources - */ -const resourceRoot = rootPath + '/vscode-resource'; - -const serviceWorkerFetchIgnoreSubdomain = searchParams.get('serviceWorkerFetchIgnoreSubdomain') ?? false; +const resourceBaseAuthority = searchParams.get('vscode-resource-base-authority'); const resolveTimeout = 30000; @@ -66,9 +60,15 @@ class RequestStore { create() { const requestId = ++this.requestPool; + /** @type {undefined | ((x: T) => void)} */ let resolve; + + /** @type {Promise} */ const promise = new Promise(r => resolve = r); - const entry = { resolve, promise }; + + /** @type {RequestStoreEntry} */ + const entry = { resolve: /** @type {(x: T) => void} */ (resolve), promise }; + this.map.set(requestId, entry); const dispose = () => { @@ -156,7 +156,6 @@ sw.addEventListener('message', async (event) => { } case 'did-load-localhost': { - const webviewId = getWebviewIdForClient(event.source); const data = event.data.data; if (!localhostRequestStore.resolve(data.id, data.location)) { console.log('Could not resolve unknown localhost', data.origin); @@ -170,17 +169,7 @@ sw.addEventListener('message', async (event) => { sw.addEventListener('fetch', (event) => { const requestUrl = new URL(event.request.url); - - if (serviceWorkerFetchIgnoreSubdomain && requestUrl.pathname.startsWith(resourceRoot + '/')) { - // #121981 - const ignoreFirstSubdomainRegex = /(.*):\/\/.*?\.(.*)/; - const match1 = resourceOrigin.match(ignoreFirstSubdomainRegex); - const match2 = requestUrl.origin.match(ignoreFirstSubdomainRegex); - if (match1 && match2 && match1[1] === match2[1] && match1[2] === match2[2]) { - return event.respondWith(processResourceRequest(event, requestUrl)); - } - } else if (requestUrl.origin === resourceOrigin && requestUrl.pathname.startsWith(resourceRoot + '/')) { - // See if it's a resource request + if (requestUrl.protocol === 'https:' && requestUrl.hostname.endsWith('.' + resourceBaseAuthority)) { return event.respondWith(processResourceRequest(event, requestUrl)); } @@ -205,12 +194,15 @@ sw.addEventListener('activate', (event) => { async function processResourceRequest(event, requestUrl) { const client = await sw.clients.get(event.clientId); if (!client) { - console.log('Could not find inner client for request'); + console.error('Could not find inner client for request'); return notFound(); } const webviewId = getWebviewIdForClient(client); - const resourcePath = requestUrl.pathname.startsWith(resourceRoot + '/') ? requestUrl.pathname.slice(resourceRoot.length) : requestUrl.pathname; + if (!webviewId) { + console.error('Could not resolve webview id'); + return notFound(); + } /** * @param {ResourceResponse} entry @@ -229,18 +221,18 @@ async function processResourceRequest(event, requestUrl) { } } - const cacheHeaders = entry.etag ? { - 'ETag': entry.etag, - 'Cache-Control': 'no-cache' - } : {}; - + /** @type {Record} */ + const headers = { + 'Content-Type': entry.mime, + 'Access-Control-Allow-Origin': '*', + }; + if (entry.etag) { + headers['ETag'] = entry.etag; + headers['Cache-Control'] = 'no-cache'; + } const response = new Response(entry.body, { status: 200, - headers: { - 'Content-Type': entry.mime, - 'Access-Control-Allow-Origin': '*', - ...cacheHeaders - } + headers }); if (entry.etag) { @@ -261,10 +253,16 @@ async function processResourceRequest(event, requestUrl) { const cached = await cache.match(event.request); const { requestId, promise } = resourceRequestStore.create(); + + const firstHostSegment = requestUrl.hostname.split('.')[0]; + const [_, scheme, authority] = firstHostSegment.match(/^(\w+)\+(.*)$/); + parentClient.postMessage({ channel: 'load-resource', id: requestId, - path: resourcePath, + path: requestUrl.pathname, + scheme, + authority: decodeURIComponent(authority), query: requestUrl.search.replace(/^\?/, ''), ifNoneMatch: cached?.headers.get('ETag'), }); @@ -273,23 +271,30 @@ async function processResourceRequest(event, requestUrl) { } /** - * @param {*} event + * @param {FetchEvent} event * @param {URL} requestUrl + * @return {Promise} */ async function processLocalhostRequest(event, requestUrl) { const client = await sw.clients.get(event.clientId); if (!client) { // This is expected when requesting resources on other localhost ports // that are not spawned by vs code - return undefined; + return fetch(event.request); } const webviewId = getWebviewIdForClient(client); + if (!webviewId) { + console.error('Could not resolve webview id'); + return fetch(event.request); + } + const origin = requestUrl.origin; /** - * @param {string} redirectOrigin + * @param {string | undefined} redirectOrigin + * @return {Promise} */ - const resolveRedirect = (redirectOrigin) => { + const resolveRedirect = async (redirectOrigin) => { if (!redirectOrigin) { return fetch(event.request); } @@ -318,16 +323,24 @@ async function processLocalhostRequest(event, requestUrl) { return promise.then(resolveRedirect); } +/** + * @param {Client} client + * @returns {string | null} + */ function getWebviewIdForClient(client) { const requesterClientUrl = new URL(client.url); - return requesterClientUrl.search.match(/\bid=([a-z0-9-]+)/i)[1]; + return requesterClientUrl.searchParams.get('id'); } +/** + * @param {string} webviewId + * @returns {Promise} + */ async function getOuterIframeClient(webviewId) { const allClients = await sw.clients.matchAll({ includeUncontrolled: true }); return allClients.find(client => { const clientUrl = new URL(client.url); const hasExpectedPathName = (clientUrl.pathname === `${rootPath}/` || clientUrl.pathname === `${rootPath}/index.html` || clientUrl.pathname === `${rootPath}/electron-browser-index.html`); - return hasExpectedPathName && clientUrl.search.match(new RegExp('\\bid=' + webviewId)); + return hasExpectedPathName && clientUrl.searchParams.get('id') === webviewId; }); } diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 79e68e5e6bf..5b6ebc58ac2 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -9,11 +9,8 @@ import { isUNC } from 'vs/base/common/extpath'; import { Schemas } from 'vs/base/common/network'; import { sep } from 'vs/base/common/path'; import { URI } from 'vs/base/common/uri'; -import { IHeaders } from 'vs/base/parts/request/common/request'; import { FileOperationError, FileOperationResult, IFileService } from 'vs/platform/files/common/files'; import { ILogService } from 'vs/platform/log/common/log'; -import { IRemoteConnectionData } from 'vs/platform/remote/common/remoteAuthorityResolver'; -import { IRequestService } from 'vs/platform/request/common/request'; import { getWebviewContentMimeType } from 'vs/platform/webview/common/mimeTypes'; export namespace WebviewResourceResponse { @@ -45,20 +42,17 @@ export namespace WebviewResourceResponse { export async function loadLocalResource( requestUri: URI, - ifNoneMatch: string | undefined, options: { + ifNoneMatch: string | undefined, roots: ReadonlyArray; - remoteConnectionData?: IRemoteConnectionData | null; - remoteAuthority: string | undefined; }, fileService: IFileService, - requestService: IRequestService, logService: ILogService, token: CancellationToken, ): Promise { logService.debug(`loadLocalResource - being. requestUri=${requestUri}`); - const resourceToLoad = getResourceToLoad(requestUri, options.roots, options.remoteAuthority); + const resourceToLoad = getResourceToLoad(requestUri, options.roots); logService.debug(`loadLocalResource - found resource to load. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`); @@ -68,33 +62,8 @@ export async function loadLocalResource( const mime = getWebviewContentMimeType(requestUri); // Use the original path for the mime - if (resourceToLoad.scheme === Schemas.http || resourceToLoad.scheme === Schemas.https) { - const headers: IHeaders = {}; - if (ifNoneMatch) { - headers['If-None-Match'] = ifNoneMatch; - } - - const response = await requestService.request({ - url: resourceToLoad.toString(true), - headers: headers - }, token); - - logService.debug(`loadLocalResource - Loaded over http(s). requestUri=${requestUri}, response=${response.res.statusCode}`); - - switch (response.res.statusCode) { - case 200: - return new WebviewResourceResponse.StreamSuccess(response.stream, response.res.headers['etag'], mime); - - case 304: // Not modified - return new WebviewResourceResponse.NotModified(mime); - - default: - return WebviewResourceResponse.Failed; - } - } - try { - const result = await fileService.readFileStream(resourceToLoad, { etag: ifNoneMatch }); + const result = await fileService.readFileStream(resourceToLoad, { etag: options.ifNoneMatch }); return new WebviewResourceResponse.StreamSuccess(result.value, result.etag, mime); } catch (err) { if (err instanceof FileOperationError) { @@ -117,32 +86,16 @@ export async function loadLocalResource( function getResourceToLoad( requestUri: URI, roots: ReadonlyArray, - remoteAuthority: string | undefined, ): URI | undefined { for (const root of roots) { if (containsResource(root, requestUri)) { - return normalizeResourcePath(requestUri, remoteAuthority); + return normalizeResourcePath(requestUri); } } return undefined; } -function normalizeResourcePath(resource: URI, remoteAuthority: string | undefined): URI { - // If the uri was from a remote authority, make we go to the remote to load it - if (remoteAuthority && resource.scheme === Schemas.file) { - return URI.from({ - scheme: Schemas.vscodeRemote, - authority: remoteAuthority, - path: '/vscode-resource', - query: JSON.stringify({ - requestResourcePath: resource.path - }) - }); - } - return resource; -} - function containsResource(root: URI, resource: URI): boolean { let rootPath = root.fsPath + (root.fsPath.endsWith(sep) ? '' : sep); let resourceFsPath = resource.fsPath; @@ -154,3 +107,18 @@ function containsResource(root: URI, resource: URI): boolean { return resourceFsPath.startsWith(rootPath); } + +function normalizeResourcePath(resource: URI): URI { + // Rewrite remote uris to a path that the remote file system can understand + if (resource.scheme === Schemas.vscodeRemote) { + return URI.from({ + scheme: Schemas.vscodeRemote, + authority: resource.authority, + path: '/vscode-resource', + query: JSON.stringify({ + requestResourcePath: resource.path + }) + }); + } + return resource; +} diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 6235a5f49bc..822f4a312f0 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -32,6 +32,11 @@ export interface IWebviewService { */ readonly activeWebview: Webview | undefined; + /** + * All webviews. + */ + readonly webviews: Iterable; + /** * Fired when the currently focused webview changes. */ @@ -75,7 +80,6 @@ export interface WebviewOptions { readonly enableFindWidget?: boolean; readonly tryRestoreScrollPosition?: boolean; readonly retainContextWhenHidden?: boolean; - readonly serviceWorkerFetchIgnoreSubdomain?: boolean; transformCssVariables?(styles: Readonly): Readonly; } diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 4cc864ed41f..499d8db288a 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -13,7 +13,6 @@ import { ILogService } from 'vs/platform/log/common/log'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IRemoteAuthorityResolverService } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { ITunnelService } from 'vs/platform/remote/common/tunnel'; -import { IRequestService } from 'vs/platform/request/common/request'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { BaseWebview, WebviewMessageChannels } from 'vs/workbench/contrib/webview/browser/baseWebviewElement'; import { WebviewThemeDataProvider } from 'vs/workbench/contrib/webview/browser/themeing'; @@ -37,7 +36,6 @@ export class IFrameWebview extends BaseWebview implements Web @IMenuService menuService: IMenuService, @INotificationService notificationService: INotificationService, @IRemoteAuthorityResolverService remoteAuthorityResolverService: IRemoteAuthorityResolverService, - @IRequestService requestService: IRequestService, @ITelemetryService telemetryService: ITelemetryService, @ITunnelService tunnelService: ITunnelService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @@ -48,7 +46,6 @@ export class IFrameWebview extends BaseWebview implements Web logService, telemetryService, environmentService, - requestService, fileService, tunnelService, remoteAuthorityResolverService, @@ -100,14 +97,17 @@ export class IFrameWebview extends BaseWebview implements Web this.element?.contentWindow?.focus(); } - protected initElement(extension: WebviewExtensionDescription | undefined, options: WebviewOptions, extraParams?: object) { - const params = { + protected initElement(extension: WebviewExtensionDescription | undefined, options: WebviewOptions, extraParams?: { [key: string]: string }) { + const params: { [key: string]: string } = { id: this.id, extensionId: extension?.id.value ?? '', // The extensionId and purpose in the URL are used for filtering in js-debug: - purpose: options.purpose, - serviceWorkerFetchIgnoreSubdomain: options.serviceWorkerFetchIgnoreSubdomain, - ...extraParams - } as const; + ...extraParams, + 'vscode-resource-base-authority': this.webviewRootResourceAuthority, + }; + + if (options.purpose) { + params.purpose = options.purpose; + } const queryString = (Object.keys(params) as Array) .map((key) => `${key}=${encodeURIComponent(params[key]!)}`) @@ -124,10 +124,6 @@ export class IFrameWebview extends BaseWebview implements Web return endpoint; } - protected get webviewResourceEndpoint(): string { - return this.webviewContentEndpoint; - } - public mountTo(parent: HTMLElement) { if (this.element) { parent.appendChild(this.element); diff --git a/src/vs/workbench/contrib/webview/browser/webviewService.ts b/src/vs/workbench/contrib/webview/browser/webviewService.ts index 25e922e271d..d99b5796ae6 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewService.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewService.ts @@ -34,6 +34,12 @@ export class WebviewService extends Disposable implements IWebviewService { } } + private _webviews = new Set(); + + public get webviews(): Iterable { + return this._webviews.values(); + } + private readonly _onDidChangeActiveWebview = this._register(new Emitter()); public readonly onDidChangeActiveWebview = this._onDidChangeActiveWebview.event; @@ -44,7 +50,7 @@ export class WebviewService extends Disposable implements IWebviewService { extension: WebviewExtensionDescription | undefined, ): WebviewElement { const webview = this._instantiationService.createInstance(IFrameWebview, id, options, contentOptions, extension, this._webviewThemeDataProvider); - this.addWebviewListeners(webview); + this.registerNewWebview(webview); return webview; } @@ -55,11 +61,13 @@ export class WebviewService extends Disposable implements IWebviewService { extension: WebviewExtensionDescription | undefined, ): WebviewOverlay { const webview = this._instantiationService.createInstance(DynamicWebviewEditorOverlay, id, options, contentOptions, extension); - this.addWebviewListeners(webview); + this.registerNewWebview(webview); return webview; } - protected addWebviewListeners(webview: Webview) { + protected registerNewWebview(webview: Webview) { + this._webviews.add(webview); + webview.onDidFocus(() => { this.updateActiveWebview(webview); }); @@ -71,6 +79,9 @@ export class WebviewService extends Disposable implements IWebviewService { }; webview.onDidBlur(onBlur); - webview.onDidDispose(onBlur); + webview.onDidDispose(() => { + onBlur(); + this._webviews.delete(webview); + }); } } diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 244e5b7c9ed..6e758d5dfae 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -19,7 +19,6 @@ import { ILogService } from 'vs/platform/log/common/log'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IRemoteAuthorityResolverService } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { ITunnelService } from 'vs/platform/remote/common/tunnel'; -import { IRequestService } from 'vs/platform/request/common/request'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { webviewPartitionId } from 'vs/platform/webview/common/webviewManagerService'; import { BaseWebview, WebviewMessageChannels } from 'vs/workbench/contrib/webview/browser/baseWebviewElement'; @@ -62,7 +61,6 @@ export class ElectronWebviewBasedWebview extends BaseWebview impleme @IMenuService menuService: IMenuService, @INotificationService notificationService: INotificationService, @IFileService fileService: IFileService, - @IRequestService requestService: IRequestService, @ITunnelService tunnelService: ITunnelService, @IRemoteAuthorityResolverService remoteAuthorityResolverService: IRemoteAuthorityResolverService, ) { @@ -74,7 +72,6 @@ export class ElectronWebviewBasedWebview extends BaseWebview impleme environmentService, fileService, menuService, - requestService, tunnelService, remoteAuthorityResolverService }); @@ -162,7 +159,7 @@ export class ElectronWebviewBasedWebview extends BaseWebview impleme // and not the `vscode-file` URI because preload scripts are loaded // via node.js from the main side and only allow `file:` protocol this.element!.preload = FileAccess.asFileUri('./pre/electron-index.js', require).toString(true); - this.element!.src = `${Schemas.vscodeWebview}://${this.id}/electron-browser-index.html?platform=electron&id=${this.id}&vscode-resource-origin=${encodeURIComponent(this.webviewResourceEndpoint)}`; + this.element!.src = `${Schemas.vscodeWebview}://${this.id}/electron-browser-index.html?platform=electron&id=${this.id}&vscode-resource-base-authority=${encodeURIComponent(this.webviewRootResourceAuthority)}`; } protected createElement(options: WebviewOptions) { @@ -195,10 +192,6 @@ export class ElectronWebviewBasedWebview extends BaseWebview impleme super.contentOptions = options; } - protected override get webviewResourceEndpoint(): string { - return `https://${this.id}.vscode-webview-test.com`; - } - protected readonly extraContentOptions = {}; public mountTo(parent: HTMLElement) { diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewService.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewService.ts index ffb4870d5d4..f1603eebd35 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewService.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewService.ts @@ -28,7 +28,7 @@ export class ElectronWebviewService extends WebviewService { ): WebviewElement { const useIframes = this._configService.getValue('webview.experimental.useIframes') ?? !options.enableFindWidget; const webview = this._instantiationService.createInstance(useIframes ? ElectronIframeWebview : ElectronWebviewBasedWebview, id, options, contentOptions, extension, this._webviewThemeDataProvider); - this.addWebviewListeners(webview); + this.registerNewWebview(webview); return webview; } @@ -39,7 +39,7 @@ export class ElectronWebviewService extends WebviewService { extension: WebviewExtensionDescription | undefined, ): WebviewOverlay { const webview = this._instantiationService.createInstance(DynamicWebviewEditorOverlay, id, options, contentOptions, extension); - this.addWebviewListeners(webview); + this.registerNewWebview(webview); return webview; } } diff --git a/src/vs/workbench/contrib/webview/electron-sandbox/iframeWebviewElement.ts b/src/vs/workbench/contrib/webview/electron-sandbox/iframeWebviewElement.ts index 0140d50fa7d..5284437a718 100644 --- a/src/vs/workbench/contrib/webview/electron-sandbox/iframeWebviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-sandbox/iframeWebviewElement.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Schemas } from 'vs/base/common/network'; import { IMenuService } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; @@ -13,7 +14,6 @@ import { INativeHostService } from 'vs/platform/native/electron-sandbox/native'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IRemoteAuthorityResolverService } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { ITunnelService } from 'vs/platform/remote/common/tunnel'; -import { IRequestService } from 'vs/platform/request/common/request'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { WebviewMessageChannels } from 'vs/workbench/contrib/webview/browser/baseWebviewElement'; import { WebviewThemeDataProvider } from 'vs/workbench/contrib/webview/browser/themeing'; @@ -38,7 +38,6 @@ export class ElectronIframeWebview extends IFrameWebview { @IContextMenuService contextMenuService: IContextMenuService, @ITunnelService tunnelService: ITunnelService, @IFileService fileService: IFileService, - @IRequestService requestService: IRequestService, @ITelemetryService telemetryService: ITelemetryService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IRemoteAuthorityResolverService _remoteAuthorityResolverService: IRemoteAuthorityResolverService, @@ -51,7 +50,7 @@ export class ElectronIframeWebview extends IFrameWebview { ) { super(id, options, contentOptions, extension, webviewThemeDataProvider, contextMenuService, - configurationService, fileService, logService, menuService, notificationService, _remoteAuthorityResolverService, requestService, telemetryService, tunnelService, environmentService); + configurationService, fileService, logService, menuService, notificationService, _remoteAuthorityResolverService, telemetryService, tunnelService, environmentService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, nativeHostService); @@ -66,25 +65,19 @@ export class ElectronIframeWebview extends IFrameWebview { protected override initElement(extension: WebviewExtensionDescription | undefined, options: WebviewOptions) { super.initElement(extension, options, { - platform: 'electron', - 'vscode-resource-origin': this.webviewResourceEndpoint, + platform: 'electron' }); } protected override get webviewContentEndpoint(): string { - const endpoint = this._environmentService.webviewExternalEndpoint!.replace('{{uuid}}', this.id); + const endpoint = `${Schemas.vscodeWebview}://${this.id}`; if (endpoint[endpoint.length - 1] === '/') { return endpoint.slice(0, endpoint.length - 1); } return endpoint; } - protected override get webviewResourceEndpoint(): string { - return `https://${this.id}.vscode-webview-test.com`; - } - protected override async doPostMessage(channel: string, data?: any): Promise { this.element?.contentWindow!.postMessage({ channel, args: data }, '*'); } - } diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewCommands.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewCommands.ts index ee06e2a50d3..2a045adb649 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewCommands.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewCommands.ts @@ -11,7 +11,7 @@ import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { CATEGORIES } from 'vs/workbench/common/actions'; -import { KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_ENABLED, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_FOCUSED, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_VISIBLE, Webview } from 'vs/workbench/contrib/webview/browser/webview'; +import { IWebviewService, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_ENABLED, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_FOCUSED, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_VISIBLE, Webview } from 'vs/workbench/contrib/webview/browser/webview'; import { WebviewEditor } from 'vs/workbench/contrib/webviewPanel/browser/webviewEditor'; import { WebviewInput } from 'vs/workbench/contrib/webviewPanel/browser/webviewEditorInput'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; @@ -118,11 +118,9 @@ export class ReloadWebviewAction extends Action2 { } public async run(accessor: ServicesAccessor): Promise { - const editorService = accessor.get(IEditorService); - for (const editor of editorService.visibleEditors) { - if (editor instanceof WebviewInput) { - editor.webview.reload(); - } + const webviewService = accessor.get(IWebviewService); + for (const webview of webviewService.webviews) { + webview.reload(); } } } diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.css b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.css index 73356b9714d..b1046a3e613 100644 --- a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.css +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.css @@ -191,6 +191,14 @@ width: 100%; } +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category.no-progress { + padding: 3px 12px; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer .getting-started-category.no-progress .category-progress { + display: none; +} + .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories ul { list-style: none; margin: 0; diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts index a31e1564393..11fec69efca 100644 --- a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts @@ -56,7 +56,6 @@ import { generateTokensCSSForColorMap } from 'vs/editor/common/modes/supports/to import { ResourceMap } from 'vs/base/common/map'; import { IFileService } from 'vs/platform/files/common/files'; import { joinPath } from 'vs/base/common/resources'; -import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { asWebviewUri } from 'vs/workbench/api/common/shared/webview'; import { Schemas } from 'vs/base/common/network'; @@ -132,7 +131,6 @@ export class GettingStartedPage extends EditorPane { @IExtensionService private readonly extensionService: IExtensionService, @IInstantiationService private readonly instantiationService: IInstantiationService, @INotificationService private readonly notificationService: INotificationService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IEditorGroupsService private readonly groupsService: IEditorGroupsService, @IContextKeyService contextService: IContextKeyService, @IQuickInputService private quickInputService: IQuickInputService, @@ -494,12 +492,7 @@ export class GettingStartedPage extends EditorPane { if (src.startsWith('https://')) { return `src="${src}"`; } const path = joinPath(base, src); - const transformed = asWebviewUri({ - isExtensionDevelopmentDebug: this.environmentService.isExtensionDevelopment, - webviewResourceRoot: this.environmentService.webviewResourceRoot, - webviewCspSource: this.environmentService.webviewCspSource, - remote: { authority: undefined }, - }, this.webviewID, path).toString(); + const transformed = asWebviewUri(path).toString(); return `src="${transformed}"`; }); @@ -825,14 +818,17 @@ export class GettingStartedPage extends EditorPane { bar.setAttribute('aria-valuemin', '0'); bar.setAttribute('aria-valuenow', '' + numDone); bar.setAttribute('aria-valuemax', '' + numTotal); - const progress = Math.max((numDone / numTotal) * 100, 3); + const progress = (numDone / numTotal) * 100; bar.style.width = `${progress}%`; + + (element.parentElement as HTMLElement).classList[numDone === 0 ? 'add' : 'remove']('no-progress'); + if (numTotal === numDone) { - bar.title = `All steps complete!`; + bar.title = localize('gettingStarted.allStepsComplete', "All {0} steps complete!", numTotal); } else { - bar.title = `${numDone} of ${numTotal} steps complete`; + bar.title = localize('gettingStarted.someStepsComplete', "{0} of {1} steps complete", numDone, numTotal); } }); } diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedInput.ts b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedInput.ts index a6639f10062..6aa2d4ec4eb 100644 --- a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedInput.ts +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedInput.ts @@ -14,13 +14,14 @@ export const gettingStartedInputTypeId = 'workbench.editors.gettingStartedInput' export class GettingStartedInput extends EditorInput { static readonly ID = gettingStartedInputTypeId; + static readonly RESOURCE = URI.from({ scheme: Schemas.walkThrough, authority: 'vscode_getting_started_page' }); override get typeId(): string { return GettingStartedInput.ID; } get resource(): URI | undefined { - return URI.from({ scheme: Schemas.walkThrough, authority: 'vscode_getting_started_page' }); + return GettingStartedInput.RESOURCE; } override matches(other: unknown) { diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedService.ts b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedService.ts index b49d8ad9148..b8cefbe30df 100644 --- a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedService.ts +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedService.ts @@ -33,6 +33,7 @@ import { walkthroughsExtensionPoint } from 'vs/workbench/contrib/welcome/getting import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { dirname } from 'vs/base/common/path'; import { coalesce, flatten } from 'vs/base/common/arrays'; +import { EditorOverride } from 'vs/platform/editor/common/editor'; export const IGettingStartedService = createDecorator('gettingStartedService'); @@ -438,14 +439,13 @@ export class GettingStartedService extends Disposable implements IGettingStarted } // Otherwise, try to find a getting started input somewhere with no selected walkthrough, and open it to this one. - for (const group of this.editorGroupsService.groups) { - for (const editor of group.editors) { - if (editor instanceof GettingStartedInput) { - if (!editor.selectedCategory) { - editor.selectedCategory = sectionToOpen; - group.openEditor(editor, { revealIfOpened: true }); - return; - } + const result = this.editorService.findEditors({ typeId: GettingStartedInput.ID, resource: GettingStartedInput.RESOURCE }); + for (const { editor, groupId } of result) { + if (editor instanceof GettingStartedInput) { + if (!editor.selectedCategory) { + editor.selectedCategory = sectionToOpen; + this.editorService.openEditor(editor, { revealIfOpened: true, override: EditorOverride.DISABLED }, groupId); + return; } } } diff --git a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts index 8471fa8ea01..89702ffe4d1 100644 --- a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts +++ b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts @@ -748,10 +748,10 @@ export class WelcomeInputSerializer implements IEditorInputSerializer { } public serialize(editorInput: EditorInput): string { - return '{}'; + return ''; } - public deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): WalkThroughInput { + public deserialize(instantiationService: IInstantiationService): WalkThroughInput { return instantiationService.createInstance(WelcomePage) .editorInput; } diff --git a/src/vs/workbench/contrib/welcome/walkThrough/browser/editor/editorWalkThrough.ts b/src/vs/workbench/contrib/welcome/walkThrough/browser/editor/editorWalkThrough.ts index 4c055d9f81e..44eeec072b2 100644 --- a/src/vs/workbench/contrib/welcome/walkThrough/browser/editor/editorWalkThrough.ts +++ b/src/vs/workbench/contrib/welcome/walkThrough/browser/editor/editorWalkThrough.ts @@ -55,10 +55,10 @@ export class EditorWalkThroughInputSerializer implements IEditorInputSerializer } public serialize(editorInput: EditorInput): string { - return '{}'; + return ''; } - public deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): WalkThroughInput { + public deserialize(instantiationService: IInstantiationService): WalkThroughInput { return instantiationService.createInstance(WalkThroughInput, inputOptions); } } diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index 148c23121e1..e894eb2020d 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -16,7 +16,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, workspaceTrustToString } from 'vs/platform/workspace/common/workspaceTrust'; import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { Codicon } from 'vs/base/common/codicons'; +import { Codicon, registerCodicon } from 'vs/base/common/codicons'; import { ThemeColor } from 'vs/workbench/api/common/extHostTypes'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; @@ -25,10 +25,10 @@ import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarA import { IEditorRegistry, EditorDescriptor } from 'vs/workbench/browser/editor'; import { shieldIcon, WorkspaceTrustEditor } from 'vs/workbench/contrib/workspace/browser/workspaceTrustEditor'; import { WorkspaceTrustEditorInput } from 'vs/workbench/services/workspaces/browser/workspaceTrustEditorInput'; -import { isWorkspaceTrustEnabled, WORKSPACE_TRUST_ENABLED, WORKSPACE_TRUST_STARTUP_PROMPT } from 'vs/workbench/services/workspaces/common/workspaceTrust'; -import { EditorInput, IEditorInputSerializer, IEditorInputFactoryRegistry, EditorExtensions } from 'vs/workbench/common/editor'; +import { isWorkspaceTrustEnabled, WORKSPACE_TRUST_EMPTY_WINDOW, WORKSPACE_TRUST_ENABLED, WORKSPACE_TRUST_STARTUP_PROMPT, WORKSPACE_TRUST_UNTRUSTED_FILES } from 'vs/workbench/services/workspaces/common/workspaceTrust'; +import { EditorInput, IEditorInputSerializer, IEditorInputFactoryRegistry, EditorExtensions, EditorResourceAccessor } from 'vs/workbench/common/editor'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { isWeb } from 'vs/base/common/platform'; import { IsWebContext } from 'vs/platform/contextkey/common/contextkeys'; @@ -47,7 +47,13 @@ import { getVirtualWorkspaceScheme } from 'vs/platform/remote/common/remoteHosts import { verifyMicrosoftInternalDomain } from 'vs/platform/telemetry/common/commonProperties'; const BANNER_RESTRICTED_MODE = 'workbench.banner.restrictedMode'; +const BANNER_VIRTUAL_WORKSPACE = 'workbench.banner.virtualWorkspace'; +const BANNER_VIRTUAL_AND_RESTRICTED = 'workbench.banner.virtualAndRestricted'; const STARTUP_PROMPT_SHOWN_KEY = 'workspace.trust.startupPrompt.shown'; +const BANNER_RESTRICTED_MODE_DISMISSED_KEY = 'workbench.banner.restrictedMode.dismissed'; +const BANNER_VIRTUAL_WORKSPACE_DISMISSED_KEY = 'workbench.banner.virtualWorkspace.dismissed'; + +const infoIcon = registerCodicon('workspace-banner-warning-icon', Codicon.info); /* * Trust Request UX Handler @@ -61,6 +67,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben constructor( @IDialogService private readonly dialogService: IDialogService, @ICommandService private readonly commandService: ICommandService, + @IEditorService private readonly editorService: IEditorService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -78,6 +85,10 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben this.registerListeners(); this.createStatusbarEntry(); + // Set empty workspace trust state + this.setEmptyWorkspaceTrustState(); + + // Show modal dialog if (this.hostService.hasFocus) { this.showModalOnStart(); } else { @@ -133,7 +144,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben switch (result.choice) { case 0: if (result.checkboxChecked) { - this.workspaceTrustManagementService.setParentFolderTrust(true); + await this.workspaceTrustManagementService.setParentFolderTrust(true); } else { await this.workspaceTrustRequestService.completeRequest(true); } @@ -149,6 +160,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben private showModalOnStart(): void { if (this.workspaceTrustManagementService.isWorkpaceTrusted()) { + this.updateWorkbenchIndicators(true); return; } @@ -158,6 +170,12 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben return; } + // Don't show modal prompt for empty workspaces by default + if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY) { + this.updateWorkbenchIndicators(false); + return; + } + if (this.startupPromptSetting === 'never') { this.updateWorkbenchIndicators(false); return; @@ -194,16 +212,56 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben private createStatusbarEntry(): void { const entry = this.getStatusbarEntry(this.workspaceTrustManagementService.isWorkpaceTrusted()); - this.statusbarEntryAccessor.value = this.statusbarService.addEntry(entry, this.entryId, localize('status.WorkspaceTrust', "Workspace Trust"), StatusbarAlignment.LEFT, 0.99 * Number.MAX_VALUE /* Right of remote indicator */); + this.statusbarEntryAccessor.value = this.statusbarService.addEntry(entry, this.entryId, StatusbarAlignment.LEFT, 0.99 * Number.MAX_VALUE /* Right of remote indicator */); this.statusbarService.updateEntryVisibility(this.entryId, false); } - private getBannerItem(): IBannerItem { - return { - id: BANNER_RESTRICTED_MODE, - icon: shieldIcon, - message: localize('restrictedModeBannerMessage', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features."), - actions: [ + private getBannerItem(isVirtualWorkspace: boolean, restrictedMode: boolean): IBannerItem | undefined { + + const dismissedVirtual = this.storageService.getBoolean(BANNER_VIRTUAL_WORKSPACE_DISMISSED_KEY, StorageScope.WORKSPACE, false); + const dismissedRestricted = this.storageService.getBoolean(BANNER_RESTRICTED_MODE_DISMISSED_KEY, StorageScope.WORKSPACE, false); + + // all important info has been dismissed + if (dismissedVirtual && dismissedRestricted) { + return undefined; + } + + // don't show restricted mode only banner + if (dismissedRestricted && !isVirtualWorkspace) { + return undefined; + } + + // don't show virtual workspace only banner + if (dismissedVirtual && !restrictedMode) { + return undefined; + } + + const choose = (virtual: any, restricted: any, virtualAndRestricted: any) => { + return (isVirtualWorkspace && !dismissedVirtual) && (restrictedMode && !dismissedRestricted) ? virtualAndRestricted : ((isVirtualWorkspace && !dismissedVirtual) ? virtual : restricted); + }; + + const id = choose(BANNER_VIRTUAL_WORKSPACE, BANNER_RESTRICTED_MODE, BANNER_VIRTUAL_AND_RESTRICTED); + const icon = choose(infoIcon, shieldIcon, infoIcon); + const ariaLabel = choose( + localize('virtualBannerAriaLabel', "Some features are not available because the current workspace is backed by a virtual file system. Use navigation keys to access banner actions."), + localize('restrictedModeBannerAriaLabel', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features. Use navigation keys to access banner actions."), + localize('virtualAndRestrictedModeBannerAriaLabel', "Some features are not available because the current workspace is backed by a virtual file system and is not trusted. You can trust this workspace to enable some of these features. Use navigation keys to access banner actions."), + ); + + const message = choose( + localize('virtualBannerMessage', "Some features are not available because the current workspace is backed by a virtual file system."), + localize('restrictedModeBannerMessage', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features."), + localize('virtualAndRestrictedModeBannerMessage', "Some features are not available because the current workspace is backed by a virtual file system and is not trusted. You can trust this workspace to enabled some of these features."), + ); + + const actions = choose( + [ + { + label: localize('virtualBannerLearnMore', "Learn More"), + href: 'https://aka.ms/vscode-virtual-workspaces' + } + ], + [ { label: localize('restrictedModeBannerManage', "Manage"), href: 'command:workbench.trust.manage' @@ -213,7 +271,33 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben href: 'https://aka.ms/vscode-workspace-trust' } ], - scope: StorageScope.WORKSPACE, + [ + { + label: localize('virtualAndRestrictedModeBannerManage', "Manage Trust"), + href: 'command:workbench.trust.manage' + }, + { + label: localize('virtualBannerLearnMore', "Learn More"), + href: 'https://aka.ms/vscode-virtual-workspaces' + } + ] + ); + + return { + id, + icon, + ariaLabel, + message, + actions, + onClose: () => { + if (isVirtualWorkspace) { + this.storageService.store(BANNER_VIRTUAL_WORKSPACE_DISMISSED_KEY, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + if (restrictedMode) { + this.storageService.store(BANNER_RESTRICTED_MODE_DISMISSED_KEY, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } }; } @@ -223,6 +307,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben const color = new ThemeColor(STATUS_BAR_PROMINENT_ITEM_FOREGROUND); return { + name: localize('status.WorkspaceTrust', "Workspace Trust"), text: trusted ? `$(shield)` : `$(shield) ${text}`, ariaLabel: trusted ? localize('status.ariaTrusted', "This workspace is trusted.") : localize('status.ariaUntrusted', "Restricted Mode: Some features are disabled because this workspace is not trusted."), tooltip: trusted ? localize('status.tooltipTrusted', "This workspace is trusted.") : localize('status.tooltipUntrusted', "Some features are disabled because this workspace is not trusted."), @@ -232,6 +317,25 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben }; } + private setEmptyWorkspaceTrustState(): void { + if (this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY) { + return; + } + + // Open files + const openFiles = this.editorService.editors.map(editor => EditorResourceAccessor.getCanonicalUri(editor, { filterByScheme: Schemas.file })).filter(uri => !!uri); + + if (openFiles.length) { + // If all open files are trusted, transition to a trusted workspace + if (openFiles.map(uri => this.workspaceTrustManagementService.getUriTrustInfo(uri!).trusted).every(trusted => trusted)) { + this.workspaceTrustManagementService.setWorkspaceTrust(true); + } + } else { + // No open files, use the setting to set workspace trust state + this.workspaceTrustManagementService.setWorkspaceTrust(this.configurationService.getValue(WORKSPACE_TRUST_EMPTY_WINDOW) ?? false); + } + } + private updateStatusbarEntry(trusted: boolean): void { this.statusbarEntryAccessor.value?.update(this.getStatusbarEntry(trusted)); this.updateStatusbarEntryVisibility(trusted); @@ -243,10 +347,20 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben private updateWorkbenchIndicators(trusted: boolean): void { this.updateStatusbarEntry(trusted); - if (!trusted) { - this.bannerService.show(this.getBannerItem()); - } else { - this.bannerService.hide(BANNER_RESTRICTED_MODE); + + const isVirtualWorkspace = getVirtualWorkspaceScheme(this.workspaceContextService.getWorkspace()) !== undefined; + const bannerItem = this.getBannerItem(isVirtualWorkspace, !trusted); + + if (bannerItem) { + if (!isVirtualWorkspace) { + if (!trusted) { + this.bannerService.show(bannerItem); + } else { + this.bannerService.hide(BANNER_RESTRICTED_MODE); + } + } else { + this.bannerService.show(bannerItem); + } } } @@ -327,7 +441,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben ); // Mark added/changed folders as trusted - this.workspaceTrustManagementService.setUrisTrust(addedFoldersTrustInfo.map(i => i.uri), result.choice === 0); + await this.workspaceTrustManagementService.setUrisTrust(addedFoldersTrustInfo.map(i => i.uri), result.choice === 0); resolve(); } @@ -355,10 +469,10 @@ class WorkspaceTrustEditorInputSerializer implements IEditorInputSerializer { } serialize(input: WorkspaceTrustEditorInput): string { - return '{}'; + return ''; } - deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): WorkspaceTrustEditorInput { + deserialize(instantiationService: IInstantiationService): WorkspaceTrustEditorInput { return instantiationService.createInstance(WorkspaceTrustEditorInput); } } @@ -427,7 +541,7 @@ Registry.as(ConfigurationExtensions.Configuration) default: verifyMicrosoftInternalDomain(product.msftInternalDomains || []), included: !isWeb, description: localize('workspace.trust.description', "Controls whether or not workspace trust is enabled within VS Code."), - scope: ConfigurationScope.APPLICATION + scope: ConfigurationScope.APPLICATION, }, [WORKSPACE_TRUST_STARTUP_PROMPT]: { type: 'string', @@ -441,6 +555,26 @@ Registry.as(ConfigurationExtensions.Configuration) localize('workspace.trust.startupPrompt.once', "Ask for trust the first time an untrusted workspace is opened."), localize('workspace.trust.startupPrompt.never', "Do not ask for trust when an untrusted workspace is opened."), ] + }, + [WORKSPACE_TRUST_UNTRUSTED_FILES]: { + type: 'string', + default: 'prompt', + included: !isWeb, + description: localize('workspace.trust.untrustedFiles.description', "Controls how to handle opening untrusted files in a trusted workspace."), + scope: ConfigurationScope.APPLICATION, + enum: ['prompt', 'open', 'newWindow'], + enumDescriptions: [ + localize('workspace.trust.untrustedFiles.prompt', "Ask how to handle untrusted files for each workspace. Once untrusted files are introduced to a trusted workspace, you will not be prompted again."), + localize('workspace.trust.untrustedFiles.open', "Always allow untrusted files to be introduced to a trusted workspace without prompting."), + localize('workspace.trust.untrustedFiles.newWindow', "Always open untrusted files in a separate window in restricted mode without prompting."), + ] + }, + [WORKSPACE_TRUST_EMPTY_WINDOW]: { + type: 'boolean', + default: false, + included: !isWeb, + description: localize('workspace.trust.emptyWindow.description', "Controls whether or not the empty window is trusted by default within VS Code."), + scope: ConfigurationScope.APPLICATION } } }); diff --git a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css index ce18a414f9d..b26d4cc9aa5 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css +++ b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css @@ -196,41 +196,6 @@ flex: 1; } -/** Settings */ -.workspace-trust-editor .workspace-trust-settings .workspace-trust-section-title { - padding: 14px; -} - -.workspace-trust-editor .settings-editor .settings-body { - margin-top: 0; -} - -.workspace-trust-editor .settings-editor .settings-body .settings-tree-container .shadow.top { - left: initial; - margin-left: initial; - max-width: initial; -} - -.workspace-trust-editor .settings-editor .settings-body .settings-tree-container .monaco-list-rows { - background: unset; -} - -.workspace-trust-editor .settings-editor .settings-body .settings-tree-container .monaco-list-row .monaco-tl-contents { - padding-left: 0; - padding-right: 0; -} - -.workspace-trust-editor .settings-editor .settings-body .settings-tree-container .monaco-list-row .monaco-tl-contents .setting-list-edit-row > .setting-list-valueInput { - width: 100%; - max-width: 100%; -} - -.workspace-trust-editor .settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-key, -.workspace-trust-editor .settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-input-key { - margin-left: 4px; - min-width: 20%; -} - .workspace-trust-intro-dialog { min-width: min(50vw, 500px); padding-right: 24px; @@ -250,3 +215,66 @@ max-height: 32px; padding-right: 10px; } + +.workspace-trust-editor .workspace-trust-settings { + padding: 20px 14px; +} + +.workspace-trust-editor .workspace-trust-settings .workspace-trusted-folders-title { + font-weight: 600; +} + +.workspace-trust-editor .monaco-table-tr .monaco-table-td .path:not(.input-mode) .monaco-inputbox, +.workspace-trust-editor .monaco-table-tr .monaco-table-td .path.input-mode .path-label { + display: none; +} + +.workspace-trust-editor .monaco-table-tr .monaco-table-td .current-workspace-parent .path-label, +.workspace-trust-editor .monaco-table-tr .monaco-table-td .current-workspace-parent .host-label { + font-weight: bold; + font-style: italic; +} + + +.workspace-trust-editor .monaco-table-tr .monaco-table-td .path .monaco-inputbox input { + padding-left: 5px; +} + +.workspace-trust-editor .monaco-table-th, +.workspace-trust-editor .monaco-table-td { + padding-left: 5px; +} + +.workspace-trust-editor .workspace-trust-settings .monaco-action-bar .action-item > .codicon { + display: flex; + align-items: center; + justify-content: center; + color: inherit; +} + +.workspace-trust-editor .workspace-trust-settings .monaco-table-tr .monaco-table-td { + align-items: center; + display: flex; + overflow: hidden; +} + +.workspace-trust-editor .workspace-trust-settings .monaco-table-tr .monaco-table-td .path { + width: 100%; +} + +.workspace-trust-editor .workspace-trust-settings .monaco-table-tr .monaco-table-td .monaco-button { + height: 18px; + padding-left: 8px; + padding-right: 8px; +} + +.workspace-trust-editor .workspace-trust-settings .monaco-table-tr .monaco-table-td .actions .monaco-action-bar { + display: none; + flex: 1; +} + +.workspace-trust-editor .workspace-trust-settings .monaco-list-row.selected .monaco-table-tr .monaco-table-td .actions .monaco-action-bar, +.workspace-trust-editor .workspace-trust-settings .monaco-table .monaco-list-row.focused .monaco-table-tr .monaco-table-td .actions .monaco-action-bar, +.workspace-trust-editor .workspace-trust-settings .monaco-list-row:hover .monaco-table-tr .monaco-table-td .actions .monaco-action-bar { + display: flex; +} diff --git a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts index eab4501118c..d4157f041ba 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts @@ -3,36 +3,43 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, append, clearNode, Dimension, EventHelper } from 'vs/base/browser/dom'; +import { $, addDisposableListener, addStandardDisposableListener, append, clearNode, Dimension, EventHelper, EventType } from 'vs/base/browser/dom'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { ButtonBar } from 'vs/base/browser/ui/button/button'; +import { InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; -import { Action } from 'vs/base/common/actions'; +import { ITableRenderer, ITableVirtualDelegate } from 'vs/base/browser/ui/table/table'; +import { Action, IAction } from 'vs/base/common/actions'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Codicon, registerCodicon } from 'vs/base/common/codicons'; import { debounce } from 'vs/base/common/decorators'; -import { Iterable } from 'vs/base/common/iterator'; +import { Emitter, Event } from 'vs/base/common/event'; +import { KeyCode } from 'vs/base/common/keyCodes'; import { splitName } from 'vs/base/common/labels'; -import { DisposableStore } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { parseLinkedText } from 'vs/base/common/linkedText'; import { Schemas } from 'vs/base/common/network'; import { ScrollbarVisibility } from 'vs/base/common/scrollable'; -import { isArray } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; -import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { ConfigurationScope, Extensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { ExtensionUntrustedWorkpaceSupportType } from 'vs/platform/extensions/common/extensions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IPromptChoiceWithMenu, Severity } from 'vs/platform/notification/common/notification'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { WorkbenchTable } from 'vs/platform/list/browser/listService'; +import { IPromptChoiceWithMenu } from 'vs/platform/notification/common/notification'; import { Link } from 'vs/platform/opener/browser/link'; import product from 'vs/platform/product/common/product'; -import { getVirtualWorkspaceScheme } from 'vs/platform/remote/common/remoteHosts'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { isVirtualWorkspace } from 'vs/platform/remote/common/remoteHosts'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { buttonBackground, buttonSecondaryBackground, editorErrorForeground } from 'vs/platform/theme/common/colorRegistry'; -import { attachButtonStyler, attachStylerCallback } from 'vs/platform/theme/common/styler'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; +import { attachButtonStyler, attachInputBoxStyler, attachStylerCallback } from 'vs/platform/theme/common/styler'; +import { IThemeService, ThemeIcon } from 'vs/platform/theme/common/themeService'; import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; import { isSingleFolderWorkspaceIdentifier, toWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; @@ -41,9 +48,10 @@ import { ChoiceAction } from 'vs/workbench/common/notifications'; import { debugIconStartForeground } from 'vs/workbench/contrib/debug/browser/debugColors'; import { IExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/common/extensions'; import { getInstalledExtensions, IExtensionStatus } from 'vs/workbench/contrib/extensions/common/extensionsUtils'; -import { IWorkspaceTrustSettingChangeEvent, WorkspaceTrustSettingArrayRenderer, WorkspaceTrustTree, WorkspaceTrustTreeModel } from 'vs/workbench/contrib/workspace/browser/workspaceTrustTree'; -import { filterSettingsRequireWorkspaceTrust, IWorkbenchConfigurationService } from 'vs/workbench/services/configuration/common/configuration'; +import { settingsEditIcon, settingsRemoveIcon } from 'vs/workbench/contrib/preferences/browser/preferencesIcons'; +import { IWorkbenchConfigurationService } from 'vs/workbench/services/configuration/common/configuration'; import { IExtensionManifestPropertiesService } from 'vs/workbench/services/extensions/common/extensionManifestPropertiesService'; +import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { WorkspaceTrustEditorInput } from 'vs/workbench/services/workspaces/browser/workspaceTrustEditorInput'; export const shieldIcon = registerCodicon('workspace-trust-icon', Codicon.shield); @@ -51,6 +59,466 @@ export const shieldIcon = registerCodicon('workspace-trust-icon', Codicon.shield const checkListIcon = registerCodicon('workspace-trusted-check-icon', Codicon.check); const xListIcon = registerCodicon('workspace-trusted-x-icon', Codicon.x); +const enum TrustedUriItemType { + Existing = 1, + Add = 2 +} + +interface ITrustedUriItem { + entryType: TrustedUriItemType; + parentOfWorkspaceItem: boolean; + uri: URI; +} + +class WorkspaceTrustedUrisTable extends Disposable { + private readonly _onDidAcceptEdit: Emitter = this._register(new Emitter()); + readonly onDidAcceptEdit: Event = this._onDidAcceptEdit.event; + + private readonly _onDidRejectEdit: Emitter = this._register(new Emitter()); + readonly onDidRejectEdit: Event = this._onDidRejectEdit.event; + + private _onEdit: Emitter = this._register(new Emitter()); + readonly onEdit: Event = this._onEdit.event; + + private _onDelete: Emitter = this._register(new Emitter()); + readonly onDelete: Event = this._onDelete.event; + + private readonly table: WorkbenchTable; + + constructor( + private readonly container: HTMLElement, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IUriIdentityService private readonly uriService: IUriIdentityService, + @IFileDialogService private readonly fileDialogService: IFileDialogService + ) { + super(); + + this.table = this.instantiationService.createInstance( + WorkbenchTable, + 'WorkspaceTrust', + this.container, + new TrustedUriTableVirtualDelegate(), + [ + { + label: localize('hostColumnLabel', "Host"), + tooltip: '', + weight: 1, + templateId: TrustedUriHostColumnRenderer.TEMPLATE_ID, + project(row: ITrustedUriItem): ITrustedUriItem { return row; } + }, + { + label: localize('pathColumnLabel', "Path"), + tooltip: '', + weight: 9, + templateId: TrustedUriPathColumnRenderer.TEMPLATE_ID, + project(row: ITrustedUriItem): ITrustedUriItem { return row; } + }, + { + label: '', + tooltip: '', + weight: 0, + minimumWidth: 55, + maximumWidth: 55, + templateId: TrustedUriActionsColumnRenderer.TEMPLATE_ID, + project(row: ITrustedUriItem): ITrustedUriItem { return row; } + }, + ], + [ + this.instantiationService.createInstance(TrustedUriHostColumnRenderer, this), + this.instantiationService.createInstance(TrustedUriPathColumnRenderer, this), + this.instantiationService.createInstance(TrustedUriActionsColumnRenderer, this), + ], + { + horizontalScrolling: false, + openOnSingleClick: false, + } + ) as WorkbenchTable; + + this._register(this.table.onDidOpen(item => { + if (item && item.element) { + this.edit(item.element); + } + })); + + this._register(this.workspaceTrustManagementService.onDidChangeTrustedFolders(() => { + this.updateTable(); + })); + } + + private getIndexOfTrustedUriEntry(item: ITrustedUriItem): number { + const index = this.trustedUriEntries.indexOf(item); + if (index === -1) { + for (let i = 0; i < this.trustedUriEntries.length; i++) { + if (this.trustedUriEntries[i].entryType !== item.entryType) { + continue; + } + + if (item.entryType === TrustedUriItemType.Add || this.trustedUriEntries[i].uri === item.uri) { + return i; + } + } + } + + return index; + } + + private selectTrustedUriEntry(item: ITrustedUriItem, focus: boolean = true): void { + const index = this.getIndexOfTrustedUriEntry(item); + if (index !== -1) { + if (focus) { + this.table.domFocus(); + this.table.setFocus([index]); + } + this.table.setSelection([index]); + } + } + + private get currentWorkspaceUri(): URI { + return this.workspaceService.getWorkspace().folders[0]?.uri || URI.file('/'); + } + + private get trustedUriEntries(): ITrustedUriItem[] { + const currentWorkspace = this.workspaceService.getWorkspace(); + const currentWorkspaceUris = currentWorkspace.folders.map(folder => folder.uri); + if (currentWorkspace.configuration) { + currentWorkspaceUris.push(currentWorkspace.configuration); + } + + const entries = this.workspaceTrustManagementService.getTrustedFolders().map(uri => { + + let relatedToCurrentWorkspace = false; + for (const workspaceUri of currentWorkspaceUris) { + relatedToCurrentWorkspace = relatedToCurrentWorkspace || this.uriService.extUri.isEqualOrParent(workspaceUri, uri); + } + + return { + uri, + entryType: TrustedUriItemType.Existing, + parentOfWorkspaceItem: relatedToCurrentWorkspace + }; + }); + entries.push({ uri: this.currentWorkspaceUri, entryType: TrustedUriItemType.Add, parentOfWorkspaceItem: false }); + return entries; + } + + layout(): void { + this.table.layout((this.trustedUriEntries.length * TrustedUriTableVirtualDelegate.ROW_HEIGHT) + TrustedUriTableVirtualDelegate.HEADER_ROW_HEIGHT, undefined); + } + + updateTable(): void { + this.table.splice(0, Number.POSITIVE_INFINITY, this.trustedUriEntries); + this.layout(); + } + + acceptEdit(item: ITrustedUriItem, uri: URI) { + const trustedFolders = this.workspaceTrustManagementService.getTrustedFolders(); + const index = this.getIndexOfTrustedUriEntry(item); + + if (index >= trustedFolders.length) { + trustedFolders.push(uri); + } else { + trustedFolders[index] = uri; + } + + this.workspaceTrustManagementService.setTrustedFolders(trustedFolders); + this._onDidAcceptEdit.fire(item); + } + + rejectEdit(item: ITrustedUriItem) { + this._onDidRejectEdit.fire(item); + } + + async delete(item: ITrustedUriItem) { + await this.workspaceTrustManagementService.setUrisTrust([item.uri], false); + this._onDelete.fire(item); + } + + async edit(item: ITrustedUriItem) { + const canUseOpenDialog = item.uri.scheme === Schemas.file || + (item.uri.scheme === this.currentWorkspaceUri.scheme && this.uriService.extUri.isEqualAuthority(this.currentWorkspaceUri.authority, item.uri.authority)); + if (canUseOpenDialog) { + const uri = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: item.uri, + openLabel: localize('trustUri', "Trust Folder"), + title: localize('selectTrustedUri', "Select Folder To Trust") + }); + + if (uri) { + this.acceptEdit(item, uri[0]); + } else { + this.rejectEdit(item); + } + } else { + this.selectTrustedUriEntry(item); + this._onEdit.fire(item); + } + } +} + +class TrustedUriTableVirtualDelegate implements ITableVirtualDelegate { + static readonly HEADER_ROW_HEIGHT = 30; + static readonly ROW_HEIGHT = 24; + readonly headerRowHeight = TrustedUriTableVirtualDelegate.HEADER_ROW_HEIGHT; + getHeight(item: ITrustedUriItem) { + return TrustedUriTableVirtualDelegate.ROW_HEIGHT; + } +} + +interface IActionsColumnTemplateData { + readonly actionBar: ActionBar; +} + +class TrustedUriActionsColumnRenderer implements ITableRenderer { + + static readonly TEMPLATE_ID = 'actions'; + + readonly templateId: string = TrustedUriActionsColumnRenderer.TEMPLATE_ID; + + constructor(private readonly table: WorkspaceTrustedUrisTable) { } + + renderTemplate(container: HTMLElement): IActionsColumnTemplateData { + const element = container.appendChild($('.actions')); + const actionBar = new ActionBar(element, { animated: false }); + return { actionBar }; + } + + renderElement(item: ITrustedUriItem, index: number, templateData: IActionsColumnTemplateData, height: number | undefined): void { + templateData.actionBar.clear(); + + if (item.entryType !== TrustedUriItemType.Add) { + const actions: IAction[] = []; + actions.push(this.createEditAction(item)); + actions.push(this.createDeleteAction(item)); + templateData.actionBar.push(actions, { icon: true }); + } + } + + private createEditAction(item: ITrustedUriItem): IAction { + return { + class: ThemeIcon.asClassName(settingsEditIcon), + enabled: true, + id: 'editTrustedUri', + tooltip: localize('editTrustedUri', "Change Path"), + run: () => { + this.table.edit(item); + } + }; + } + + private createDeleteAction(item: ITrustedUriItem): IAction { + return { + class: ThemeIcon.asClassName(settingsRemoveIcon), + enabled: true, + id: 'deleteTrustedUri', + tooltip: localize('deleteTrustedUri', "Delete Path"), + run: async () => { + await this.table.delete(item); + } + }; + } + + disposeTemplate(templateData: IActionsColumnTemplateData): void { + templateData.actionBar.dispose(); + } + +} + +interface ITrustedUriPathColumnTemplateData { + element: HTMLElement; + pathLabel: HTMLElement; + pathInput: InputBox; + renderDisposables: DisposableStore; + disposables: DisposableStore; +} + +class TrustedUriPathColumnRenderer implements ITableRenderer { + static readonly TEMPLATE_ID = 'path'; + + readonly templateId: string = TrustedUriPathColumnRenderer.TEMPLATE_ID; + + constructor( + private readonly table: WorkspaceTrustedUrisTable, + @IContextViewService private readonly contextViewService: IContextViewService, + @IThemeService private readonly themeService: IThemeService, + ) { + } + + renderTemplate(container: HTMLElement): ITrustedUriPathColumnTemplateData { + const element = container.appendChild($('.path')); + const pathLabel = element.appendChild($('div.path-label')); + + const pathInput = new InputBox(element, this.contextViewService); + + const disposables = new DisposableStore(); + disposables.add(attachInputBoxStyler(pathInput, this.themeService)); + + const renderDisposables = disposables.add(new DisposableStore()); + + return { + element, + pathLabel, + pathInput, + disposables, + renderDisposables + }; + } + + renderElement(item: ITrustedUriItem, index: number, templateData: ITrustedUriPathColumnTemplateData, height: number | undefined): void { + templateData.renderDisposables.clear(); + + templateData.renderDisposables.add(this.table.onEdit(async (e) => { + if (item === e) { + templateData.element.classList.add('input-mode'); + templateData.pathInput.focus(); + templateData.pathInput.select(); + templateData.element.parentElement!.style.paddingLeft = '0px'; + } + })); + + + const hideInputBox = () => { + templateData.element.classList.remove('input-mode'); + templateData.element.parentElement!.style.paddingLeft = '5px'; + }; + + const accept = () => { + hideInputBox(); + const uri = item.uri.with({ path: templateData.pathInput.value }); + templateData.pathLabel.innerText = templateData.pathInput.value; + + if (uri) { + this.table.acceptEdit(item, uri); + } + }; + + const reject = () => { + hideInputBox(); + templateData.pathInput.value = stringValue; + this.table.rejectEdit(item); + }; + + templateData.renderDisposables.add(addStandardDisposableListener(templateData.pathInput.inputElement, EventType.KEY_DOWN, e => { + let handled = false; + if (e.equals(KeyCode.Enter)) { + accept(); + handled = true; + } else if (e.equals(KeyCode.Escape)) { + reject(); + handled = true; + } + + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + })); + templateData.renderDisposables.add((addDisposableListener(templateData.pathInput.inputElement, EventType.BLUR, () => { + reject(); + }))); + + const stringValue = item.uri.scheme === Schemas.file ? URI.revive(item.uri).fsPath : item.uri.path; + templateData.pathInput.value = stringValue; + templateData.pathLabel.innerText = stringValue; + templateData.element.classList.toggle('current-workspace-parent', item.parentOfWorkspaceItem); + + templateData.pathLabel.style.display = item.entryType === TrustedUriItemType.Add ? 'none' : ''; + } + + disposeTemplate(templateData: ITrustedUriPathColumnTemplateData): void { + templateData.disposables.dispose(); + templateData.renderDisposables.dispose(); + } + +} + + +interface ITrustedUriHostColumnTemplateData { + element: HTMLElement; + hostContainer: HTMLElement; + buttonBarContainer: HTMLElement; + disposables: DisposableStore; + renderDisposables: DisposableStore; +} + +class TrustedUriHostColumnRenderer implements ITableRenderer { + static readonly TEMPLATE_ID = 'host'; + + readonly templateId: string = TrustedUriHostColumnRenderer.TEMPLATE_ID; + + constructor( + private readonly table: WorkspaceTrustedUrisTable, + @ILabelService private readonly labelService: ILabelService, + @IThemeService private readonly themeService: IThemeService, + ) { } + + renderTemplate(container: HTMLElement): ITrustedUriHostColumnTemplateData { + const disposables = new DisposableStore(); + const renderDisposables = disposables.add(new DisposableStore()); + + const element = container.appendChild($('.host')); + const hostContainer = element.appendChild($('div.host-label')); + const buttonBarContainer = element.appendChild($('div.button-bar')); + + return { + element, + hostContainer, + buttonBarContainer, + disposables, + renderDisposables + }; + } + + renderElement(item: ITrustedUriItem, index: number, templateData: ITrustedUriHostColumnTemplateData, height: number | undefined): void { + templateData.renderDisposables.clear(); + templateData.renderDisposables.add({ dispose: () => { clearNode(templateData.buttonBarContainer); } }); + + templateData.hostContainer.innerText = item.uri.authority ? this.labelService.getHostLabel(item.uri.scheme, item.uri.authority) : localize('localAuthority', "Local"); + templateData.element.classList.toggle('current-workspace-parent', item.parentOfWorkspaceItem); + + if (item.entryType === TrustedUriItemType.Add) { + templateData.hostContainer.style.display = 'none'; + templateData.buttonBarContainer.style.display = ''; + + const buttonBar = templateData.renderDisposables.add(new ButtonBar(templateData.buttonBarContainer)); + const addButton = templateData.renderDisposables.add(buttonBar.addButton({ title: localize('addButton', "Add Folder") })); + addButton.label = localize('addButton', "Add Folder"); + + templateData.renderDisposables.add(attachButtonStyler(addButton, this.themeService)); + + templateData.renderDisposables.add(addButton.onDidClick(() => { + this.table.edit(item); + })); + + templateData.renderDisposables.add(this.table.onEdit(e => { + if (item === e) { + templateData.hostContainer.style.display = ''; + templateData.buttonBarContainer.style.display = 'none'; + } + })); + + templateData.renderDisposables.add(this.table.onDidRejectEdit(e => { + if (item === e) { + templateData.hostContainer.style.display = 'none'; + templateData.buttonBarContainer.style.display = ''; + } + })); + } else { + templateData.hostContainer.style.display = ''; + templateData.buttonBarContainer.style.display = 'none'; + } + } + + disposeTemplate(templateData: ITrustedUriHostColumnTemplateData): void { + templateData.disposables.dispose(); + } + +} + export class WorkspaceTrustEditor extends EditorPane { static readonly ID: string = 'workbench.editor.workspaceTrust'; private rootElement!: HTMLElement; @@ -69,9 +537,7 @@ export class WorkspaceTrustEditor extends EditorPane { // Settings Section private configurationContainer!: HTMLElement; - private trustSettingsTree!: WorkspaceTrustTree; - private workspaceTrustSettingsTreeModel!: WorkspaceTrustTreeModel; - + private workpaceTrustedUrisTable!: WorkspaceTrustedUrisTable; constructor( @ITelemetryService telemetryService: ITelemetryService, @@ -82,7 +548,6 @@ export class WorkspaceTrustEditor extends EditorPane { @IExtensionManifestPropertiesService private readonly extensionManifestPropertiesService: IExtensionManifestPropertiesService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IDialogService private readonly dialogService: IDialogService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkbenchConfigurationService private readonly configurationService: IWorkbenchConfigurationService, ) { super(WorkspaceTrustEditor.ID, telemetryService, themeService, storageService); } @@ -139,17 +604,19 @@ export class WorkspaceTrustEditor extends EditorPane { return 'workspace-trust-header workspace-trust-untrusted'; } - private useWorkspaceLanguage(): boolean { - return !isSingleFolderWorkspaceIdentifier(toWorkspaceIdentifier(this.workspaceService.getWorkspace())); - } - private getHeaderTitleText(trusted: boolean): string { - if (trusted) { - return this.useWorkspaceLanguage() ? localize('trustedHeaderWorkspace', "You trust this workspace") : localize('trustedHeaderFolder', "You trust this folder"); + switch (this.workspaceService.getWorkbenchState()) { + case WorkbenchState.EMPTY: + return localize('trustedHeaderWindow', "You trust this window"); + case WorkbenchState.FOLDER: + return localize('trustedHeaderFolder', "You trust this folder"); + case WorkbenchState.WORKSPACE: + return localize('trustedHeaderWorkspace', "You trust this workspace"); + } } - return this.useWorkspaceLanguage() ? localize('untrustedHeaderWorkspace', "You are in restricted mode") : localize('untrustedHeaderFolder', "You are in Restricted Mode"); + return localize('untrustedHeader', "You are in Restricted Mode"); } private getHeaderDescriptionText(trusted: boolean): string { @@ -164,6 +631,34 @@ export class WorkspaceTrustEditor extends EditorPane { return shieldIcon.classNamesArray; } + private getFeaturesHeaderText(trusted: boolean): [string, string] { + let title: string = ''; + let subTitle: string = ''; + + switch (this.workspaceService.getWorkbenchState()) { + case WorkbenchState.EMPTY: { + title = trusted ? localize('trustedWindow', "In a trusted window") : localize('untrustedWorkspace', "In Restricted Mode"); + subTitle = trusted ? localize('trustedWindowSubtitle', "You trust the authors of the files in the current window. All features are enabled:") : + localize('untrustedWindowSubtitle', "You do not trust the authors of the files in the current window. The following features are disabled:"); + break; + } + case WorkbenchState.FOLDER: { + title = trusted ? localize('trustedFolder', "In a trusted folder") : localize('untrustedWorkspace', "In Restricted Mode"); + subTitle = trusted ? localize('trustedFolderSubtitle', "You trust the authors of the files in the current folder. All features are enabled:") : + localize('untrustedFolderSubtitle', "You do not trust the authors of the files in the current folder. The following features are disabled:"); + break; + } + case WorkbenchState.WORKSPACE: { + title = trusted ? localize('trustedWorkspace', "In a trusted workspace") : localize('untrustedWorkspace', "In Restricted Mode"); + subTitle = trusted ? localize('trustedWorkspaceSubtitle', "You trust the authors of the files in the current workspace. All features are enabled:") : + localize('untrustedWorkspaceSubtitle', "You do not trust the authors of the files in the current workspace. The following features are disabled:"); + break; + } + } + + return [title, subTitle]; + } + private rendering = false; private rerenderDisposables: DisposableStore = this._register(new DisposableStore()); @debounce(100) @@ -201,7 +696,33 @@ export class WorkspaceTrustEditor extends EditorPane { this.rootElement.setAttribute('aria-label', `${localize('root element label', "Manage Workspace Trust")}: ${this.headerContainer.innerText}`); // Settings - const settingsRequiringTrustedWorkspaceCount = filterSettingsRequireWorkspaceTrust(this.configurationService.restrictedSettings.default).length; + const restrictedSettings = this.configurationService.restrictedSettings; + const configurationRegistry = Registry.as(Extensions.Configuration); + const settingsRequiringTrustedWorkspaceCount = restrictedSettings.default.filter(key => { + const property = configurationRegistry.getConfigurationProperties()[key]; + + // cannot be configured in workspace + if (property.scope === ConfigurationScope.APPLICATION || property.scope === ConfigurationScope.MACHINE) { + return false; + } + + // If deprecated include only those configured in the workspace + if (property.deprecationMessage || property.markdownDeprecationMessage) { + if (restrictedSettings.workspace?.includes(key)) { + return true; + } + if (restrictedSettings.workspaceFolder) { + for (const workspaceFolderSettings of restrictedSettings.workspaceFolder.values()) { + if (workspaceFolderSettings.includes(key)) { + return true; + } + } + } + return false; + } + + return true; + }).length; // Features List const installedExtensions = await this.instantiationService.invokeFunction(getInstalledExtensions); @@ -211,8 +732,7 @@ export class WorkspaceTrustEditor extends EditorPane { this.renderAffectedFeatures(settingsRequiringTrustedWorkspaceCount, onDemandExtensionCount + onStartExtensionCount); // Configuration Tree - this.workspaceTrustSettingsTreeModel.update(this.workspaceTrustManagementService.getTrustedFolders()); - this.trustSettingsTree.setChildren(null, Iterable.map(this.workspaceTrustSettingsTreeModel.settings, s => { return { element: s }; })); + this.workpaceTrustedUrisTable.updateTable(); this.bodyScrollBar.getDomNode().style.height = `calc(100% - ${this.headerContainer.clientHeight}px)`; this.bodyScrollBar.scanDomNode(); @@ -222,9 +742,9 @@ export class WorkspaceTrustEditor extends EditorPane { private getExtensionCountByUntrustedWorkspaceSupport(extensions: IExtensionStatus[], trustRequestType: ExtensionUntrustedWorkpaceSupportType): number { const filtered = extensions.filter(ext => this.extensionManifestPropertiesService.getExtensionUntrustedWorkspaceSupportType(ext.local.manifest) === trustRequestType); const set = new Set(); + const inVirtualWorkspace = isVirtualWorkspace(this.workspaceService.getWorkspace()); for (const ext of filtered) { - const isVirtualWorkspace = getVirtualWorkspaceScheme(this.workspaceService.getWorkspace()) !== undefined; - if (!isVirtualWorkspace || this.extensionManifestPropertiesService.canSupportVirtualWorkspace(ext.local.manifest)) { + if (!inVirtualWorkspace || this.extensionManifestPropertiesService.getExtensionVirtualWorkspaceSupportType(ext.local.manifest) !== false) { set.add(ext.identifier.id); } } @@ -241,20 +761,16 @@ export class WorkspaceTrustEditor extends EditorPane { } private createConfigurationElement(parent: HTMLElement): void { - this.configurationContainer = append(parent, $('.workspace-trust-settings.settings-editor')); + this.configurationContainer = append(parent, $('.workspace-trust-settings')); + const configurationTitle = append(this.configurationContainer, $('.workspace-trusted-folders-title')); + configurationTitle.innerText = localize('trustedFoldersAndWorkspaces', "Trusted Folders & Workspaces"); - const settingsBody = append(this.configurationContainer, $('.workspace-trust-settings-body.settings-body')); + const configurationDescription = append(this.configurationContainer, $('.workspace-trusted-folders-description')); + configurationDescription.innerText = localize('trustedFoldersDescription', "You trust the following folders, their children, and workspace files."); - const workspaceTrustTreeContainer = append(settingsBody, $('.workspace-trust-settings-tree-container.settings-tree-container')); - const renderer = this.instantiationService.createInstance(WorkspaceTrustSettingArrayRenderer,); + this.workpaceTrustedUrisTable = this._register(this.instantiationService.createInstance(WorkspaceTrustedUrisTable, this.configurationContainer)); - this.trustSettingsTree = this._register(this.instantiationService.createInstance(WorkspaceTrustTree, - workspaceTrustTreeContainer, - [renderer])); - this.workspaceTrustSettingsTreeModel = this.instantiationService.createInstance(WorkspaceTrustTreeModel); - - this._register(renderer.onDidChangeSetting(e => this.onDidChangeSetting(e))); } private createAffectedFeaturesElement(parent: HTMLElement): void { @@ -264,9 +780,8 @@ export class WorkspaceTrustEditor extends EditorPane { private renderAffectedFeatures(numSettings: number, numExtensions: number): void { clearNode(this.affectedFeaturesContainer); const trustedContainer = append(this.affectedFeaturesContainer, $('.workspace-trust-limitations.trusted')); - this.renderLimitationsHeaderElement(trustedContainer, - this.useWorkspaceLanguage() ? localize('trustedWorkspace', "In a trusted workspace") : localize('trustedFolder', "In a Trusted Folder"), - this.useWorkspaceLanguage() ? localize('trustedWorkspaceSubtitle', "You trust the authors of the files in the current workspace. All features are enabled:") : localize('trustedFolderSubtitle', "You trust the authors of the files in the current folder. All features are enabled:")); + const [trustedTitle, trustedSubTitle] = this.getFeaturesHeaderText(true); + this.renderLimitationsHeaderElement(trustedContainer, trustedTitle, trustedSubTitle); this.renderLimitationsListElement(trustedContainer, [ localize('trustedTasks', "Tasks are allowed to run"), localize('trustedDebugging', "Debugging is enabled"), @@ -275,9 +790,8 @@ export class WorkspaceTrustEditor extends EditorPane { ], checkListIcon.classNamesArray); const untrustedContainer = append(this.affectedFeaturesContainer, $('.workspace-trust-limitations.untrusted')); - this.renderLimitationsHeaderElement(untrustedContainer, - localize('untrustedWorkspace', "In Restricted Mode"), - this.useWorkspaceLanguage() ? localize('untrustedWorkspaceSubtitle', "You do not trust the authors of the files in the current workspace. The following features are disabled:") : localize('untrustedFolderSubtitle', "You do not trust the authors of the files in the current folder. The following features are disabled:")); + const [untrustedTitle, untrustedSubTitle] = this.getFeaturesHeaderText(false); + this.renderLimitationsHeaderElement(untrustedContainer, untrustedTitle, untrustedSubTitle); this.renderLimitationsListElement(untrustedContainer, [ localize('untrustedTasks', "Tasks are disabled"), @@ -369,8 +883,12 @@ export class WorkspaceTrustEditor extends EditorPane { } private addTrustedTextToElement(parent: HTMLElement): void { + if (this.workspaceService.getWorkbenchState() === WorkbenchState.EMPTY) { + return; + } + const textElement = append(parent, $('.workspace-trust-untrusted-description')); - textElement.innerText = this.useWorkspaceLanguage() ? localize('untrustedWorkspaceReason', "This workspace is trusted via one or more of the trusted folders below.") : localize('untrustedFolderReason', "This folder is trusted via one or more of the trusted folders below."); + textElement.innerText = this.workspaceService.getWorkbenchState() === WorkbenchState.WORKSPACE ? localize('untrustedWorkspaceReason', "This workspace is trusted via the bolded entries in the trusted folders below.") : localize('untrustedFolderReason', "This folder is trusted via the bolded entries in the the trusted folders below."); } private renderLimitationsHeaderElement(parent: HTMLElement, headerText: string, subtitleText: string): void { @@ -406,37 +924,13 @@ export class WorkspaceTrustEditor extends EditorPane { } } - private onDidChangeSetting(change: IWorkspaceTrustSettingChangeEvent) { - const applyChangesWithPrompt = async (showPrompt: boolean, applyChanges: () => void) => { - if (showPrompt) { - const message = localize('workspaceTrustSettingModificationMessage', "Update Workspace Trust Settings"); - const detail = localize('workspaceTrustTransitionDetail', "In order to safely complete this action, all affected windows will have to be reloaded. Are you sure you want to proceed with this action?"); - const primaryButton = localize('workspaceTrustTransitionPrimaryButton', "Yes"); - const secondaryButton = localize('workspaceTrustTransitionSecondaryButton', "No"); - - const result = await this.dialogService.show(Severity.Info, message, [primaryButton, secondaryButton], { cancelId: 1, detail, custom: { icon: Codicon.shield } }); - if (result.choice !== 0) { - return; - } - } - - applyChanges(); - }; - - if (isArray(change.value)) { - if (change.key === 'trustedFolders') { - applyChangesWithPrompt(false, () => this.workspaceTrustManagementService.setTrustedFolders(change.value!)); - } - } - } - private layoutParticipants: { layout: () => void; }[] = []; layout(dimension: Dimension): void { if (!this.isVisible()) { return; } - this.trustSettingsTree.layout(dimension.height, dimension.width); + this.workpaceTrustedUrisTable.layout(); this.layoutParticipants.forEach(participant => { participant.layout(); diff --git a/src/vs/workbench/contrib/workspace/browser/workspaceTrustTree.ts b/src/vs/workbench/contrib/workspace/browser/workspaceTrustTree.ts deleted file mode 100644 index 2c3e7d6eff5..00000000000 --- a/src/vs/workbench/contrib/workspace/browser/workspaceTrustTree.ts +++ /dev/null @@ -1,619 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { addDisposableListener, append, EventType, $, createStyleSheet, trackFocus, addStandardDisposableListener } from 'vs/base/browser/dom'; -import { DefaultStyleController, IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; -import { IList } from 'vs/base/browser/ui/tree/indexTreeModel'; -import { IObjectTreeOptions } from 'vs/base/browser/ui/tree/objectTree'; -import { ITreeModel, ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; -import { Color, RGBA } from 'vs/base/common/color'; -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore, dispose } from 'vs/base/common/lifecycle'; -import { isArray } from 'vs/base/common/types'; -import { localize } from 'vs/nls'; -import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; -import { ICommandService } from 'vs/platform/commands/common/commands'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { IListService, WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; -import { IOpenerService } from 'vs/platform/opener/common/opener'; -import { editorBackground, errorForeground, focusBorder, foreground, inputValidationErrorBackground, inputValidationErrorBorder, inputValidationErrorForeground } from 'vs/platform/theme/common/colorRegistry'; -import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant, ThemeIcon } from 'vs/platform/theme/common/themeService'; -import { NonCollapsibleObjectTreeModel } from 'vs/workbench/contrib/preferences/browser/settingsTree'; -import { AbstractListSettingWidget, focusedRowBackground, focusedRowBorder, ISettingListChangeEvent, settingsHeaderForeground, settingsSelectBackground, settingsTextInputBorder, settingsTextInputForeground } from 'vs/workbench/contrib/preferences/browser/settingsWidgets'; -import { attachButtonStyler, attachInputBoxStyler, attachStyler } from 'vs/platform/theme/common/styler'; -import { CachedListVirtualDelegate } from 'vs/base/browser/ui/list/list'; -import { IAction } from 'vs/base/common/actions'; -import { settingsEditIcon, settingsRemoveIcon } from 'vs/workbench/contrib/preferences/browser/preferencesIcons'; -import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { KeyCode } from 'vs/base/common/keyCodes'; -import { InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; -import { Button } from 'vs/base/browser/ui/button/button'; -import { disposableTimeout } from 'vs/base/common/async'; -import { URI, UriComponents } from 'vs/base/common/uri'; -import { Schemas } from 'vs/base/common/network'; -import { ILabelService } from 'vs/platform/label/common/label'; - - -export class WorkspaceTrustSettingsTreeEntry { - id: string; - displayLabel: string; - setting: { - key: string; - description: string; - }; - value: URI[]; - - constructor(key: string, displayLabel: string, description: string, value: URI[]) { - this.setting = { key, description }; - this.displayLabel = displayLabel; - this.value = value; - this.id = key; - } -} - -export interface IWorkspaceTrustSettingItemTemplate { - onChange?: (value: T, type: WorkspaceTrustSettingListItemChangeType) => void; - - toDispose: DisposableStore; - context?: WorkspaceTrustSettingsTreeEntry; - containerElement: HTMLElement; - labelElement: HTMLElement; - descriptionElement: HTMLElement; - controlElement: HTMLElement; - elementDisposables: DisposableStore; -} - -export interface IWorkspaceTrustUriDataItem extends UriComponents { } - -class WorkspaceTrustFolderSettingWidget extends AbstractListSettingWidget { - constructor( - container: HTMLElement, - @ILabelService protected readonly labelService: ILabelService, - @IThemeService themeService: IThemeService, - @IContextViewService contextViewService: IContextViewService - ) { - super(container, themeService, contextViewService); - } - - protected getEmptyItem(): IWorkspaceTrustUriDataItem { - return URI.file(''); - } - - protected getContainerClasses() { - return ['workspace-trust-uri-setting-widget', 'setting-list-object-widget']; - } - - protected getActionsForItem(item: IWorkspaceTrustUriDataItem, idx: number): IAction[] { - return [ - { - class: ThemeIcon.asClassName(settingsEditIcon), - enabled: true, - id: 'workbench.action.editListItem', - tooltip: this.getLocalizedStrings().editActionTooltip, - run: () => this.editSetting(idx) - }, - { - class: ThemeIcon.asClassName(settingsRemoveIcon), - enabled: true, - id: 'workbench.action.removeListItem', - tooltip: this.getLocalizedStrings().deleteActionTooltip, - run: () => this._onDidChangeList.fire({ originalItem: item, item: undefined, targetIndex: idx }) - } - ] as IAction[]; - } - - protected override renderHeader() { - const header = $('.setting-list-row-header'); - const hostHeader = append(header, $('.setting-list-object-key')); - const pathHeader = append(header, $('.setting-list-object-value')); - const { hostHeaderText, pathHeaderText } = this.getLocalizedStrings(); - - hostHeader.textContent = hostHeaderText; - pathHeader.textContent = pathHeaderText; - - return header; - } - - protected renderItem(item: IWorkspaceTrustUriDataItem): HTMLElement { - const rowElement = $('.setting-list-row'); - rowElement.classList.add('setting-list-object-row'); - - const hostElement = append(rowElement, $('.setting-list-object-key')); - const pathElement = append(rowElement, $('.setting-list-object-value')); - - hostElement.textContent = item.authority ? this.labelService.getHostLabel(item.scheme, item.authority) : localize('localAuthority', "Local"); - pathElement.textContent = item.scheme === Schemas.file ? URI.revive(item).fsPath : item.path; - - return rowElement; - } - - - protected renderEdit(item: IWorkspaceTrustUriDataItem, idx: number): HTMLElement { - const rowElement = $('.setting-list-edit-row'); - - const hostElement = append(rowElement, $('.setting-list-object-key')); - hostElement.textContent = item.authority ? this.labelService.getHostLabel(item.scheme, item.authority) : localize('localAuthority', "Local"); - - const updatedItem = () => { - if (item.scheme === Schemas.file) { - return URI.file(pathInput.value); - } else { - return URI.revive(item).with({ path: pathInput.value }); - } - }; - - const onKeyDown = (e: StandardKeyboardEvent) => { - if (e.equals(KeyCode.Enter)) { - this.handleItemChange(item, updatedItem(), idx); - } else if (e.equals(KeyCode.Escape)) { - this.cancelEdit(); - e.preventDefault(); - } - rowElement?.focus(); - }; - - const pathInput = new InputBox(rowElement, this.contextViewService, { - placeholder: this.getLocalizedStrings().inputPlaceholder - }); - - pathInput.element.classList.add('setting-list-valueInput'); - this.listDisposables.add(attachInputBoxStyler(pathInput, this.themeService, { - inputBackground: settingsSelectBackground, - inputForeground: settingsTextInputForeground, - inputBorder: settingsTextInputBorder - })); - this.listDisposables.add(pathInput); - pathInput.value = item.scheme === Schemas.file ? URI.revive(item).fsPath : item.path; - - this.listDisposables.add( - addStandardDisposableListener(pathInput.inputElement, EventType.KEY_DOWN, onKeyDown) - ); - - const okButton = this._register(new Button(rowElement)); - okButton.label = localize('okButton', "OK"); - okButton.element.classList.add('setting-list-ok-button'); - - this.listDisposables.add(attachButtonStyler(okButton, this.themeService)); - this.listDisposables.add(okButton.onDidClick(() => this.handleItemChange(item, updatedItem(), idx))); - - const cancelButton = this._register(new Button(rowElement)); - cancelButton.label = localize('cancelButton', "Cancel"); - cancelButton.element.classList.add('setting-list-cancel-button'); - - this.listDisposables.add(attachButtonStyler(cancelButton, this.themeService)); - this.listDisposables.add(cancelButton.onDidClick(() => this.cancelEdit())); - - this.listDisposables.add( - disposableTimeout(() => { - pathInput.focus(); - pathInput.select(); - }) - ); - - return rowElement; - } - - protected isItemNew(item: IWorkspaceTrustUriDataItem): boolean { - return item.path === ''; - } - - protected getLocalizedRowTitle(item: IWorkspaceTrustUriDataItem): string { - return localize('trustedRow', "Trusted Path: {0}", this.labelService.getUriLabel(URI.from(item))); - } - - protected getLocalizedStrings() { - return { - deleteActionTooltip: localize('removePath', "Remove Path"), - editActionTooltip: localize('editPath', "Edit Path"), - addButtonLabel: localize('addPath', "Add Path"), - hostHeaderText: localize('hostHeaderText', "Host"), - pathHeaderText: localize('pathHeaderText', "Path"), - inputPlaceholder: localize('pathInputPlaceholder', "Path Item..."), - }; - } -} - -interface IWorkspaceTrustSettingListItemTemplate extends IWorkspaceTrustSettingItemTemplate { - listWidget: WorkspaceTrustFolderSettingWidget; - validationErrorMessageElement: HTMLElement; -} - -export type WorkspaceTrustSettingListItemChangeType = 'added' | 'removed' | 'changed'; -export interface IWorkspaceTrustSettingChangeEvent { - key: string; - value: URI[] | undefined; // undefined => reset/unconfigure - type: WorkspaceTrustSettingListItemChangeType; -} - - -export class WorkspaceTrustSettingArrayRenderer extends Disposable implements ITreeRenderer { - templateId = 'template.setting.array'; - - static readonly CONTROL_CLASS = 'setting-control-focus-target'; - static readonly CONTROL_SELECTOR = '.' + WorkspaceTrustSettingArrayRenderer.CONTROL_CLASS; - static readonly CONTENTS_CLASS = 'setting-item-contents'; - static readonly CONTENTS_SELECTOR = '.' + WorkspaceTrustSettingArrayRenderer.CONTENTS_CLASS; - static readonly ALL_ROWS_SELECTOR = '.monaco-list-row'; - - static readonly SETTING_KEY_ATTR = 'data-key'; - static readonly SETTING_ID_ATTR = 'data-id'; - static readonly ELEMENT_FOCUSABLE_ATTR = 'data-focusable'; - - protected readonly _onDidChangeSetting = this._register(new Emitter()); - readonly onDidChangeSetting: Event = this._onDidChangeSetting.event; - - private readonly _onDidFocusSetting = this._register(new Emitter()); - readonly onDidFocusSetting: Event = this._onDidFocusSetting.event; - - private readonly _onDidChangeIgnoredSettings = this._register(new Emitter()); - readonly onDidChangeIgnoredSettings: Event = this._onDidChangeIgnoredSettings.event; - - constructor( - @IThemeService protected readonly _themeService: IThemeService, - @IContextViewService protected readonly _contextViewService: IContextViewService, - @IOpenerService protected readonly _openerService: IOpenerService, - @IInstantiationService protected readonly _instantiationService: IInstantiationService, - @ICommandService protected readonly _commandService: ICommandService, - @IContextMenuService protected readonly _contextMenuService: IContextMenuService, - @IKeybindingService protected readonly _keybindingService: IKeybindingService, - @IConfigurationService protected readonly _configService: IConfigurationService, - ) { - super(); - } - - renderCommonTemplate(tree: any, _container: HTMLElement, typeClass: string): IWorkspaceTrustSettingItemTemplate { - _container.classList.add('setting-item'); - _container.classList.add('setting-item-' + typeClass); - - const container = append(_container, $(WorkspaceTrustSettingArrayRenderer.CONTENTS_SELECTOR)); - container.classList.add('settings-row-inner-container'); - const titleElement = append(container, $('.setting-item-title')); - const labelCategoryContainer = append(titleElement, $('.setting-item-cat-label-container')); - const labelElement = append(labelCategoryContainer, $('span.setting-item-label')); - const descriptionElement = append(container, $('.setting-item-description')); - const modifiedIndicatorElement = append(container, $('.setting-item-modified-indicator')); - modifiedIndicatorElement.title = localize('modified', "Modified"); - - const valueElement = append(container, $('.setting-item-value')); - const controlElement = append(valueElement, $('div.setting-item-control')); - const toDispose = new DisposableStore(); - - const template: IWorkspaceTrustSettingItemTemplate = { - toDispose, - elementDisposables: new DisposableStore(), - containerElement: container, - labelElement, - descriptionElement, - controlElement - }; - - // Prevent clicks from being handled by list - toDispose.add(addDisposableListener(controlElement, EventType.MOUSE_DOWN, e => e.stopPropagation())); - - toDispose.add(addDisposableListener(titleElement, EventType.MOUSE_ENTER, e => container.classList.add('mouseover'))); - toDispose.add(addDisposableListener(titleElement, EventType.MOUSE_LEAVE, e => container.classList.remove('mouseover'))); - - return template; - } - - addSettingElementFocusHandler(template: IWorkspaceTrustSettingItemTemplate): void { - const focusTracker = trackFocus(template.containerElement); - template.toDispose.add(focusTracker); - focusTracker.onDidBlur(() => { - if (template.containerElement.classList.contains('focused')) { - template.containerElement.classList.remove('focused'); - } - }); - - focusTracker.onDidFocus(() => { - template.containerElement.classList.add('focused'); - - if (template.context) { - this._onDidFocusSetting.fire(template.context); - } - }); - } - - renderTemplate(container: HTMLElement): IWorkspaceTrustSettingListItemTemplate { - const common = this.renderCommonTemplate(null, container, 'list'); - const descriptionElement = common.containerElement.querySelector('.setting-item-description')!; - const validationErrorMessageElement = $('.setting-item-validation-message'); - descriptionElement.after(validationErrorMessageElement); - - const listWidget = this._instantiationService.createInstance(WorkspaceTrustFolderSettingWidget, common.controlElement); - listWidget.domNode.classList.add(WorkspaceTrustSettingArrayRenderer.CONTROL_CLASS); - common.toDispose.add(listWidget); - - const template: IWorkspaceTrustSettingListItemTemplate = { - ...common, - listWidget, - validationErrorMessageElement - }; - - this.addSettingElementFocusHandler(template); - - common.toDispose.add( - listWidget.onDidChangeList(e => { - const { list: newList, changeType } = this.computeNewList(template, e); - if (newList !== null && template.onChange) { - template.onChange(newList, changeType); - } - }) - ); - - return template; - } - - private computeNewList(template: IWorkspaceTrustSettingListItemTemplate, e: ISettingListChangeEvent): { list: URI[] | null, changeType: WorkspaceTrustSettingListItemChangeType } { - if (template.context) { - let newValue: URI[] = []; - - let changeType: WorkspaceTrustSettingListItemChangeType = 'changed'; - if (isArray(template.context.value)) { - newValue = [...template.context.value]; - } - - if (e.targetIndex !== undefined) { - // Delete value - if (!e.item?.path && e.originalItem.path && e.targetIndex > -1) { - newValue.splice(e.targetIndex, 1); - changeType = 'removed'; - } - // Update value - else if (e.item?.path && e.originalItem.path) { - if (e.targetIndex > -1) { - newValue[e.targetIndex] = URI.revive(e.item); - changeType = e.targetIndex < template.context.value.length ? 'changed' : 'added'; - } - // For some reason, we are updating and cannot find original value - // Just append the value in this case - else { - newValue.push(URI.revive(e.item)); - changeType = 'added'; - } - } - // Add value - else if (e.item?.path && !e.originalItem.path && e.targetIndex >= newValue.length) { - newValue.push(URI.revive(e.item)); - changeType = 'added'; - } - } - - return { list: newValue, changeType }; - } - - return { list: null, changeType: 'changed' }; - } - - renderElement(node: ITreeNode, index: number, template: IWorkspaceTrustSettingListItemTemplate): void { - const element = node.element; - template.context = element; - - template.containerElement.setAttribute(WorkspaceTrustSettingArrayRenderer.SETTING_KEY_ATTR, element.setting.key); - template.containerElement.setAttribute(WorkspaceTrustSettingArrayRenderer.SETTING_ID_ATTR, element.id); - - template.labelElement.textContent = element.displayLabel; - - template.descriptionElement.innerText = element.setting.description; - - const onChange = (value: any, type: WorkspaceTrustSettingListItemChangeType) => this._onDidChangeSetting.fire({ key: element.setting.key, value, type }); - this.renderValue(element, template, onChange); - } - - protected renderValue(dataElement: WorkspaceTrustSettingsTreeEntry, template: IWorkspaceTrustSettingListItemTemplate, onChange: (value: URI[] | undefined, type: WorkspaceTrustSettingListItemChangeType) => void): void { - const value = getListDisplayValue(dataElement); - template.listWidget.setValue(value); - template.context = dataElement; - - template.onChange = (v, t) => { - onChange(v, t); - renderArrayValidations(dataElement, template, v, false); - }; - - renderArrayValidations(dataElement, template, value.map(v => URI.revive(v)), true); - } - - disposeTemplate(template: IWorkspaceTrustSettingItemTemplate): void { - dispose(template.toDispose); - } - - disposeElement(_element: ITreeNode, _index: number, template: IWorkspaceTrustSettingItemTemplate, _height: number | undefined): void { - if (template.elementDisposables) { - template.elementDisposables.clear(); - } - } -} - -export class WorkspaceTrustTree extends WorkbenchObjectTree { - constructor( - container: HTMLElement, - renderers: ITreeRenderer[], - @IContextKeyService contextKeyService: IContextKeyService, - @IListService listService: IListService, - @IThemeService themeService: IThemeService, - @IConfigurationService configurationService: IConfigurationService, - @IKeybindingService keybindingService: IKeybindingService, - @IAccessibilityService accessibilityService: IAccessibilityService, - @IInstantiationService instantiationService: IInstantiationService, - ) { - super('WorkspaceTrustTree', container, - new WorkspaceTrustTreeDelegate(), - renderers, - { - horizontalScrolling: false, - alwaysConsumeMouseWheel: false, - supportDynamicHeights: true, - identityProvider: { - getId(e) { - return e.id; - } - }, - accessibilityProvider: new WorkspaceTrustTreeAccessibilityProvider(), - styleController: id => new DefaultStyleController(createStyleSheet(container), id), - smoothScrolling: configurationService.getValue('workbench.list.smoothScrolling'), - multipleSelectionSupport: false, - }, - contextKeyService, - listService, - themeService, - configurationService, - keybindingService, - accessibilityService, - ); - - this.disposables.add(registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { - const foregroundColor = theme.getColor(foreground); - if (foregroundColor) { - // Links appear inside other elements in markdown. CSS opacity acts like a mask. So we have to dynamically compute the description color to avoid - // applying an opacity to the link color. - const fgWithOpacity = new Color(new RGBA(foregroundColor.rgba.r, foregroundColor.rgba.g, foregroundColor.rgba.b, 0.9)); - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-description { color: ${fgWithOpacity}; }`); - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .settings-toc-container .monaco-list-row:not(.selected) { color: ${fgWithOpacity}; }`); - - // Hack for subpixel antialiasing - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-title .setting-item-overrides, - .workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-title .setting-item-ignored { color: ${fgWithOpacity}; }`); - } - - const errorColor = theme.getColor(errorForeground); - if (errorColor) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-deprecation-message { color: ${errorColor}; }`); - } - - const invalidInputBackground = theme.getColor(inputValidationErrorBackground); - if (invalidInputBackground) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-validation-message { background-color: ${invalidInputBackground}; }`); - } - - const invalidInputForeground = theme.getColor(inputValidationErrorForeground); - if (invalidInputForeground) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-validation-message { color: ${invalidInputForeground}; }`); - } - - const invalidInputBorder = theme.getColor(inputValidationErrorBorder); - if (invalidInputBorder) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-validation-message { border-style:solid; border-width: 1px; border-color: ${invalidInputBorder}; }`); - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item.invalid-input .setting-item-control .monaco-inputbox.idle { outline-width: 0; border-style:solid; border-width: 1px; border-color: ${invalidInputBorder}; }`); - } - - const focusedRowBackgroundColor = theme.getColor(focusedRowBackground); - if (focusedRowBackgroundColor) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .monaco-list-row.focused .settings-row-inner-container { background-color: ${focusedRowBackgroundColor}; }`); - } - - const focusedRowBorderColor = theme.getColor(focusedRowBorder); - if (focusedRowBorderColor) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .monaco-list:focus-within .monaco-list-row.focused .setting-item-contents::before, - .workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .monaco-list:focus-within .monaco-list-row.focused .setting-item-contents::after { border-top: 1px solid ${focusedRowBorderColor} }`); - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .monaco-list:focus-within .monaco-list-row.focused .settings-group-title-label::before, - .workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .monaco-list:focus-within .monaco-list-row.focused .settings-group-title-label::after { border-top: 1px solid ${focusedRowBorderColor} }`); - } - - const headerForegroundColor = theme.getColor(settingsHeaderForeground); - if (headerForegroundColor) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .settings-group-title-label { color: ${headerForegroundColor}; }`); - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-label { color: ${headerForegroundColor}; }`); - } - - const focusBorderColor = theme.getColor(focusBorder); - if (focusBorderColor) { - collector.addRule(`.workspace-trust-editor .workspace-trust-settings .workspace-trust-settings-tree-container .setting-item-contents .setting-item-markdown a:focus { outline-color: ${focusBorderColor} }`); - } - })); - - this.getHTMLElement().classList.add('settings-editor-tree'); - - this.disposables.add(attachStyler(themeService, { - listBackground: editorBackground, - listActiveSelectionBackground: editorBackground, - listActiveSelectionForeground: foreground, - listFocusAndSelectionBackground: editorBackground, - listFocusAndSelectionForeground: foreground, - listFocusBackground: editorBackground, - listFocusForeground: foreground, - listHoverForeground: foreground, - listHoverBackground: editorBackground, - listHoverOutline: editorBackground, - listFocusOutline: editorBackground, - listInactiveSelectionBackground: editorBackground, - listInactiveSelectionForeground: foreground, - listInactiveFocusBackground: editorBackground, - listInactiveFocusOutline: editorBackground - }, colors => { - this.style(colors); - })); - - this.disposables.add(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('workbench.list.smoothScrolling')) { - this.updateOptions({ - smoothScrolling: configurationService.getValue('workbench.list.smoothScrolling') - }); - } - })); - } - - protected override createModel(user: string, view: IList>, options: IObjectTreeOptions): ITreeModel { - return new NonCollapsibleObjectTreeModel(user, view, options); - } -} - -export class WorkspaceTrustTreeModel { - - settings: WorkspaceTrustSettingsTreeEntry[] = []; - - update(trustedFolders: URI[]): void { - this.settings = []; - this.settings.push(new WorkspaceTrustSettingsTreeEntry( - 'trustedFolders', - localize('trustedFolders', "Trusted Folders"), - localize('trustedFoldersDescription', "You trust the following folders and their children: "), - trustedFolders)); - } -} - -class WorkspaceTrustTreeAccessibilityProvider implements IListAccessibilityProvider { - getAriaLabel(element: WorkspaceTrustSettingsTreeEntry) { - if (element instanceof WorkspaceTrustSettingsTreeEntry) { - return `element.displayLabel`; - } - - return null; - } - - getWidgetAriaLabel() { - return localize('settings', "Workspace Trust Setting"); - } -} - -class WorkspaceTrustTreeDelegate extends CachedListVirtualDelegate { - - getTemplateId(element: WorkspaceTrustSettingsTreeEntry): string { - return 'template.setting.array'; - } - - hasDynamicHeight(element: WorkspaceTrustSettingsTreeEntry): boolean { - return true; - } - - protected estimateHeight(element: WorkspaceTrustSettingsTreeEntry): number { - return 104; - } -} - -function getListDisplayValue(element: WorkspaceTrustSettingsTreeEntry): IWorkspaceTrustUriDataItem[] { - if (!element.value || !isArray(element.value)) { - return []; - } - - return element.value; -} - -function renderArrayValidations(dataElement: WorkspaceTrustSettingsTreeEntry, template: IWorkspaceTrustSettingListItemTemplate, v: URI[] | undefined, arg3: boolean) { -} - diff --git a/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts b/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts index e168d463051..c27cd8e4f63 100644 --- a/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts +++ b/src/vs/workbench/electron-sandbox/sandbox.simpleservices.ts @@ -12,7 +12,7 @@ import { IAddressProvider } from 'vs/platform/remote/common/remoteAgentConnectio import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IExtensionService, NullExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { isWindows } from 'vs/base/common/platform'; -import { IWebviewService, WebviewContentOptions, WebviewElement, WebviewExtensionDescription, WebviewOptions, WebviewOverlay } from 'vs/workbench/contrib/webview/browser/webview'; +import { IWebviewService, Webview, WebviewContentOptions, WebviewElement, WebviewExtensionDescription, WebviewOptions, WebviewOverlay } from 'vs/workbench/contrib/webview/browser/webview'; import { ITunnelProvider, ITunnelService, RemoteTunnel, TunnelProviderFeatures } from 'vs/platform/remote/common/tunnel'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { joinPath } from 'vs/base/common/resources'; @@ -270,6 +270,7 @@ class SimpleWebviewService implements IWebviewService { declare readonly _serviceBrand: undefined; readonly activeWebview = undefined; + readonly webviews: Webview[] = []; readonly onDidChangeActiveWebview = Event.None; createWebviewElement(id: string, options: WebviewOptions, contentOptions: WebviewContentOptions, extension: WebviewExtensionDescription | undefined): WebviewElement { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/electron-sandbox/window.ts b/src/vs/workbench/electron-sandbox/window.ts index 23c3f609359..2035ba6adce 100644 --- a/src/vs/workbench/electron-sandbox/window.ts +++ b/src/vs/workbench/electron-sandbox/window.ts @@ -525,6 +525,21 @@ export class NativeWindow extends Disposable { } } } + + // Assume `uri` this is a workspace uri, let's see if we can handle it + await this.fileService.activateProvider(uri.scheme); + + if (this.fileService.canHandleResource(uri)) { + return { + resolved: URI.from({ + scheme: this.productService.urlProtocol, + path: 'workspace', + query: uri.toString() + }), + dispose() { } + }; + } + return undefined; } }); @@ -661,12 +676,7 @@ export class NativeWindow extends Disposable { return this.editorService.openEditor({ leftResource: resources[0].resource, rightResource: resources[1].resource, options: { pinned: true } }); } - // For one file, just put it into the current active editor - if (resources.length === 1) { - return this.editorService.openEditor(resources[0]); - } - - // Otherwise open all + // Open resource(s) return this.editorService.openEditors(resources); } } diff --git a/src/vs/workbench/services/activity/common/activity.ts b/src/vs/workbench/services/activity/common/activity.ts index 8eda8c67e8e..1f9fc1f0d1f 100644 --- a/src/vs/workbench/services/activity/common/activity.ts +++ b/src/vs/workbench/services/activity/common/activity.ts @@ -46,7 +46,7 @@ export interface IBadge { class BaseBadge implements IBadge { - constructor(public readonly descriptorFn: (arg: any) => string) { + constructor(readonly descriptorFn: (arg: any) => string) { this.descriptorFn = descriptorFn; } @@ -57,7 +57,7 @@ class BaseBadge implements IBadge { export class NumberBadge extends BaseBadge { - constructor(public readonly number: number, descriptorFn: (num: number) => string) { + constructor(readonly number: number, descriptorFn: (num: number) => string) { super(descriptorFn); this.number = number; @@ -70,13 +70,13 @@ export class NumberBadge extends BaseBadge { export class TextBadge extends BaseBadge { - constructor(public readonly text: string, descriptorFn: () => string) { + constructor(readonly text: string, descriptorFn: () => string) { super(descriptorFn); } } export class IconBadge extends BaseBadge { - constructor(public readonly icon: ThemeIcon, descriptorFn: () => string) { + constructor(readonly icon: ThemeIcon, descriptorFn: () => string) { super(descriptorFn); } } diff --git a/src/vs/workbench/services/banner/browser/bannerService.ts b/src/vs/workbench/services/banner/browser/bannerService.ts index 859bf6035e8..15f9fe15afd 100644 --- a/src/vs/workbench/services/banner/browser/bannerService.ts +++ b/src/vs/workbench/services/banner/browser/bannerService.ts @@ -7,16 +7,15 @@ import { Codicon } from 'vs/base/common/codicons'; import { MarkdownString } from 'vs/base/common/htmlContent'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ILinkDescriptor } from 'vs/platform/opener/browser/link'; -import { StorageScope } from 'vs/platform/storage/common/storage'; export interface IBannerItem { readonly id: string; readonly icon: Codicon; readonly message: string | MarkdownString; - readonly scope?: StorageScope; /* Used to remember that the banner has been closed. */ readonly actions?: ILinkDescriptor[]; readonly ariaLabel?: string; + readonly onClose?: () => void; } export const IBannerService = createDecorator('bannerService'); diff --git a/src/vs/workbench/services/configuration/common/configuration.ts b/src/vs/workbench/services/configuration/common/configuration.ts index 9cff28447ca..105b65223d7 100644 --- a/src/vs/workbench/services/configuration/common/configuration.ts +++ b/src/vs/workbench/services/configuration/common/configuration.ts @@ -3,13 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ConfigurationScope, Extensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; import { URI } from 'vs/base/common/uri'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { refineServiceDecorator } from 'vs/platform/instantiation/common/instantiation'; import { Event } from 'vs/base/common/event'; import { ResourceMap } from 'vs/base/common/map'; -import { Registry } from 'vs/platform/registry/common/platform'; export const FOLDER_CONFIG_FOLDER_NAME = '.vscode'; export const FOLDER_SETTINGS_NAME = 'settings'; @@ -48,14 +47,6 @@ export interface IConfigurationCache { } -export function filterSettingsRequireWorkspaceTrust(settings: ReadonlyArray): ReadonlyArray { - const configurationRegistry = Registry.as(Extensions.Configuration); - return settings.filter(key => { - const property = configurationRegistry.getConfigurationProperties()[key]; - return property.restricted && property.scope !== ConfigurationScope.APPLICATION && property.scope !== ConfigurationScope.MACHINE; - }); -} - export type RestrictedSettings = { default: ReadonlyArray; userLocal?: ReadonlyArray; diff --git a/src/vs/workbench/services/contextmenu/electron-sandbox/contextmenuService.ts b/src/vs/workbench/services/contextmenu/electron-sandbox/contextmenuService.ts index deefcdc30ba..b6ef5caeb41 100644 --- a/src/vs/workbench/services/contextmenu/electron-sandbox/contextmenuService.ts +++ b/src/vs/workbench/services/contextmenu/electron-sandbox/contextmenuService.ts @@ -25,6 +25,7 @@ import { IThemeService } from 'vs/platform/theme/common/themeService'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { stripIcons } from 'vs/base/common/iconLabels'; import { coalesce } from 'vs/base/common/arrays'; +import { Emitter } from 'vs/base/common/event'; export class ContextMenuService extends Disposable implements IContextMenuService { @@ -32,6 +33,9 @@ export class ContextMenuService extends Disposable implements IContextMenuServic private impl: IContextMenuService; + private readonly _onDidShowContextMenu = this._register(new Emitter()); + readonly onDidShowContextMenu = this._onDidShowContextMenu.event; + constructor( @INotificationService notificationService: INotificationService, @ITelemetryService telemetryService: ITelemetryService, @@ -56,6 +60,7 @@ export class ContextMenuService extends Disposable implements IContextMenuServic showContextMenu(delegate: IContextMenuDelegate): void { this.impl.showContextMenu(delegate); + this._onDidShowContextMenu.fire(); } } @@ -63,6 +68,8 @@ class NativeContextMenuService extends Disposable implements IContextMenuService declare readonly _serviceBrand: undefined; + readonly onDidShowContextMenu = new Emitter().event; + constructor( @INotificationService private readonly notificationService: INotificationService, @ITelemetryService private readonly telemetryService: ITelemetryService, diff --git a/src/vs/workbench/services/editor/browser/editorOverrideService.ts b/src/vs/workbench/services/editor/browser/editorOverrideService.ts index bcb8ad48584..13a06d4fdc5 100644 --- a/src/vs/workbench/services/editor/browser/editorOverrideService.ts +++ b/src/vs/workbench/services/editor/browser/editorOverrideService.ts @@ -40,9 +40,13 @@ type ContributionPoints = Array; export class EditorOverrideService extends Disposable implements IEditorOverrideService { readonly _serviceBrand: undefined; + // Constants private static readonly configureDefaultID = 'promptOpenWith.configureDefault'; - private _contributionPoints: Map = new Map(); private static readonly overrideCacheStorageID = 'editorOverrideService.cache'; + private static readonly conflictingDefaultsStorageID = 'editorOverrideService.conflictingDefaults'; + + // Data Stores + private _contributionPoints: Map = new Map(); private cache: Set | undefined; constructor( @@ -129,13 +133,10 @@ export class EditorOverrideService extends Disposable implements IEditorOverride } const input = await this.doOverrideEditorInput(editor, options, group, selectedContribution); if (conflictingDefault && input) { - // Wait one second to give the user ample time to see the current editor then ask them to configure a default - this.doHandleConflictingDefaults(selectedContribution.editorInfo.label, input.editor, input.options ?? options, group); - } - // Dispose of the passed in editor as we will return a new one - if (!input?.editor.matches(editor)) { - editor.dispose(); + // Show the conflicting default dialog + await this.doHandleConflictingDefaults(selectedContribution.editorInfo.label, input.editor, input.options ?? options, group); } + // Add the group as we might've changed it with the quickpick if (input) { this.sendOverrideTelemetry(input.editor); @@ -164,8 +165,8 @@ export class EditorOverrideService extends Disposable implements IEditorOverride return toDisposable(() => remove()); } - hasContributionPoint(schemeOrGlob: string): boolean { - return this._contributionPoints.has(schemeOrGlob); + hasContributionPoint(glob: string): boolean { + return this._contributionPoints.has(glob); } getAssociationsForResource(resource: URI): EditorAssociations { @@ -226,12 +227,15 @@ export class EditorOverrideService extends Disposable implements IEditorOverride } private findMatchingContributions(resource: URI): ContributionPoint[] { + // The user setting should be respected even if the editor doesn't specify that resource in package.json + const userSettings = this.getAssociationsForResource(resource); let contributions: ContributionPoint[] = []; // Then all glob patterns for (const key of this._contributionPoints.keys()) { const contributionPoints = this._contributionPoints.get(key)!; for (const contributionPoint of contributionPoints) { - if (globMatchesResource(key, resource)) { + const foundInSettings = userSettings.find(setting => setting.viewType === contributionPoint.editorInfo.id); + if (foundInSettings || globMatchesResource(key, resource)) { contributions.push(contributionPoint); } } @@ -367,12 +371,23 @@ export class EditorOverrideService extends Disposable implements IEditorOverride } private async doHandleConflictingDefaults(editorName: string, currentEditor: IContributedEditorInput, options: IEditorOptions | undefined, group: IEditorGroup) { - const makeCurrentEditorDefault = () => { - const viewType = currentEditor.viewType; - if (viewType) { - this.updateUserAssociations(`*${extname(currentEditor.resource!)}`, viewType); - } + type StoredChoice = { + [key: string]: string[]; }; + const contributionPoints = this.findMatchingContributions(currentEditor.resource!); + const storedChoices: StoredChoice = JSON.parse(this.storageService.get(EditorOverrideService.conflictingDefaultsStorageID, StorageScope.GLOBAL, '{}')); + const globForResource = `*${extname(currentEditor.resource!)}`; + // Writes to the storage service that a choice has been made for the currently installed editors + const writeCurrentEditorsToStorage = () => { + storedChoices[globForResource] = []; + contributionPoints.forEach(contrib => storedChoices[globForResource].push(contrib.editorInfo.id)); + this.storageService.store(EditorOverrideService.conflictingDefaultsStorageID, JSON.stringify(storedChoices), StorageScope.GLOBAL, StorageTarget.MACHINE); + }; + + // If the user has already made a choice for this editor we don't want to ask them again + if (storedChoices[globForResource] && storedChoices[globForResource].find(editorID => editorID === currentEditor.viewType)) { + return; + } const handle = this.notificationService.prompt(Severity.Warning, localize('editorOverride.conflictingDefaults', 'There are multiple default editors available for the resource.'), @@ -400,12 +415,12 @@ export class EditorOverrideService extends Disposable implements IEditorOverride }, { label: localize('editorOverride.keepDefault', 'Keep {0}', editorName), - run: makeCurrentEditorDefault + run: writeCurrentEditorsToStorage } ]); // If the user pressed X we assume they want to keep the current editor as default const onCloseListener = handle.onDidClose(() => { - makeCurrentEditorDefault(); + writeCurrentEditorsToStorage(); onCloseListener.dispose(); }); } diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 8368e50d9f9..d5ac0c9f7bb 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -37,6 +37,8 @@ import { IModelService } from 'vs/editor/common/services/modelService'; import { ILogService } from 'vs/platform/log/common/log'; import { ContributedEditorPriority, DEFAULT_EDITOR_ASSOCIATION, IEditorOverrideService } from 'vs/workbench/services/editor/common/editorOverrideService'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IWorkspaceTrustRequestService, WorkspaceTrustUriResponse } from 'vs/platform/workspace/common/workspaceTrust'; +import { IHostService } from 'vs/workbench/services/host/browser/host'; type CachedEditorInput = TextResourceEditorInput | IFileEditorInput | UntitledTextEditorInput; type OpenInEditorGroup = IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE; @@ -76,7 +78,9 @@ export class EditorService extends Disposable implements EditorServiceImpl { @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @ILogService private readonly logService: ILogService, @IEditorOverrideService private readonly editorOverrideService: IEditorOverrideService, - @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IHostService private readonly hostService: IHostService, ) { super(); @@ -614,9 +618,11 @@ export class EditorService extends Disposable implements EditorServiceImpl { // Override handling: ask providers to override if (resolvedOptions?.override !== EditorOverride.DISABLED) { + // TODO@lramos15 this will get cleaned up soon, but since the override // service no longer uses the override flow we must check that const resolvedInputWithOptionsAndGroup = await this.editorOverrideService.resolveEditorOverride(resolvedEditor, resolvedOptions, resolvedGroup); + // If we didn't override try the legacy overrides if (!resolvedInputWithOptionsAndGroup || resolvedEditor.matches(resolvedInputWithOptionsAndGroup.editor)) { const override = this.doOverrideOpenEditor(resolvedEditor, resolvedOptions, resolvedGroup); @@ -793,6 +799,13 @@ export class EditorService extends Disposable implements EditorServiceImpl { openEditors(editors: IResourceEditorInputType[], group?: OpenInEditorGroup): Promise; async openEditors(editors: Array, group?: OpenInEditorGroup): Promise { + // Pass all editors to trust service to determine if + // we should proceed with opening the editors + const editorsTrusted = await this.handleWorkspaceTrust(editors); + if (!editorsTrusted) { + return []; + } + // Convert to typed editors and options const typedEditors: IEditorInputWithOptions[] = editors.map(editor => { if (isEditorInputWithOptions(editor)) { @@ -855,6 +868,60 @@ export class EditorService extends Disposable implements EditorServiceImpl { return coalesce(await Promises.settled(result)); } + private async handleWorkspaceTrust(editors: Array): Promise { + const resources = this.extractEditorResources(editors); + + const trustResult = await this.workspaceTrustRequestService.requestOpenUris(resources); + switch (trustResult) { + case WorkspaceTrustUriResponse.Open: + return true; + case WorkspaceTrustUriResponse.OpenInNewWindow: + await this.hostService.openWindow(resources.map(resource => ({ fileUri: resource })), { forceNewWindow: true }); + return false; + case WorkspaceTrustUriResponse.Cancel: + return false; + } + } + + private extractEditorResources(editors: Array): URI[] { + const resources = new ResourceMap(); + + for (const editor of editors) { + + // Typed Editor + if (isEditorInputWithOptions(editor)) { + const resource = EditorResourceAccessor.getOriginalUri(editor.editor, { supportSideBySide: SideBySideEditor.BOTH }); + if (URI.isUri(resource)) { + resources.set(resource, true); + } else if (resource) { + if (resource.primary) { + resources.set(resource.primary, true); + } + + if (resource.secondary) { + resources.set(resource.secondary, true); + } + } + } + + // Untyped editor + else { + const resourceDiffEditor = editor as IResourceDiffEditorInput; + if (URI.isUri(resourceDiffEditor.leftResource) && URI.isUri(resourceDiffEditor.rightResource)) { + resources.set(resourceDiffEditor.leftResource, true); + resources.set(resourceDiffEditor.rightResource, true); + } + + const resourceEditor = editor as IResourceEditorInput; + if (URI.isUri(resourceEditor.resource)) { + resources.set(resourceEditor.resource, true); + } + } + } + + return Array.from(resources.keys()); + } + //#endregion //#region isOpened() diff --git a/src/vs/workbench/services/editor/test/browser/editorService.test.ts b/src/vs/workbench/services/editor/test/browser/editorService.test.ts index a5ff884573c..9dc1b5c02a3 100644 --- a/src/vs/workbench/services/editor/test/browser/editorService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorService.test.ts @@ -31,6 +31,8 @@ import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServic import { isLinux } from 'vs/base/common/platform'; import { MockScopableContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; import { ContributedEditorPriority } from 'vs/workbench/services/editor/common/editorOverrideService'; +import { IWorkspaceTrustRequestService, WorkspaceTrustUriResponse } from 'vs/platform/workspace/common/workspaceTrust'; +import { TestWorkspaceTrustRequestService } from 'vs/workbench/services/workspaces/test/common/testWorkspaceTrustService'; suite('EditorService', () => { @@ -61,6 +63,7 @@ suite('EditorService', () => { const part = await createEditorPart(instantiationService, disposables); instantiationService.stub(IEditorGroupsService, part); + instantiationService.stub(IWorkspaceTrustRequestService, new TestWorkspaceTrustRequestService(false)); const editorService = instantiationService.createInstance(EditorService); instantiationService.stub(IEditorService, editorService); @@ -227,6 +230,77 @@ suite('EditorService', () => { assert.strictEqual(part.activeGroup.getIndexOfEditor(replaceInput), 0); }); + test('openEditors() handles workspace trust (typed editors)', async () => { + const [part, service, accessor] = await createEditorService(); + + const input1 = new TestFileEditorInput(URI.parse('my://resource1-openEditors'), TEST_EDITOR_INPUT_ID); + const input2 = new TestFileEditorInput(URI.parse('my://resource2-openEditors'), TEST_EDITOR_INPUT_ID); + + const input3 = new TestFileEditorInput(URI.parse('my://resource3-openEditors'), TEST_EDITOR_INPUT_ID); + const input4 = new TestFileEditorInput(URI.parse('my://resource4-openEditors'), TEST_EDITOR_INPUT_ID); + const sideBySideInput = new SideBySideEditorInput('side by side', undefined, input3, input4); + + const oldHandler = accessor.workspaceTrustRequestService.requestOpenUrisHandler; + + try { + + // Trust: cancel + let trustEditorUris: URI[] = []; + accessor.workspaceTrustRequestService.requestOpenUrisHandler = async uris => { + trustEditorUris = uris; + return WorkspaceTrustUriResponse.Cancel; + }; + + await service.openEditors([{ editor: input1 }, { editor: input2 }, { editor: sideBySideInput }]); + assert.strictEqual(part.activeGroup.count, 0); + assert.strictEqual(trustEditorUris.length, 4); + assert.strictEqual(trustEditorUris.some(uri => uri.toString() === input1.resource.toString()), true); + assert.strictEqual(trustEditorUris.some(uri => uri.toString() === input2.resource.toString()), true); + assert.strictEqual(trustEditorUris.some(uri => uri.toString() === input3.resource.toString()), true); + assert.strictEqual(trustEditorUris.some(uri => uri.toString() === input4.resource.toString()), true); + + // Trust: open in new window + accessor.workspaceTrustRequestService.requestOpenUrisHandler = async uris => WorkspaceTrustUriResponse.OpenInNewWindow; + + await service.openEditors([{ editor: input1 }, { editor: input2 }, { editor: sideBySideInput }]); + assert.strictEqual(part.activeGroup.count, 0); + + // Trust: allow + accessor.workspaceTrustRequestService.requestOpenUrisHandler = async uris => WorkspaceTrustUriResponse.Open; + + await service.openEditors([{ editor: input1 }, { editor: input2 }, { editor: sideBySideInput }]); + assert.strictEqual(part.activeGroup.count, 3); + } finally { + accessor.workspaceTrustRequestService.requestOpenUrisHandler = oldHandler; + } + }); + + test('openEditors() extracts proper resources from untyped editors for workspace trust', async () => { + const [part, service, accessor] = await createEditorService(); + + const input = { resource: URI.parse('my://resource-openEditors') }; + const otherInput = { leftResource: URI.parse('my://resource2-openEditors'), rightResource: URI.parse('my://resource3-openEditors') }; + + const oldHandler = accessor.workspaceTrustRequestService.requestOpenUrisHandler; + + try { + let trustEditorUris: URI[] = []; + accessor.workspaceTrustRequestService.requestOpenUrisHandler = async uris => { + trustEditorUris = uris; + return oldHandler(uris); + }; + + await service.openEditors([input, otherInput]); + assert.strictEqual(part.activeGroup.count, 0); + assert.strictEqual(trustEditorUris.length, 3); + assert.strictEqual(trustEditorUris.some(uri => uri.toString() === input.resource.toString()), true); + assert.strictEqual(trustEditorUris.some(uri => uri.toString() === otherInput.leftResource.toString()), true); + assert.strictEqual(trustEditorUris.some(uri => uri.toString() === otherInput.rightResource.toString()), true); + } finally { + accessor.workspaceTrustRequestService.requestOpenUrisHandler = oldHandler; + } + }); + test('caching', function () { const instantiationService = workbenchInstantiationService(); const service = instantiationService.createInstance(EditorService); diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index 92da6861768..a6b832cf35b 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -226,25 +226,12 @@ export class BrowserWorkbenchEnvironmentService implements IWorkbenchEnvironment get disableExtensions() { return this.payload?.get('disableExtensions') === 'true'; } - private get webviewEndpoint(): string { - // TODO@matt: get fallback from product service - return this.options.webviewEndpoint || 'https://{{uuid}}.vscode-webview-test.com/{{commit}}'; - } - @memoize get webviewExternalEndpoint(): string { - return (this.webviewEndpoint).replace('{{commit}}', this.productService.commit || '23a2409675bc1bde94f3532bc7c5826a6e99e4b6'); - } - - @memoize - get webviewResourceRoot(): string { - return `${this.webviewExternalEndpoint}/vscode-resource/{{resource}}`; - } - - @memoize - get webviewCspSource(): string { - const uri = URI.parse(this.webviewEndpoint.replace('{{uuid}}', '*')); - return `${uri.scheme}://${uri.authority}`; + const endpoint = this.options.webviewEndpoint || this.productService.webviewContentExternalBaseUrlTemplate; + return endpoint + .replace('{{commit}}', this.productService.commit || '97740a7d253650f9f186c211de5247e2577ce9f7') + .replace('{{quality}}', this.productService.quality || 'insider'); } @memoize diff --git a/src/vs/workbench/services/environment/common/environmentService.ts b/src/vs/workbench/services/environment/common/environmentService.ts index 6604c9823ae..f8950d42da8 100644 --- a/src/vs/workbench/services/environment/common/environmentService.ts +++ b/src/vs/workbench/services/environment/common/environmentService.ts @@ -36,8 +36,6 @@ export interface IWorkbenchEnvironmentService extends IEnvironmentService { readonly extensionEnabledProposedApi?: string[]; readonly webviewExternalEndpoint: string; - readonly webviewResourceRoot: string; - readonly webviewCspSource: string; readonly skipReleaseNotes: boolean; diff --git a/src/vs/workbench/services/environment/electron-sandbox/environmentService.ts b/src/vs/workbench/services/environment/electron-sandbox/environmentService.ts index 9fb4fb45d75..948a3aa8935 100644 --- a/src/vs/workbench/services/environment/electron-sandbox/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-sandbox/environmentService.ts @@ -68,21 +68,6 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment @memoize get webviewExternalEndpoint(): string { return `${Schemas.vscodeWebview}://{{uuid}}`; } - @memoize - get webviewResourceRoot(): string { - // On desktop, this endpoint is only used for the service worker to identify resource loads and - // should never actually be requested. - // - // Required due to https://github.com/electron/electron/issues/28528 - return 'https://{{uuid}}.vscode-webview-test.com/vscode-resource/{{resource}}'; - } - - @memoize - get webviewCspSource(): string { - const uri = URI.parse(this.webviewResourceRoot.replace('{{uuid}}', '*')); - return `${uri.scheme}://${uri.authority}`; - } - @memoize get skipReleaseNotes(): boolean { return !!this.args['skip-release-notes']; } diff --git a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts index 85bc8459740..46a96306cb0 100644 --- a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts +++ b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts @@ -26,7 +26,7 @@ import { IExtensionBisectService } from 'vs/workbench/services/extensionManageme import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from 'vs/platform/workspace/common/workspaceTrust'; import { Promises } from 'vs/base/common/async'; import { IExtensionManifestPropertiesService } from 'vs/workbench/services/extensions/common/extensionManifestPropertiesService'; -import { getVirtualWorkspaceScheme } from 'vs/platform/remote/common/remoteHosts'; +import { isVirtualWorkspace } from 'vs/platform/remote/common/remoteHosts'; const SOURCE = 'IWorkbenchExtensionEnablementService'; @@ -229,8 +229,8 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench } private _isDisabledByVirtualWorkspace(extension: IExtension): boolean { - if (getVirtualWorkspaceScheme(this.contextService.getWorkspace()) !== undefined) { - return !this.extensionManifestPropertiesService.canSupportVirtualWorkspace(extension.manifest); + if (isVirtualWorkspace(this.contextService.getWorkspace())) { + return this.extensionManifestPropertiesService.getExtensionVirtualWorkspaceSupportType(extension.manifest) === false; } return false; } diff --git a/src/vs/workbench/services/extensionManagement/common/webExtensionsScannerService.ts b/src/vs/workbench/services/extensionManagement/common/webExtensionsScannerService.ts index f8faa18b51c..14b42deed94 100644 --- a/src/vs/workbench/services/extensionManagement/common/webExtensionsScannerService.ts +++ b/src/vs/workbench/services/extensionManagement/common/webExtensionsScannerService.ts @@ -25,7 +25,7 @@ import { Event } from 'vs/base/common/event'; import { localizeManifest } from 'vs/platform/extensionManagement/common/extensionNls'; import { localize } from 'vs/nls'; import * as semver from 'vs/base/common/semver/semver'; -import { isArray } from 'vs/base/common/types'; +import { isArray, isFunction } from 'vs/base/common/types'; interface IUserExtension { identifier: IExtensionIdentifier; @@ -76,8 +76,16 @@ export class WebExtensionsScannerService extends Disposable implements IWebExten } private async readSystemExtensions(): Promise { - const extensions = await this.builtinExtensionsScannerService.scanBuiltinExtensions(); - return extensions.concat(this.getStaticExtensions(true)); + let [builtinExtensions, staticExtensions] = await Promise.all([ + this.builtinExtensionsScannerService.scanBuiltinExtensions(), + this.getStaticExtensions(true) + ]); + + if (isFunction(this.environmentService.options?.builtinExtensionsFilter)) { + builtinExtensions = builtinExtensions.filter(e => this.environmentService.options!.builtinExtensionsFilter!(e.identifier.id)); + } + + return [...builtinExtensions, ...staticExtensions]; } /** diff --git a/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts b/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts index fee40e289d8..bf44e6b8d70 100644 --- a/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts +++ b/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts @@ -360,8 +360,6 @@ export class WebWorkerExtensionHost extends Disposable implements IExtensionHost extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: this._environmentService.globalStorageHome, workspaceStorageHome: this._environmentService.workspaceStorageHome, - webviewResourceRoot: this._environmentService.webviewResourceRoot, - webviewCspSource: this._environmentService.webviewCspSource, }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? undefined : { configuration: workspace.configuration || undefined, diff --git a/src/vs/workbench/services/extensions/common/extensionHostManager.ts b/src/vs/workbench/services/extensions/common/extensionHostManager.ts index b363ff4c1ff..b67841044c3 100644 --- a/src/vs/workbench/services/extensions/common/extensionHostManager.ts +++ b/src/vs/workbench/services/extensions/common/extensionHostManager.ts @@ -24,6 +24,7 @@ import { IExtensionHost, ExtensionHostKind, ActivationKind } from 'vs/workbench/ import { ExtensionActivationReason } from 'vs/workbench/api/common/extHostExtensionActivator'; import { CATEGORIES } from 'vs/workbench/common/actions'; import { timeout } from 'vs/base/common/async'; +import { URI } from 'vs/base/common/uri'; // Enable to see detailed message communication between window and extension host const LOG_EXTENSION_HOST_COMMUNICATION = false; @@ -285,6 +286,15 @@ export class ExtensionHostManager extends Disposable { } } + public async getCanonicalURI(remoteAuthority: string, uri: URI): Promise { + const proxy = await this._getProxy(); + if (!proxy) { + throw new Error(`Cannot resolve canonical URI`); + } + const result = await proxy.$getCanonicalURI(remoteAuthority, uri); + return URI.revive(result); + } + public async start(enabledExtensionIds: ExtensionIdentifier[]): Promise { const proxy = await this._getProxy(); if (!proxy) { diff --git a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts index 913b0082624..ec1a6437ba0 100644 --- a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts +++ b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IExtensionManifest, ExtensionKind, ExtensionIdentifier, ExtensionUntrustedWorkpaceSupportType } from 'vs/platform/extensions/common/extensions'; +import { IExtensionManifest, ExtensionKind, ExtensionIdentifier, ExtensionUntrustedWorkpaceSupportType, ExtensionVirtualWorkpaceSupportType } from 'vs/platform/extensions/common/extensions'; import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; import { getGalleryExtensionId } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { isNonEmptyArray } from 'vs/base/common/arrays'; @@ -14,6 +14,7 @@ import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { ExtensionUntrustedWorkspaceSupport } from 'vs/base/common/product'; import { Disposable } from 'vs/base/common/lifecycle'; import { isWorkspaceTrustEnabled, WORKSPACE_TRUST_EXTENSION_SUPPORT } from 'vs/workbench/services/workspaces/common/workspaceTrust'; +import { isBoolean } from 'vs/base/common/types'; export const IExtensionManifestPropertiesService = createDecorator('extensionManifestPropertiesService'); @@ -30,7 +31,7 @@ export interface IExtensionManifestPropertiesService { getExtensionKind(manifest: IExtensionManifest): ExtensionKind[]; getExtensionUntrustedWorkspaceSupportType(manifest: IExtensionManifest): ExtensionUntrustedWorkpaceSupportType; - canSupportVirtualWorkspace(manifest: IExtensionManifest): boolean; + getExtensionVirtualWorkspaceSupportType(manifest: IExtensionManifest): ExtensionVirtualWorkpaceSupportType; } export class ExtensionManifestPropertiesService extends Disposable implements IExtensionManifestPropertiesService { @@ -156,7 +157,7 @@ export class ExtensionManifestPropertiesService extends Disposable implements IE return false; } - canSupportVirtualWorkspace(manifest: IExtensionManifest): boolean { + getExtensionVirtualWorkspaceSupportType(manifest: IExtensionManifest): ExtensionVirtualWorkpaceSupportType { // check user configured const userConfiguredVirtualWorkspaceSupport = this.getConfiguredVirtualWorkspaceSupport(manifest); if (userConfiguredVirtualWorkspaceSupport !== undefined) { @@ -171,8 +172,14 @@ export class ExtensionManifestPropertiesService extends Disposable implements IE } // check the manifest - if (manifest.capabilities?.virtualWorkspaces !== undefined) { - return manifest.capabilities?.virtualWorkspaces; + const virtualWorkspaces = manifest.capabilities?.virtualWorkspaces; + if (isBoolean(virtualWorkspaces)) { + return virtualWorkspaces; + } else if (virtualWorkspaces) { + const supported = virtualWorkspaces.supported; + if (isBoolean(supported) || supported === 'limited') { + return supported; + } } // check default from product diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index dca05b7ca6d..4ebd9782b43 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -320,6 +320,11 @@ export const schema: IJSONSchema = { body: 'onAuthenticationRequest:${11:authenticationProviderId}', description: nls.localize('vscode.extension.activationEvents.onAuthenticationRequest', 'An activation event emitted whenever sessions are requested from the specified authentication provider.') }, + { + label: 'onRenderer', + description: nls.localize('vscode.extension.activationEvents.onRenderer', 'An activation event emitted whenever a notebook output renderer is used.'), + body: 'onRenderer:${11:rendererId}' + }, { label: '*', description: nls.localize('vscode.extension.activationEvents.star', 'An activation event emitted on VS Code startup. To ensure a great end user experience, please use this activation event in your extension only when no other activation events combination works in your use-case.'), @@ -421,8 +426,28 @@ export const schema: IJSONSchema = { properties: { virtualWorkspaces: { description: nls.localize('vscode.extension.capabilities.virtualWorkspaces', "Declares whether the extension should be enabled in virtual workspaces. A virtual workspace is a workspace which is not backed by any on-disk resources. When false, this extension will be automatically disabled in virtual workspaces. Default is true."), - type: 'boolean', - default: true + type: ['boolean', 'object'], + defaultSnippets: [ + { label: 'limited', body: { supported: '${1:limited}', description: '${2}' } }, + { label: 'false', body: { supported: false, description: '${2}' } }, + ], + default: true.valueOf, + properties: { + supported: { + markdownDescription: nls.localize('vscode.extension.capabilities.virtualWorkspaces.supported', "Declares the level of support for virtual workspaces by the extension."), + type: ['string', 'boolean'], + enum: ['limited', true, false], + enumDescriptions: [ + nls.localize('vscode.extension.capabilities.virtualWorkspaces.supported.limited', "The extension will be enabled in virtual workspaces with some functionality disabled."), + nls.localize('vscode.extension.capabilities.virtualWorkspaces.supported.true', "The extension will be enabled in virtual workspaces with all functionality enabled."), + nls.localize('vscode.extension.capabilities.virtualWorkspaces.supported.false', "The extension will not be enabled in virtual workspaces."), + ] + }, + description: { + type: 'string', + markdownDescription: nls.localize('vscode.extension.capabilities.virtualWorkspaces.description', "A description of how virtual workspaces affects the extensions behavior and why it is needed. This only applies when `supported` is not `true`."), + } + } }, untrustedWorkspaces: { description: nls.localize('vscode.extension.capabilities.untrustedWorkspaces', 'Declares how the extension should be handled in untrusted workspaces.'), diff --git a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts index 4b07928a83d..d238526f526 100644 --- a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts +++ b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts @@ -235,9 +235,7 @@ export class RemoteExtensionHost extends Disposable implements IExtensionHost { extensionDevelopmentLocationURI: this._environmentService.extensionDevelopmentLocationURI, extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: remoteInitData.globalStorageHome, - workspaceStorageHome: remoteInitData.workspaceStorageHome, - webviewResourceRoot: this._environmentService.webviewResourceRoot, - webviewCspSource: this._environmentService.webviewCspSource, + workspaceStorageHome: remoteInitData.workspaceStorageHome }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? null : { configuration: workspace.configuration, diff --git a/src/vs/workbench/services/extensions/electron-browser/cachedExtensionScanner.ts b/src/vs/workbench/services/extensions/electron-browser/cachedExtensionScanner.ts index c441c61c1f6..aa78078e14e 100644 --- a/src/vs/workbench/services/extensions/electron-browser/cachedExtensionScanner.ts +++ b/src/vs/workbench/services/extensions/electron-browser/cachedExtensionScanner.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; import * as nls from 'vs/nls'; import * as path from 'vs/base/common/path'; import * as errors from 'vs/base/common/errors'; @@ -152,7 +151,7 @@ export class CachedExtensionScanner { const cacheFile = path.join(cacheFolder, cacheKey); try { - const cacheRawContents = await fs.promises.readFile(cacheFile, 'utf8'); + const cacheRawContents = await pfs.Promises.readFile(cacheFile, 'utf8'); return JSON.parse(cacheRawContents); } catch (err) { // That's ok... @@ -166,7 +165,7 @@ export class CachedExtensionScanner { const cacheFile = path.join(cacheFolder, cacheKey); try { - await fs.promises.mkdir(cacheFolder, { recursive: true }); + await pfs.Promises.mkdir(cacheFolder, { recursive: true }); } catch (err) { // That's ok... } @@ -185,7 +184,7 @@ export class CachedExtensionScanner { } try { - const folderStat = await fs.promises.stat(input.absoluteFolderPath); + const folderStat = await pfs.Promises.stat(input.absoluteFolderPath); input.mtime = folderStat.mtime.getTime(); } catch (err) { // That's ok... @@ -225,7 +224,7 @@ export class CachedExtensionScanner { private static async _readTranslationConfig(): Promise { if (platform.translationsConfigFile) { try { - const content = await fs.promises.readFile(platform.translationsConfigFile, 'utf8'); + const content = await pfs.Promises.readFile(platform.translationsConfigFile, 'utf8'); return JSON.parse(content) as Translations; } catch (err) { // no problemo @@ -265,7 +264,7 @@ export class CachedExtensionScanner { const builtInExtensions = Promise.resolve(productService.builtInExtensions || []); const controlFilePath = joinPath(environmentService.userHome, '.vscode-oss-dev', 'extensions', 'control.json').fsPath; - const controlFile = fs.promises.readFile(controlFilePath, 'utf8') + const controlFile = pfs.Promises.readFile(controlFilePath, 'utf8') .then(raw => JSON.parse(raw), () => ({} as any)); const input = new ExtensionScannerInput(version, date, commit, locale, devMode, getExtraDevSystemExtensionsRoot(), true, false, translations); diff --git a/src/vs/workbench/services/extensions/electron-browser/extensionService.ts b/src/vs/workbench/services/extensions/electron-browser/extensionService.ts index 46850e22397..67d5ce01398 100644 --- a/src/vs/workbench/services/extensions/electron-browser/extensionService.ts +++ b/src/vs/workbench/services/extensions/electron-browser/extensionService.ts @@ -15,7 +15,7 @@ import { IWorkbenchExtensionEnablementService, EnablementState, IWebExtensionsSc import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IRemoteExtensionHostDataProvider, RemoteExtensionHost, IRemoteExtensionHostInitData } from 'vs/workbench/services/extensions/common/remoteExtensionHost'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; -import { IRemoteAuthorityResolverService, RemoteAuthorityResolverError, RemoteTrustOption, ResolverResult } from 'vs/platform/remote/common/remoteAuthorityResolver'; +import { IRemoteAuthorityResolverService, RemoteAuthorityResolverError, ResolverResult } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ILifecycleService, LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; @@ -42,13 +42,9 @@ import { Schemas } from 'vs/base/common/network'; import { ExtensionHostExitCode } from 'vs/workbench/services/extensions/common/extensionHostProtocol'; import { updateProxyConfigurationsScope } from 'vs/platform/request/common/request'; import { ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; -import { Codicon } from 'vs/base/common/codicons'; -import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; import { IExtensionManifestPropertiesService } from 'vs/workbench/services/extensions/common/extensionManifestPropertiesService'; -const MACHINE_PROMPT = false; - export class ExtensionService extends AbstractExtensionService implements IExtensionService { private readonly _enableLocalWebWorker: boolean; @@ -75,7 +71,6 @@ export class ExtensionService extends AbstractExtensionService implements IExten @IRemoteExplorerService private readonly _remoteExplorerService: IRemoteExplorerService, @IExtensionGalleryService private readonly _extensionGalleryService: IExtensionGalleryService, @ILogService private readonly _logService: ILogService, - @IDialogService private readonly _dialogService: IDialogService, @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, @IExtensionManifestPropertiesService extensionManifestPropertiesService: IExtensionManifestPropertiesService, ) { @@ -348,6 +343,16 @@ export class ExtensionService extends AbstractExtensionService implements IExten let remoteExtensions: IExtensionDescription[] = []; if (remoteAuthority) { + + this._remoteAuthorityResolverService._setCanonicalURIProvider(async (uri) => { + if (uri.scheme !== Schemas.vscodeRemote || uri.authority !== remoteAuthority) { + // The current remote authority resolver cannot give the canonical URI for this URI + return uri; + } + const localProcessExtensionHost = this._getExtensionHostManager(ExtensionHostKind.LocalProcess)!; + return localProcessExtensionHost.getCanonicalURI(remoteAuthority, uri); + }); + let resolverResult: ResolverResult; try { @@ -368,36 +373,8 @@ export class ExtensionService extends AbstractExtensionService implements IExten return; } - let promptForMachineTrust = MACHINE_PROMPT; - - if (resolverResult.options?.trust === RemoteTrustOption.DisableTrust) { - promptForMachineTrust = false; + if (resolverResult.options?.isTrusted) { await this._workspaceTrustManagementService.setWorkspaceTrust(true); - } else if (resolverResult.options?.trust === RemoteTrustOption.MachineTrusted) { - promptForMachineTrust = false; - } - - if (promptForMachineTrust) { - const dialogResult = await this._dialogService.show( - Severity.Info, - nls.localize('machineTrustQuestion', "Do you trust the machine you're connecting to?"), - [nls.localize('yes', "Yes, connect."), nls.localize('no', "No, do not connect.")], - { - cancelId: 1, - custom: { - icon: Codicon.remoteExplorer - }, - // checkbox: { label: nls.localize('remember', "Remember my choice"), checked: true } - } - ); - - if (dialogResult.choice !== 0) { - // Did not confirm trust - this._notificationService.notify({ severity: Severity.Warning, message: nls.localize('trustFailure', "Refused to connect to untrusted machine.") }); - // Proceed with the local extension host - await this._startLocalExtensionHost(localExtensions); - return; - } } // set the resolved authority @@ -430,6 +407,10 @@ export class ExtensionService extends AbstractExtensionService implements IExten } updateProxyConfigurationsScope(remoteEnv.useHostProxy ? ConfigurationScope.APPLICATION : ConfigurationScope.MACHINE); + } else { + + this._remoteAuthorityResolverService._setCanonicalURIProvider(async (uri) => uri); + } await this._startLocalExtensionHost(localExtensions, remoteAuthority, remoteEnv, remoteExtensions); diff --git a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts index 72535eae553..245da60c35b 100644 --- a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts +++ b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts @@ -476,8 +476,6 @@ export class LocalProcessExtensionHost implements IExtensionHost { extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: this._environmentService.globalStorageHome, workspaceStorageHome: this._environmentService.workspaceStorageHome, - webviewResourceRoot: this._environmentService.webviewResourceRoot, - webviewCspSource: this._environmentService.webviewCspSource, }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? undefined : { configuration: withNullAsUndefined(workspace.configuration), @@ -627,6 +625,8 @@ export class LocalProcessExtensionHost implements IExtensionHost { // (graceful termination) protocol.send(createMessageOfType(MessageType.Terminate)); + protocol.getSocket().dispose(); + protocol.dispose(); // Give the extension host 10s, after which we will diff --git a/src/vs/workbench/services/extensions/node/extensionHostProcessSetup.ts b/src/vs/workbench/services/extensions/node/extensionHostProcessSetup.ts index 96ab14ef3ca..a0baef25034 100644 --- a/src/vs/workbench/services/extensions/node/extensionHostProcessSetup.ts +++ b/src/vs/workbench/services/extensions/node/extensionHostProcessSetup.ts @@ -173,6 +173,9 @@ function _createExtHostProtocol(): Promise { }); socket.once('error', reject); + socket.on('close', () => { + onTerminate('renderer closed the socket'); + }); }); } } diff --git a/src/vs/workbench/services/extensions/node/extensionPoints.ts b/src/vs/workbench/services/extensions/node/extensionPoints.ts index 076ad50b84c..14106fd427a 100644 --- a/src/vs/workbench/services/extensions/node/extensionPoints.ts +++ b/src/vs/workbench/services/extensions/node/extensionPoints.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs'; import * as nls from 'vs/nls'; import * as path from 'vs/base/common/path'; import * as semver from 'vs/base/common/semver/semver'; @@ -60,7 +59,7 @@ class ExtensionManifestParser extends ExtensionManifestHandler { } public parse(): Promise { - return fs.promises.readFile(this._absoluteManifestPath).then((manifestContents) => { + return pfs.Promises.readFile(this._absoluteManifestPath).then((manifestContents) => { const errors: json.ParseError[] = []; const manifest = ExtensionManifestParser._fastParseJSON(manifestContents.toString(), errors); if (json.getNodeType(manifest) !== 'object') { @@ -130,7 +129,7 @@ class ExtensionManifestNLSReplacer extends ExtensionManifestHandler { let translationPath = this._nlsConfig.translations[translationId]; let localizedMessages: Promise; if (translationPath) { - localizedMessages = fs.promises.readFile(translationPath, 'utf8').then((content) => { + localizedMessages = pfs.Promises.readFile(translationPath, 'utf8').then((content) => { let errors: json.ParseError[] = []; let translationBundle: TranslationBundle = json.parse(content, errors); if (errors.length > 0) { @@ -155,7 +154,7 @@ class ExtensionManifestNLSReplacer extends ExtensionManifestHandler { if (!messageBundle.localized) { return { values: undefined, default: messageBundle.original }; } - return fs.promises.readFile(messageBundle.localized, 'utf8').then(messageBundleContent => { + return pfs.Promises.readFile(messageBundle.localized, 'utf8').then(messageBundleContent => { let errors: json.ParseError[] = []; let messages: MessageBag = json.parse(messageBundleContent, errors); if (errors.length > 0) { @@ -204,7 +203,7 @@ class ExtensionManifestNLSReplacer extends ExtensionManifestHandler { private static resolveOriginalMessageBundle(originalMessageBundle: string | null, errors: json.ParseError[]) { return new Promise<{ [key: string]: string; } | null>((c, e) => { if (originalMessageBundle) { - fs.promises.readFile(originalMessageBundle).then(originalBundleContent => { + pfs.Promises.readFile(originalMessageBundle).then(originalBundleContent => { c(json.parse(originalBundleContent.toString(), errors)); }, (err) => { c(null); @@ -553,7 +552,7 @@ export class ExtensionScanner { let obsolete: { [folderName: string]: boolean; } = {}; if (!isBuiltin) { try { - const obsoleteFileContents = await fs.promises.readFile(path.join(absoluteFolderPath, '.obsolete'), 'utf8'); + const obsoleteFileContents = await pfs.Promises.readFile(path.join(absoluteFolderPath, '.obsolete'), 'utf8'); obsolete = JSON.parse(obsoleteFileContents); } catch (err) { // Don't care diff --git a/src/vs/workbench/services/hover/browser/hoverService.ts b/src/vs/workbench/services/hover/browser/hoverService.ts index 0e1b809311c..f89f70cd98e 100644 --- a/src/vs/workbench/services/hover/browser/hoverService.ts +++ b/src/vs/workbench/services/hover/browser/hoverService.ts @@ -8,11 +8,12 @@ import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { editorHoverBackground, editorHoverBorder, textLinkForeground, editorHoverForeground, editorHoverStatusBarBackground, textCodeBlockBackground, widgetShadow } from 'vs/platform/theme/common/colorRegistry'; import { IHoverService, IHoverOptions } from 'vs/workbench/services/hover/browser/hover'; -import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { HoverWidget } from 'vs/workbench/services/hover/browser/hoverWidget'; import { IContextViewProvider, IDelegate } from 'vs/base/browser/ui/contextview/contextview'; -import { IDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { addDisposableListener, EventType } from 'vs/base/browser/dom'; export class HoverService implements IHoverService { declare readonly _serviceBrand: undefined; @@ -21,8 +22,10 @@ export class HoverService implements IHoverService { constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IContextViewService private readonly _contextViewService: IContextViewService + @IContextViewService private readonly _contextViewService: IContextViewService, + @IContextMenuService contextMenuService: IContextMenuService ) { + contextMenuService.onDidShowContextMenu(() => this.hideHover()); } showHover(options: IHoverOptions, focus?: boolean): IDisposable | undefined { @@ -31,17 +34,28 @@ export class HoverService implements IHoverService { } this._currentHoverOptions = options; + const hoverDisposables = new DisposableStore(); const hover = this._instantiationService.createInstance(HoverWidget, options); - hover.onDispose(() => this._currentHoverOptions = undefined); + hover.onDispose(() => { + this._currentHoverOptions = undefined; + hoverDisposables.dispose(); + }); const provider = this._contextViewService as IContextViewProvider; provider.showContextView(new HoverContextViewDelegate(hover, focus)); hover.onRequestLayout(() => provider.layout()); + if ('targetElements' in options.target) { + for (const element of options.target.targetElements) { + hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this.hideHover())); + } + } else { + hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this.hideHover())); + } if ('IntersectionObserver' in window) { const observer = new IntersectionObserver(e => this._intersectionChange(e, hover), { threshold: 0 }); const firstTargetElement = 'targetElements' in options.target ? options.target.targetElements[0] : options.target; observer.observe(firstTargetElement); - hover.onDispose(() => observer.disconnect()); + hoverDisposables.add(toDisposable(() => observer.disconnect())); } return hover; diff --git a/src/vs/workbench/services/keybinding/test/electron-browser/keyboardMapperTestUtils.ts b/src/vs/workbench/services/keybinding/test/electron-browser/keyboardMapperTestUtils.ts index 64967e8838c..d17478838fe 100644 --- a/src/vs/workbench/services/keybinding/test/electron-browser/keyboardMapperTestUtils.ts +++ b/src/vs/workbench/services/keybinding/test/electron-browser/keyboardMapperTestUtils.ts @@ -5,11 +5,10 @@ import * as assert from 'assert'; import * as path from 'vs/base/common/path'; -import { promises } from 'fs'; import { getPathFromAmdModule } from 'vs/base/test/node/testUtils'; import { Keybinding, ResolvedKeybinding, SimpleKeybinding } from 'vs/base/common/keyCodes'; import { ScanCodeBinding } from 'vs/base/common/scanCode'; -import { writeFile } from 'vs/base/node/pfs'; +import { Promises, writeFile } from 'vs/base/node/pfs'; import { IKeyboardEvent } from 'vs/platform/keybinding/common/keybinding'; import { IKeyboardMapper } from 'vs/platform/keyboardLayout/common/keyboardMapper'; @@ -53,7 +52,7 @@ export function assertResolveUserBinding(mapper: IKeyboardMapper, parts: (Simple } export function readRawMapping(file: string): Promise { - return promises.readFile(getPathFromAmdModule(require, `vs/workbench/services/keybinding/test/electron-browser/${file}.js`)).then((buff) => { + return Promises.readFile(getPathFromAmdModule(require, `vs/workbench/services/keybinding/test/electron-browser/${file}.js`)).then((buff) => { let contents = buff.toString(); let func = new Function('define', contents); let rawMappings: T | null = null; @@ -67,7 +66,7 @@ export function readRawMapping(file: string): Promise { export function assertMapping(writeFileIfDifferent: boolean, mapper: IKeyboardMapper, file: string): Promise { const filePath = path.normalize(getPathFromAmdModule(require, `vs/workbench/services/keybinding/test/electron-browser/${file}`)); - return promises.readFile(filePath).then((buff) => { + return Promises.readFile(filePath).then((buff) => { const expected = buff.toString().replace(/\r\n/g, '\n'); const actual = mapper.dumpDebugInfo().replace(/\r\n/g, '\n'); if (actual !== expected && writeFileIfDifferent) { diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 25edebb2c71..83d72d6341e 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -163,6 +163,11 @@ export interface IWorkbenchLayoutService extends ILayoutService { */ setActivityBarHidden(hidden: boolean): void; + /** + * Set banner hidden or not + */ + setBannerHidden(hidden: boolean): void; + /** * * Set editor area hidden or not diff --git a/src/vs/workbench/services/progress/browser/progressService.ts b/src/vs/workbench/services/progress/browser/progressService.ts index 82b7a7dfc45..fbdb00a85f6 100644 --- a/src/vs/workbench/services/progress/browser/progressService.ts +++ b/src/vs/workbench/services/progress/browser/progressService.ts @@ -152,6 +152,7 @@ export class ProgressService extends Disposable implements IProgressService { } const statusEntryProperties: IStatusbarEntry = { + name: localize('status.progress', "Progress Message"), text, showProgress: true, ariaLabel: text, @@ -162,7 +163,7 @@ export class ProgressService extends Disposable implements IProgressService { if (this.windowProgressStatusEntry) { this.windowProgressStatusEntry.update(statusEntryProperties); } else { - this.windowProgressStatusEntry = this.statusbarService.addEntry(statusEntryProperties, 'status.progress', localize('status.progress', "Progress Message"), StatusbarAlignment.LEFT); + this.windowProgressStatusEntry = this.statusbarService.addEntry(statusEntryProperties, 'status.progress', StatusbarAlignment.LEFT); } } diff --git a/src/vs/workbench/services/remote/common/remoteExplorerService.ts b/src/vs/workbench/services/remote/common/remoteExplorerService.ts index 3fb24b9fff0..43e7c540559 100644 --- a/src/vs/workbench/services/remote/common/remoteExplorerService.ts +++ b/src/vs/workbench/services/remote/common/remoteExplorerService.ts @@ -11,7 +11,7 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag import { ALL_INTERFACES_ADDRESSES, isAllInterfaces, isLocalhost, ITunnelService, LOCALHOST_ADDRESSES, PortAttributesProvider, ProvidedOnAutoForward, ProvidedPortAttributes, RemoteTunnel } from 'vs/platform/remote/common/tunnel'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { IEditableData } from 'vs/workbench/common/views'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TunnelInformation, TunnelDescription, IRemoteAuthorityResolverService } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IAddressProvider } from 'vs/platform/remote/common/remoteAgentConnection'; @@ -25,6 +25,7 @@ import { flatten } from 'vs/base/common/arrays'; import Severity from 'vs/base/common/severity'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { URI } from 'vs/base/common/uri'; +import { deepClone } from 'vs/base/common/objects'; export const IRemoteExplorerService = createDecorator('remoteExplorerService'); export const REMOTE_EXPLORER_TYPE_KEY: string = 'remote.explorerType'; @@ -147,12 +148,17 @@ export enum OnPortForward { Ignore = 'ignore' } +export enum TunnelProtocol { + Http = 'http', + Https = 'https' +} + export interface Attributes { label: string | undefined; onAutoForward: OnPortForward | undefined, elevateIfNeeded: boolean | undefined; requireLocalPort: boolean | undefined; - protocol: string | undefined; + protocol: TunnelProtocol | undefined; } interface PortRange { start: number, end: number } @@ -331,12 +337,33 @@ export class PortsAttributes extends Disposable { default: return undefined; } } + + public async addAttributes(port: number, attributes: Partial) { + let settingValue = this.configurationService.inspect(PortsAttributes.SETTING); + const userValue: any = settingValue.userLocalValue; + let newUserValue: any; + if (!userValue || !isObject(userValue)) { + newUserValue = {}; + } else { + newUserValue = deepClone(userValue); + } + + if (!newUserValue[`${port}`]) { + newUserValue[`${port}`] = {}; + } + for (const attribute in attributes) { + newUserValue[`${port}`][attribute] = (attributes)[attribute]; + } + + return this.configurationService.updateValue(PortsAttributes.SETTING, newUserValue, ConfigurationTarget.USER_LOCAL); + } } const MISMATCH_LOCAL_PORT_COOLDOWN = 10 * 1000; // 10 seconds export class TunnelModel extends Disposable { readonly forwarded: Map; + private readonly inProgress: Map = new Map(); readonly detected: Map; private remoteTunnels: Map; private _onForwardPort: Emitter = new Emitter(); @@ -354,7 +381,7 @@ export class TunnelModel extends Disposable { private _onEnvironmentTunnelsSet: Emitter = new Emitter(); public onEnvironmentTunnelsSet: Event = this._onEnvironmentTunnelsSet.event; private _environmentTunnelsSet: boolean = false; - private configPortsAttributes: PortsAttributes; + public readonly configPortsAttributes: PortsAttributes; private restoreListener: IDisposable | undefined; private portAttributesProviders: PortAttributesProvider[] = []; @@ -401,7 +428,9 @@ export class TunnelModel extends Disposable { this.detected = new Map(); this._register(this.tunnelService.onTunnelOpened(async (tunnel) => { const key = makeAddress(tunnel.tunnelRemoteHost, tunnel.tunnelRemotePort); - if ((!this.forwarded.has(key)) && tunnel.localAddress) { + if (!mapHasAddressLocalhostOrAllInterfaces(this.forwarded, tunnel.tunnelRemoteHost, tunnel.tunnelRemotePort) + && !mapHasAddressLocalhostOrAllInterfaces(this.inProgress, tunnel.tunnelRemoteHost, tunnel.tunnelRemotePort) + && tunnel.localAddress) { const matchingCandidate = mapHasAddressLocalhostOrAllInterfaces(this._candidates ?? new Map(), tunnel.tunnelRemoteHost, tunnel.tunnelRemotePort); this.forwarded.set(key, { remoteHost: tunnel.tunnelRemoteHost, @@ -511,9 +540,10 @@ export class TunnelModel extends Disposable { return this.dialogService.show(Severity.Info, mismatchString, [nls.localize('remote.localPortMismatch.Ok', "Ok")]); } - async forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string, elevateIfNeeded?: boolean, isPublic?: boolean, restore: boolean = true, attributes?: Attributes): Promise { + async forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string, elevateIfNeeded?: boolean, + isPublic?: boolean, restore: boolean = true, attributes?: Attributes | null): Promise { const existingTunnel = mapHasAddressLocalhostOrAllInterfaces(this.forwarded, remote.host, remote.port); - attributes = attributes ?? (await this.getAttributes([remote.port]))?.get(remote.port); + attributes = attributes ?? ((attributes !== null) ? (await this.getAttributes([remote.port]))?.get(remote.port) : undefined); const localPort = (local !== undefined) ? local : remote.port; if (!existingTunnel) { @@ -522,6 +552,8 @@ export class TunnelModel extends Disposable { getAddress: async () => { return (await this.remoteAuthorityResolverService.resolveAuthority(authority)).authority; } } : undefined; + const key = makeAddress(remote.host, remote.port); + this.inProgress.set(key, true); const tunnel = await this.tunnelService.openTunnel(addressProvider, remote.host, remote.port, localPort, (!elevateIfNeeded) ? attributes?.elevateIfNeeded : elevateIfNeeded, isPublic); if (tunnel && tunnel.localAddress) { const matchingCandidate = mapHasAddressLocalhostOrAllInterfaces(this._candidates ?? new Map(), remote.host, remote.port); @@ -540,9 +572,9 @@ export class TunnelModel extends Disposable { privacy: this.makeTunnelPrivacy(tunnel.public), userForwarded: restore }; - const key = makeAddress(remote.host, remote.port); this.forwarded.set(key, newForward); this.remoteTunnels.set(key, tunnel); + this.inProgress.delete(key); await this.storeForwarded(); await this.showPortMismatchModalIfNeeded(tunnel, localPort, attributes); this._onForwardPort.fire(newForward); @@ -551,11 +583,12 @@ export class TunnelModel extends Disposable { } else { if (attributes?.label ?? name) { existingTunnel.name = attributes?.label ?? name; + this._onForwardPort.fire(); } - if (attributes?.protocol) { - existingTunnel.localUri = this.makeLocalUri(existingTunnel.localAddress, attributes); + if (attributes?.protocol && attributes.protocol !== existingTunnel.localUri.scheme) { + await this.close(existingTunnel.remoteHost, existingTunnel.remotePort); + await this.forward({ host: existingTunnel.remoteHost, port: existingTunnel.remotePort }, local, name, source, elevateIfNeeded, isPublic, restore, attributes); } - this._onForwardPort.fire(); return mapHasAddressLocalhostOrAllInterfaces(this.remoteTunnels, remote.host, remote.port); } } @@ -782,7 +815,7 @@ export interface IRemoteExplorerService { onDidChangeEditable: Event<{ tunnel: ITunnelItem, editId: TunnelEditId } | undefined>; setEditable(tunnelItem: ITunnelItem | undefined, editId: TunnelEditId, data: IEditableData | null): void; getEditableData(tunnelItem: ITunnelItem | undefined, editId?: TunnelEditId): IEditableData | undefined; - forward(remote: { host: string, port: number }, localPort?: number, name?: string, source?: string, elevateIfNeeded?: boolean, isPublic?: boolean, restore?: boolean): Promise; + forward(remote: { host: string, port: number }, localPort?: number, name?: string, source?: string, elevateIfNeeded?: boolean, isPublic?: boolean, restore?: boolean, attributes?: Attributes | null): Promise; close(remote: { host: string, port: number }): Promise; setTunnelInformation(tunnelInformation: TunnelInformation | undefined): void; setCandidateFilter(filter: ((candidates: CandidatePort[]) => Promise) | undefined): IDisposable; @@ -841,8 +874,8 @@ class RemoteExplorerService implements IRemoteExplorerService { return this._tunnelModel; } - forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string, elevateIfNeeded?: boolean, isPublic?: boolean, restore?: boolean): Promise { - return this.tunnelModel.forward(remote, local, name, source, elevateIfNeeded, isPublic, restore); + forward(remote: { host: string, port: number }, local?: number, name?: string, source?: string, elevateIfNeeded?: boolean, isPublic?: boolean, restore?: boolean, attributes?: Attributes | null): Promise { + return this.tunnelModel.forward(remote, local, name, source, elevateIfNeeded, isPublic, restore, attributes); } close(remote: { host: string, port: number }): Promise { diff --git a/src/vs/workbench/services/search/electron-browser/searchService.ts b/src/vs/workbench/services/search/electron-browser/searchService.ts index 3a667048048..3e742a28ace 100644 --- a/src/vs/workbench/services/search/electron-browser/searchService.ts +++ b/src/vs/workbench/services/search/electron-browser/searchService.ts @@ -27,6 +27,7 @@ import { IExtensionService } from 'vs/workbench/services/extensions/common/exten import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { FileAccess } from 'vs/base/common/network'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; +import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; export class LocalSearchService extends SearchService { constructor( @@ -54,6 +55,7 @@ export class DiskSearch implements ISearchResultProvider { searchDebug: IDebugParams | undefined, @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configService: IConfigurationService, + @ILifecycleService private readonly lifecycleService: ILifecycleService ) { const timeout = this.configService.getValue().search.maintainFileSearchCache ? 100 * 60 * 60 * 1000 : @@ -84,6 +86,8 @@ export class DiskSearch implements ISearchResultProvider { const client = new Client(FileAccess.asFileUri('bootstrap-fork', require).fsPath, opts); const channel = getNextTickChannel(client.getChannel('search')); this.raw = new SearchChannelClient(channel); + + this.lifecycleService.onWillShutdown(_ => client.dispose()); } textSearch(query: ITextQuery, onProgress?: (p: ISearchProgressItem) => void, token?: CancellationToken): Promise { diff --git a/src/vs/workbench/services/statusbar/common/statusbar.ts b/src/vs/workbench/services/statusbar/common/statusbar.ts index ee19a350567..7ef7ee73e64 100644 --- a/src/vs/workbench/services/statusbar/common/statusbar.ts +++ b/src/vs/workbench/services/statusbar/common/statusbar.ts @@ -21,6 +21,12 @@ export const enum StatusbarAlignment { */ export interface IStatusbarEntry { + /** + * The (short) name to show for the entry like 'Language Indicator', + * 'Git Status' etc. + */ + readonly name: string; + /** * The text to show for the entry. You can embed icons in the text by leveraging the syntax: * @@ -79,12 +85,11 @@ export interface IStatusbarService { * to update or remove the statusbar entry. * * @param id identifier of the entry is needed to allow users to hide entries via settings - * @param name human readable name the entry is about * @param alignment either LEFT or RIGHT * @param priority items get arranged from highest priority to lowest priority from left to right * in their respective alignment slot */ - addEntry(entry: IStatusbarEntry, id: string, name: string, alignment: StatusbarAlignment, priority?: number): IStatusbarEntryAccessor; + addEntry(entry: IStatusbarEntry, id: string, alignment: StatusbarAlignment, priority?: number): IStatusbarEntryAccessor; /** * An event that is triggered when an entry's visibility is changed. diff --git a/src/vs/workbench/services/telemetry/test/electron-browser/commonProperties.test.ts b/src/vs/workbench/services/telemetry/test/electron-browser/commonProperties.test.ts index b311ad724f6..08e0aeee528 100644 --- a/src/vs/workbench/services/telemetry/test/electron-browser/commonProperties.test.ts +++ b/src/vs/workbench/services/telemetry/test/electron-browser/commonProperties.test.ts @@ -10,7 +10,7 @@ import { release, tmpdir, hostname } from 'os'; import { resolveWorkbenchCommonProperties } from 'vs/workbench/services/telemetry/electron-sandbox/workbenchCommonProperties'; import { getRandomTestPath } from 'vs/base/test/node/testUtils'; import { IStorageService, StorageScope, InMemoryStorageService, StorageTarget } from 'vs/platform/storage/common/storage'; -import { rimraf } from 'vs/base/node/pfs'; +import { Promises, rimraf } from 'vs/base/node/pfs'; import { timeout } from 'vs/base/common/async'; import { IFileService } from 'vs/platform/files/common/files'; import { FileService } from 'vs/platform/files/common/fileService'; @@ -44,7 +44,7 @@ suite('Telemetry - common properties', function () { }); test('default', async function () { - await fs.promises.mkdir(parentDir, { recursive: true }); + await Promises.mkdir(parentDir, { recursive: true }); fs.writeFileSync(installSource, 'my.install.source'); const props = await resolveWorkbenchCommonProperties(testStorageService, testFileService, release(), hostname(), commit, version, 'someMachineId', undefined, installSource); assert.ok('commitHash' in props); diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts index 754fddd0d28..11c67c66e0b 100644 --- a/src/vs/workbench/services/textfile/browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/browser/textFileService.ts @@ -457,7 +457,7 @@ export abstract class AbstractTextFileService extends Disposable implements ITex // Otherwise try to suggest a path that can be saved let suggestedFilename: string | undefined = undefined; if (resource.scheme === Schemas.untitled) { - const model = this.untitledTextEditorService.get(resource); + const model = this.untitled.get(resource); if (model) { // Untitled with associated file path diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index 0b141f6a648..c98a79f6c3f 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -94,7 +94,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil private inErrorMode = false; constructor( - public readonly resource: URI, + readonly resource: URI, private preferredEncoding: string | undefined, // encoding as chosen by the user private preferredMode: string | undefined, // mode as chosen by the user @IModeService modeService: IModeService, diff --git a/src/vs/workbench/services/textfile/test/electron-browser/nativeTextFileService.io.test.ts b/src/vs/workbench/services/textfile/test/electron-browser/nativeTextFileService.io.test.ts index 57f55b51b9d..e46be38a1ee 100644 --- a/src/vs/workbench/services/textfile/test/electron-browser/nativeTextFileService.io.test.ts +++ b/src/vs/workbench/services/textfile/test/electron-browser/nativeTextFileService.io.test.ts @@ -4,13 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { tmpdir } from 'os'; -import { promises } from 'fs'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { IFileService } from 'vs/platform/files/common/files'; import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager'; import { Schemas } from 'vs/base/common/network'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; -import { rimraf, copy, exists } from 'vs/base/node/pfs'; +import { rimraf, copy, exists, Promises } from 'vs/base/node/pfs'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { FileService } from 'vs/platform/files/common/fileService'; import { NullLogService } from 'vs/platform/log/common/log'; @@ -32,7 +31,7 @@ flakySuite('Files - NativeTextFileService i/o', function () { function readFile(path: string): Promise; function readFile(path: string, encoding: BufferEncoding): Promise; function readFile(path: string, encoding?: BufferEncoding): Promise { - return promises.readFile(path, encoding); + return Promises.readFile(path, encoding); } createSuite({ @@ -70,7 +69,7 @@ flakySuite('Files - NativeTextFileService i/o', function () { }, exists, - stat: promises.stat, + stat: Promises.stat, readFile, detectEncodingByBOM }); diff --git a/src/vs/workbench/services/untitled/common/untitledTextEditorInput.ts b/src/vs/workbench/services/untitled/common/untitledTextEditorInput.ts index f7c7af6cc9e..435ffae63e2 100644 --- a/src/vs/workbench/services/untitled/common/untitledTextEditorInput.ts +++ b/src/vs/workbench/services/untitled/common/untitledTextEditorInput.ts @@ -26,7 +26,7 @@ export class UntitledTextEditorInput extends AbstractTextResourceEditorInput imp private modelResolve: Promise | undefined = undefined; constructor( - public readonly model: IUntitledTextEditorModel, + readonly model: IUntitledTextEditorModel, @ITextFileService textFileService: ITextFileService, @ILabelService labelService: ILabelService, @IEditorService editorService: IEditorService, diff --git a/src/vs/workbench/services/untitled/common/untitledTextEditorModel.ts b/src/vs/workbench/services/untitled/common/untitledTextEditorModel.ts index 732985245f5..aa0deaa39b3 100644 --- a/src/vs/workbench/services/untitled/common/untitledTextEditorModel.ts +++ b/src/vs/workbench/services/untitled/common/untitledTextEditorModel.ts @@ -12,7 +12,7 @@ import { Event, Emitter } from 'vs/base/common/event'; import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfigurationService'; import { ITextModel } from 'vs/editor/common/model'; -import { createTextBufferFactory, createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel'; +import { createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel'; import { ITextEditorModel } from 'vs/editor/common/services/resolverService'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { IWorkingCopy, WorkingCopyCapabilities, IWorkingCopyBackup, NO_TYPE_ID } from 'vs/workbench/services/workingCopy/common/workingCopy'; @@ -63,12 +63,6 @@ export interface IUntitledTextEditorModel extends ITextEditorModel, IModeSupport * Resolves the untitled model. */ resolve(): Promise; - - /** - * Updates the value of the untitled model optionally allowing to ignore dirty. - * The model must be resolved for this method to work. - */ - setValue(value: string, ignoreDirty?: boolean): void; } export class UntitledTextEditorModel extends BaseTextEditorModel implements IUntitledTextEditorModel { @@ -99,6 +93,10 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt readonly capabilities = WorkingCopyCapabilities.Untitled; + //#region Name + + private configuredLabelFormat: 'content' | 'name' = 'content'; + private cachedModelFirstLineWords: string | undefined = undefined; get name(): string { // Take name from first line if present and only if @@ -112,17 +110,12 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt return this.labelService.getUriBasenameLabel(this.resource); } - private dirty = this.hasAssociatedFilePath || !!this.initialValue; - private ignoreDirtyOnModelContentChange = false; + //#endregion - private versionId = 0; - - private configuredEncoding: string | undefined; - private configuredLabelFormat: 'content' | 'name' = 'content'; constructor( - public readonly resource: URI, - public readonly hasAssociatedFilePath: boolean, + readonly resource: URI, + readonly hasAssociatedFilePath: boolean, private readonly initialValue: string | undefined, private preferredMode: string | undefined, private preferredEncoding: string | undefined, @@ -153,7 +146,7 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt private registerListeners(): void { // Config Changes - this._register(this.textResourceConfigurationService.onDidChangeConfiguration(e => this.onConfigurationChange(true))); + this._register(this.textResourceConfigurationService.onDidChangeConfiguration(() => this.onConfigurationChange(true))); } private onConfigurationChange(fromEvent: boolean): void { @@ -179,9 +172,8 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt } } - getVersionId(): number { - return this.versionId; - } + + //#region Mode private _hasModeSetExplicitly: boolean = false; get hasModeSetExplicitly(): boolean { return this._hasModeSetExplicitly; } @@ -216,6 +208,13 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt return this.preferredMode; } + //#endregion + + + //#region Encoding + + private configuredEncoding: string | undefined; + getEncoding(): string | undefined { return this.preferredEncoding || this.configuredEncoding; } @@ -230,25 +229,13 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt } } - setValue(value: string, ignoreDirty?: boolean): void { - if (ignoreDirty) { - this.ignoreDirtyOnModelContentChange = true; - } - - try { - this.updateTextEditorModel(createTextBufferFactory(value)); - } finally { - this.ignoreDirtyOnModelContentChange = false; - } - } - - override isReadonly(): boolean { - return false; - } + //#endregion //#region Dirty + private dirty = this.hasAssociatedFilePath || !!this.initialValue; + isDirty(): boolean { return this.dirty; } @@ -360,19 +347,16 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt } private onModelContentChanged(textEditorModel: ITextModel, e: IModelContentChangedEvent): void { - this.versionId++; - if (!this.ignoreDirtyOnModelContentChange) { - // mark the untitled text editor as non-dirty once its content becomes empty and we do - // not have an associated path set. we never want dirty indicator in that case. - if (!this.hasAssociatedFilePath && textEditorModel.getLineCount() === 1 && textEditorModel.getLineContent(1) === '') { - this.setDirty(false); - } + // mark the untitled text editor as non-dirty once its content becomes empty and we do + // not have an associated path set. we never want dirty indicator in that case. + if (!this.hasAssociatedFilePath && textEditorModel.getLineCount() === 1 && textEditorModel.getLineContent(1) === '') { + this.setDirty(false); + } - // turn dirty otherwise - else { - this.setDirty(true); - } + // turn dirty otherwise + else { + this.setDirty(true); } // Check for name change if first line changed in the range of 0-FIRST_LINE_NAME_CANDIDATE_MAX_LENGTH columns @@ -421,4 +405,9 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt } //#endregion + + + override isReadonly(): boolean { + return false; + } } diff --git a/src/vs/workbench/services/untitled/test/browser/untitledTextEditor.test.ts b/src/vs/workbench/services/untitled/test/browser/untitledTextEditor.test.ts index e3abf1938ea..2eea7d8a629 100644 --- a/src/vs/workbench/services/untitled/test/browser/untitledTextEditor.test.ts +++ b/src/vs/workbench/services/untitled/test/browser/untitledTextEditor.test.ts @@ -102,22 +102,6 @@ suite('Untitled text editors', () => { }); } - test('setValue()', async () => { - const service = accessor.untitledTextEditorService; - const untitled = instantiationService.createInstance(UntitledTextEditorInput, service.create()); - - const model = await untitled.resolve(); - - model.setValue('not dirty', true); - assert.ok(!model.isDirty()); - - model.setValue('dirty'); - assert.ok(model.isDirty()); - - untitled.dispose(); - model.dispose(); - }); - test('associated resource is dirty', async () => { const service = accessor.untitledTextEditorService; const file = URI.file(join('C:\\', '/foo/file.txt')); diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts new file mode 100644 index 00000000000..fa58b182002 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { ResourceMap } from 'vs/base/common/map'; +import { Promises } from 'vs/base/common/async'; +import { IFileService } from 'vs/platform/files/common/files'; +import { URI } from 'vs/base/common/uri'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; +import { IFileWorkingCopy, IFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; + +export interface IBaseFileWorkingCopyManager> extends IDisposable { + + /** + * An event for when a file working copy was created. + */ + readonly onDidCreate: Event; + + /** + * Access to all known file working copies within the manager. + */ + readonly workingCopies: readonly W[]; + + /** + * Returns the file working copy for the provided resource + * or `undefined` if none. + */ + get(resource: URI): W | undefined; + + /** + * Disposes all working copies of the manager and disposes the manager. This + * method is different from `dispose` in that it will unregister any working + * copy from the `IWorkingCopyService`. Since this impact things like backups, + * the method is `async` because it needs to trigger `save` for any dirty + * working copy to preserve the data. + * + * Callers should make sure to e.g. close any editors associated with the + * working copy. + */ + destroy(): Promise; +} + +export abstract class BaseFileWorkingCopyManager> extends Disposable implements IBaseFileWorkingCopyManager { + + private readonly _onDidCreate = this._register(new Emitter()); + readonly onDidCreate = this._onDidCreate.event; + + private readonly mapResourceToWorkingCopy = new ResourceMap(); + private readonly mapResourceToDisposeListener = new ResourceMap(); + + constructor( + @IFileService protected readonly fileService: IFileService, + @ILogService protected readonly logService: ILogService, + @IWorkingCopyBackupService protected readonly workingCopyBackupService: IWorkingCopyBackupService + ) { + super(); + } + + protected has(resource: URI): boolean { + return this.mapResourceToWorkingCopy.has(resource); + } + + protected add(resource: URI, workingCopy: W): void { + const knownWorkingCopy = this.get(resource); + if (knownWorkingCopy === workingCopy) { + return; // already cached + } + + // Add to our working copy map + this.mapResourceToWorkingCopy.set(resource, workingCopy); + + // Update our dipsose listener to remove it on dispose + this.mapResourceToDisposeListener.get(resource)?.dispose(); + this.mapResourceToDisposeListener.set(resource, workingCopy.onWillDispose(() => this.remove(resource))); + + // Signal creation event + this._onDidCreate.fire(workingCopy); + } + + protected remove(resource: URI): void { + + // Dispose any existing listener + const disposeListener = this.mapResourceToDisposeListener.get(resource); + if (disposeListener) { + dispose(disposeListener); + this.mapResourceToDisposeListener.delete(resource); + } + + // Remove from our working copy map + this.mapResourceToWorkingCopy.delete(resource); + } + + //#region Get / Get all + + get workingCopies(): W[] { + return [...this.mapResourceToWorkingCopy.values()]; + } + + get(resource: URI): W | undefined { + return this.mapResourceToWorkingCopy.get(resource); + } + + //#endregion + + //#region Lifecycle + + override dispose(): void { + super.dispose(); + + // Clear working copy caches + // + // Note: we are not explicitly disposing the working copies + // known to the manager because this can have unwanted side + // effects such as backups getting discarded once the working + // copy unregisters. We have an explicit `destroy` + // for that purpose (https://github.com/microsoft/vscode/pull/123555) + // + this.mapResourceToWorkingCopy.clear(); + + // Dispose the dispose listeners + dispose(this.mapResourceToDisposeListener.values()); + this.mapResourceToDisposeListener.clear(); + } + + async destroy(): Promise { + + // Make sure all dirty working copies are saved to disk + try { + await Promises.settled(this.workingCopies.map(async workingCopy => { + if (workingCopy.isDirty()) { + await this.saveWithFallback(workingCopy); + } + })); + } catch (error) { + this.logService.error(error); + } + + // Dispose all working copies + dispose(this.mapResourceToWorkingCopy.values()); + + // Finally dispose manager + this.dispose(); + } + + private async saveWithFallback(workingCopy: W): Promise { + + // First try regular save + let saveFailed = false; + try { + await workingCopy.save(); + } catch (error) { + saveFailed = true; + } + + // Then fallback to backup if that exists + if (saveFailed || workingCopy.isDirty()) { + const backup = await this.workingCopyBackupService.resolve(workingCopy); + if (backup) { + await this.fileService.writeFile(workingCopy.resource, backup.value, { unlock: true }); + } + } + } + + //#endregion +} diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts index 5d573428ee8..141dd0596bb 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts @@ -3,94 +3,53 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from 'vs/nls'; -import { URI } from 'vs/base/common/uri'; -import { Event, Emitter } from 'vs/base/common/event'; -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { IDisposable } from 'vs/base/common/lifecycle'; -import { ETAG_DISABLED, FileOperationError, FileOperationResult, FileSystemProviderCapabilities, IFileService, IFileStatWithMetadata, IFileStreamContent, IWriteFileOptions } from 'vs/platform/files/common/files'; -import { ISaveOptions, IRevertOptions, SaveReason } from 'vs/workbench/common/editor'; -import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -import { IWorkingCopyBackup, IWorkingCopyBackupMeta, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; -import { raceCancellation, TaskSequentializer, timeout } from 'vs/base/common/async'; -import { ILogService } from 'vs/platform/log/common/log'; -import { assertIsDefined } from 'vs/base/common/types'; -import { ITextFileEditorModel, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { Event } from 'vs/base/common/event'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { VSBufferReadableStream } from 'vs/base/common/buffer'; -import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; -import { IWorkingCopyBackupService, IResolvedWorkingCopyBackup } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; -import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; -import { hash } from 'vs/base/common/hash'; -import { toErrorMessage } from 'vs/base/common/errorMessage'; -import { IAction, toAction } from 'vs/base/common/actions'; -import { isWindows } from 'vs/base/common/platform'; -import { IWorkingCopyEditorService } from 'vs/workbench/services/workingCopy/common/workingCopyEditorService'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { IElevatedFileService } from 'vs/workbench/services/files/common/elevatedFileService'; -import { IResourceWorkingCopy, ResourceWorkingCopy } from 'vs/workbench/services/workingCopy/common/resourceWorkingCopy'; +import { URI } from 'vs/base/common/uri'; +import { IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopy'; -export interface IFileWorkingCopyModelFactory { +export interface IFileWorkingCopyModelFactory { /** - * Asks the file working copy delegate to create a model from the given - * content under the provided resource. The content may originate from - * different sources depending on context: - * - from a backup if that exists - * - from the underlying file resource - * - passed in from the caller + * Create a model for the untitled or stored working copy + * from the given content under the provided resource. * * @param resource the `URI` of the model * @param contents the content of the model to create it * @param token support for cancellation */ - createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise; + createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise; } /** - * The underlying model of a file working copy provides some - * methods for the file working copy to function. The model is - * typically only available after the working copy has been - * resolved via it's `resolve()` method. + * A generic file working copy model to be reused by untitled + * and stored file working copies. */ export interface IFileWorkingCopyModel extends IDisposable { /** - * This event signals ANY changes to the contents of the file - * working copy model, for example: + * This event signals ANY changes to the contents, for example: * - through the user typing into the editor * - from API usage (e.g. bulk edits) * - when `IFileWorkingCopyModel#update` is invoked with contents * that are different from the current contents * - * The file working copy will listen to these changes and mark + * The file working copy will listen to these changes and may mark * the working copy as dirty whenever this event fires. * * Note: ONLY report changes to the model but not the underlying * file. The file working copy is tracking changes to the file * automatically. */ - readonly onDidChangeContent: Event; + readonly onDidChangeContent: Event; /** * An event emitted right before disposing the model. */ readonly onWillDispose: Event; - /** - * A version ID of the model. If a `onDidChangeContent` is fired - * from the model and the last known saved `versionId` matches - * with the `model.versionId`, the file working copy will discard - * any dirty state. - * - * A use case is the following: - * - a file working copy gets edited and thus dirty - * - the user triggers undo to revert the changes - * - at this point the `versionId` should match the one we had saved - * - * This requires the model to be aware of undo/redo operations. - */ - readonly versionId: unknown; - /** * Snapshots the model's current content for writing. This must include * any changes that were made to the model that are in memory. @@ -112,1164 +71,43 @@ export interface IFileWorkingCopyModel extends IDisposable { * @param token support for cancellation */ update(contents: VSBufferReadableStream, token: CancellationToken): Promise; - - /** - * Close the current undo-redo element. This offers a way - * to create an undo/redo stop point. - * - * This method may for example be called right before the - * save is triggered so that the user can always undo back - * to the state before saving. - */ - pushStackElement(): void; } -export interface IFileWorkingCopyModelContentChangedEvent { +export interface IFileWorkingCopy extends IWorkingCopy, IDisposable { /** - * Flag that indicates that this event was generated while undoing. - */ - readonly isUndoing: boolean; - - /** - * Flag that indicates that this event was generated while redoing. - */ - readonly isRedoing: boolean; -} - -/** - * A file based `IWorkingCopy` is backed by a `URI` from a - * known file system provider. Given this assumption, a lot - * of functionality can be built on top, such as saving in - * a secure way to prevent data loss. - */ -export interface IFileWorkingCopy extends IResourceWorkingCopy { - - /** - * An event for when a file working copy was resolved. - */ - readonly onDidResolve: Event; - - /** - * An event for when a file working copy was saved successfully. - */ - readonly onDidSave: Event; - - /** - * An event indicating that a file working copy save operation failed. - */ - readonly onDidSaveError: Event; - - /** - * An event for when the file working copy was reverted. + * An event for when the file working copy has been reverted. */ readonly onDidRevert: Event; /** - * An event for when the readonly state of the file working copy changes. + * An event for when the file working copy has been disposed. */ - readonly onDidChangeReadonly: Event; + readonly onWillDispose: Event; /** * Provides access to the underlying model of this file * based working copy. As long as the file working copy * has not been resolved, the model is `undefined`. */ - readonly model: T | undefined; + readonly model: M | undefined; /** - * Resolves a file working copy. + * Resolves the file working copy and thus makes the `model` + * available. */ - resolve(options?: IFileWorkingCopyResolveOptions): Promise; - - /** - * Explicitly sets the working copy to be dirty. - */ - markDirty(): void; - - /** - * Whether the file working copy is in the provided `state` - * or not. - * - * @param state the `FileWorkingCopyState` to check on. - */ - hasState(state: FileWorkingCopyState): boolean; - - /** - * Allows to join a state change away from the provided `state`. - * - * @param state currently only `FileWorkingCopyState.PENDING_SAVE` - * can be awaited on to resolve. - */ - joinState(state: FileWorkingCopyState.PENDING_SAVE): Promise; + resolve(): Promise; /** * Whether we have a resolved model or not. */ - isResolved(): this is IResolvedFileWorkingCopy; - - /** - * Whether the file working copy is readonly or not. - */ - isReadonly(): boolean; + isResolved(): this is IResolvedFileWorkingCopy; } -export interface IResolvedFileWorkingCopy extends IFileWorkingCopy { +export interface IResolvedFileWorkingCopy extends IFileWorkingCopy { /** - * A resolved file working copy has a resolved model `T`. + * A resolved file working copy has a resolved model. */ - readonly model: T; -} - -/** - * States the file working copy can be in. - */ -export const enum FileWorkingCopyState { - - /** - * A file working copy is saved. - */ - SAVED, - - /** - * A file working copy is dirty. - */ - DIRTY, - - /** - * A file working copy is currently being saved but - * this operation has not completed yet. - */ - PENDING_SAVE, - - /** - * A file working copy is in conflict mode when changes - * cannot be saved because the underlying file has changed. - * File working copies in conflict mode are always dirty. - */ - CONFLICT, - - /** - * A file working copy is in orphan state when the underlying - * file has been deleted. - */ - ORPHAN, - - /** - * Any error that happens during a save that is not causing - * the `FileWorkingCopyState.CONFLICT` state. - * File working copies in error mode are always dirty. - */ - ERROR -} - -export interface IFileWorkingCopySaveOptions extends ISaveOptions { - - /** - * Save the file working copy with an attempt to unlock it. - */ - writeUnlock?: boolean; - - /** - * Save the file working copy with elevated privileges. - * - * Note: This may not be supported in all environments. - */ - writeElevated?: boolean; - - /** - * Allows to write to a file working copy even if it has been - * modified on disk. This should only be triggered from an - * explicit user action. - */ - ignoreModifiedSince?: boolean; - - /** - * If set, will bubble up the file working copy save error to - * the caller instead of handling it. - */ - ignoreErrorHandler?: boolean; -} - -export interface IFileWorkingCopyResolveOptions { - - /** - * The contents to use for the file working copy if known. If not - * provided, the contents will be retrieved from the underlying - * resource or backup if present. - * - * If contents are provided, the file working copy will be marked - * as dirty right from the beginning. - */ - contents?: VSBufferReadableStream; - - /** - * Go to disk bypassing any cache of the file working copy if any. - */ - forceReadFromFile?: boolean; -} - -/** - * Metadata associated with a file working copy backup. - */ -interface IFileWorkingCopyBackupMetaData extends IWorkingCopyBackupMeta { - mtime: number; - ctime: number; - size: number; - etag: string; - orphaned: boolean; -} - -export class FileWorkingCopy extends ResourceWorkingCopy implements IFileWorkingCopy { - - readonly capabilities: WorkingCopyCapabilities = WorkingCopyCapabilities.None; - - private _model: T | undefined = undefined; - get model(): T | undefined { return this._model; } - - //#region events - - private readonly _onDidChangeContent = this._register(new Emitter()); - readonly onDidChangeContent = this._onDidChangeContent.event; - - private readonly _onDidResolve = this._register(new Emitter()); - readonly onDidResolve = this._onDidResolve.event; - - private readonly _onDidChangeDirty = this._register(new Emitter()); - readonly onDidChangeDirty = this._onDidChangeDirty.event; - - private readonly _onDidSaveError = this._register(new Emitter()); - readonly onDidSaveError = this._onDidSaveError.event; - - private readonly _onDidSave = this._register(new Emitter()); - readonly onDidSave = this._onDidSave.event; - - private readonly _onDidRevert = this._register(new Emitter()); - readonly onDidRevert = this._onDidRevert.event; - - private readonly _onDidChangeReadonly = this._register(new Emitter()); - readonly onDidChangeReadonly = this._onDidChangeReadonly.event; - - //#endregion - - constructor( - readonly typeId: string, - resource: URI, - readonly name: string, - private readonly modelFactory: IFileWorkingCopyModelFactory, - @IFileService fileService: IFileService, - @ILogService private readonly logService: ILogService, - @ITextFileService private readonly textFileService: ITextFileService, - @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, - @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, - @IWorkingCopyService workingCopyService: IWorkingCopyService, - @INotificationService private readonly notificationService: INotificationService, - @IWorkingCopyEditorService private readonly workingCopyEditorService: IWorkingCopyEditorService, - @IEditorService private readonly editorService: IEditorService, - @IElevatedFileService private readonly elevatedFileService: IElevatedFileService - ) { - super(resource, fileService); - - if (!fileService.canHandleResource(this.resource)) { - throw new Error(`The file working copy resource ${this.resource.toString(true)} does not have an associated file system provider.`); - } - - // Make known to working copy service - this._register(workingCopyService.registerWorkingCopy(this)); - } - - //#region Dirty - - private dirty = false; - private savedVersionId: unknown; - - isDirty(): this is IResolvedFileWorkingCopy { - return this.dirty; - } - - markDirty(): void { - this.setDirty(true); - } - - private setDirty(dirty: boolean): void { - if (!this.isResolved()) { - return; // only resolved working copies can be marked dirty - } - - // Track dirty state and version id - const wasDirty = this.dirty; - this.doSetDirty(dirty); - - // Emit as Event if dirty changed - if (dirty !== wasDirty) { - this._onDidChangeDirty.fire(); - } - } - - private doSetDirty(dirty: boolean): () => void { - const wasDirty = this.dirty; - const wasInConflictMode = this.inConflictMode; - const wasInErrorMode = this.inErrorMode; - const oldSavedVersionId = this.savedVersionId; - - if (!dirty) { - this.dirty = false; - this.inConflictMode = false; - this.inErrorMode = false; - - // we remember the models alternate version id to remember when the version - // of the model matches with the saved version on disk. we need to keep this - // in order to find out if the model changed back to a saved version (e.g. - // when undoing long enough to reach to a version that is saved and then to - // clear the dirty flag) - if (this.isResolved()) { - this.savedVersionId = this.model.versionId; - } - } else { - this.dirty = true; - } - - // Return function to revert this call - return () => { - this.dirty = wasDirty; - this.inConflictMode = wasInConflictMode; - this.inErrorMode = wasInErrorMode; - this.savedVersionId = oldSavedVersionId; - }; - } - - //#endregion - - //#region Resolve - - private lastResolvedFileStat: IFileStatWithMetadata | undefined; - - async resolve(options?: IFileWorkingCopyResolveOptions): Promise { - this.trace('[file working copy] resolve() - enter'); - - // Return early if we are disposed - if (this.isDisposed()) { - this.trace('[file working copy] resolve() - exit - without resolving because file working copy is disposed'); - - return; - } - - // Unless there are explicit contents provided, it is important that we do not - // resolve a working copy that is dirty or is in the process of saving to prevent - // data loss. - if (!options?.contents && (this.dirty || this.saveSequentializer.hasPending())) { - this.trace('[file working copy] resolve() - exit - without resolving because file working copy is dirty or being saved'); - - return; - } - - return this.doResolve(options); - } - - private async doResolve(options?: IFileWorkingCopyResolveOptions): Promise { - - // First check if we have contents to use for the working copy - if (options?.contents) { - return this.resolveFromBuffer(options.contents); - } - - // Second, check if we have a backup to resolve from (only for new working copies) - const isNew = !this.isResolved(); - if (isNew) { - const resolvedFromBackup = await this.resolveFromBackup(); - if (resolvedFromBackup) { - return; - } - } - - // Finally, resolve from file resource - return this.resolveFromFile(options); - } - - private async resolveFromBuffer(buffer: VSBufferReadableStream): Promise { - this.trace('[file working copy] resolveFromBuffer()'); - - // Try to resolve metdata from disk - let mtime: number; - let ctime: number; - let size: number; - let etag: string; - try { - const metadata = await this.fileService.resolve(this.resource, { resolveMetadata: true }); - mtime = metadata.mtime; - ctime = metadata.ctime; - size = metadata.size; - etag = metadata.etag; - - // Clear orphaned state when resolving was successful - this.setOrphaned(false); - } catch (error) { - - // Put some fallback values in error case - mtime = Date.now(); - ctime = Date.now(); - size = 0; - etag = ETAG_DISABLED; - - // Apply orphaned state based on error code - this.setOrphaned(error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND); - } - - // Resolve with buffer - return this.resolveFromContent({ - resource: this.resource, - name: this.name, - mtime, - ctime, - size, - etag, - value: buffer, - readonly: false - }, true /* dirty (resolved from buffer) */); - } - - private async resolveFromBackup(): Promise { - - // Resolve backup if any - const backup = await this.workingCopyBackupService.resolve(this); - - // Abort if someone else managed to resolve the working copy by now - let isNew = !this.isResolved(); - if (!isNew) { - this.trace('[file working copy] resolveFromBackup() - exit - withoutresolving because previously new file working copy got created meanwhile'); - - return true; // imply that resolving has happened in another operation - } - - // Try to resolve from backup if we have any - if (backup) { - await this.doResolveFromBackup(backup); - - return true; - } - - // Otherwise signal back that resolving did not happen - return false; - } - - private async doResolveFromBackup(backup: IResolvedWorkingCopyBackup): Promise { - this.trace('[file working copy] doResolveFromBackup()'); - - // Resolve with backup - await this.resolveFromContent({ - resource: this.resource, - name: this.name, - mtime: backup.meta ? backup.meta.mtime : Date.now(), - ctime: backup.meta ? backup.meta.ctime : Date.now(), - size: backup.meta ? backup.meta.size : 0, - etag: backup.meta ? backup.meta.etag : ETAG_DISABLED, // etag disabled if unknown! - value: backup.value, - readonly: false - }, true /* dirty (resolved from backup) */); - - // Restore orphaned flag based on state - if (backup.meta && backup.meta.orphaned) { - this.setOrphaned(true); - } - } - - private async resolveFromFile(options?: IFileWorkingCopyResolveOptions): Promise { - this.trace('[file working copy] resolveFromFile()'); - - const forceReadFromFile = options?.forceReadFromFile; - - // Decide on etag - let etag: string | undefined; - if (forceReadFromFile) { - etag = ETAG_DISABLED; // disable ETag if we enforce to read from disk - } else if (this.lastResolvedFileStat) { - etag = this.lastResolvedFileStat.etag; // otherwise respect etag to support caching - } - - // Remember current version before doing any long running operation - // to ensure we are not changing a working copy that was changed - // meanwhile - const currentVersionId = this.versionId; - - // Resolve Content - try { - const content = await this.fileService.readFileStream(this.resource, { etag }); - - // Clear orphaned state when resolving was successful - this.setOrphaned(false); - - // Return early if the working copy content has changed - // meanwhile to prevent loosing any changes - if (currentVersionId !== this.versionId) { - this.trace('[file working copy] resolveFromFile() - exit - without resolving because file working copy content changed'); - - return; - } - - await this.resolveFromContent(content, false /* not dirty (resolved from file) */); - } catch (error) { - const result = error.fileOperationResult; - - // Apply orphaned state based on error code - this.setOrphaned(result === FileOperationResult.FILE_NOT_FOUND); - - // NotModified status is expected and can be handled gracefully - // if we are resolved - if (this.isResolved() && result === FileOperationResult.FILE_NOT_MODIFIED_SINCE) { - return; - } - - // Unless we are forced to read from the file, ignore when a working copy has - // been resolved once and the file was deleted meanwhile. Since we already have - // the working copy resolved, we can return to this state and update the orphaned - // flag to indicate that this working copy has no version on disk anymore. - if (this.isResolved() && result === FileOperationResult.FILE_NOT_FOUND && !forceReadFromFile) { - return; - } - - // Otherwise bubble up the error - throw error; - } - } - - private async resolveFromContent(content: IFileStreamContent, dirty: boolean): Promise { - this.trace('[file working copy] resolveFromContent() - enter'); - - // Return early if we are disposed - if (this.isDisposed()) { - this.trace('[file working copy] resolveFromContent() - exit - because working copy is disposed'); - - return; - } - - // Update our resolved disk stat - this.updateLastResolvedFileStat({ - resource: this.resource, - name: content.name, - mtime: content.mtime, - ctime: content.ctime, - size: content.size, - etag: content.etag, - readonly: content.readonly, - isFile: true, - isDirectory: false, - isSymbolicLink: false - }); - - // Update existing model if we had been resolved - if (this.isResolved()) { - await this.doUpdateModel(content.value); - } - - // Create new model otherwise - else { - await this.doCreateModel(content.value); - } - - // Update working copy dirty flag. This is very important to call - // in both cases of dirty or not because it conditionally updates - // the `savedVersionId` to determine the version when to consider - // the working copy as saved again (e.g. when undoing back to the - // saved state) - this.setDirty(!!dirty); - - // Emit as event - this._onDidResolve.fire(); - } - - private async doCreateModel(contents: VSBufferReadableStream): Promise { - this.trace('[file working copy] doCreateModel()'); - - // Create model and dispose it when we get disposed - this._model = this._register(await this.modelFactory.createModel(this.resource, contents, CancellationToken.None)); - - // Model listeners - this.installModelListeners(this._model); - } - - private ignoreDirtyOnModelContentChange = false; - - private async doUpdateModel(contents: VSBufferReadableStream): Promise { - this.trace('[file working copy] doUpdateModel()'); - - // Update model value in a block that ignores content change events for dirty tracking - this.ignoreDirtyOnModelContentChange = true; - try { - await this.model?.update(contents, CancellationToken.None); - } finally { - this.ignoreDirtyOnModelContentChange = false; - } - } - - private installModelListeners(model: IFileWorkingCopyModel): void { - - // See https://github.com/microsoft/vscode/issues/30189 - // This code has been extracted to a different method because it caused a memory leak - // where `value` was captured in the content change listener closure scope. - - // Content Change - this._register(model.onDidChangeContent(e => this.onModelContentChanged(model, e.isUndoing || e.isRedoing))); - - // Lifecycle - this._register(model.onWillDispose(() => this.dispose())); - } - - private onModelContentChanged(model: IFileWorkingCopyModel, isUndoingOrRedoing: boolean): void { - this.trace(`[file working copy] onModelContentChanged() - enter`); - - // In any case increment the version id because it tracks the textual content state of the model at all times - this.versionId++; - this.trace(`[file working copy] onModelContentChanged() - new versionId ${this.versionId}`); - - // Remember when the user changed the model through a undo/redo operation. - // We need this information to throttle save participants to fix - // https://github.com/microsoft/vscode/issues/102542 - if (isUndoingOrRedoing) { - this.lastContentChangeFromUndoRedo = Date.now(); - } - - // We mark check for a dirty-state change upon model content change, unless: - // - explicitly instructed to ignore it (e.g. from model.resolve()) - // - the model is readonly (in that case we never assume the change was done by the user) - if (!this.ignoreDirtyOnModelContentChange && !this.isReadonly()) { - - // The contents changed as a matter of Undo and the version reached matches the saved one - // In this case we clear the dirty flag and emit a SAVED event to indicate this state. - if (model.versionId === this.savedVersionId) { - this.trace('[file working copy] onModelContentChanged() - model content changed back to last saved version'); - - // Clear flags - const wasDirty = this.dirty; - this.setDirty(false); - - // Emit revert event if we were dirty - if (wasDirty) { - this._onDidRevert.fire(); - } - } - - // Otherwise the content has changed and we signal this as becoming dirty - else { - this.trace('[file working copy] onModelContentChanged() - model content changed and marked as dirty'); - - // Mark as dirty - this.setDirty(true); - } - } - - // Emit as event - this._onDidChangeContent.fire(); - } - - //#endregion - - //#region Backup - - async backup(token: CancellationToken): Promise { - - // Fill in metadata if we are resolved - let meta: IFileWorkingCopyBackupMetaData | undefined = undefined; - if (this.lastResolvedFileStat) { - meta = { - mtime: this.lastResolvedFileStat.mtime, - ctime: this.lastResolvedFileStat.ctime, - size: this.lastResolvedFileStat.size, - etag: this.lastResolvedFileStat.etag, - orphaned: this.isOrphaned() - }; - } - - // Fill in content if we are resolved - let content: VSBufferReadableStream | undefined = undefined; - if (this.isResolved()) { - content = await raceCancellation(this.model.snapshot(token), token); - } - - return { meta, content }; - } - - //#endregion - - //#region Save - - private versionId = 0; - - private static readonly UNDO_REDO_SAVE_PARTICIPANTS_AUTO_SAVE_THROTTLE_THRESHOLD = 500; - private lastContentChangeFromUndoRedo: number | undefined = undefined; - - private readonly saveSequentializer = new TaskSequentializer(); - - async save(options: IFileWorkingCopySaveOptions = Object.create(null)): Promise { - if (!this.isResolved()) { - return false; - } - - if (this.isReadonly()) { - this.trace('[file working copy] save() - ignoring request for readonly resource'); - - return false; // if working copy is readonly we do not attempt to save at all - } - - if ( - (this.hasState(FileWorkingCopyState.CONFLICT) || this.hasState(FileWorkingCopyState.ERROR)) && - (options.reason === SaveReason.AUTO || options.reason === SaveReason.FOCUS_CHANGE || options.reason === SaveReason.WINDOW_CHANGE) - ) { - this.trace('[file working copy] save() - ignoring auto save request for file working copy that is in conflict or error'); - - return false; // if working copy is in save conflict or error, do not save unless save reason is explicit - } - - // Actually do save - this.trace('[file working copy] save() - enter'); - await this.doSave(options); - this.trace('[file working copy] save() - exit'); - - return true; - } - - private async doSave(options: IFileWorkingCopySaveOptions): Promise { - if (typeof options.reason !== 'number') { - options.reason = SaveReason.EXPLICIT; - } - - let versionId = this.versionId; - this.trace(`[file working copy] doSave(${versionId}) - enter with versionId ${versionId}`); - - // Lookup any running pending save for this versionId and return it if found - // - // Scenario: user invoked the save action multiple times quickly for the same contents - // while the save was not yet finished to disk - // - if (this.saveSequentializer.hasPending(versionId)) { - this.trace(`[file working copy] doSave(${versionId}) - exit - found a pending save for versionId ${versionId}`); - - return this.saveSequentializer.pending; - } - - // Return early if not dirty (unless forced) - // - // Scenario: user invoked save action even though the working copy is not dirty - if (!options.force && !this.dirty) { - this.trace(`[file working copy] doSave(${versionId}) - exit - because not dirty and/or versionId is different (this.isDirty: ${this.dirty}, this.versionId: ${this.versionId})`); - - return; - } - - // Return if currently saving by storing this save request as the next save that should happen. - // Never ever must 2 saves execute at the same time because this can lead to dirty writes and race conditions. - // - // Scenario A: auto save was triggered and is currently busy saving to disk. this takes long enough that another auto save - // kicks in. - // Scenario B: save is very slow (e.g. network share) and the user manages to change the working copy and trigger another save - // while the first save has not returned yet. - // - if (this.saveSequentializer.hasPending()) { - this.trace(`[file working copy] doSave(${versionId}) - exit - because busy saving`); - - // Indicate to the save sequentializer that we want to - // cancel the pending operation so that ours can run - // before the pending one finishes. - // Currently this will try to cancel pending save - // participants and pending snapshots from the - // save operation, but not the actual save which does - // not support cancellation yet. - this.saveSequentializer.cancelPending(); - - // Register this as the next upcoming save and return - return this.saveSequentializer.setNext(() => this.doSave(options)); - } - - // Push all edit operations to the undo stack so that the user has a chance to - // Ctrl+Z back to the saved version. - if (this.isResolved()) { - this.model.pushStackElement(); - } - - const saveCancellation = new CancellationTokenSource(); - - return this.saveSequentializer.setPending(versionId, (async () => { - - // A save participant can still change the working copy now - // and since we are so close to saving we do not want to trigger - // another auto save or similar, so we block this - // In addition we update our version right after in case it changed - // because of a working copy change - // Save participants can also be skipped through API. - if (this.isResolved() && !options.skipSaveParticipants && this.isTextFileModel(this.model)) { - try { - - // Measure the time it took from the last undo/redo operation to this save. If this - // time is below `UNDO_REDO_SAVE_PARTICIPANTS_THROTTLE_THRESHOLD`, we make sure to - // delay the save participant for the remaining time if the reason is auto save. - // - // This fixes the following issue: - // - the user has configured auto save with delay of 100ms or shorter - // - the user has a save participant enabled that modifies the file on each save - // - the user types into the file and the file gets saved - // - the user triggers undo operation - // - this will undo the save participant change but trigger the save participant right after - // - the user has no chance to undo over the save participant - // - // Reported as: https://github.com/microsoft/vscode/issues/102542 - if (options.reason === SaveReason.AUTO && typeof this.lastContentChangeFromUndoRedo === 'number') { - const timeFromUndoRedoToSave = Date.now() - this.lastContentChangeFromUndoRedo; - if (timeFromUndoRedoToSave < FileWorkingCopy.UNDO_REDO_SAVE_PARTICIPANTS_AUTO_SAVE_THROTTLE_THRESHOLD) { - await timeout(FileWorkingCopy.UNDO_REDO_SAVE_PARTICIPANTS_AUTO_SAVE_THROTTLE_THRESHOLD - timeFromUndoRedoToSave); - } - } - - // Run save participants unless save was cancelled meanwhile - if (!saveCancellation.token.isCancellationRequested) { - await this.textFileService.files.runSaveParticipants(this.model, { reason: options.reason ?? SaveReason.EXPLICIT }, saveCancellation.token); - } - } catch (error) { - this.logService.error(`[file working copy] runSaveParticipants(${versionId}) - resulted in an error: ${error.toString()}`, this.resource.toString(true), this.typeId); - } - } - - // It is possible that a subsequent save is cancelling this - // running save. As such we return early when we detect that. - if (saveCancellation.token.isCancellationRequested) { - return; - } - - // We have to protect against being disposed at this point. It could be that the save() operation - // was triggerd followed by a dispose() operation right after without waiting. Typically we cannot - // be disposed if we are dirty, but if we are not dirty, save() and dispose() can still be triggered - // one after the other without waiting for the save() to complete. If we are disposed(), we risk - // saving contents to disk that are stale (see https://github.com/microsoft/vscode/issues/50942). - // To fix this issue, we will not store the contents to disk when we got disposed. - if (this.isDisposed()) { - return; - } - - // We require a resolved working copy from this point on, since we are about to write data to disk. - if (!this.isResolved()) { - return; - } - - // update versionId with its new value (if pre-save changes happened) - versionId = this.versionId; - - // Clear error flag since we are trying to save again - this.inErrorMode = false; - - // Save to Disk. We mark the save operation as currently pending with - // the latest versionId because it might have changed from a save - // participant triggering - this.trace(`[file working copy] doSave(${versionId}) - before write()`); - const lastResolvedFileStat = assertIsDefined(this.lastResolvedFileStat); - const resolvedFileWorkingCopy = this; - return this.saveSequentializer.setPending(versionId, (async () => { - try { - - // Snapshot working copy model contents - const snapshot = await raceCancellation(resolvedFileWorkingCopy.model.snapshot(saveCancellation.token), saveCancellation.token); - - // It is possible that a subsequent save is cancelling this - // running save. As such we return early when we detect that - // However, we do not pass the token into the file service - // because that is an atomic operation currently without - // cancellation support, so we dispose the cancellation if - // it was not cancelled yet. - if (saveCancellation.token.isCancellationRequested) { - return; - } else { - saveCancellation.dispose(); - } - - const writeFileOptions: IWriteFileOptions = { - mtime: lastResolvedFileStat.mtime, - etag: (options.ignoreModifiedSince || !this.filesConfigurationService.preventSaveConflicts(lastResolvedFileStat.resource)) ? ETAG_DISABLED : lastResolvedFileStat.etag, - unlock: options.writeUnlock - }; - - // Write them to disk - let stat: IFileStatWithMetadata; - if (options?.writeElevated && this.elevatedFileService.isSupported(lastResolvedFileStat.resource)) { - stat = await this.elevatedFileService.writeFileElevated(lastResolvedFileStat.resource, assertIsDefined(snapshot), writeFileOptions); - } else { - stat = await this.fileService.writeFile(lastResolvedFileStat.resource, assertIsDefined(snapshot), writeFileOptions); - } - - this.handleSaveSuccess(stat, versionId, options); - } catch (error) { - this.handleSaveError(error, versionId, options); - } - })(), () => saveCancellation.cancel()); - })(), () => saveCancellation.cancel()); - } - - private handleSaveSuccess(stat: IFileStatWithMetadata, versionId: number, options: IFileWorkingCopySaveOptions): void { - - // Updated resolved stat with updated stat - this.updateLastResolvedFileStat(stat); - - // Update dirty state unless working copy has changed meanwhile - if (versionId === this.versionId) { - this.trace(`[file working copy] handleSaveSuccess(${versionId}) - setting dirty to false because versionId did not change`); - this.setDirty(false); - } else { - this.trace(`[file working copy] handleSaveSuccess(${versionId}) - not setting dirty to false because versionId did change meanwhile`); - } - - // Update orphan state given save was successful - this.setOrphaned(false); - - // Emit Save Event - this._onDidSave.fire(options.reason ?? SaveReason.EXPLICIT); - } - - private handleSaveError(error: Error, versionId: number, options: IFileWorkingCopySaveOptions): void { - this.logService.error(`[file working copy] handleSaveError(${versionId}) - exit - resulted in a save error: ${error.toString()}`, this.resource.toString(true), this.typeId); - - // Return early if the save() call was made asking to - // handle the save error itself. - if (options.ignoreErrorHandler) { - throw error; - } - - // In any case of an error, we mark the working copy as dirty to prevent data loss - // It could be possible that the write corrupted the file on disk (e.g. when - // an error happened after truncating the file) and as such we want to preserve - // the working copy contents to prevent data loss. - this.setDirty(true); - - // Flag as error state - this.inErrorMode = true; - - // Look out for a save conflict - if ((error as FileOperationError).fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) { - this.inConflictMode = true; - } - - // Delegate to save error handler - if (this.isTextFileModel(this.model)) { - this.textFileService.files.saveErrorHandler.onSaveError(error, this.model); - } else { - this.doHandleSaveError(error); - } - - // Emit as event - this._onDidSaveError.fire(); - } - - private doHandleSaveError(error: Error): void { - const fileOperationError = error as FileOperationError; - const primaryActions: IAction[] = []; - - let message: string; - - // Dirty write prevention - if (fileOperationError.fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) { - message = localize('staleSaveError', "Failed to save '{0}': The content of the file is newer. Do you want to overwrite the file with your changes?", this.name); - - primaryActions.push(toAction({ id: 'fileWorkingCopy.overwrite', label: localize('overwrite', "Overwrite"), run: () => this.save({ ignoreModifiedSince: true }) })); - primaryActions.push(toAction({ id: 'fileWorkingCopy.revert', label: localize('discard', "Discard"), run: () => this.revert() })); - } - - // Any other save error - else { - const isWriteLocked = fileOperationError.fileOperationResult === FileOperationResult.FILE_WRITE_LOCKED; - const triedToUnlock = isWriteLocked && fileOperationError.options?.unlock; - const isPermissionDenied = fileOperationError.fileOperationResult === FileOperationResult.FILE_PERMISSION_DENIED; - const canSaveElevated = this.elevatedFileService.isSupported(this.resource); - - // Save Elevated - if (canSaveElevated && (isPermissionDenied || triedToUnlock)) { - primaryActions.push(toAction({ - id: 'fileWorkingCopy.saveElevated', - label: triedToUnlock ? - isWindows ? localize('overwriteElevated', "Overwrite as Admin...") : localize('overwriteElevatedSudo', "Overwrite as Sudo...") : - isWindows ? localize('saveElevated', "Retry as Admin...") : localize('saveElevatedSudo', "Retry as Sudo..."), - run: () => { - this.save({ writeElevated: true, writeUnlock: triedToUnlock, reason: SaveReason.EXPLICIT }); - } - })); - } - - // Unlock - else if (isWriteLocked) { - primaryActions.push(toAction({ id: 'fileWorkingCopy.unlock', label: localize('overwrite', "Overwrite"), run: () => this.save({ writeUnlock: true, reason: SaveReason.EXPLICIT }) })); - } - - // Retry - else { - primaryActions.push(toAction({ id: 'fileWorkingCopy.retry', label: localize('retry', "Retry"), run: () => this.save({ reason: SaveReason.EXPLICIT }) })); - } - - // Save As - primaryActions.push(toAction({ - id: 'fileWorkingCopy.saveAs', - label: localize('saveAs', "Save As..."), - run: () => { - const editor = this.workingCopyEditorService.findEditor(this); - if (editor) { - this.editorService.save(editor, { saveAs: true, reason: SaveReason.EXPLICIT }); - } - } - })); - - // Discard - primaryActions.push(toAction({ id: 'fileWorkingCopy.revert', label: localize('discard', "Discard"), run: () => this.revert() })); - - // Message - if (isWriteLocked) { - if (triedToUnlock && canSaveElevated) { - message = isWindows ? - localize('readonlySaveErrorAdmin', "Failed to save '{0}': File is read-only. Select 'Overwrite as Admin' to retry as administrator.", this.name) : - localize('readonlySaveErrorSudo', "Failed to save '{0}': File is read-only. Select 'Overwrite as Sudo' to retry as superuser.", this.name); - } else { - message = localize('readonlySaveError', "Failed to save '{0}': File is read-only. Select 'Overwrite' to attempt to make it writeable.", this.name); - } - } else if (canSaveElevated && isPermissionDenied) { - message = isWindows ? - localize('permissionDeniedSaveError', "Failed to save '{0}': Insufficient permissions. Select 'Retry as Admin' to retry as administrator.", this.name) : - localize('permissionDeniedSaveErrorSudo', "Failed to save '{0}': Insufficient permissions. Select 'Retry as Sudo' to retry as superuser.", this.name); - } else { - message = localize({ key: 'genericSaveError', comment: ['{0} is the resource that failed to save and {1} the error message'] }, "Failed to save '{0}': {1}", this.name, toErrorMessage(error, false)); - } - } - - // Show to the user as notification - const handle = this.notificationService.notify({ id: `${hash(this.resource.toString())}`, severity: Severity.Error, message, actions: { primary: primaryActions } }); - - // Remove automatically when we get saved/reverted - const listener = Event.once(Event.any(this.onDidSave, this.onDidRevert))(() => handle.close()); - Event.once(handle.onDidClose)(() => listener.dispose()); - } - - private updateLastResolvedFileStat(newFileStat: IFileStatWithMetadata): void { - const oldReadonly = this.isReadonly(); - - // First resolve - just take - if (!this.lastResolvedFileStat) { - this.lastResolvedFileStat = newFileStat; - } - - // Subsequent resolve - make sure that we only assign it if the mtime - // is equal or has advanced. - // This prevents race conditions from resolving and saving. If a save - // comes in late after a revert was called, the mtime could be out of - // sync. - else if (this.lastResolvedFileStat.mtime <= newFileStat.mtime) { - this.lastResolvedFileStat = newFileStat; - } - - // Signal that the readonly state changed - if (this.isReadonly() !== oldReadonly) { - this._onDidChangeReadonly.fire(); - } - } - - //#endregion - - //#region Revert - - async revert(options?: IRevertOptions): Promise { - if (!this.isResolved() || (!this.dirty && !options?.force)) { - return; // ignore if not resolved or not dirty and not enforced - } - - // Unset flags - const wasDirty = this.dirty; - const undoSetDirty = this.doSetDirty(false); - - // Force read from disk unless reverting soft - const softUndo = options?.soft; - if (!softUndo) { - try { - await this.resolve({ forceReadFromFile: true }); - } catch (error) { - - // FileNotFound means the file got deleted meanwhile, so ignore it - if ((error as FileOperationError).fileOperationResult !== FileOperationResult.FILE_NOT_FOUND) { - - // Set flags back to previous values, we are still dirty if revert failed - undoSetDirty(); - - throw error; - } - } - } - - // Emit file change event - this._onDidRevert.fire(); - - // Emit dirty change event - if (wasDirty) { - this._onDidChangeDirty.fire(); - } - } - - //#endregion - - //#region State - - private inConflictMode = false; - private inErrorMode = false; - - hasState(state: FileWorkingCopyState): boolean { - switch (state) { - case FileWorkingCopyState.CONFLICT: - return this.inConflictMode; - case FileWorkingCopyState.DIRTY: - return this.dirty; - case FileWorkingCopyState.ERROR: - return this.inErrorMode; - case FileWorkingCopyState.ORPHAN: - return this.isOrphaned(); - case FileWorkingCopyState.PENDING_SAVE: - return this.saveSequentializer.hasPending(); - case FileWorkingCopyState.SAVED: - return !this.dirty; - } - } - - joinState(state: FileWorkingCopyState.PENDING_SAVE): Promise { - return this.saveSequentializer.pending ?? Promise.resolve(); - } - - //#endregion - - //#region Utilities - - isResolved(): this is IResolvedFileWorkingCopy { - return !!this.model; - } - - isReadonly(): boolean { - return this.lastResolvedFileStat?.readonly || this.fileService.hasCapability(this.resource, FileSystemProviderCapabilities.Readonly); - } - - private trace(msg: string): void { - this.logService.trace(msg, this.resource.toString(true), this.typeId); - } - - //#endregion - - //#region Dispose - - override dispose(): void { - this.trace('[file working copy] dispose()'); - - // State - this.inConflictMode = false; - this.inErrorMode = false; - - super.dispose(); - } - - //#endregion - - //#region Remainders of text file model world (TODO@bpasero callers have to be handled in a generic way) - - private isTextFileModel(model: unknown): model is ITextFileEditorModel { - const textFileModel = this.textFileService.files.get(this.resource); - - return !!(textFileModel && this.model && (textFileModel as unknown) === (this.model as unknown)); - } - - //#endregion + readonly model: M; } diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index a3ba3b249dc..2447d8a3451 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -3,92 +3,96 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; -import { Event, Emitter } from 'vs/base/common/event'; -import { FileWorkingCopy, FileWorkingCopyState, IFileWorkingCopy, IFileWorkingCopyModel, IFileWorkingCopyModelFactory, IFileWorkingCopySaveOptions } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; -import { SaveReason } from 'vs/workbench/common/editor'; -import { ResourceMap } from 'vs/base/common/map'; -import { Promises, ResourceQueue } from 'vs/base/common/async'; -import { FileChangesEvent, FileChangeType, FileOperation, IFileService } from 'vs/platform/files/common/files'; -import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; +import { Event } from 'vs/base/common/event'; +import { Promises } from 'vs/base/common/async'; import { VSBufferReadableStream } from 'vs/base/common/buffer'; -import { ILabelService } from 'vs/platform/label/common/label'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { ILogService } from 'vs/platform/log/common/log'; -import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { joinPath } from 'vs/base/common/resources'; -import { IWorkingCopyFileService, WorkingCopyFileEvent } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; -import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { toLocalResource, joinPath, isEqual, basename, dirname } from 'vs/base/common/resources'; +import { URI } from 'vs/base/common/uri'; +import { IFileDialogService, IDialogService, IConfirmation } from 'vs/platform/dialogs/common/dialogs'; +import { IFileService } from 'vs/platform/files/common/files'; +import { ISaveOptions } from 'vs/workbench/common/editor'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { IPathService } from 'vs/workbench/services/path/common/pathService'; +import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; +import { IStoredFileWorkingCopy, IStoredFileWorkingCopyModel, IStoredFileWorkingCopyModelFactory, IStoredFileWorkingCopyResolveOptions } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy'; +import { StoredFileWorkingCopyManager, IStoredFileWorkingCopyManager, IStoredFileWorkingCopyManagerResolveOptions } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopyManager'; +import { IUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelFactory, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; +import { INewOrExistingUntitledFileWorkingCopyOptions, INewUntitledFileWorkingCopyOptions, INewUntitledFileWorkingCopyWithAssociatedResourceOptions, IUntitledFileWorkingCopyManager, UntitledFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { isValidBasename } from 'vs/base/common/extpath'; +import { IBaseFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; +import { IFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { ILogService } from 'vs/platform/log/common/log'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IElevatedFileService } from 'vs/workbench/services/files/common/elevatedFileService'; +import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; +import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; +import { IWorkingCopyEditorService } from 'vs/workbench/services/workingCopy/common/workingCopyEditorService'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -/** - * The only one that should be dealing with `IFileWorkingCopy` and handle all - * operations that are working copy related, such as save/revert, backup - * and resolving. - */ -export interface IFileWorkingCopyManager extends IDisposable { +export interface IFileWorkingCopyManager extends IBaseFileWorkingCopyManager> { /** - * An event for when a file working copy was created. + * Provides access to the manager for stored file working copies. */ - readonly onDidCreate: Event>; + readonly stored: IStoredFileWorkingCopyManager; /** - * An event for when a file working copy was resolved. + * Provides access to the manager for untitled file working copies. */ - readonly onDidResolve: Event>; + readonly untitled: IUntitledFileWorkingCopyManager; /** - * An event for when a file working copy changed it's dirty state. - */ - readonly onDidChangeDirty: Event>; - - /** - * An event for when a file working copy failed to save. - */ - readonly onDidSaveError: Event>; - - /** - * An event for when a file working copy successfully saved. - */ - readonly onDidSave: Event>; - - /** - * An event for when a file working copy was reverted. - */ - readonly onDidRevert: Event>; - - /** - * Access to all known file working copies within the manager. - */ - readonly workingCopies: readonly IFileWorkingCopy[]; - - /** - * Returns the file working copy for the provided resource - * or `undefined` if none. - */ - get(resource: URI): IFileWorkingCopy | undefined; - - /** - * Allows to resolve a file working copy. If the manager already knows - * about a file working copy with the same `URI`, it will return that - * existing file working copy. There will never be more than one - * file working copy per `URI` until the file working copy is disposed. + * Allows to resolve a stored file working copy. If the manager already knows + * about a stored file working copy with the same `URI`, it will return that + * existing stored file working copy. There will never be more than one + * stored file working copy per `URI` until the stored file working copy is + * disposed. * - * Use the `IFileWorkingCopyResolveOptions.reload` option to control the - * behaviour for when a file working copy was previously already resolved + * Use the `IStoredFileWorkingCopyResolveOptions.reload` option to control the + * behaviour for when a stored file working copy was previously already resolved * with regards to resolving it again from the underlying file resource * or not. * * Note: Callers must `dispose` the working copy when no longer needed. * - * @param resource used as unique identifier of the file working copy in + * @param resource used as unique identifier of the stored file working copy in * case one is already known for this `URI`. * @param options */ - resolve(resource: URI, options?: IFileWorkingCopyResolveOptions): Promise>; + resolve(resource: URI, options?: IStoredFileWorkingCopyManagerResolveOptions): Promise>; + + /** + * Create a new untitled file working copy with optional initial contents. + * + * Note: Callers must `dispose` the working copy when no longer needed. + */ + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + + /** + * Create a new untitled file working copy with optional initial contents + * and associated resource. The associated resource will be used when + * saving and will not require to ask the user for a file path. + * + * Note: Callers must `dispose` the working copy when no longer needed. + */ + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + + /** + * Creates a new untitled file working copy with optional initial contents + * with the provided resource or return an existing untitled file working + * copy otherwise. + * + * Note: Callers must `dispose` the working copy when no longer needed. + */ + resolve(options?: INewOrExistingUntitledFileWorkingCopyOptions): Promise>; /** * Implements "Save As" for file based working copies. The API is `URI` based @@ -104,74 +108,19 @@ export interface IFileWorkingCopyManager extend * * Note: Callers must `dispose` the working copy when no longer needed. * + * Note: Untitled file working copies are being disposed when saved. + * * @param source the source resource to save as * @param target the optional target resource to save to. if not defined, the user * will be asked for input - * @returns the target working copy that was saved to or `undefined` in case of + * @returns the target stored working copy that was saved to or `undefined` in case of * cancellation */ - saveAs(source: URI, target: URI, options?: IFileWorkingCopySaveOptions): Promise | undefined>; - saveAs(source: URI, target: undefined, options?: IFileWorkingCopySaveAsOptions): Promise | undefined>; - - /** - * Waits for the file working copy to be ready to be disposed. There may be - * conditions under which the file working copy cannot be disposed, e.g. when - * it is dirty. Once the promise is settled, it is safe to dispose. - */ - canDispose(workingCopy: IFileWorkingCopy): true | Promise; - - /** - * Disposes all working copies of the manager and disposes the manager. This - * method is different from `dispose` in that it will unregister any working - * copy from the `IWorkingCopyService`. Since this impact things like backups, - * the method is `async` because it needs to trigger `save` for any dirty - * working copy to preserve the data. - * - * Callers should make sure to e.g. close any editors associated with the - * working copy. - */ - destroy(): Promise; + saveAs(source: URI, target: URI, options?: ISaveOptions): Promise | undefined>; + saveAs(source: URI, target: undefined, options?: IFileWorkingCopySaveAsOptions): Promise | undefined>; } -export interface IFileWorkingCopySaveEvent { - - /** - * The file working copy that was successfully saved. - */ - workingCopy: IFileWorkingCopy; - - /** - * The reason why the file working copy was saved. - */ - reason: SaveReason; -} - -export interface IFileWorkingCopyResolveOptions { - - /** - * The contents to use for the file working copy if known. - * If not provided, the contents will be retrieved from the - * underlying resource or backup if present. - * - * If contents are provided, the file working copy will be marked - * as dirty right from the beginning. - */ - contents?: VSBufferReadableStream; - - /** - * If the file working copy was already resolved before, - * allows to trigger a reload of it to fetch the latest contents: - * - async: resolve() will return immediately and trigger - * a reload that will run in the background. - * - sync: resolve() will only return resolved when the - * file working copy has finished reloading. - */ - reload?: { - async: boolean - }; -} - -export interface IFileWorkingCopySaveAsOptions extends IFileWorkingCopySaveOptions { +export interface IFileWorkingCopySaveAsOptions extends ISaveOptions { /** * Optional target resource to suggest to the user in case @@ -180,426 +129,151 @@ export interface IFileWorkingCopySaveAsOptions extends IFileWorkingCopySaveOptio suggestedTarget?: URI; } -export class FileWorkingCopyManager extends Disposable implements IFileWorkingCopyManager { +export class FileWorkingCopyManager extends Disposable implements IFileWorkingCopyManager { - //#region Events + readonly onDidCreate: Event>; - private readonly _onDidCreate = this._register(new Emitter>()); - readonly onDidCreate = this._onDidCreate.event; - - private readonly _onDidResolve = this._register(new Emitter>()); - readonly onDidResolve = this._onDidResolve.event; - - private readonly _onDidChangeDirty = this._register(new Emitter>()); - readonly onDidChangeDirty = this._onDidChangeDirty.event; - - private readonly _onDidSaveError = this._register(new Emitter>()); - readonly onDidSaveError = this._onDidSaveError.event; - - private readonly _onDidSave = this._register(new Emitter>()); - readonly onDidSave = this._onDidSave.event; - - private readonly _onDidRevert = this._register(new Emitter>()); - readonly onDidRevert = this._onDidRevert.event; - - //#endregion - - private readonly mapResourceToWorkingCopy = new ResourceMap>(); - private readonly mapResourceToWorkingCopyListeners = new ResourceMap(); - private readonly mapResourceToDisposeListener = new ResourceMap(); - private readonly mapResourceToPendingWorkingCopyResolve = new ResourceMap>(); - - private readonly workingCopyResolveQueue = this._register(new ResourceQueue()); + readonly stored: IStoredFileWorkingCopyManager; + readonly untitled: IUntitledFileWorkingCopyManager; constructor( private readonly workingCopyTypeId: string, - private readonly modelFactory: IFileWorkingCopyModelFactory, + private readonly storedWorkingCopyModelFactory: IStoredFileWorkingCopyModelFactory, + private readonly untitledWorkingCopyModelFactory: IUntitledFileWorkingCopyModelFactory, @IFileService private readonly fileService: IFileService, - @ILifecycleService private readonly lifecycleService: ILifecycleService, - @ILabelService private readonly labelService: ILabelService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @ILogService private readonly logService: ILogService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, + @ILifecycleService lifecycleService: ILifecycleService, + @ILabelService labelService: ILabelService, + @ILogService logService: ILogService, @IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService, - @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService + @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @ITextFileService textFileService: ITextFileService, + @IFilesConfigurationService filesConfigurationService: IFilesConfigurationService, + @IWorkingCopyService workingCopyService: IWorkingCopyService, + @INotificationService notificationService: INotificationService, + @IWorkingCopyEditorService workingCopyEditorService: IWorkingCopyEditorService, + @IEditorService editorService: IEditorService, + @IElevatedFileService elevatedFileService: IElevatedFileService, + @IPathService private readonly pathService: IPathService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IDialogService private readonly dialogService: IDialogService ) { super(); - this.registerListeners(); + // Stored file working copies manager + this.stored = this._register(new StoredFileWorkingCopyManager( + this.workingCopyTypeId, + this.storedWorkingCopyModelFactory, + fileService, lifecycleService, labelService, logService, workingCopyFileService, + workingCopyBackupService, uriIdentityService, textFileService, filesConfigurationService, + workingCopyService, notificationService, workingCopyEditorService, editorService, elevatedFileService + )); + + // Untitled file working copies manager + this.untitled = this._register(new UntitledFileWorkingCopyManager( + this.workingCopyTypeId, + this.untitledWorkingCopyModelFactory, + async (workingCopy, options) => { + const result = await this.saveAs(workingCopy.resource, undefined, options); + + return result ? true : false; + }, + fileService, labelService, logService, workingCopyBackupService, workingCopyService + )); + + // Events + this.onDidCreate = Event.any>(this.stored.onDidCreate, this.untitled.onDidCreate); } - private registerListeners(): void { + //#region get / get all - // Update working copies from file change events - this._register(this.fileService.onDidFilesChange(e => this.onDidFilesChange(e))); - - // Working copy operations - this._register(this.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => this.onWillRunWorkingCopyFileOperation(e))); - this._register(this.workingCopyFileService.onDidFailWorkingCopyFileOperation(e => this.onDidFailWorkingCopyFileOperation(e))); - this._register(this.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => this.onDidRunWorkingCopyFileOperation(e))); - - // Lifecycle - this.lifecycleService.onWillShutdown(event => event.join(this.onWillShutdown(), 'join.fileWorkingCopyManager')); + get workingCopies(): (IUntitledFileWorkingCopy | IStoredFileWorkingCopy)[] { + return [...this.stored.workingCopies, ...this.untitled.workingCopies]; } - private async onWillShutdown(): Promise { - let fileWorkingCopies: IFileWorkingCopy[]; - - // As long as file working copies are pending to be saved, we prolong the shutdown - // until that has happened to ensure we are not shutting down in the middle of - // writing to the working copy (https://github.com/microsoft/vscode/issues/116600). - while ((fileWorkingCopies = this.workingCopies.filter(workingCopy => workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE))).length > 0) { - await Promises.settled(fileWorkingCopies.map(workingCopy => workingCopy.joinState(FileWorkingCopyState.PENDING_SAVE))); - } - } - - //#region Resolve from file changes - - private onDidFilesChange(e: FileChangesEvent): void { - for (const workingCopy of this.workingCopies) { - if (workingCopy.isDirty() || !workingCopy.isResolved()) { - continue; // require a resolved, saved working copy to continue - } - - // Trigger a resolve for any update or add event that impacts - // the working copy. We also consider the added event - // because it could be that a file was added and updated - // right after. - if (e.contains(workingCopy.resource, FileChangeType.UPDATED, FileChangeType.ADDED)) { - this.queueWorkingCopyResolve(workingCopy); - } - } - } - - private queueWorkingCopyResolve(workingCopy: IFileWorkingCopy): void { - - // Resolves a working copy to update (use a queue to prevent accumulation of - // resolve when the resolving actually takes long. At most we only want the - // queue to have a size of 2 (1 running resolve and 1 queued resolve). - const queue = this.workingCopyResolveQueue.queueFor(workingCopy.resource); - if (queue.size <= 1) { - queue.queue(async () => { - try { - await workingCopy.resolve(); - } catch (error) { - this.logService.error(error); - } - }); - } + get(resource: URI): IUntitledFileWorkingCopy | IStoredFileWorkingCopy | undefined { + return this.stored.get(resource) ?? this.untitled.get(resource); } //#endregion - //#region Working Copy File Events + //#region resolve - private readonly mapCorrelationIdToWorkingCopiesToRestore = new Map(); - - private onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { - - // Move / Copy: remember working copies to restore after the operation - if (e.operation === FileOperation.MOVE || e.operation === FileOperation.COPY) { - e.waitUntil((async () => { - const workingCopiesToRestore: { source: URI, target: URI, snapshot?: VSBufferReadableStream; }[] = []; - - for (const { source, target } of e.files) { - if (source) { - if (this.uriIdentityService.extUri.isEqual(source, target)) { - continue; // ignore if resources are considered equal - } - - // Find all working copies that related to source (can be many if resource is a folder) - const sourceWorkingCopies: IFileWorkingCopy[] = []; - for (const workingCopy of this.workingCopies) { - if (this.uriIdentityService.extUri.isEqualOrParent(workingCopy.resource, source)) { - sourceWorkingCopies.push(workingCopy); - } - } - - // Remember each source working copy to load again after move is done - // with optional content to restore if it was dirty - for (const sourceWorkingCopy of sourceWorkingCopies) { - const sourceResource = sourceWorkingCopy.resource; - - // If the source is the actual working copy, just use target as new resource - let targetResource: URI; - if (this.uriIdentityService.extUri.isEqual(sourceResource, source)) { - targetResource = target; - } - - // Otherwise a parent folder of the source is being moved, so we need - // to compute the target resource based on that - else { - targetResource = joinPath(target, sourceResource.path.substr(source.path.length + 1)); - } - - workingCopiesToRestore.push({ - source: sourceResource, - target: targetResource, - snapshot: sourceWorkingCopy.isDirty() ? await sourceWorkingCopy.model?.snapshot(CancellationToken.None) : undefined - }); - } - } - } - - this.mapCorrelationIdToWorkingCopiesToRestore.set(e.correlationId, workingCopiesToRestore); - })()); + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + resolve(options?: INewOrExistingUntitledFileWorkingCopyOptions): Promise>; + resolve(resource: URI, options?: IStoredFileWorkingCopyResolveOptions): Promise>; + resolve(arg1?: URI | INewUntitledFileWorkingCopyOptions | INewUntitledFileWorkingCopyWithAssociatedResourceOptions | INewOrExistingUntitledFileWorkingCopyOptions, arg2?: IStoredFileWorkingCopyResolveOptions): Promise | IStoredFileWorkingCopy> { + if (URI.isUri(arg1)) { + return this.stored.resolve(arg1, arg2); } - } - private onDidFailWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { - - // Move / Copy: restore dirty flag on working copies to restore that were dirty - if ((e.operation === FileOperation.MOVE || e.operation === FileOperation.COPY)) { - const workingCopiesToRestore = this.mapCorrelationIdToWorkingCopiesToRestore.get(e.correlationId); - if (workingCopiesToRestore) { - this.mapCorrelationIdToWorkingCopiesToRestore.delete(e.correlationId); - - workingCopiesToRestore.forEach(workingCopy => { - - // Snapshot presence means this working copy used to be dirty and so we restore that - // flag. we do NOT have to restore the content because the working copy was only soft - // reverted and did not loose its original dirty contents. - if (workingCopy.snapshot) { - this.get(workingCopy.source)?.markDirty(); - } - }); - } - } - } - - private onDidRunWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { - switch (e.operation) { - - // Create: Revert existing working copies - case FileOperation.CREATE: - e.waitUntil((async () => { - for (const { target } of e.files) { - const workingCopy = this.get(target); - if (workingCopy && !workingCopy.isDisposed()) { - await workingCopy.revert(); - } - } - })()); - break; - - // Move/Copy: restore working copies that were loaded before the operation took place - case FileOperation.MOVE: - case FileOperation.COPY: - e.waitUntil((async () => { - const workingCopiesToRestore = this.mapCorrelationIdToWorkingCopiesToRestore.get(e.correlationId); - if (workingCopiesToRestore) { - this.mapCorrelationIdToWorkingCopiesToRestore.delete(e.correlationId); - - await Promises.settled(workingCopiesToRestore.map(async workingCopyToRestore => { - - // Restore the working copy at the target. if we have previous dirty content, we pass it - // over to be used, otherwise we force a reload from disk. this is important - // because we know the file has changed on disk after the move and the working copy might - // have still existed with the previous state. this ensures that the working copy is not - // tracking a stale state. - await this.resolve(workingCopyToRestore.target, { - reload: { async: false }, // enforce a reload - contents: workingCopyToRestore.snapshot - }); - })); - } - })()); - break; - } + return this.untitled.resolve(arg1); } //#endregion - //#region Get / Get all + //#region Save - get workingCopies(): IFileWorkingCopy[] { - return [...this.mapResourceToWorkingCopy.values()]; + async saveAs(source: URI, target?: URI, options?: IFileWorkingCopySaveAsOptions): Promise | undefined> { + + // Get to target resource + if (!target) { + const workingCopy = this.get(source); + if (workingCopy instanceof UntitledFileWorkingCopy && workingCopy.hasAssociatedFilePath) { + target = await this.suggestSavePath(source); + } else { + target = await this.fileDialogService.pickFileToSave(await this.suggestSavePath(options?.suggestedTarget ?? source), options?.availableFileSystems); + } + } + + if (!target) { + return; // user canceled + } + + // Just save if target is same as working copies own resource + // and we are not saving an untitled file working copy + if (this.fileService.canHandleResource(source) && isEqual(source, target)) { + return this.doSave(source, { ...options, force: true /* force to save, even if not dirty (https://github.com/microsoft/vscode/issues/99619) */ }); + } + + // If the target is different but of same identity, we + // move the source to the target, knowing that the + // underlying file system cannot have both and then save. + // However, this will only work if the source exists + // and is not orphaned, so we need to check that too. + if (this.fileService.canHandleResource(source) && this.uriIdentityService.extUri.isEqual(source, target) && (await this.fileService.exists(source))) { + + // Move via working copy file service to enable participants + await this.workingCopyFileService.move([{ file: { source, target } }], CancellationToken.None); + + // At this point we don't know whether we have a + // working copy for the source or the target URI so we + // simply try to save with both resources. + return (await this.doSave(source, options)) ?? (await this.doSave(target, options)); + } + + // Perform normal "Save As" + return this.doSaveAs(source, target, options); } - get(resource: URI): IFileWorkingCopy | undefined { - return this.mapResourceToWorkingCopy.get(resource); - } + private async doSave(resource: URI, options?: ISaveOptions): Promise | undefined> { - //#endregion - - //#region Resolve - - async resolve(resource: URI, options?: IFileWorkingCopyResolveOptions): Promise> { - - // Await a pending working copy resolve first before proceeding - // to ensure that we never resolve a working copy more than once - // in parallel - const pendingResolve = this.joinPendingResolve(resource); - if (pendingResolve) { - await pendingResolve; - } - - let workingCopyResolve: Promise; - let workingCopy = this.get(resource); - let didCreateWorkingCopy = false; - - // Working copy exists - if (workingCopy) { - - // Always reload if contents are provided - if (options?.contents) { - workingCopyResolve = workingCopy.resolve(options); + // Save is only possible with stored file working copies, + // any other have to go via `saveAs` flow. + const storedFileWorkingCopy = this.stored.get(resource); + if (storedFileWorkingCopy) { + const success = await storedFileWorkingCopy.save(options); + if (success) { + return storedFileWorkingCopy; } - - // Reload async or sync based on options - else if (options?.reload) { - - // Async reload: trigger a reload but return immediately - if (options.reload.async) { - workingCopy.resolve(options); - workingCopyResolve = Promise.resolve(); - } - - // Sync reload: do not return until working copy reloaded - else { - workingCopyResolve = workingCopy.resolve(options); - } - } - - // Do not reload - else { - workingCopyResolve = Promise.resolve(); - } - } - - // File working copy does not exist - else { - didCreateWorkingCopy = true; - - const newWorkingCopy = workingCopy = this.instantiationService.createInstance(FileWorkingCopy, this.workingCopyTypeId, resource, this.labelService.getUriBasenameLabel(resource), this.modelFactory) as unknown as IFileWorkingCopy; - workingCopyResolve = workingCopy.resolve(options); - - this.registerWorkingCopy(newWorkingCopy); - } - - // Store pending resolve to avoid race conditions - this.mapResourceToPendingWorkingCopyResolve.set(resource, workingCopyResolve); - - // Make known to manager (if not already known) - this.add(resource, workingCopy); - - // Emit some events if we created the working copy - if (didCreateWorkingCopy) { - this._onDidCreate.fire(workingCopy); - - // If the working copy is dirty right from the beginning, - // make sure to emit this as an event - if (workingCopy.isDirty()) { - this._onDidChangeDirty.fire(workingCopy); - } - } - - try { - - // Wait for working copy to resolve - await workingCopyResolve; - - // Remove from pending resolves - this.mapResourceToPendingWorkingCopyResolve.delete(resource); - - // File working copy can be dirty if a backup was restored, so we make sure to - // have this event delivered if we created the working copy here - if (didCreateWorkingCopy && workingCopy.isDirty()) { - this._onDidChangeDirty.fire(workingCopy); - } - - return workingCopy; - } catch (error) { - - // Free resources of this invalid working copy - if (workingCopy) { - workingCopy.dispose(); - } - - // Remove from pending resolves - this.mapResourceToPendingWorkingCopyResolve.delete(resource); - - throw error; - } - } - - private joinPendingResolve(resource: URI): Promise | undefined { - const pendingWorkingCopyResolve = this.mapResourceToPendingWorkingCopyResolve.get(resource); - if (pendingWorkingCopyResolve) { - return pendingWorkingCopyResolve.then(undefined, error => {/* ignore any error here, it will bubble to the original requestor*/ }); } return undefined; } - private registerWorkingCopy(workingCopy: IFileWorkingCopy): void { - - // Install working copy listeners - const workingCopyListeners = new DisposableStore(); - workingCopyListeners.add(workingCopy.onDidResolve(() => this._onDidResolve.fire(workingCopy))); - workingCopyListeners.add(workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(workingCopy))); - workingCopyListeners.add(workingCopy.onDidSaveError(() => this._onDidSaveError.fire(workingCopy))); - workingCopyListeners.add(workingCopy.onDidSave(reason => this._onDidSave.fire({ workingCopy: workingCopy, reason }))); - workingCopyListeners.add(workingCopy.onDidRevert(() => this._onDidRevert.fire(workingCopy))); - - // Keep for disposal - this.mapResourceToWorkingCopyListeners.set(workingCopy.resource, workingCopyListeners); - } - - private add(resource: URI, workingCopy: IFileWorkingCopy): void { - const knownWorkingCopy = this.mapResourceToWorkingCopy.get(resource); - if (knownWorkingCopy === workingCopy) { - return; // already cached - } - - // Dispose any previously stored dispose listener for this resource - const disposeListener = this.mapResourceToDisposeListener.get(resource); - if (disposeListener) { - disposeListener.dispose(); - } - - // Store in cache but remove when working copy gets disposed - this.mapResourceToWorkingCopy.set(resource, workingCopy); - this.mapResourceToDisposeListener.set(resource, workingCopy.onWillDispose(() => this.remove(resource))); - } - - private remove(resource: URI): void { - this.mapResourceToWorkingCopy.delete(resource); - - const disposeListener = this.mapResourceToDisposeListener.get(resource); - if (disposeListener) { - dispose(disposeListener); - this.mapResourceToDisposeListener.delete(resource); - } - - const workingCopyListener = this.mapResourceToWorkingCopyListeners.get(resource); - if (workingCopyListener) { - dispose(workingCopyListener); - this.mapResourceToWorkingCopyListeners.delete(resource); - } - } - - //#endregion - - //#region Save As... - - async saveAs(source: URI, target?: URI, options?: IFileWorkingCopySaveAsOptions): Promise | undefined> { - - // If not provided, ask user for target - if (!target) { - target = await this.fileDialogService.pickFileToSave(options?.suggestedTarget ?? source); - - if (!target) { - return undefined; // user canceled - } - } - - // Do it - return this.doSaveAs(source, target, options); - } - - private async doSaveAs(source: URI, target: URI, options?: IFileWorkingCopySaveAsOptions): Promise | undefined> { + private async doSaveAs(source: URI, target: URI, options?: IFileWorkingCopySaveAsOptions): Promise | undefined> { let sourceContents: VSBufferReadableStream; // If the source is an existing file working copy, we can directly @@ -614,28 +288,54 @@ export class FileWorkingCopyManager extends Dis sourceContents = (await this.fileService.readFileStream(source)).value; } - // Save the contents through working copy to benefit from save - // participants and handling a potential already existing target - return this.doSaveAsWorkingCopy(source, sourceContents, target, options); + // Resolve target + const { targetFileExists, targetStoredFileWorkingCopy } = await this.doResolveSaveTarget(source, target); + + // Confirm to overwrite if we have an untitled file working copy with associated path where + // the file actually exists on disk and we are instructed to save to that file path. + // This can happen if the file was created after the untitled file was opened. + // See https://github.com/microsoft/vscode/issues/67946 + if ( + sourceWorkingCopy instanceof UntitledFileWorkingCopy && + sourceWorkingCopy.hasAssociatedFilePath && + targetFileExists && + this.uriIdentityService.extUri.isEqual(target, toLocalResource(sourceWorkingCopy.resource, this.environmentService.remoteAuthority, this.pathService.defaultUriScheme)) + ) { + const overwrite = await this.confirmOverwrite(target); + if (!overwrite) { + return undefined; + } + } + + // Take over content from source to target + await targetStoredFileWorkingCopy.model?.update(sourceContents, CancellationToken.None); + + // Save target + await targetStoredFileWorkingCopy.save({ ...options, force: true /* force to save, even if not dirty (https://github.com/microsoft/vscode/issues/99619) */ }); + + // Revert the source + await sourceWorkingCopy?.revert(); + + return targetStoredFileWorkingCopy; } - private async doSaveAsWorkingCopy(source: URI, sourceContents: VSBufferReadableStream, target: URI, options?: IFileWorkingCopySaveAsOptions): Promise> { + private async doResolveSaveTarget(source: URI, target: URI): Promise<{ targetFileExists: boolean, targetStoredFileWorkingCopy: IStoredFileWorkingCopy }> { - // Prefer an existing working copy if it is already resolved + // Prefer an existing stored file working copy if it is already resolved // for the given target resource - let targetExists = false; - let targetWorkingCopy = this.get(target); - if (targetWorkingCopy?.isResolved()) { - targetExists = true; + let targetFileExists = false; + let targetStoredFileWorkingCopy = this.stored.get(target); + if (targetStoredFileWorkingCopy?.isResolved()) { + targetFileExists = true; } // Otherwise create the target working copy empty if // it does not exist already and resolve it from there else { - targetExists = await this.fileService.exists(target); + targetFileExists = await this.fileService.exists(target); // Create target file adhoc if it does not exist yet - if (!targetExists) { + if (!targetFileExists) { await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); } @@ -643,136 +343,61 @@ export class FileWorkingCopyManager extends Dis // and we have to do an explicit check if the source URI // equals the target via URI identity. If they match and we // have had an existing working copy with the source, we - // prefer that one over resolving the target. Otherwiese we + // prefer that one over resolving the target. Otherwise we // would potentially introduce a if (this.uriIdentityService.extUri.isEqual(source, target) && this.get(source)) { - targetWorkingCopy = await this.resolve(source); + targetStoredFileWorkingCopy = await this.stored.resolve(source); } else { - targetWorkingCopy = await this.resolve(target); + targetStoredFileWorkingCopy = await this.stored.resolve(target); } } - // Take over content from source to target - await targetWorkingCopy.model?.update(sourceContents, CancellationToken.None); - - // Save target - await targetWorkingCopy.save({ ...options, force: true /* force to save, even if not dirty (https://github.com/microsoft/vscode/issues/99619) */ }); - - // Revert the source - await this.doRevert(source); - - return targetWorkingCopy; + return { targetFileExists, targetStoredFileWorkingCopy }; } - private async doRevert(resource: URI): Promise { - const workingCopy = this.get(resource); - if (!workingCopy) { - return undefined; + private async confirmOverwrite(resource: URI): Promise { + const confirm: IConfirmation = { + message: localize('confirmOverwrite', "'{0}' already exists. Do you want to replace it?", basename(resource)), + detail: localize('irreversible', "A file or folder with the name '{0}' already exists in the folder '{1}'. Replacing it will overwrite its current contents.", basename(resource), basename(dirname(resource))), + primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), + type: 'warning' + }; + + const result = await this.dialogService.confirm(confirm); + return result.confirmed; + } + + private async suggestSavePath(resource: URI): Promise { + + // 1.) Just take the resource as is if the file service can handle it + if (this.fileService.canHandleResource(resource)) { + return resource; } - return workingCopy.revert(); + // 2.) Pick the associated file path for untitled working copies if any + const workingCopy = this.get(resource); + if (workingCopy instanceof UntitledFileWorkingCopy && workingCopy.hasAssociatedFilePath) { + return toLocalResource(resource, this.environmentService.remoteAuthority, this.pathService.defaultUriScheme); + } + + // 3.) Pick the working copy name if valid joined with default path + if (workingCopy && isValidBasename(workingCopy.name)) { + return joinPath(await this.fileDialogService.defaultFilePath(), workingCopy.name); + } + + // 4.) Finally fallback to the name of the resource joined with default path + return joinPath(await this.fileDialogService.defaultFilePath(), basename(resource)); } //#endregion //#region Lifecycle - canDispose(workingCopy: IFileWorkingCopy): true | Promise { - - // Quick return if working copy already disposed or not dirty and not resolving - if ( - workingCopy.isDisposed() || - (!this.mapResourceToPendingWorkingCopyResolve.has(workingCopy.resource) && !workingCopy.isDirty()) - ) { - return true; - } - - // Promise based return in all other cases - return this.doCanDispose(workingCopy); - } - - private async doCanDispose(workingCopy: IFileWorkingCopy): Promise { - - // If we have a pending working copy resolve, await it first and then try again - const pendingResolve = this.joinPendingResolve(workingCopy.resource); - if (pendingResolve) { - await pendingResolve; - - return this.canDispose(workingCopy); - } - - // Dirty working copy: we do not allow to dispose dirty working copys - // to prevent data loss cases. dirty working copys can only be disposed when - // they are either saved or reverted - if (workingCopy.isDirty()) { - await Event.toPromise(workingCopy.onDidChangeDirty); - - return this.canDispose(workingCopy); - } - - return true; - } - - override dispose(): void { - super.dispose(); - - // Clear working copy caches - // - // Note: we are not explicitly disposing the working copies - // known to the manager because this can have unwanted side - // effects such as backups getting discarded once the working - // copy unregisters. We have an explicit `destroy` - // for that purpose (https://github.com/microsoft/vscode/pull/123555) - // - this.mapResourceToWorkingCopy.clear(); - this.mapResourceToPendingWorkingCopyResolve.clear(); - - // Dispose the dispose listeners - dispose(this.mapResourceToDisposeListener.values()); - this.mapResourceToDisposeListener.clear(); - - // Dispose the working copy change listeners - dispose(this.mapResourceToWorkingCopyListeners.values()); - this.mapResourceToWorkingCopyListeners.clear(); - } - async destroy(): Promise { - - // Make sure all dirty working copies are saved to disk - try { - await Promises.settled(this.workingCopies.map(async workingCopy => { - if (workingCopy.isDirty()) { - await this.saveWithFallback(workingCopy); - } - })); - } catch (error) { - this.logService.error(error); - } - - // Dispose all working copies - dispose(this.mapResourceToWorkingCopy.values()); - - // Finally dispose manager - this.dispose(); - } - - private async saveWithFallback(workingCopy: IFileWorkingCopy): Promise { - - // First try regular save - let saveFailed = false; - try { - await workingCopy.save(); - } catch (error) { - saveFailed = true; - } - - // Then fallback to backup if that exists - if (saveFailed || workingCopy.isDirty()) { - const backup = await this.workingCopyBackupService.resolve(workingCopy); - if (backup) { - await this.fileService.writeFile(workingCopy.resource, backup.value, { unlock: true }); - } - } + await Promises.settled([ + this.stored.destroy(), + this.untitled.destroy() + ]); } //#endregion diff --git a/src/vs/workbench/services/workingCopy/common/resourceWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/resourceWorkingCopy.ts index f278448fa6d..52e1f05c6fb 100644 --- a/src/vs/workbench/services/workingCopy/common/resourceWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/resourceWorkingCopy.ts @@ -42,7 +42,7 @@ export interface IResourceWorkingCopy extends IWorkingCopy, IDisposable { export abstract class ResourceWorkingCopy extends Disposable implements IResourceWorkingCopy { constructor( - public readonly resource: URI, + readonly resource: URI, @IFileService protected readonly fileService: IFileService ) { super(); diff --git a/src/vs/workbench/services/workingCopy/common/storedFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/storedFileWorkingCopy.ts new file mode 100644 index 00000000000..8a56b821263 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/storedFileWorkingCopy.ts @@ -0,0 +1,1211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { URI } from 'vs/base/common/uri'; +import { Event, Emitter } from 'vs/base/common/event'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { ETAG_DISABLED, FileOperationError, FileOperationResult, FileSystemProviderCapabilities, IFileService, IFileStatWithMetadata, IFileStreamContent, IWriteFileOptions } from 'vs/platform/files/common/files'; +import { ISaveOptions, IRevertOptions, SaveReason } from 'vs/workbench/common/editor'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IWorkingCopyBackup, IWorkingCopyBackupMeta, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; +import { raceCancellation, TaskSequentializer, timeout } from 'vs/base/common/async'; +import { ILogService } from 'vs/platform/log/common/log'; +import { assertIsDefined } from 'vs/base/common/types'; +import { ITextFileEditorModel, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { VSBufferReadableStream } from 'vs/base/common/buffer'; +import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; +import { IWorkingCopyBackupService, IResolvedWorkingCopyBackup } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import { hash } from 'vs/base/common/hash'; +import { toErrorMessage } from 'vs/base/common/errorMessage'; +import { IAction, toAction } from 'vs/base/common/actions'; +import { isWindows } from 'vs/base/common/platform'; +import { IWorkingCopyEditorService } from 'vs/workbench/services/workingCopy/common/workingCopyEditorService'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IElevatedFileService } from 'vs/workbench/services/files/common/elevatedFileService'; +import { IResourceWorkingCopy, ResourceWorkingCopy } from 'vs/workbench/services/workingCopy/common/resourceWorkingCopy'; +import { IFileWorkingCopy, IFileWorkingCopyModel, IFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; + +/** + * Stored file specific working copy model factory. + */ +export interface IStoredFileWorkingCopyModelFactory extends IFileWorkingCopyModelFactory { } + +/** + * The underlying model of a stored file working copy provides some + * methods for the stored file working copy to function. The model is + * typically only available after the working copy has been + * resolved via it's `resolve()` method. + */ +export interface IStoredFileWorkingCopyModel extends IFileWorkingCopyModel { + + readonly onDidChangeContent: Event; + + /** + * A version ID of the model. If a `onDidChangeContent` is fired + * from the model and the last known saved `versionId` matches + * with the `model.versionId`, the stored file working copy will + * discard any dirty state. + * + * A use case is the following: + * - a stored file working copy gets edited and thus dirty + * - the user triggers undo to revert the changes + * - at this point the `versionId` should match the one we had saved + * + * This requires the model to be aware of undo/redo operations. + */ + readonly versionId: unknown; + + /** + * Close the current undo-redo element. This offers a way + * to create an undo/redo stop point. + * + * This method may for example be called right before the + * save is triggered so that the user can always undo back + * to the state before saving. + */ + pushStackElement(): void; +} + +export interface IStoredFileWorkingCopyModelContentChangedEvent { + + /** + * Flag that indicates that this event was generated while undoing. + */ + readonly isUndoing: boolean; + + /** + * Flag that indicates that this event was generated while redoing. + */ + readonly isRedoing: boolean; +} + +/** + * A stored file based `IWorkingCopy` is backed by a `URI` from a + * known file system provider. Given this assumption, a lot + * of functionality can be built on top, such as saving in + * a secure way to prevent data loss. + */ +export interface IStoredFileWorkingCopy extends IResourceWorkingCopy, IFileWorkingCopy { + + /** + * An event for when a stored file working copy was resolved. + */ + readonly onDidResolve: Event; + + /** + * An event for when a stored file working copy was saved successfully. + */ + readonly onDidSave: Event; + + /** + * An event indicating that a stored file working copy save operation failed. + */ + readonly onDidSaveError: Event; + + /** + * An event for when the readonly state of the stored file working copy changes. + */ + readonly onDidChangeReadonly: Event; + + /** + * Resolves a stored file working copy. + */ + resolve(options?: IStoredFileWorkingCopyResolveOptions): Promise; + + /** + * Explicitly sets the working copy to be dirty. + */ + markDirty(): void; + + /** + * Whether the stored file working copy is in the provided `state` + * or not. + * + * @param state the `FileWorkingCopyState` to check on. + */ + hasState(state: StoredFileWorkingCopyState): boolean; + + /** + * Allows to join a state change away from the provided `state`. + * + * @param state currently only `FileWorkingCopyState.PENDING_SAVE` + * can be awaited on to resolve. + */ + joinState(state: StoredFileWorkingCopyState.PENDING_SAVE): Promise; + + /** + * Whether we have a resolved model or not. + */ + isResolved(): this is IResolvedStoredFileWorkingCopy; + + /** + * Whether the stored file working copy is readonly or not. + */ + isReadonly(): boolean; +} + +export interface IResolvedStoredFileWorkingCopy extends IStoredFileWorkingCopy { + + /** + * A resolved stored file working copy has a resolved model. + */ + readonly model: M; +} + +/** + * States the stored file working copy can be in. + */ +export const enum StoredFileWorkingCopyState { + + /** + * A stored file working copy is saved. + */ + SAVED, + + /** + * A stored file working copy is dirty. + */ + DIRTY, + + /** + * A stored file working copy is currently being saved but + * this operation has not completed yet. + */ + PENDING_SAVE, + + /** + * A stored file working copy is in conflict mode when changes + * cannot be saved because the underlying file has changed. + * Stored file working copies in conflict mode are always dirty. + */ + CONFLICT, + + /** + * A stored file working copy is in orphan state when the underlying + * file has been deleted. + */ + ORPHAN, + + /** + * Any error that happens during a save that is not causing + * the `StoredFileWorkingCopyState.CONFLICT` state. + * Stored file working copies in error mode are always dirty. + */ + ERROR +} + +export interface IStoredFileWorkingCopySaveOptions extends ISaveOptions { + + /** + * Save the stored file working copy with an attempt to unlock it. + */ + writeUnlock?: boolean; + + /** + * Save the stored file working copy with elevated privileges. + * + * Note: This may not be supported in all environments. + */ + writeElevated?: boolean; + + /** + * Allows to write to a stored file working copy even if it has been + * modified on disk. This should only be triggered from an + * explicit user action. + */ + ignoreModifiedSince?: boolean; + + /** + * If set, will bubble up the stored file working copy save error to + * the caller instead of handling it. + */ + ignoreErrorHandler?: boolean; +} + +export interface IStoredFileWorkingCopyResolveOptions { + + /** + * The contents to use for the stored file working copy if known. If not + * provided, the contents will be retrieved from the underlying + * resource or backup if present. + * + * If contents are provided, the stored file working copy will be marked + * as dirty right from the beginning. + */ + contents?: VSBufferReadableStream; + + /** + * Go to disk bypassing any cache of the stored file working copy if any. + */ + forceReadFromFile?: boolean; +} + +/** + * Metadata associated with a stored file working copy backup. + */ +interface IStoredFileWorkingCopyBackupMetaData extends IWorkingCopyBackupMeta { + mtime: number; + ctime: number; + size: number; + etag: string; + orphaned: boolean; +} + +export class StoredFileWorkingCopy extends ResourceWorkingCopy implements IStoredFileWorkingCopy { + + readonly capabilities: WorkingCopyCapabilities = WorkingCopyCapabilities.None; + + private _model: M | undefined = undefined; + get model(): M | undefined { return this._model; } + + //#region events + + private readonly _onDidChangeContent = this._register(new Emitter()); + readonly onDidChangeContent = this._onDidChangeContent.event; + + private readonly _onDidResolve = this._register(new Emitter()); + readonly onDidResolve = this._onDidResolve.event; + + private readonly _onDidChangeDirty = this._register(new Emitter()); + readonly onDidChangeDirty = this._onDidChangeDirty.event; + + private readonly _onDidSaveError = this._register(new Emitter()); + readonly onDidSaveError = this._onDidSaveError.event; + + private readonly _onDidSave = this._register(new Emitter()); + readonly onDidSave = this._onDidSave.event; + + private readonly _onDidRevert = this._register(new Emitter()); + readonly onDidRevert = this._onDidRevert.event; + + private readonly _onDidChangeReadonly = this._register(new Emitter()); + readonly onDidChangeReadonly = this._onDidChangeReadonly.event; + + //#endregion + + constructor( + readonly typeId: string, + resource: URI, + readonly name: string, + private readonly modelFactory: IStoredFileWorkingCopyModelFactory, + @IFileService fileService: IFileService, + @ILogService private readonly logService: ILogService, + @ITextFileService private readonly textFileService: ITextFileService, + @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, + @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, + @IWorkingCopyService workingCopyService: IWorkingCopyService, + @INotificationService private readonly notificationService: INotificationService, + @IWorkingCopyEditorService private readonly workingCopyEditorService: IWorkingCopyEditorService, + @IEditorService private readonly editorService: IEditorService, + @IElevatedFileService private readonly elevatedFileService: IElevatedFileService + ) { + super(resource, fileService); + + if (!fileService.canHandleResource(this.resource)) { + throw new Error(`The file working copy resource ${this.resource.toString(true)} does not have an associated file system provider.`); + } + + // Make known to working copy service + this._register(workingCopyService.registerWorkingCopy(this)); + } + + //#region Dirty + + private dirty = false; + private savedVersionId: unknown; + + isDirty(): this is IResolvedStoredFileWorkingCopy { + return this.dirty; + } + + markDirty(): void { + this.setDirty(true); + } + + private setDirty(dirty: boolean): void { + if (!this.isResolved()) { + return; // only resolved working copies can be marked dirty + } + + // Track dirty state and version id + const wasDirty = this.dirty; + this.doSetDirty(dirty); + + // Emit as Event if dirty changed + if (dirty !== wasDirty) { + this._onDidChangeDirty.fire(); + } + } + + private doSetDirty(dirty: boolean): () => void { + const wasDirty = this.dirty; + const wasInConflictMode = this.inConflictMode; + const wasInErrorMode = this.inErrorMode; + const oldSavedVersionId = this.savedVersionId; + + if (!dirty) { + this.dirty = false; + this.inConflictMode = false; + this.inErrorMode = false; + + // we remember the models alternate version id to remember when the version + // of the model matches with the saved version on disk. we need to keep this + // in order to find out if the model changed back to a saved version (e.g. + // when undoing long enough to reach to a version that is saved and then to + // clear the dirty flag) + if (this.isResolved()) { + this.savedVersionId = this.model.versionId; + } + } else { + this.dirty = true; + } + + // Return function to revert this call + return () => { + this.dirty = wasDirty; + this.inConflictMode = wasInConflictMode; + this.inErrorMode = wasInErrorMode; + this.savedVersionId = oldSavedVersionId; + }; + } + + //#endregion + + //#region Resolve + + private lastResolvedFileStat: IFileStatWithMetadata | undefined; + + isResolved(): this is IResolvedStoredFileWorkingCopy { + return !!this.model; + } + + async resolve(options?: IStoredFileWorkingCopyResolveOptions): Promise { + this.trace('[stored file working copy] resolve() - enter'); + + // Return early if we are disposed + if (this.isDisposed()) { + this.trace('[stored file working copy] resolve() - exit - without resolving because file working copy is disposed'); + + return; + } + + // Unless there are explicit contents provided, it is important that we do not + // resolve a working copy that is dirty or is in the process of saving to prevent + // data loss. + if (!options?.contents && (this.dirty || this.saveSequentializer.hasPending())) { + this.trace('[stored file working copy] resolve() - exit - without resolving because file working copy is dirty or being saved'); + + return; + } + + return this.doResolve(options); + } + + private async doResolve(options?: IStoredFileWorkingCopyResolveOptions): Promise { + + // First check if we have contents to use for the working copy + if (options?.contents) { + return this.resolveFromBuffer(options.contents); + } + + // Second, check if we have a backup to resolve from (only for new working copies) + const isNew = !this.isResolved(); + if (isNew) { + const resolvedFromBackup = await this.resolveFromBackup(); + if (resolvedFromBackup) { + return; + } + } + + // Finally, resolve from file resource + return this.resolveFromFile(options); + } + + private async resolveFromBuffer(buffer: VSBufferReadableStream): Promise { + this.trace('[stored file working copy] resolveFromBuffer()'); + + // Try to resolve metdata from disk + let mtime: number; + let ctime: number; + let size: number; + let etag: string; + try { + const metadata = await this.fileService.resolve(this.resource, { resolveMetadata: true }); + mtime = metadata.mtime; + ctime = metadata.ctime; + size = metadata.size; + etag = metadata.etag; + + // Clear orphaned state when resolving was successful + this.setOrphaned(false); + } catch (error) { + + // Put some fallback values in error case + mtime = Date.now(); + ctime = Date.now(); + size = 0; + etag = ETAG_DISABLED; + + // Apply orphaned state based on error code + this.setOrphaned(error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND); + } + + // Resolve with buffer + return this.resolveFromContent({ + resource: this.resource, + name: this.name, + mtime, + ctime, + size, + etag, + value: buffer, + readonly: false + }, true /* dirty (resolved from buffer) */); + } + + private async resolveFromBackup(): Promise { + + // Resolve backup if any + const backup = await this.workingCopyBackupService.resolve(this); + + // Abort if someone else managed to resolve the working copy by now + let isNew = !this.isResolved(); + if (!isNew) { + this.trace('[stored file working copy] resolveFromBackup() - exit - withoutresolving because previously new file working copy got created meanwhile'); + + return true; // imply that resolving has happened in another operation + } + + // Try to resolve from backup if we have any + if (backup) { + await this.doResolveFromBackup(backup); + + return true; + } + + // Otherwise signal back that resolving did not happen + return false; + } + + private async doResolveFromBackup(backup: IResolvedWorkingCopyBackup): Promise { + this.trace('[stored file working copy] doResolveFromBackup()'); + + // Resolve with backup + await this.resolveFromContent({ + resource: this.resource, + name: this.name, + mtime: backup.meta ? backup.meta.mtime : Date.now(), + ctime: backup.meta ? backup.meta.ctime : Date.now(), + size: backup.meta ? backup.meta.size : 0, + etag: backup.meta ? backup.meta.etag : ETAG_DISABLED, // etag disabled if unknown! + value: backup.value, + readonly: false + }, true /* dirty (resolved from backup) */); + + // Restore orphaned flag based on state + if (backup.meta && backup.meta.orphaned) { + this.setOrphaned(true); + } + } + + private async resolveFromFile(options?: IStoredFileWorkingCopyResolveOptions): Promise { + this.trace('[stored file working copy] resolveFromFile()'); + + const forceReadFromFile = options?.forceReadFromFile; + + // Decide on etag + let etag: string | undefined; + if (forceReadFromFile) { + etag = ETAG_DISABLED; // disable ETag if we enforce to read from disk + } else if (this.lastResolvedFileStat) { + etag = this.lastResolvedFileStat.etag; // otherwise respect etag to support caching + } + + // Remember current version before doing any long running operation + // to ensure we are not changing a working copy that was changed + // meanwhile + const currentVersionId = this.versionId; + + // Resolve Content + try { + const content = await this.fileService.readFileStream(this.resource, { etag }); + + // Clear orphaned state when resolving was successful + this.setOrphaned(false); + + // Return early if the working copy content has changed + // meanwhile to prevent loosing any changes + if (currentVersionId !== this.versionId) { + this.trace('[stored file working copy] resolveFromFile() - exit - without resolving because file working copy content changed'); + + return; + } + + await this.resolveFromContent(content, false /* not dirty (resolved from file) */); + } catch (error) { + const result = error.fileOperationResult; + + // Apply orphaned state based on error code + this.setOrphaned(result === FileOperationResult.FILE_NOT_FOUND); + + // NotModified status is expected and can be handled gracefully + // if we are resolved + if (this.isResolved() && result === FileOperationResult.FILE_NOT_MODIFIED_SINCE) { + return; + } + + // Unless we are forced to read from the file, ignore when a working copy has + // been resolved once and the file was deleted meanwhile. Since we already have + // the working copy resolved, we can return to this state and update the orphaned + // flag to indicate that this working copy has no version on disk anymore. + if (this.isResolved() && result === FileOperationResult.FILE_NOT_FOUND && !forceReadFromFile) { + return; + } + + // Otherwise bubble up the error + throw error; + } + } + + private async resolveFromContent(content: IFileStreamContent, dirty: boolean): Promise { + this.trace('[stored file working copy] resolveFromContent() - enter'); + + // Return early if we are disposed + if (this.isDisposed()) { + this.trace('[stored file working copy] resolveFromContent() - exit - because working copy is disposed'); + + return; + } + + // Update our resolved disk stat + this.updateLastResolvedFileStat({ + resource: this.resource, + name: content.name, + mtime: content.mtime, + ctime: content.ctime, + size: content.size, + etag: content.etag, + readonly: content.readonly, + isFile: true, + isDirectory: false, + isSymbolicLink: false + }); + + // Update existing model if we had been resolved + if (this.isResolved()) { + await this.doUpdateModel(content.value); + } + + // Create new model otherwise + else { + await this.doCreateModel(content.value); + } + + // Update working copy dirty flag. This is very important to call + // in both cases of dirty or not because it conditionally updates + // the `savedVersionId` to determine the version when to consider + // the working copy as saved again (e.g. when undoing back to the + // saved state) + this.setDirty(!!dirty); + + // Emit as event + this._onDidResolve.fire(); + } + + private async doCreateModel(contents: VSBufferReadableStream): Promise { + this.trace('[stored file working copy] doCreateModel()'); + + // Create model and dispose it when we get disposed + this._model = this._register(await this.modelFactory.createModel(this.resource, contents, CancellationToken.None)); + + // Model listeners + this.installModelListeners(this._model); + } + + private ignoreDirtyOnModelContentChange = false; + + private async doUpdateModel(contents: VSBufferReadableStream): Promise { + this.trace('[stored file working copy] doUpdateModel()'); + + // Update model value in a block that ignores content change events for dirty tracking + this.ignoreDirtyOnModelContentChange = true; + try { + await this.model?.update(contents, CancellationToken.None); + } finally { + this.ignoreDirtyOnModelContentChange = false; + } + } + + private installModelListeners(model: M): void { + + // See https://github.com/microsoft/vscode/issues/30189 + // This code has been extracted to a different method because it caused a memory leak + // where `value` was captured in the content change listener closure scope. + + // Content Change + this._register(model.onDidChangeContent(e => this.onModelContentChanged(model, e.isUndoing || e.isRedoing))); + + // Lifecycle + this._register(model.onWillDispose(() => this.dispose())); + } + + private onModelContentChanged(model: M, isUndoingOrRedoing: boolean): void { + this.trace(`[stored file working copy] onModelContentChanged() - enter`); + + // In any case increment the version id because it tracks the textual content state of the model at all times + this.versionId++; + this.trace(`[stored file working copy] onModelContentChanged() - new versionId ${this.versionId}`); + + // Remember when the user changed the model through a undo/redo operation. + // We need this information to throttle save participants to fix + // https://github.com/microsoft/vscode/issues/102542 + if (isUndoingOrRedoing) { + this.lastContentChangeFromUndoRedo = Date.now(); + } + + // We mark check for a dirty-state change upon model content change, unless: + // - explicitly instructed to ignore it (e.g. from model.resolve()) + // - the model is readonly (in that case we never assume the change was done by the user) + if (!this.ignoreDirtyOnModelContentChange && !this.isReadonly()) { + + // The contents changed as a matter of Undo and the version reached matches the saved one + // In this case we clear the dirty flag and emit a SAVED event to indicate this state. + if (model.versionId === this.savedVersionId) { + this.trace('[stored file working copy] onModelContentChanged() - model content changed back to last saved version'); + + // Clear flags + const wasDirty = this.dirty; + this.setDirty(false); + + // Emit revert event if we were dirty + if (wasDirty) { + this._onDidRevert.fire(); + } + } + + // Otherwise the content has changed and we signal this as becoming dirty + else { + this.trace('[stored file working copy] onModelContentChanged() - model content changed and marked as dirty'); + + // Mark as dirty + this.setDirty(true); + } + } + + // Emit as event + this._onDidChangeContent.fire(); + } + + //#endregion + + //#region Backup + + async backup(token: CancellationToken): Promise { + + // Fill in metadata if we are resolved + let meta: IStoredFileWorkingCopyBackupMetaData | undefined = undefined; + if (this.lastResolvedFileStat) { + meta = { + mtime: this.lastResolvedFileStat.mtime, + ctime: this.lastResolvedFileStat.ctime, + size: this.lastResolvedFileStat.size, + etag: this.lastResolvedFileStat.etag, + orphaned: this.isOrphaned() + }; + } + + // Fill in content if we are resolved + let content: VSBufferReadableStream | undefined = undefined; + if (this.isResolved()) { + content = await raceCancellation(this.model.snapshot(token), token); + } + + return { meta, content }; + } + + //#endregion + + //#region Save + + private versionId = 0; + + private static readonly UNDO_REDO_SAVE_PARTICIPANTS_AUTO_SAVE_THROTTLE_THRESHOLD = 500; + private lastContentChangeFromUndoRedo: number | undefined = undefined; + + private readonly saveSequentializer = new TaskSequentializer(); + + async save(options: IStoredFileWorkingCopySaveOptions = Object.create(null)): Promise { + if (!this.isResolved()) { + return false; + } + + if (this.isReadonly()) { + this.trace('[stored file working copy] save() - ignoring request for readonly resource'); + + return false; // if working copy is readonly we do not attempt to save at all + } + + if ( + (this.hasState(StoredFileWorkingCopyState.CONFLICT) || this.hasState(StoredFileWorkingCopyState.ERROR)) && + (options.reason === SaveReason.AUTO || options.reason === SaveReason.FOCUS_CHANGE || options.reason === SaveReason.WINDOW_CHANGE) + ) { + this.trace('[stored file working copy] save() - ignoring auto save request for file working copy that is in conflict or error'); + + return false; // if working copy is in save conflict or error, do not save unless save reason is explicit + } + + // Actually do save + this.trace('[stored file working copy] save() - enter'); + await this.doSave(options); + this.trace('[stored file working copy] save() - exit'); + + return true; + } + + private async doSave(options: IStoredFileWorkingCopySaveOptions): Promise { + if (typeof options.reason !== 'number') { + options.reason = SaveReason.EXPLICIT; + } + + let versionId = this.versionId; + this.trace(`[stored file working copy] doSave(${versionId}) - enter with versionId ${versionId}`); + + // Lookup any running pending save for this versionId and return it if found + // + // Scenario: user invoked the save action multiple times quickly for the same contents + // while the save was not yet finished to disk + // + if (this.saveSequentializer.hasPending(versionId)) { + this.trace(`[stored file working copy] doSave(${versionId}) - exit - found a pending save for versionId ${versionId}`); + + return this.saveSequentializer.pending; + } + + // Return early if not dirty (unless forced) + // + // Scenario: user invoked save action even though the working copy is not dirty + if (!options.force && !this.dirty) { + this.trace(`[stored file working copy] doSave(${versionId}) - exit - because not dirty and/or versionId is different (this.isDirty: ${this.dirty}, this.versionId: ${this.versionId})`); + + return; + } + + // Return if currently saving by storing this save request as the next save that should happen. + // Never ever must 2 saves execute at the same time because this can lead to dirty writes and race conditions. + // + // Scenario A: auto save was triggered and is currently busy saving to disk. this takes long enough that another auto save + // kicks in. + // Scenario B: save is very slow (e.g. network share) and the user manages to change the working copy and trigger another save + // while the first save has not returned yet. + // + if (this.saveSequentializer.hasPending()) { + this.trace(`[stored file working copy] doSave(${versionId}) - exit - because busy saving`); + + // Indicate to the save sequentializer that we want to + // cancel the pending operation so that ours can run + // before the pending one finishes. + // Currently this will try to cancel pending save + // participants and pending snapshots from the + // save operation, but not the actual save which does + // not support cancellation yet. + this.saveSequentializer.cancelPending(); + + // Register this as the next upcoming save and return + return this.saveSequentializer.setNext(() => this.doSave(options)); + } + + // Push all edit operations to the undo stack so that the user has a chance to + // Ctrl+Z back to the saved version. + if (this.isResolved()) { + this.model.pushStackElement(); + } + + const saveCancellation = new CancellationTokenSource(); + + return this.saveSequentializer.setPending(versionId, (async () => { + + // A save participant can still change the working copy now + // and since we are so close to saving we do not want to trigger + // another auto save or similar, so we block this + // In addition we update our version right after in case it changed + // because of a working copy change + // Save participants can also be skipped through API. + if (this.isResolved() && !options.skipSaveParticipants && this.isTextFileModel(this.model)) { + try { + + // Measure the time it took from the last undo/redo operation to this save. If this + // time is below `UNDO_REDO_SAVE_PARTICIPANTS_THROTTLE_THRESHOLD`, we make sure to + // delay the save participant for the remaining time if the reason is auto save. + // + // This fixes the following issue: + // - the user has configured auto save with delay of 100ms or shorter + // - the user has a save participant enabled that modifies the file on each save + // - the user types into the file and the file gets saved + // - the user triggers undo operation + // - this will undo the save participant change but trigger the save participant right after + // - the user has no chance to undo over the save participant + // + // Reported as: https://github.com/microsoft/vscode/issues/102542 + if (options.reason === SaveReason.AUTO && typeof this.lastContentChangeFromUndoRedo === 'number') { + const timeFromUndoRedoToSave = Date.now() - this.lastContentChangeFromUndoRedo; + if (timeFromUndoRedoToSave < StoredFileWorkingCopy.UNDO_REDO_SAVE_PARTICIPANTS_AUTO_SAVE_THROTTLE_THRESHOLD) { + await timeout(StoredFileWorkingCopy.UNDO_REDO_SAVE_PARTICIPANTS_AUTO_SAVE_THROTTLE_THRESHOLD - timeFromUndoRedoToSave); + } + } + + // Run save participants unless save was cancelled meanwhile + if (!saveCancellation.token.isCancellationRequested) { + await this.textFileService.files.runSaveParticipants(this.model, { reason: options.reason ?? SaveReason.EXPLICIT }, saveCancellation.token); + } + } catch (error) { + this.logService.error(`[stored file working copy] runSaveParticipants(${versionId}) - resulted in an error: ${error.toString()}`, this.resource.toString(true), this.typeId); + } + } + + // It is possible that a subsequent save is cancelling this + // running save. As such we return early when we detect that. + if (saveCancellation.token.isCancellationRequested) { + return; + } + + // We have to protect against being disposed at this point. It could be that the save() operation + // was triggerd followed by a dispose() operation right after without waiting. Typically we cannot + // be disposed if we are dirty, but if we are not dirty, save() and dispose() can still be triggered + // one after the other without waiting for the save() to complete. If we are disposed(), we risk + // saving contents to disk that are stale (see https://github.com/microsoft/vscode/issues/50942). + // To fix this issue, we will not store the contents to disk when we got disposed. + if (this.isDisposed()) { + return; + } + + // We require a resolved working copy from this point on, since we are about to write data to disk. + if (!this.isResolved()) { + return; + } + + // update versionId with its new value (if pre-save changes happened) + versionId = this.versionId; + + // Clear error flag since we are trying to save again + this.inErrorMode = false; + + // Save to Disk. We mark the save operation as currently pending with + // the latest versionId because it might have changed from a save + // participant triggering + this.trace(`[stored file working copy] doSave(${versionId}) - before write()`); + const lastResolvedFileStat = assertIsDefined(this.lastResolvedFileStat); + const resolvedFileWorkingCopy = this; + return this.saveSequentializer.setPending(versionId, (async () => { + try { + + // Snapshot working copy model contents + const snapshot = await raceCancellation(resolvedFileWorkingCopy.model.snapshot(saveCancellation.token), saveCancellation.token); + + // It is possible that a subsequent save is cancelling this + // running save. As such we return early when we detect that + // However, we do not pass the token into the file service + // because that is an atomic operation currently without + // cancellation support, so we dispose the cancellation if + // it was not cancelled yet. + if (saveCancellation.token.isCancellationRequested) { + return; + } else { + saveCancellation.dispose(); + } + + const writeFileOptions: IWriteFileOptions = { + mtime: lastResolvedFileStat.mtime, + etag: (options.ignoreModifiedSince || !this.filesConfigurationService.preventSaveConflicts(lastResolvedFileStat.resource)) ? ETAG_DISABLED : lastResolvedFileStat.etag, + unlock: options.writeUnlock + }; + + // Write them to disk + let stat: IFileStatWithMetadata; + if (options?.writeElevated && this.elevatedFileService.isSupported(lastResolvedFileStat.resource)) { + stat = await this.elevatedFileService.writeFileElevated(lastResolvedFileStat.resource, assertIsDefined(snapshot), writeFileOptions); + } else { + stat = await this.fileService.writeFile(lastResolvedFileStat.resource, assertIsDefined(snapshot), writeFileOptions); + } + + this.handleSaveSuccess(stat, versionId, options); + } catch (error) { + this.handleSaveError(error, versionId, options); + } + })(), () => saveCancellation.cancel()); + })(), () => saveCancellation.cancel()); + } + + private handleSaveSuccess(stat: IFileStatWithMetadata, versionId: number, options: IStoredFileWorkingCopySaveOptions): void { + + // Updated resolved stat with updated stat + this.updateLastResolvedFileStat(stat); + + // Update dirty state unless working copy has changed meanwhile + if (versionId === this.versionId) { + this.trace(`[stored file working copy] handleSaveSuccess(${versionId}) - setting dirty to false because versionId did not change`); + this.setDirty(false); + } else { + this.trace(`[stored file working copy] handleSaveSuccess(${versionId}) - not setting dirty to false because versionId did change meanwhile`); + } + + // Update orphan state given save was successful + this.setOrphaned(false); + + // Emit Save Event + this._onDidSave.fire(options.reason ?? SaveReason.EXPLICIT); + } + + private handleSaveError(error: Error, versionId: number, options: IStoredFileWorkingCopySaveOptions): void { + this.logService.error(`[stored file working copy] handleSaveError(${versionId}) - exit - resulted in a save error: ${error.toString()}`, this.resource.toString(true), this.typeId); + + // Return early if the save() call was made asking to + // handle the save error itself. + if (options.ignoreErrorHandler) { + throw error; + } + + // In any case of an error, we mark the working copy as dirty to prevent data loss + // It could be possible that the write corrupted the file on disk (e.g. when + // an error happened after truncating the file) and as such we want to preserve + // the working copy contents to prevent data loss. + this.setDirty(true); + + // Flag as error state + this.inErrorMode = true; + + // Look out for a save conflict + if ((error as FileOperationError).fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) { + this.inConflictMode = true; + } + + // Delegate to save error handler + if (this.isTextFileModel(this.model)) { + this.textFileService.files.saveErrorHandler.onSaveError(error, this.model); + } else { + this.doHandleSaveError(error); + } + + // Emit as event + this._onDidSaveError.fire(); + } + + private doHandleSaveError(error: Error): void { + const fileOperationError = error as FileOperationError; + const primaryActions: IAction[] = []; + + let message: string; + + // Dirty write prevention + if (fileOperationError.fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) { + message = localize('staleSaveError', "Failed to save '{0}': The content of the file is newer. Do you want to overwrite the file with your changes?", this.name); + + primaryActions.push(toAction({ id: 'fileWorkingCopy.overwrite', label: localize('overwrite', "Overwrite"), run: () => this.save({ ignoreModifiedSince: true }) })); + primaryActions.push(toAction({ id: 'fileWorkingCopy.revert', label: localize('discard', "Discard"), run: () => this.revert() })); + } + + // Any other save error + else { + const isWriteLocked = fileOperationError.fileOperationResult === FileOperationResult.FILE_WRITE_LOCKED; + const triedToUnlock = isWriteLocked && fileOperationError.options?.unlock; + const isPermissionDenied = fileOperationError.fileOperationResult === FileOperationResult.FILE_PERMISSION_DENIED; + const canSaveElevated = this.elevatedFileService.isSupported(this.resource); + + // Save Elevated + if (canSaveElevated && (isPermissionDenied || triedToUnlock)) { + primaryActions.push(toAction({ + id: 'fileWorkingCopy.saveElevated', + label: triedToUnlock ? + isWindows ? localize('overwriteElevated', "Overwrite as Admin...") : localize('overwriteElevatedSudo', "Overwrite as Sudo...") : + isWindows ? localize('saveElevated', "Retry as Admin...") : localize('saveElevatedSudo', "Retry as Sudo..."), + run: () => { + this.save({ writeElevated: true, writeUnlock: triedToUnlock, reason: SaveReason.EXPLICIT }); + } + })); + } + + // Unlock + else if (isWriteLocked) { + primaryActions.push(toAction({ id: 'fileWorkingCopy.unlock', label: localize('overwrite', "Overwrite"), run: () => this.save({ writeUnlock: true, reason: SaveReason.EXPLICIT }) })); + } + + // Retry + else { + primaryActions.push(toAction({ id: 'fileWorkingCopy.retry', label: localize('retry', "Retry"), run: () => this.save({ reason: SaveReason.EXPLICIT }) })); + } + + // Save As + primaryActions.push(toAction({ + id: 'fileWorkingCopy.saveAs', + label: localize('saveAs', "Save As..."), + run: () => { + const editor = this.workingCopyEditorService.findEditor(this); + if (editor) { + this.editorService.save(editor, { saveAs: true, reason: SaveReason.EXPLICIT }); + } + } + })); + + // Discard + primaryActions.push(toAction({ id: 'fileWorkingCopy.revert', label: localize('discard', "Discard"), run: () => this.revert() })); + + // Message + if (isWriteLocked) { + if (triedToUnlock && canSaveElevated) { + message = isWindows ? + localize('readonlySaveErrorAdmin', "Failed to save '{0}': File is read-only. Select 'Overwrite as Admin' to retry as administrator.", this.name) : + localize('readonlySaveErrorSudo', "Failed to save '{0}': File is read-only. Select 'Overwrite as Sudo' to retry as superuser.", this.name); + } else { + message = localize('readonlySaveError', "Failed to save '{0}': File is read-only. Select 'Overwrite' to attempt to make it writeable.", this.name); + } + } else if (canSaveElevated && isPermissionDenied) { + message = isWindows ? + localize('permissionDeniedSaveError', "Failed to save '{0}': Insufficient permissions. Select 'Retry as Admin' to retry as administrator.", this.name) : + localize('permissionDeniedSaveErrorSudo', "Failed to save '{0}': Insufficient permissions. Select 'Retry as Sudo' to retry as superuser.", this.name); + } else { + message = localize({ key: 'genericSaveError', comment: ['{0} is the resource that failed to save and {1} the error message'] }, "Failed to save '{0}': {1}", this.name, toErrorMessage(error, false)); + } + } + + // Show to the user as notification + const handle = this.notificationService.notify({ id: `${hash(this.resource.toString())}`, severity: Severity.Error, message, actions: { primary: primaryActions } }); + + // Remove automatically when we get saved/reverted + const listener = Event.once(Event.any(this.onDidSave, this.onDidRevert))(() => handle.close()); + Event.once(handle.onDidClose)(() => listener.dispose()); + } + + private updateLastResolvedFileStat(newFileStat: IFileStatWithMetadata): void { + const oldReadonly = this.isReadonly(); + + // First resolve - just take + if (!this.lastResolvedFileStat) { + this.lastResolvedFileStat = newFileStat; + } + + // Subsequent resolve - make sure that we only assign it if the mtime + // is equal or has advanced. + // This prevents race conditions from resolving and saving. If a save + // comes in late after a revert was called, the mtime could be out of + // sync. + else if (this.lastResolvedFileStat.mtime <= newFileStat.mtime) { + this.lastResolvedFileStat = newFileStat; + } + + // Signal that the readonly state changed + if (this.isReadonly() !== oldReadonly) { + this._onDidChangeReadonly.fire(); + } + } + + //#endregion + + //#region Revert + + async revert(options?: IRevertOptions): Promise { + if (!this.isResolved() || (!this.dirty && !options?.force)) { + return; // ignore if not resolved or not dirty and not enforced + } + + this.trace('[stored file working copy] revert()'); + + // Unset flags + const wasDirty = this.dirty; + const undoSetDirty = this.doSetDirty(false); + + // Force read from disk unless reverting soft + const softUndo = options?.soft; + if (!softUndo) { + try { + await this.resolve({ forceReadFromFile: true }); + } catch (error) { + + // FileNotFound means the file got deleted meanwhile, so ignore it + if ((error as FileOperationError).fileOperationResult !== FileOperationResult.FILE_NOT_FOUND) { + + // Set flags back to previous values, we are still dirty if revert failed + undoSetDirty(); + + throw error; + } + } + } + + // Emit file change event + this._onDidRevert.fire(); + + // Emit dirty change event + if (wasDirty) { + this._onDidChangeDirty.fire(); + } + } + + //#endregion + + //#region State + + private inConflictMode = false; + private inErrorMode = false; + + hasState(state: StoredFileWorkingCopyState): boolean { + switch (state) { + case StoredFileWorkingCopyState.CONFLICT: + return this.inConflictMode; + case StoredFileWorkingCopyState.DIRTY: + return this.dirty; + case StoredFileWorkingCopyState.ERROR: + return this.inErrorMode; + case StoredFileWorkingCopyState.ORPHAN: + return this.isOrphaned(); + case StoredFileWorkingCopyState.PENDING_SAVE: + return this.saveSequentializer.hasPending(); + case StoredFileWorkingCopyState.SAVED: + return !this.dirty; + } + } + + joinState(state: StoredFileWorkingCopyState.PENDING_SAVE): Promise { + return this.saveSequentializer.pending ?? Promise.resolve(); + } + + //#endregion + + //#region Utilities + + isReadonly(): boolean { + return this.lastResolvedFileStat?.readonly || this.fileService.hasCapability(this.resource, FileSystemProviderCapabilities.Readonly); + } + + private trace(msg: string): void { + this.logService.trace(msg, this.resource.toString(true), this.typeId); + } + + //#endregion + + //#region Dispose + + override dispose(): void { + this.trace('[stored file working copy] dispose()'); + + // State + this.inConflictMode = false; + this.inErrorMode = false; + + super.dispose(); + } + + //#endregion + + //#region Remainders of text file model world (TODO@bpasero callers have to be handled in a generic way) + + private isTextFileModel(model: unknown): model is ITextFileEditorModel { + const textFileModel = this.textFileService.files.get(this.resource); + + return !!(textFileModel && this.model && (textFileModel as unknown) === (this.model as unknown)); + } + + //#endregion +} diff --git a/src/vs/workbench/services/workingCopy/common/storedFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/storedFileWorkingCopyManager.ts new file mode 100644 index 00000000000..24b22e5681a --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/storedFileWorkingCopyManager.ts @@ -0,0 +1,546 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { Event, Emitter } from 'vs/base/common/event'; +import { StoredFileWorkingCopy, StoredFileWorkingCopyState, IStoredFileWorkingCopy, IStoredFileWorkingCopyModel, IStoredFileWorkingCopyModelFactory, IStoredFileWorkingCopyResolveOptions } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy'; +import { SaveReason } from 'vs/workbench/common/editor'; +import { ResourceMap } from 'vs/base/common/map'; +import { Promises, ResourceQueue } from 'vs/base/common/async'; +import { FileChangesEvent, FileChangeType, FileOperation, IFileService } from 'vs/platform/files/common/files'; +import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { VSBufferReadableStream } from 'vs/base/common/buffer'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { ILogService } from 'vs/platform/log/common/log'; +import { joinPath } from 'vs/base/common/resources'; +import { IWorkingCopyFileService, WorkingCopyFileEvent } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; +import { BaseFileWorkingCopyManager, IBaseFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IElevatedFileService } from 'vs/workbench/services/files/common/elevatedFileService'; +import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { IWorkingCopyEditorService } from 'vs/workbench/services/workingCopy/common/workingCopyEditorService'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; + +/** + * The only one that should be dealing with `IStoredFileWorkingCopy` and handle all + * operations that are working copy related, such as save/revert, backup + * and resolving. + */ +export interface IStoredFileWorkingCopyManager extends IBaseFileWorkingCopyManager> { + + /** + * An event for when a stored file working copy was resolved. + */ + readonly onDidResolve: Event>; + + /** + * An event for when a stored file working copy changed it's dirty state. + */ + readonly onDidChangeDirty: Event>; + + /** + * An event for when a stored file working copy failed to save. + */ + readonly onDidSaveError: Event>; + + /** + * An event for when a stored file working copy successfully saved. + */ + readonly onDidSave: Event>; + + /** + * An event for when a stored file working copy was reverted. + */ + readonly onDidRevert: Event>; + + /** + * Allows to resolve a stored file working copy. If the manager already knows + * about a stored file working copy with the same `URI`, it will return that + * existing stored file working copy. There will never be more than one + * stored file working copy per `URI` until the stored file working copy is + * disposed. + * + * Use the `IStoredFileWorkingCopyResolveOptions.reload` option to control the + * behaviour for when a stored file working copy was previously already resolved + * with regards to resolving it again from the underlying file resource + * or not. + * + * Note: Callers must `dispose` the working copy when no longer needed. + * + * @param resource used as unique identifier of the stored file working copy in + * case one is already known for this `URI`. + * @param options + */ + resolve(resource: URI, options?: IStoredFileWorkingCopyManagerResolveOptions): Promise>; + + /** + * Waits for the stored file working copy to be ready to be disposed. There may be + * conditions under which the stored file working copy cannot be disposed, e.g. when + * it is dirty. Once the promise is settled, it is safe to dispose. + */ + canDispose(workingCopy: IStoredFileWorkingCopy): true | Promise; +} + +export interface IStoredFileWorkingCopySaveEvent { + + /** + * The stored file working copy that was successfully saved. + */ + workingCopy: IStoredFileWorkingCopy; + + /** + * The reason why the stored file working copy was saved. + */ + reason: SaveReason; +} + +export interface IStoredFileWorkingCopyManagerResolveOptions extends IStoredFileWorkingCopyResolveOptions { + + /** + * If the stored file working copy was already resolved before, + * allows to trigger a reload of it to fetch the latest contents: + * - async: resolve() will return immediately and trigger + * a reload that will run in the background. + * - sync: resolve() will only return resolved when the + * stored file working copy has finished reloading. + */ + reload?: { + async: boolean + }; +} + +export class StoredFileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IStoredFileWorkingCopyManager { + + //#region Events + + private readonly _onDidResolve = this._register(new Emitter>()); + readonly onDidResolve = this._onDidResolve.event; + + private readonly _onDidChangeDirty = this._register(new Emitter>()); + readonly onDidChangeDirty = this._onDidChangeDirty.event; + + private readonly _onDidSaveError = this._register(new Emitter>()); + readonly onDidSaveError = this._onDidSaveError.event; + + private readonly _onDidSave = this._register(new Emitter>()); + readonly onDidSave = this._onDidSave.event; + + private readonly _onDidRevert = this._register(new Emitter>()); + readonly onDidRevert = this._onDidRevert.event; + + //#endregion + + private readonly mapResourceToWorkingCopyListeners = new ResourceMap(); + private readonly mapResourceToPendingWorkingCopyResolve = new ResourceMap>(); + + private readonly workingCopyResolveQueue = this._register(new ResourceQueue()); + + constructor( + private readonly workingCopyTypeId: string, + private readonly modelFactory: IStoredFileWorkingCopyModelFactory, + @IFileService fileService: IFileService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @ILabelService private readonly labelService: ILabelService, + @ILogService logService: ILogService, + @IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService, + @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @ITextFileService private readonly textFileService: ITextFileService, + @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + @INotificationService private readonly notificationService: INotificationService, + @IWorkingCopyEditorService private readonly workingCopyEditorService: IWorkingCopyEditorService, + @IEditorService private readonly editorService: IEditorService, + @IElevatedFileService private readonly elevatedFileService: IElevatedFileService + ) { + super(fileService, logService, workingCopyBackupService); + + this.registerListeners(); + } + + private registerListeners(): void { + + // Update working copies from file change events + this._register(this.fileService.onDidFilesChange(e => this.onDidFilesChange(e))); + + // Working copy operations + this._register(this.workingCopyFileService.onWillRunWorkingCopyFileOperation(e => this.onWillRunWorkingCopyFileOperation(e))); + this._register(this.workingCopyFileService.onDidFailWorkingCopyFileOperation(e => this.onDidFailWorkingCopyFileOperation(e))); + this._register(this.workingCopyFileService.onDidRunWorkingCopyFileOperation(e => this.onDidRunWorkingCopyFileOperation(e))); + + // Lifecycle + this.lifecycleService.onWillShutdown(event => event.join(this.onWillShutdown(), 'join.fileWorkingCopyManager')); + } + + private async onWillShutdown(): Promise { + let fileWorkingCopies: IStoredFileWorkingCopy[]; + + // As long as stored file working copies are pending to be saved, we prolong the shutdown + // until that has happened to ensure we are not shutting down in the middle of + // writing to the working copy (https://github.com/microsoft/vscode/issues/116600). + while ((fileWorkingCopies = this.workingCopies.filter(workingCopy => workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE))).length > 0) { + await Promises.settled(fileWorkingCopies.map(workingCopy => workingCopy.joinState(StoredFileWorkingCopyState.PENDING_SAVE))); + } + } + + //#region Resolve from file changes + + private onDidFilesChange(e: FileChangesEvent): void { + for (const workingCopy of this.workingCopies) { + if (workingCopy.isDirty() || !workingCopy.isResolved()) { + continue; // require a resolved, saved working copy to continue + } + + // Trigger a resolve for any update or add event that impacts + // the working copy. We also consider the added event + // because it could be that a file was added and updated + // right after. + if (e.contains(workingCopy.resource, FileChangeType.UPDATED, FileChangeType.ADDED)) { + this.queueWorkingCopyResolve(workingCopy); + } + } + } + + private queueWorkingCopyResolve(workingCopy: IStoredFileWorkingCopy): void { + + // Resolves a working copy to update (use a queue to prevent accumulation of + // resolve when the resolving actually takes long. At most we only want the + // queue to have a size of 2 (1 running resolve and 1 queued resolve). + const queue = this.workingCopyResolveQueue.queueFor(workingCopy.resource); + if (queue.size <= 1) { + queue.queue(async () => { + try { + await workingCopy.resolve(); + } catch (error) { + this.logService.error(error); + } + }); + } + } + + //#endregion + + //#region Working Copy File Events + + private readonly mapCorrelationIdToWorkingCopiesToRestore = new Map(); + + private onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { + + // Move / Copy: remember working copies to restore after the operation + if (e.operation === FileOperation.MOVE || e.operation === FileOperation.COPY) { + e.waitUntil((async () => { + const workingCopiesToRestore: { source: URI, target: URI, snapshot?: VSBufferReadableStream; }[] = []; + + for (const { source, target } of e.files) { + if (source) { + if (this.uriIdentityService.extUri.isEqual(source, target)) { + continue; // ignore if resources are considered equal + } + + // Find all working copies that related to source (can be many if resource is a folder) + const sourceWorkingCopies: IStoredFileWorkingCopy[] = []; + for (const workingCopy of this.workingCopies) { + if (this.uriIdentityService.extUri.isEqualOrParent(workingCopy.resource, source)) { + sourceWorkingCopies.push(workingCopy); + } + } + + // Remember each source working copy to load again after move is done + // with optional content to restore if it was dirty + for (const sourceWorkingCopy of sourceWorkingCopies) { + const sourceResource = sourceWorkingCopy.resource; + + // If the source is the actual working copy, just use target as new resource + let targetResource: URI; + if (this.uriIdentityService.extUri.isEqual(sourceResource, source)) { + targetResource = target; + } + + // Otherwise a parent folder of the source is being moved, so we need + // to compute the target resource based on that + else { + targetResource = joinPath(target, sourceResource.path.substr(source.path.length + 1)); + } + + workingCopiesToRestore.push({ + source: sourceResource, + target: targetResource, + snapshot: sourceWorkingCopy.isDirty() ? await sourceWorkingCopy.model?.snapshot(CancellationToken.None) : undefined + }); + } + } + } + + this.mapCorrelationIdToWorkingCopiesToRestore.set(e.correlationId, workingCopiesToRestore); + })()); + } + } + + private onDidFailWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { + + // Move / Copy: restore dirty flag on working copies to restore that were dirty + if ((e.operation === FileOperation.MOVE || e.operation === FileOperation.COPY)) { + const workingCopiesToRestore = this.mapCorrelationIdToWorkingCopiesToRestore.get(e.correlationId); + if (workingCopiesToRestore) { + this.mapCorrelationIdToWorkingCopiesToRestore.delete(e.correlationId); + + workingCopiesToRestore.forEach(workingCopy => { + + // Snapshot presence means this working copy used to be dirty and so we restore that + // flag. we do NOT have to restore the content because the working copy was only soft + // reverted and did not loose its original dirty contents. + if (workingCopy.snapshot) { + this.get(workingCopy.source)?.markDirty(); + } + }); + } + } + } + + private onDidRunWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { + switch (e.operation) { + + // Create: Revert existing working copies + case FileOperation.CREATE: + e.waitUntil((async () => { + for (const { target } of e.files) { + const workingCopy = this.get(target); + if (workingCopy && !workingCopy.isDisposed()) { + await workingCopy.revert(); + } + } + })()); + break; + + // Move/Copy: restore working copies that were loaded before the operation took place + case FileOperation.MOVE: + case FileOperation.COPY: + e.waitUntil((async () => { + const workingCopiesToRestore = this.mapCorrelationIdToWorkingCopiesToRestore.get(e.correlationId); + if (workingCopiesToRestore) { + this.mapCorrelationIdToWorkingCopiesToRestore.delete(e.correlationId); + + await Promises.settled(workingCopiesToRestore.map(async workingCopyToRestore => { + + // Restore the working copy at the target. if we have previous dirty content, we pass it + // over to be used, otherwise we force a reload from disk. this is important + // because we know the file has changed on disk after the move and the working copy might + // have still existed with the previous state. this ensures that the working copy is not + // tracking a stale state. + await this.resolve(workingCopyToRestore.target, { + reload: { async: false }, // enforce a reload + contents: workingCopyToRestore.snapshot + }); + })); + } + })()); + break; + } + } + + //#endregion + + //#region Resolve + + async resolve(resource: URI, options?: IStoredFileWorkingCopyManagerResolveOptions): Promise> { + + // Await a pending working copy resolve first before proceeding + // to ensure that we never resolve a working copy more than once + // in parallel + const pendingResolve = this.joinPendingResolve(resource); + if (pendingResolve) { + await pendingResolve; + } + + let workingCopyResolve: Promise; + let workingCopy = this.get(resource); + let didCreateWorkingCopy = false; + + // Working copy exists + if (workingCopy) { + + // Always reload if contents are provided + if (options?.contents) { + workingCopyResolve = workingCopy.resolve(options); + } + + // Reload async or sync based on options + else if (options?.reload) { + + // Async reload: trigger a reload but return immediately + if (options.reload.async) { + workingCopy.resolve(options); + workingCopyResolve = Promise.resolve(); + } + + // Sync reload: do not return until working copy reloaded + else { + workingCopyResolve = workingCopy.resolve(options); + } + } + + // Do not reload + else { + workingCopyResolve = Promise.resolve(); + } + } + + // Stored file working copy does not exist + else { + didCreateWorkingCopy = true; + + workingCopy = new StoredFileWorkingCopy( + this.workingCopyTypeId, + resource, + this.labelService.getUriBasenameLabel(resource), + this.modelFactory, + this.fileService, this.logService, this.textFileService, this.filesConfigurationService, + this.workingCopyBackupService, this.workingCopyService, this.notificationService, this.workingCopyEditorService, + this.editorService, this.elevatedFileService + ); + + workingCopyResolve = workingCopy.resolve(options); + + this.registerWorkingCopy(workingCopy); + } + + // Store pending resolve to avoid race conditions + this.mapResourceToPendingWorkingCopyResolve.set(resource, workingCopyResolve); + + // Make known to manager (if not already known) + this.add(resource, workingCopy); + + // Emit some events if we created the working copy + if (didCreateWorkingCopy) { + + // If the working copy is dirty right from the beginning, + // make sure to emit this as an event + if (workingCopy.isDirty()) { + this._onDidChangeDirty.fire(workingCopy); + } + } + + try { + + // Wait for working copy to resolve + await workingCopyResolve; + + // Remove from pending resolves + this.mapResourceToPendingWorkingCopyResolve.delete(resource); + + // Stored file working copy can be dirty if a backup was restored, so we make sure to + // have this event delivered if we created the working copy here + if (didCreateWorkingCopy && workingCopy.isDirty()) { + this._onDidChangeDirty.fire(workingCopy); + } + + return workingCopy; + } catch (error) { + + // Free resources of this invalid working copy + if (workingCopy) { + workingCopy.dispose(); + } + + // Remove from pending resolves + this.mapResourceToPendingWorkingCopyResolve.delete(resource); + + throw error; + } + } + + private joinPendingResolve(resource: URI): Promise | undefined { + const pendingWorkingCopyResolve = this.mapResourceToPendingWorkingCopyResolve.get(resource); + if (pendingWorkingCopyResolve) { + return pendingWorkingCopyResolve.then(undefined, error => {/* ignore any error here, it will bubble to the original requestor*/ }); + } + + return undefined; + } + + private registerWorkingCopy(workingCopy: IStoredFileWorkingCopy): void { + + // Install working copy listeners + const workingCopyListeners = new DisposableStore(); + workingCopyListeners.add(workingCopy.onDidResolve(() => this._onDidResolve.fire(workingCopy))); + workingCopyListeners.add(workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(workingCopy))); + workingCopyListeners.add(workingCopy.onDidSaveError(() => this._onDidSaveError.fire(workingCopy))); + workingCopyListeners.add(workingCopy.onDidSave(reason => this._onDidSave.fire({ workingCopy: workingCopy, reason }))); + workingCopyListeners.add(workingCopy.onDidRevert(() => this._onDidRevert.fire(workingCopy))); + + // Keep for disposal + this.mapResourceToWorkingCopyListeners.set(workingCopy.resource, workingCopyListeners); + } + + protected override remove(resource: URI): void { + super.remove(resource); + + // Dispose any exsting working copy listeners + const workingCopyListener = this.mapResourceToWorkingCopyListeners.get(resource); + if (workingCopyListener) { + dispose(workingCopyListener); + this.mapResourceToWorkingCopyListeners.delete(resource); + } + } + + //#endregion + + //#region Lifecycle + + canDispose(workingCopy: IStoredFileWorkingCopy): true | Promise { + + // Quick return if working copy already disposed or not dirty and not resolving + if ( + workingCopy.isDisposed() || + (!this.mapResourceToPendingWorkingCopyResolve.has(workingCopy.resource) && !workingCopy.isDirty()) + ) { + return true; + } + + // Promise based return in all other cases + return this.doCanDispose(workingCopy); + } + + private async doCanDispose(workingCopy: IStoredFileWorkingCopy): Promise { + + // If we have a pending working copy resolve, await it first and then try again + const pendingResolve = this.joinPendingResolve(workingCopy.resource); + if (pendingResolve) { + await pendingResolve; + + return this.canDispose(workingCopy); + } + + // Dirty working copy: we do not allow to dispose dirty working copys + // to prevent data loss cases. dirty working copys can only be disposed when + // they are either saved or reverted + if (workingCopy.isDirty()) { + await Event.toPromise(workingCopy.onDidChangeDirty); + + return this.canDispose(workingCopy); + } + + return true; + } + + override dispose(): void { + super.dispose(); + + // Clear pending working copy resolves + this.mapResourceToPendingWorkingCopyResolve.clear(); + + // Dispose the working copy change listeners + dispose(this.mapResourceToWorkingCopyListeners.values()); + this.mapResourceToWorkingCopyListeners.clear(); + } + + //#endregion +} diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts new file mode 100644 index 00000000000..2ce885dc70b --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -0,0 +1,288 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event, Emitter } from 'vs/base/common/event'; +import { VSBufferReadableStream } from 'vs/base/common/buffer'; +import { IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; +import { IFileWorkingCopy, IFileWorkingCopyModel, IFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { Schemas } from 'vs/base/common/network'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { ISaveOptions } from 'vs/workbench/common/editor'; +import { raceCancellation } from 'vs/base/common/async'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; +import { emptyStream } from 'vs/base/common/stream'; + +/** + * Untitled file specific working copy model factory. + */ +export interface IUntitledFileWorkingCopyModelFactory extends IFileWorkingCopyModelFactory { } + +/** + * The underlying model of a untitled file working copy provides + * some methods for the untitled file working copy to function. + * The model is typically only available after the working copy + * has been resolved via it's `resolve()` method. + */ +export interface IUntitledFileWorkingCopyModel extends IFileWorkingCopyModel { + + readonly onDidChangeContent: Event; +} + +export interface IUntitledFileWorkingCopyModelContentChangedEvent { + + /** + * Flag that indicates that the content change + * resulted in empty contents. A untitled file + * working copy without contents may be marked + * as non-dirty. + */ + readonly isEmpty: boolean; +} + +export interface IUntitledFileWorkingCopy extends IFileWorkingCopy { + + /** + * Whether this untitled file working copy model has an associated file path. + */ + readonly hasAssociatedFilePath: boolean; + + /** + * Whether we have a resolved model or not. + */ + isResolved(): this is IResolvedUntitledFileWorkingCopy; +} + +export interface IResolvedUntitledFileWorkingCopy extends IUntitledFileWorkingCopy { + + /** + * A resolved untitled file working copy has a resolved model. + */ + readonly model: M; +} + +export interface IUntitledFileWorkingCopySaveDelegate { + + /** + * A delegate to enable saving of untitled file working copies. + */ + (workingCopy: IUntitledFileWorkingCopy, options?: ISaveOptions): Promise; +} + +export class UntitledFileWorkingCopy extends Disposable implements IUntitledFileWorkingCopy { + + readonly capabilities: WorkingCopyCapabilities = WorkingCopyCapabilities.Untitled; + + private _model: M | undefined = undefined; + get model(): M | undefined { return this._model; } + + //#region Events + + private readonly _onDidChangeContent = this._register(new Emitter()); + readonly onDidChangeContent = this._onDidChangeContent.event; + + private readonly _onDidChangeDirty = this._register(new Emitter()); + readonly onDidChangeDirty = this._onDidChangeDirty.event; + + private readonly _onDidRevert = this._register(new Emitter()); + readonly onDidRevert = this._onDidRevert.event; + + private readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose = this._onWillDispose.event; + + //#endregion + + constructor( + readonly typeId: string, + readonly resource: URI, + readonly name: string, + readonly hasAssociatedFilePath: boolean, + private readonly initialValue: VSBufferReadableStream | undefined, + private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, + private readonly saveDelegate: IUntitledFileWorkingCopySaveDelegate, + @IWorkingCopyService workingCopyService: IWorkingCopyService, + @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, + @ILogService private readonly logService: ILogService + ) { + super(); + + if (resource.scheme !== Schemas.untitled) { + throw new Error(`The untitled file working copy resource ${this.resource.toString(true)} is not using untitled as scheme.`); + } + + // Make known to working copy service + this._register(workingCopyService.registerWorkingCopy(this)); + } + + //#region Dirty + + private dirty = this.hasAssociatedFilePath || !!this.initialValue; + + isDirty(): boolean { + return this.dirty; + } + + private setDirty(dirty: boolean): void { + if (this.dirty === dirty) { + return; + } + + this.dirty = dirty; + this._onDidChangeDirty.fire(); + } + + //#endregion + + + //#region Resolve + + async resolve(): Promise { + this.trace('[untitled file working copy] resolve()'); + + if (this.isResolved()) { + this.trace('[untitled file working copy] resolve() - exit (already resolved)'); + + // return early if the untitled file working copy is already + // resolved assuming that the contents have meanwhile changed + // in the underlying model. we only resolve untitled once. + return; + } + + let untitledContents: VSBufferReadableStream; + + // Check for backups or use initial value or empty + const backup = await this.workingCopyBackupService.resolve(this); + if (backup) { + this.trace('[untitled file working copy] resolve() - with backup'); + + untitledContents = backup.value; + } else if (this.initialValue) { + this.trace('[untitled file working copy] resolve() - with initial contents'); + + untitledContents = this.initialValue; + } else { + this.trace('[untitled file working copy] resolve() - empty'); + + untitledContents = emptyStream(); + } + + // Create model + await this.doCreateModel(untitledContents); + + // Untitled associated to file path are dirty right away as well as untitled with content + this.setDirty(this.hasAssociatedFilePath || !!backup || !!this.initialValue); + + // If we have initial contents, make sure to emit this + // as the appropiate events to the outside. + if (!!backup || this.initialValue) { + this._onDidChangeContent.fire(); + } + } + + private async doCreateModel(contents: VSBufferReadableStream): Promise { + this.trace('[untitled file working copy] doCreateModel()'); + + // Create model and dispose it when we get disposed + this._model = this._register(await this.modelFactory.createModel(this.resource, contents, CancellationToken.None)); + + // Model listeners + this.installModelListeners(this._model); + } + + private installModelListeners(model: M): void { + + // Content Change + this._register(model.onDidChangeContent(e => this.onModelContentChanged(e))); + + // Lifecycle + this._register(model.onWillDispose(() => this.dispose())); + } + + private onModelContentChanged(e: IUntitledFileWorkingCopyModelContentChangedEvent): void { + + // Mark the untitled file working copy as non-dirty once its + // content becomes empty and we do not have an associated + // path set. we never want dirty indicator in that case. + if (!this.hasAssociatedFilePath && e.isEmpty) { + this.setDirty(false); + } + + // Turn dirty otherwise + else { + this.setDirty(true); + } + + // Emit as general content change event + this._onDidChangeContent.fire(); + } + + isResolved(): this is IResolvedUntitledFileWorkingCopy { + return !!this.model; + } + + //#endregion + + + //#region Backup + + async backup(token: CancellationToken): Promise { + + // Fill in content if we are resolved + let content: VSBufferReadableStream | undefined = undefined; + if (this.isResolved()) { + content = await raceCancellation(this.model.snapshot(token), token); + } + + return { content }; + } + + //#endregion + + + //#region Save + + save(options?: ISaveOptions): Promise { + this.trace('[untitled file working copy] save()'); + + return this.saveDelegate(this, options); + } + + //#endregion + + + //#region Revert + + async revert(): Promise { + this.trace('[untitled file working copy] revert()'); + + // No longer dirty + this.setDirty(false); + + // Emit as event + this._onDidRevert.fire(); + + // A reverted untitled file working copy is invalid + // because it has no actual source on disk to revert to. + // As such we dispose the model. + this.dispose(); + } + + //#endregion + + override dispose(): void { + this.trace('[untitled file working copy] dispose()'); + + this._onWillDispose.fire(); + + super.dispose(); + } + + private trace(msg: string): void { + this.logService.trace(msg, this.resource.toString(true), this.typeId); + } +} diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts new file mode 100644 index 00000000000..8ca960de898 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -0,0 +1,256 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBufferReadableStream } from 'vs/base/common/buffer'; +import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { IUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelFactory, IUntitledFileWorkingCopySaveDelegate, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; +import { Event, Emitter } from 'vs/base/common/event'; +import { Schemas } from 'vs/base/common/network'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; +import { IFileService } from 'vs/platform/files/common/files'; +import { BaseFileWorkingCopyManager, IBaseFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; +import { ResourceMap } from 'vs/base/common/map'; + +/** + * The only one that should be dealing with `IUntitledFileWorkingCopy` and + * handle all operations that are working copy related, such as save/revert, + * backup and resolving. + */ +export interface IUntitledFileWorkingCopyManager extends IBaseFileWorkingCopyManager> { + + /** + * An event for when a untitled file working copy changed it's dirty state. + */ + readonly onDidChangeDirty: Event>; + + /** + * An event for when a untitled file working copy is about to be disposed. + */ + readonly onWillDispose: Event>; + + /** + * Create a new untitled file working copy with optional initial contents. + * + * Note: Callers must `dispose` the working copy when no longer needed. + */ + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + + /** + * Create a new untitled file working copy with optional initial contents + * and associated resource. The associated resource will be used when + * saving and will not require to ask the user for a file path. + * + * Note: Callers must `dispose` the working copy when no longer needed. + */ + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + + /** + * Creates a new untitled file working copy with optional initial contents + * with the provided resource or return an existing untitled file working + * copy otherwise. + * + * Note: Callers must `dispose` the working copy when no longer needed. + */ + resolve(options?: INewOrExistingUntitledFileWorkingCopyOptions): Promise>; +} + +export interface INewUntitledFileWorkingCopyOptions { + + /** + * Initial value of the untitled file working copy. + * + * Note: An untitled file working copy with initial + * value is dirty right from the beginning. + */ + contents?: VSBufferReadableStream; +} + +export interface INewUntitledFileWorkingCopyWithAssociatedResourceOptions extends INewUntitledFileWorkingCopyOptions { + + /** + * Resource components to associate with the untitled file working copy. + * When saving, the associated components will be used and the user + * is not being asked to provide a file path. + * + * Note: currently it is not possible to specify the `scheme` to use. The + * untitled file working copy will saved to the default local or remote resource. + */ + associatedResource: { authority?: string; path?: string; query?: string; fragment?: string; } +} + +export interface INewOrExistingUntitledFileWorkingCopyOptions extends INewUntitledFileWorkingCopyOptions { + + /** + * A resource to identify the untitled file working copy + * to create or return if already existing. + * + * Note: the resource will not be used unless the scheme is `untitled`. + */ + untitledResource: URI; +} + +type IInternalUntitledFileWorkingCopyOptions = INewUntitledFileWorkingCopyOptions & INewUntitledFileWorkingCopyWithAssociatedResourceOptions & INewOrExistingUntitledFileWorkingCopyOptions; + +export class UntitledFileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IUntitledFileWorkingCopyManager { + + //#region Events + + private readonly _onDidChangeDirty = this._register(new Emitter>()); + readonly onDidChangeDirty = this._onDidChangeDirty.event; + + private readonly _onWillDispose = this._register(new Emitter>()); + readonly onWillDispose = this._onWillDispose.event; + + //#endregion + + private readonly mapResourceToWorkingCopyListeners = new ResourceMap(); + + constructor( + private readonly workingCopyTypeId: string, + private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, + private readonly saveDelegate: IUntitledFileWorkingCopySaveDelegate, + @IFileService fileService: IFileService, + @ILabelService private readonly labelService: ILabelService, + @ILogService logService: ILogService, + @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService + ) { + super(fileService, logService, workingCopyBackupService); + } + + //#region Resolve + + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + resolve(options?: INewOrExistingUntitledFileWorkingCopyOptions): Promise>; + async resolve(options?: IInternalUntitledFileWorkingCopyOptions): Promise> { + const workingCopy = this.doCreateOrGet(options); + await workingCopy.resolve(); + + return workingCopy; + } + + private doCreateOrGet(options: IInternalUntitledFileWorkingCopyOptions = Object.create(null)): IUntitledFileWorkingCopy { + const massagedOptions = this.massageOptions(options); + + // Return existing instance if asked for it + if (massagedOptions.untitledResource) { + const existingWorkingCopy = this.get(massagedOptions.untitledResource); + if (existingWorkingCopy) { + return existingWorkingCopy; + } + } + + // Create new instance otherwise + return this.doCreate(massagedOptions); + } + + private massageOptions(options: IInternalUntitledFileWorkingCopyOptions): IInternalUntitledFileWorkingCopyOptions { + const massagedOptions: IInternalUntitledFileWorkingCopyOptions = Object.create(null); + + // Handle associcated resource + if (options.associatedResource) { + massagedOptions.untitledResource = URI.from({ + scheme: Schemas.untitled, + authority: options.associatedResource.authority, + fragment: options.associatedResource.fragment, + path: options.associatedResource.path, + query: options.associatedResource.query + }); + massagedOptions.associatedResource = options.associatedResource; + } + + // Handle untitled resource + else if (options.untitledResource?.scheme === Schemas.untitled) { + massagedOptions.untitledResource = options.untitledResource; + } + + // Take over initial value + massagedOptions.contents = options.contents; + + return massagedOptions; + } + + private doCreate(options: IInternalUntitledFileWorkingCopyOptions): IUntitledFileWorkingCopy { + + // Create a new untitled resource if none is provided + let untitledResource = options.untitledResource; + if (!untitledResource) { + let counter = 1; + do { + untitledResource = URI.from({ scheme: Schemas.untitled, path: `Untitled-${counter}` }); + counter++; + } while (this.has(untitledResource)); + } + + // Create new working copy with provided options + const workingCopy = new UntitledFileWorkingCopy( + this.workingCopyTypeId, + untitledResource, + this.labelService.getUriBasenameLabel(untitledResource), + !!options.associatedResource, + options.contents, + this.modelFactory, + this.saveDelegate, + this.workingCopyService, + this.workingCopyBackupService, + this.logService + ); + + // Register + this.registerWorkingCopy(workingCopy); + + return workingCopy; + } + + private registerWorkingCopy(workingCopy: IUntitledFileWorkingCopy): void { + + // Install working copy listeners + const workingCopyListeners = new DisposableStore(); + workingCopyListeners.add(workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(workingCopy))); + workingCopyListeners.add(workingCopy.onWillDispose(() => this._onWillDispose.fire(workingCopy))); + + // Keep for disposal + this.mapResourceToWorkingCopyListeners.set(workingCopy.resource, workingCopyListeners); + + // Add to cache + this.add(workingCopy.resource, workingCopy); + + // If the working copy is dirty right from the beginning, + // make sure to emit this as an event + if (workingCopy.isDirty()) { + this._onDidChangeDirty.fire(workingCopy); + } + } + + protected override remove(resource: URI): void { + super.remove(resource); + + // Dispose any exsting working copy listeners + const workingCopyListener = this.mapResourceToWorkingCopyListeners.get(resource); + if (workingCopyListener) { + dispose(workingCopyListener); + this.mapResourceToWorkingCopyListeners.delete(resource); + } + } + + //#endregion + + //#region Lifecycle + + override dispose(): void { + super.dispose(); + + // Dispose the working copy change listeners + dispose(this.mapResourceToWorkingCopyListeners.values()); + this.mapResourceToWorkingCopyListeners.clear(); + } + + //#endregion +} diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyService.ts b/src/vs/workbench/services/workingCopy/common/workingCopyService.ts index c39b9872644..39a2fa4797e 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyService.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyService.ts @@ -97,6 +97,12 @@ export interface IWorkingCopyService { has(identifier: IWorkingCopyIdentifier): boolean; has(resource: URI): boolean; + /** + * Returns a working copy with the given identifier or `undefined` + * if no such working copy exists. + */ + get(identifier: IWorkingCopyIdentifier): IWorkingCopy | undefined; + //#endregion } @@ -192,6 +198,10 @@ export class WorkingCopyService extends Disposable implements IWorkingCopyServic return this.mapResourceToWorkingCopies.get(resourceOrIdentifier.resource)?.has(resourceOrIdentifier.typeId) ?? false; } + get(identifier: IWorkingCopyIdentifier): IWorkingCopy | undefined { + return this.mapResourceToWorkingCopies.get(identifier.resource)?.get(identifier.typeId); + } + //#endregion diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts index 4e456cc0f32..96c7401fd61 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts @@ -6,513 +6,187 @@ import * as assert from 'assert'; import { URI } from 'vs/base/common/uri'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { workbenchInstantiationService, TestServiceAccessor, TestWillShutdownEvent } from 'vs/workbench/test/browser/workbenchTestServices'; -import { FileWorkingCopyManager, IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; -import { IFileWorkingCopy, IFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { workbenchInstantiationService, TestServiceAccessor, TestInMemoryFileSystemProvider } from 'vs/workbench/test/browser/workbenchTestServices'; +import { StoredFileWorkingCopy, IStoredFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy'; import { bufferToStream, VSBuffer } from 'vs/base/common/buffer'; -import { FileChangesEvent, FileChangeType } from 'vs/platform/files/common/files'; -import { timeout } from 'vs/base/common/async'; -import { TestFileWorkingCopyModel, TestFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test'; -import { CancellationToken } from 'vs/base/common/cancellation'; +import { TestStoredFileWorkingCopyModel, TestStoredFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopy.test'; +import { Schemas } from 'vs/base/common/network'; +import { IFileWorkingCopyManager, FileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; +import { TestUntitledFileWorkingCopyModel, TestUntitledFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test'; +import { UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; suite('FileWorkingCopyManager', () => { let instantiationService: IInstantiationService; let accessor: TestServiceAccessor; - let manager: IFileWorkingCopyManager; + let manager: IFileWorkingCopyManager; setup(() => { instantiationService = workbenchInstantiationService(); accessor = instantiationService.createInstance(TestServiceAccessor); - const factory = new TestFileWorkingCopyModelFactory(); - manager = new FileWorkingCopyManager('testWorkingCopyType', factory, accessor.fileService, accessor.lifecycleService, accessor.labelService, instantiationService, accessor.logService, accessor.fileDialogService, accessor.workingCopyFileService, accessor.workingCopyBackupService, accessor.uriIdentityService); + accessor.fileService.registerProvider(Schemas.file, new TestInMemoryFileSystemProvider()); + accessor.fileService.registerProvider(Schemas.vscodeRemote, new TestInMemoryFileSystemProvider()); + + manager = new FileWorkingCopyManager( + 'testFileWorkingCopyType', + new TestStoredFileWorkingCopyModelFactory(), + new TestUntitledFileWorkingCopyModelFactory(), + accessor.fileService, accessor.lifecycleService, accessor.labelService, accessor.logService, + accessor.workingCopyFileService, accessor.workingCopyBackupService, accessor.uriIdentityService, accessor.fileDialogService, + accessor.textFileService, accessor.filesConfigurationService, accessor.workingCopyService, accessor.notificationService, + accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService, accessor.pathService, + accessor.environmentService, accessor.dialogService + ); }); teardown(() => { manager.dispose(); }); + test('onDidCreate, get, workingCopies', async () => { + let createCounter = 0; + manager.onDidCreate(e => { + createCounter++; + }); + + const fileUri = URI.file('/test.html'); + + assert.strictEqual(manager.workingCopies.length, 0); + assert.strictEqual(manager.get(fileUri), undefined); + + const fileWorkingCopy = await manager.resolve(fileUri); + const untitledFileWorkingCopy = await manager.resolve(); + + assert.strictEqual(manager.workingCopies.length, 2); + assert.strictEqual(createCounter, 2); + assert.strictEqual(manager.get(fileWorkingCopy.resource), fileWorkingCopy); + assert.strictEqual(manager.get(untitledFileWorkingCopy.resource), untitledFileWorkingCopy); + + const sameFileWorkingCopy = await manager.resolve(fileUri); + const sameUntitledFileWorkingCopy = await manager.resolve({ untitledResource: untitledFileWorkingCopy.resource }); + assert.strictEqual(sameFileWorkingCopy, fileWorkingCopy); + assert.strictEqual(sameUntitledFileWorkingCopy, untitledFileWorkingCopy); + assert.strictEqual(manager.workingCopies.length, 2); + assert.strictEqual(createCounter, 2); + + fileWorkingCopy.dispose(); + untitledFileWorkingCopy.dispose(); + }); + test('resolve', async () => { - const resource = URI.file('/test.html'); + const fileWorkingCopy = await manager.resolve(URI.file('/test.html')); + assert.ok(fileWorkingCopy instanceof StoredFileWorkingCopy); + assert.strictEqual(await manager.stored.resolve(fileWorkingCopy.resource), fileWorkingCopy); - const events: IFileWorkingCopy[] = []; - const listener = manager.onDidCreate(workingCopy => { - events.push(workingCopy); - }); + const untitledFileWorkingCopy = await manager.resolve(); + assert.ok(untitledFileWorkingCopy instanceof UntitledFileWorkingCopy); + assert.strictEqual(await manager.untitled.resolve({ untitledResource: untitledFileWorkingCopy.resource }), untitledFileWorkingCopy); - const resolvePromise = manager.resolve(resource); - assert.ok(manager.get(resource)); // working copy known even before resolved() - assert.strictEqual(manager.workingCopies.length, 1); - - const workingCopy1 = await resolvePromise; - assert.ok(workingCopy1); - assert.ok(workingCopy1.model); - assert.strictEqual(workingCopy1.typeId, 'testWorkingCopyType'); - assert.strictEqual(manager.get(resource), workingCopy1); - - const workingCopy2 = await manager.resolve(resource); - assert.strictEqual(workingCopy2, workingCopy1); - assert.strictEqual(manager.workingCopies.length, 1); - workingCopy1.dispose(); - - const workingCopy3 = await manager.resolve(resource); - assert.notStrictEqual(workingCopy3, workingCopy2); - assert.strictEqual(manager.workingCopies.length, 1); - assert.strictEqual(manager.get(resource), workingCopy3); - workingCopy3.dispose(); - - assert.strictEqual(manager.workingCopies.length, 0); - - assert.strictEqual(events.length, 2); - assert.strictEqual(events[0].resource.toString(), workingCopy1.resource.toString()); - assert.strictEqual(events[1].resource.toString(), workingCopy2.resource.toString()); - - listener.dispose(); - - workingCopy1.dispose(); - workingCopy2.dispose(); - workingCopy3.dispose(); - }); - - test('resolve async', async () => { - const resource = URI.file('/path/index.txt'); - - const workingCopy = await manager.resolve(resource); - - let didResolve = false; - const onDidResolve = new Promise(resolve => { - manager.onDidResolve(() => { - if (workingCopy.resource.toString() === resource.toString()) { - didResolve = true; - resolve(); - } - }); - }); - - manager.resolve(resource, { reload: { async: true } }); - - await onDidResolve; - - assert.strictEqual(didResolve, true); - }); - - test('resolve with initial contents', async () => { - const resource = URI.file('/test.html'); - - const workingCopy = await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); - assert.strictEqual(workingCopy.model?.contents, 'Hello World'); - assert.strictEqual(workingCopy.isDirty(), true); - - await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('More Changes')) }); - assert.strictEqual(workingCopy.model?.contents, 'More Changes'); - assert.strictEqual(workingCopy.isDirty(), true); - - workingCopy.dispose(); - }); - - test('multiple resolves execute in sequence (same resources)', async () => { - const resource = URI.file('/test.html'); - - const firstPromise = manager.resolve(resource); - const secondPromise = manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); - const thirdPromise = manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('More Changes')) }); - - await firstPromise; - await secondPromise; - const workingCopy = await thirdPromise; - - assert.strictEqual(workingCopy.model?.contents, 'More Changes'); - assert.strictEqual(workingCopy.isDirty(), true); - - workingCopy.dispose(); - }); - - test('multiple resolves execute in parallel (different resources)', async () => { - const resource1 = URI.file('/test1.html'); - const resource2 = URI.file('/test2.html'); - const resource3 = URI.file('/test3.html'); - - const firstPromise = manager.resolve(resource1); - const secondPromise = manager.resolve(resource2); - const thirdPromise = manager.resolve(resource3); - - const [workingCopy1, workingCopy2, workingCopy3] = await Promise.all([firstPromise, secondPromise, thirdPromise]); - - assert.strictEqual(manager.workingCopies.length, 3); - assert.strictEqual(workingCopy1.resource.toString(), resource1.toString()); - assert.strictEqual(workingCopy2.resource.toString(), resource2.toString()); - assert.strictEqual(workingCopy3.resource.toString(), resource3.toString()); - - workingCopy1.dispose(); - workingCopy2.dispose(); - workingCopy3.dispose(); - }); - - test('removed from cache when working copy or model gets disposed', async () => { - const resource = URI.file('/test.html'); - - let workingCopy = await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); - - assert.strictEqual(manager.get(URI.file('/test.html')), workingCopy); - - workingCopy.dispose(); - assert(!manager.get(URI.file('/test.html'))); - - workingCopy = await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); - - assert.strictEqual(manager.get(URI.file('/test.html')), workingCopy); - - workingCopy.model?.dispose(); - assert(!manager.get(URI.file('/test.html'))); - }); - - test('events', async () => { - const resource1 = URI.file('/path/index.txt'); - const resource2 = URI.file('/path/other.txt'); - - let createdCounter = 0; - let resolvedCounter = 0; - let gotDirtyCounter = 0; - let gotNonDirtyCounter = 0; - let revertedCounter = 0; - let savedCounter = 0; - - manager.onDidCreate(workingCopy => { - createdCounter++; - }); - - manager.onDidResolve(workingCopy => { - if (workingCopy.resource.toString() === resource1.toString()) { - resolvedCounter++; - } - }); - - manager.onDidChangeDirty(workingCopy => { - if (workingCopy.resource.toString() === resource1.toString()) { - if (workingCopy.isDirty()) { - gotDirtyCounter++; - } else { - gotNonDirtyCounter++; - } - } - }); - - manager.onDidRevert(workingCopy => { - if (workingCopy.resource.toString() === resource1.toString()) { - revertedCounter++; - } - }); - - manager.onDidSave(({ workingCopy }) => { - if (workingCopy.resource.toString() === resource1.toString()) { - savedCounter++; - } - }); - - const workingCopy1 = await manager.resolve(resource1); - assert.strictEqual(resolvedCounter, 1); - assert.strictEqual(createdCounter, 1); - - accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: resource1, type: FileChangeType.DELETED }], false)); - accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: resource1, type: FileChangeType.ADDED }], false)); - - const workingCopy2 = await manager.resolve(resource2); - assert.strictEqual(resolvedCounter, 2); - assert.strictEqual(createdCounter, 2); - - workingCopy1.model?.updateContents('changed'); - - await workingCopy1.revert(); - workingCopy1.model?.updateContents('changed again'); - - await workingCopy1.save(); - workingCopy1.dispose(); - workingCopy2.dispose(); - - await workingCopy1.revert(); - assert.strictEqual(gotDirtyCounter, 2); - assert.strictEqual(gotNonDirtyCounter, 2); - assert.strictEqual(revertedCounter, 1); - assert.strictEqual(savedCounter, 1); - assert.strictEqual(createdCounter, 2); - - workingCopy1.dispose(); - workingCopy2.dispose(); - }); - - test('resolve registers as working copy and dispose clears', async () => { - const resource1 = URI.file('/test1.html'); - const resource2 = URI.file('/test2.html'); - const resource3 = URI.file('/test3.html'); - - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - - const firstPromise = manager.resolve(resource1); - const secondPromise = manager.resolve(resource2); - const thirdPromise = manager.resolve(resource3); - - await Promise.all([firstPromise, secondPromise, thirdPromise]); - - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); - assert.strictEqual(manager.workingCopies.length, 3); - - manager.dispose(); - - assert.strictEqual(manager.workingCopies.length, 0); - - // dispose does not remove from working copy service, only `destroy` should - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); + fileWorkingCopy.dispose(); + untitledFileWorkingCopy.dispose(); }); test('destroy', async () => { - const resource1 = URI.file('/test1.html'); - const resource2 = URI.file('/test2.html'); - const resource3 = URI.file('/test3.html'); - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - const firstPromise = manager.resolve(resource1); - const secondPromise = manager.resolve(resource2); - const thirdPromise = manager.resolve(resource3); + await manager.resolve(URI.file('/test.html')); + await manager.resolve({ contents: bufferToStream(VSBuffer.fromString('Hello Untitled')) }); - await Promise.all([firstPromise, secondPromise, thirdPromise]); - - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); - assert.strictEqual(manager.workingCopies.length, 3); + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 2); + assert.strictEqual(manager.stored.workingCopies.length, 1); + assert.strictEqual(manager.untitled.workingCopies.length, 1); await manager.destroy(); assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - assert.strictEqual(manager.workingCopies.length, 0); + assert.strictEqual(manager.stored.workingCopies.length, 0); + assert.strictEqual(manager.untitled.workingCopies.length, 0); }); - test('destroy saves dirty working copies', async () => { - const resource = URI.file('/path/source.txt'); - - const workingCopy = await manager.resolve(resource); - - let saved = false; - workingCopy.onDidSave(() => { - saved = true; - }); - - workingCopy.model?.updateContents('hello create'); - assert.strictEqual(workingCopy.isDirty(), true); - - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 1); - assert.strictEqual(manager.workingCopies.length, 1); - - await manager.destroy(); - - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - assert.strictEqual(manager.workingCopies.length, 0); - - assert.strictEqual(saved, true); - }); - - test('destroy falls back to using backup when save fails', async () => { - const resource = URI.file('/path/source.txt'); - - const workingCopy = await manager.resolve(resource); - workingCopy.model?.setThrowOnSnapshot(); - - let unexpectedSave = false; - workingCopy.onDidSave(() => { - unexpectedSave = true; - }); - - workingCopy.model?.updateContents('hello create'); - assert.strictEqual(workingCopy.isDirty(), true); - - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 1); - assert.strictEqual(manager.workingCopies.length, 1); - - assert.strictEqual(accessor.workingCopyBackupService.resolved.has(workingCopy), true); - - await manager.destroy(); - - assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - assert.strictEqual(manager.workingCopies.length, 0); - - assert.strictEqual(unexpectedSave, false); - }); - - test('file change event triggers working copy resolve', async () => { - const resource = URI.file('/path/index.txt'); - - const workingCopy = await manager.resolve(resource); - - let didResolve = false; - const onDidResolve = new Promise(resolve => { - manager.onDidResolve(() => { - if (workingCopy.resource.toString() === resource.toString()) { - didResolve = true; - resolve(); - } - }); - }); - - accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.UPDATED }], false)); - - await onDidResolve; - - assert.strictEqual(didResolve, true); - }); - - test('working copy file event handling: create', async () => { - const resource = URI.file('/path/source.txt'); - - const workingCopy = await manager.resolve(resource); - workingCopy.model?.updateContents('hello create'); - assert.strictEqual(workingCopy.isDirty(), true); - - await accessor.workingCopyFileService.create([{ resource }], CancellationToken.None); - assert.strictEqual(workingCopy.isDirty(), false); - }); - - test('working copy file event handling: move', () => { - return testMoveCopyFileWorkingCopy(true); - }); - - test('working copy file event handling: copy', () => { - return testMoveCopyFileWorkingCopy(false); - }); - - async function testMoveCopyFileWorkingCopy(move: boolean) { - const source = URI.file('/path/source.txt'); - const target = URI.file('/path/other.txt'); - - const sourceWorkingCopy = await manager.resolve(source); - sourceWorkingCopy.model?.updateContents('hello move or copy'); - assert.strictEqual(sourceWorkingCopy.isDirty(), true); - - if (move) { - await accessor.workingCopyFileService.move([{ file: { source, target } }], CancellationToken.None); - } else { - await accessor.workingCopyFileService.copy([{ file: { source, target } }], CancellationToken.None); - } - - const targetWorkingCopy = await manager.resolve(target); - assert.strictEqual(targetWorkingCopy.isDirty(), true); - assert.strictEqual(targetWorkingCopy.model?.contents, 'hello move or copy'); - } - - test('working copy file event handling: delete', async () => { - const resource = URI.file('/path/source.txt'); - - const workingCopy = await manager.resolve(resource); - workingCopy.model?.updateContents('hello delete'); - assert.strictEqual(workingCopy.isDirty(), true); - - await accessor.workingCopyFileService.delete([{ resource }], CancellationToken.None); - assert.strictEqual(workingCopy.isDirty(), false); - }); - - test('working copy file event handling: move to same resource', async () => { + test('saveAs - file (same target, unresolved source, unresolved target)', () => { const source = URI.file('/path/source.txt'); - const sourceWorkingCopy = await manager.resolve(source); - sourceWorkingCopy.model?.updateContents('hello move'); - assert.strictEqual(sourceWorkingCopy.isDirty(), true); - - await accessor.workingCopyFileService.move([{ file: { source, target: source } }], CancellationToken.None); - - assert.strictEqual(sourceWorkingCopy.isDirty(), true); - assert.strictEqual(sourceWorkingCopy.model?.contents, 'hello move'); + return testSaveAsFile(source, source, false, false); }); - // saveAs: unresolved source, unresolved target - - test('saveAs (same target, unresolved source, unresolved target)', () => { - const source = URI.file('/path/source.txt'); - - return testSaveAs(source, source, false, false); - }); - - test('saveAs (same target, different case, unresolved source, unresolved target)', async () => { + test('saveAs - file (same target, different case, unresolved source, unresolved target)', async () => { const source = URI.file('/path/source.txt'); const target = URI.file('/path/SOURCE.txt'); - return testSaveAs(source, target, false, false); + return testSaveAsFile(source, target, false, false); }); - test('saveAs (different target, unresolved source, unresolved target)', async () => { + test('saveAs - file (different target, unresolved source, unresolved target)', async () => { const source = URI.file('/path/source.txt'); const target = URI.file('/path/target.txt'); - return testSaveAs(source, target, false, false); + return testSaveAsFile(source, target, false, false); }); - // saveAs: resolved source, unresolved target - - test('saveAs (same target, resolved source, unresolved target)', () => { + test('saveAs - file (same target, resolved source, unresolved target)', () => { const source = URI.file('/path/source.txt'); - return testSaveAs(source, source, true, false); + return testSaveAsFile(source, source, true, false); }); - test('saveAs (same target, different case, resolved source, unresolved target)', async () => { + test('saveAs - file (same target, different case, resolved source, unresolved target)', async () => { const source = URI.file('/path/source.txt'); const target = URI.file('/path/SOURCE.txt'); - return testSaveAs(source, target, true, false); + return testSaveAsFile(source, target, true, false); }); - test('saveAs (different target, resolved source, unresolved target)', async () => { + test('saveAs - file (different target, resolved source, unresolved target)', async () => { const source = URI.file('/path/source.txt'); const target = URI.file('/path/target.txt'); - return testSaveAs(source, target, true, false); + return testSaveAsFile(source, target, true, false); }); - // saveAs: unresolved source, resolved target - - test('saveAs (same target, unresolved source, resolved target)', () => { + test('saveAs - file (same target, unresolved source, resolved target)', () => { const source = URI.file('/path/source.txt'); - return testSaveAs(source, source, false, true); + return testSaveAsFile(source, source, false, true); }); - test('saveAs (same target, different case, unresolved source, resolved target)', async () => { + test('saveAs - file (same target, different case, unresolved source, resolved target)', async () => { const source = URI.file('/path/source.txt'); const target = URI.file('/path/SOURCE.txt'); - return testSaveAs(source, target, false, true); + return testSaveAsFile(source, target, false, true); }); - test('saveAs (different target, unresolved source, resolved target)', async () => { + test('saveAs - file (different target, unresolved source, resolved target)', async () => { const source = URI.file('/path/source.txt'); const target = URI.file('/path/target.txt'); - return testSaveAs(source, target, false, true); + return testSaveAsFile(source, target, false, true); }); - // saveAs: resolved source, resolved target - - test('saveAs (same target, resolved source, resolved target)', () => { + test('saveAs - file (same target, resolved source, resolved target)', () => { const source = URI.file('/path/source.txt'); - return testSaveAs(source, source, true, true); + return testSaveAsFile(source, source, true, true); }); - test('saveAs (different target, resolved source, resolved target)', async () => { + test('saveAs - file (different target, resolved source, resolved target)', async () => { const source = URI.file('/path/source.txt'); const target = URI.file('/path/target.txt'); - return testSaveAs(source, target, true, true); + return testSaveAsFile(source, target, true, true); }); - async function testSaveAs(source: URI, target: URI, resolveSource: boolean, resolveTarget: boolean) { - let sourceWorkingCopy: IFileWorkingCopy | undefined = undefined; + async function testSaveAsFile(source: URI, target: URI, resolveSource: boolean, resolveTarget: boolean) { + let sourceWorkingCopy: IStoredFileWorkingCopy | undefined = undefined; if (resolveSource) { sourceWorkingCopy = await manager.resolve(source); sourceWorkingCopy.model?.updateContents('hello world'); assert.ok(sourceWorkingCopy.isDirty()); } - let targetWorkingCopy: IFileWorkingCopy | undefined = undefined; + let targetWorkingCopy: IStoredFileWorkingCopy | undefined = undefined; if (resolveTarget) { targetWorkingCopy = await manager.resolve(target); targetWorkingCopy.model?.updateContents('hello world'); @@ -527,7 +201,15 @@ suite('FileWorkingCopyManager', () => { // the same in that case assert.strictEqual(source.toString(), result?.resource.toString()); } else { - assert.strictEqual(target.toString(), result?.resource.toString()); + if (resolveSource || resolveTarget) { + assert.strictEqual(target.toString(), result?.resource.toString()); + } else { + if (accessor.uriIdentityService.extUri.isEqual(source, target)) { + assert.strictEqual(undefined, result); + } else { + assert.strictEqual(target.toString(), result?.resource.toString()); + } + } } if (resolveSource) { @@ -539,58 +221,56 @@ suite('FileWorkingCopyManager', () => { } } - test('canDispose with dirty working copy', async () => { - const resource = URI.file('/path/index_something.txt'); + test('saveAs - untitled (without associated resource)', async () => { + const workingCopy = await manager.resolve(); + workingCopy.model?.updateContents('Simple Save As'); - const workingCopy = await manager.resolve(resource); - workingCopy.model?.updateContents('make dirty'); + const target = URI.file('simple/file.txt'); + accessor.fileDialogService.setPickFileToSave(target); - let canDisposePromise = manager.canDispose(workingCopy); - assert.ok(canDisposePromise instanceof Promise); + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result?.resource.toString(), target.toString()); - let canDispose = false; - (async () => { - canDispose = await canDisposePromise; - })(); + assert.strictEqual((result?.model as TestStoredFileWorkingCopyModel).contents, 'Simple Save As'); - assert.strictEqual(canDispose, false); - workingCopy.revert({ soft: true }); + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); - await timeout(0); - - assert.strictEqual(canDispose, true); - - let canDispose2 = manager.canDispose(workingCopy); - assert.strictEqual(canDispose2, true); + workingCopy.dispose(); }); - test('pending saves join on shutdown', async () => { - const resource1 = URI.file('/path/index_something1.txt'); - const resource2 = URI.file('/path/index_something2.txt'); + test('saveAs - untitled (with associated resource)', async () => { + const workingCopy = await manager.resolve({ associatedResource: { path: '/some/associated.txt' } }); + workingCopy.model?.updateContents('Simple Save As with associated resource'); - const workingCopy1 = await manager.resolve(resource1); - workingCopy1.model?.updateContents('make dirty'); + const target = URI.from({ scheme: Schemas.vscodeRemote, path: '/some/associated.txt' }); - const workingCopy2 = await manager.resolve(resource2); - workingCopy2.model?.updateContents('make dirty'); + accessor.fileService.notExistsSet.set(target, true); - let saved1 = false; - workingCopy1.save().then(() => { - saved1 = true; - }); + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result?.resource.toString(), target.toString()); - let saved2 = false; - workingCopy2.save().then(() => { - saved2 = true; - }); + assert.strictEqual((result?.model as TestStoredFileWorkingCopyModel).contents, 'Simple Save As with associated resource'); - const event = new TestWillShutdownEvent(); - accessor.lifecycleService.fireWillShutdown(event); + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); - assert.ok(event.value.length > 0); - await Promise.all(event.value); + workingCopy.dispose(); + }); - assert.strictEqual(saved1, true); - assert.strictEqual(saved2, true); + test('saveAs - untitled (target exists and is resolved)', async () => { + const workingCopy = await manager.resolve(); + workingCopy.model?.updateContents('Simple Save As'); + + const target = URI.file('simple/file.txt'); + const targetFileWorkingCopy = await manager.resolve(target); + accessor.fileDialogService.setPickFileToSave(target); + + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result, targetFileWorkingCopy); + + assert.strictEqual((result?.model as TestStoredFileWorkingCopyModel).contents, 'Simple Save As'); + + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); + + workingCopy.dispose(); }); }); diff --git a/src/vs/workbench/services/workingCopy/test/browser/resourceWorkingCopyTest.ts b/src/vs/workbench/services/workingCopy/test/browser/resourceWorkingCopyTest.ts index 66376a44b79..e3fdc5f46b7 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/resourceWorkingCopyTest.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/resourceWorkingCopyTest.ts @@ -53,7 +53,7 @@ suite('ResourceWorkingCopy', function () { assert.strictEqual(workingCopy.isOrphaned(), false); let onDidChangeOrphanedPromise = Event.toPromise(workingCopy.onDidChangeOrphaned); - accessor.fileService.notExistsSet.add(resource); + accessor.fileService.notExistsSet.set(resource, true); accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }], false)); await onDidChangeOrphanedPromise; diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopy.test.ts similarity index 75% rename from src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts rename to src/vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopy.test.ts index 3c0028841b7..37a28f22ecf 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopy.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import { Event, Emitter } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; -import { FileWorkingCopy, FileWorkingCopyState, IFileWorkingCopyModel, IFileWorkingCopyModelContentChangedEvent, IFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { StoredFileWorkingCopy, StoredFileWorkingCopyState, IStoredFileWorkingCopyModel, IStoredFileWorkingCopyModelContentChangedEvent, IStoredFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy'; import { bufferToStream, newWriteableBufferStream, streamToBuffer, VSBuffer, VSBufferReadableStream } from 'vs/base/common/buffer'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Disposable } from 'vs/base/common/lifecycle'; @@ -18,9 +18,9 @@ import { SaveReason } from 'vs/workbench/common/editor'; import { Promises } from 'vs/base/common/async'; import { consumeReadable, consumeStream, isReadableStream } from 'vs/base/common/stream'; -export class TestFileWorkingCopyModel extends Disposable implements IFileWorkingCopyModel { +export class TestStoredFileWorkingCopyModel extends Disposable implements IStoredFileWorkingCopyModel { - private readonly _onDidChangeContent = this._register(new Emitter()); + private readonly _onDidChangeContent = this._register(new Emitter()); readonly onDidChangeContent = this._onDidChangeContent.event; private readonly _onWillDispose = this._register(new Emitter()); @@ -30,7 +30,7 @@ export class TestFileWorkingCopyModel extends Disposable implements IFileWorking super(); } - fireContentChangeEvent(event: IFileWorkingCopyModelContentChangedEvent): void { + fireContentChangeEvent(event: IStoredFileWorkingCopyModelContentChangedEvent): void { this._onDidChangeContent.fire(event); } @@ -81,24 +81,24 @@ export class TestFileWorkingCopyModel extends Disposable implements IFileWorking } } -export class TestFileWorkingCopyModelFactory implements IFileWorkingCopyModelFactory { +export class TestStoredFileWorkingCopyModelFactory implements IStoredFileWorkingCopyModelFactory { - async createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise { - return new TestFileWorkingCopyModel(resource, (await streamToBuffer(contents)).toString()); + async createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise { + return new TestStoredFileWorkingCopyModel(resource, (await streamToBuffer(contents)).toString()); } } -suite('FileWorkingCopy', function () { +suite('StoredFileWorkingCopy', function () { - const factory = new TestFileWorkingCopyModelFactory(); + const factory = new TestStoredFileWorkingCopyModelFactory(); let resource = URI.file('test/resource'); let instantiationService: IInstantiationService; let accessor: TestServiceAccessor; - let workingCopy: FileWorkingCopy; + let workingCopy: StoredFileWorkingCopy; function createWorkingCopy(uri: URI = resource) { - return new FileWorkingCopy('testWorkingCopyType', uri, basename(uri), factory, accessor.fileService, accessor.logService, accessor.textFileService, accessor.filesConfigurationService, accessor.workingCopyBackupService, accessor.workingCopyService, accessor.notificationService, accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService); + return new StoredFileWorkingCopy('testStoredFileWorkingCopyType', uri, basename(uri), factory, accessor.fileService, accessor.logService, accessor.textFileService, accessor.filesConfigurationService, accessor.workingCopyBackupService, accessor.workingCopyService, accessor.notificationService, accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService); } setup(() => { @@ -112,31 +112,39 @@ suite('FileWorkingCopy', function () { workingCopy.dispose(); }); + test('registers with working copy service', async () => { + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 1); + + workingCopy.dispose(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + }); + test('requires good file system URI', async () => { assert.throws(() => createWorkingCopy(URI.from({ scheme: 'unknown', path: 'somePath' }))); }); test('orphaned tracking', async () => { - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), false); let onDidChangeOrphanedPromise = Event.toPromise(workingCopy.onDidChangeOrphaned); - accessor.fileService.notExistsSet.add(resource); + accessor.fileService.notExistsSet.set(resource, true); accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }], false)); await onDidChangeOrphanedPromise; - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), true); onDidChangeOrphanedPromise = Event.toPromise(workingCopy.onDidChangeOrphaned); accessor.fileService.notExistsSet.delete(resource); accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.ADDED }], false)); await onDidChangeOrphanedPromise; - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), false); }); test('dirty', async () => { assert.strictEqual(workingCopy.isDirty(), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), false); await workingCopy.resolve(); assert.strictEqual(workingCopy.isResolved(), true); @@ -156,13 +164,13 @@ suite('FileWorkingCopy', function () { assert.strictEqual(contentChangeCounter, 1); assert.strictEqual(workingCopy.isDirty(), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), true); assert.strictEqual(changeDirtyCounter, 1); await workingCopy.save(); assert.strictEqual(workingCopy.isDirty(), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), false); assert.strictEqual(changeDirtyCounter, 2); // Dirty from: Initial contents @@ -170,26 +178,26 @@ suite('FileWorkingCopy', function () { assert.strictEqual(contentChangeCounter, 2); // content of model did not change assert.strictEqual(workingCopy.isDirty(), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), true); assert.strictEqual(changeDirtyCounter, 3); await workingCopy.revert({ soft: true }); assert.strictEqual(workingCopy.isDirty(), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), false); assert.strictEqual(changeDirtyCounter, 4); // Dirty from: API workingCopy.markDirty(); assert.strictEqual(workingCopy.isDirty(), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), true); assert.strictEqual(changeDirtyCounter, 5); await workingCopy.revert(); assert.strictEqual(workingCopy.isDirty(), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), false); assert.strictEqual(changeDirtyCounter, 6); }); @@ -283,11 +291,11 @@ suite('FileWorkingCopy', function () { const orphanedPromise = Event.toPromise(workingCopy.onDidChangeOrphaned); - accessor.fileService.notExistsSet.add(resource); + accessor.fileService.notExistsSet.set(resource, true); accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }], false)); await orphanedPromise; - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), true); const backup = await workingCopy.backup(CancellationToken.None); await accessor.workingCopyBackupService.backup(workingCopy, backup.content, undefined, backup.meta); @@ -299,7 +307,7 @@ suite('FileWorkingCopy', function () { workingCopy = createWorkingCopy(); await workingCopy.resolve(); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), true); const backup2 = await workingCopy.backup(CancellationToken.None); assert.deepStrictEqual(backup.meta, backup2.meta); @@ -310,22 +318,22 @@ suite('FileWorkingCopy', function () { const orphanedPromise = Event.toPromise(workingCopy.onDidChangeOrphaned); - accessor.fileService.notExistsSet.add(resource); + accessor.fileService.notExistsSet.set(resource, true); accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }], false)); await orphanedPromise; - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), true); // resolving clears orphaned state when successful accessor.fileService.notExistsSet.delete(resource); await workingCopy.resolve({ forceReadFromFile: true }); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), false); // resolving adds orphaned state when fail to read try { accessor.fileService.readShouldThrowError = new FileOperationError('file not found', FileOperationResult.FILE_NOT_FOUND); await workingCopy.resolve(); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), true); } finally { accessor.fileService.readShouldThrowError = undefined; } @@ -456,17 +464,17 @@ suite('FileWorkingCopy', function () { // save clears orphaned const orphanedPromise = Event.toPromise(workingCopy.onDidChangeOrphaned); - accessor.fileService.notExistsSet.add(resource); + accessor.fileService.notExistsSet.set(resource, true); accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.DELETED }], false)); await orphanedPromise; - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), true); await workingCopy.save({ force: true }); assert.strictEqual(savedCounter, 6); assert.strictEqual(saveErrorCounter, 0); assert.strictEqual(workingCopy.isDirty(), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ORPHAN), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ORPHAN), false); }); test('save (errors)', async () => { @@ -487,38 +495,36 @@ suite('FileWorkingCopy', function () { accessor.fileService.writeShouldThrowError = new FileOperationError('write error', FileOperationResult.FILE_PERMISSION_DENIED); await workingCopy.save({ force: true }); - } catch (error) { - // error is expected } finally { accessor.fileService.writeShouldThrowError = undefined; } assert.strictEqual(savedCounter, 0); assert.strictEqual(saveErrorCounter, 1); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ERROR), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.CONFLICT), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ERROR), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.CONFLICT), false); assert.strictEqual(workingCopy.isDirty(), true); // save is a no-op unless forced when in error case await workingCopy.save({ reason: SaveReason.AUTO }); assert.strictEqual(savedCounter, 0); assert.strictEqual(saveErrorCounter, 1); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ERROR), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.CONFLICT), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ERROR), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.CONFLICT), false); assert.strictEqual(workingCopy.isDirty(), true); // save clears error flags when successful await workingCopy.save({ reason: SaveReason.EXPLICIT }); assert.strictEqual(savedCounter, 1); assert.strictEqual(saveErrorCounter, 1); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ERROR), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.CONFLICT), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ERROR), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.CONFLICT), false); assert.strictEqual(workingCopy.isDirty(), false); // save error: conflict @@ -534,23 +540,40 @@ suite('FileWorkingCopy', function () { assert.strictEqual(savedCounter, 1); assert.strictEqual(saveErrorCounter, 2); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ERROR), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.CONFLICT), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ERROR), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.CONFLICT), true); assert.strictEqual(workingCopy.isDirty(), true); // save clears error flags when successful await workingCopy.save({ reason: SaveReason.EXPLICIT }); assert.strictEqual(savedCounter, 2); assert.strictEqual(saveErrorCounter, 2); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.ERROR), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.CONFLICT), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.ERROR), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.CONFLICT), false); assert.strictEqual(workingCopy.isDirty(), false); }); + test('save (errors, bubbles up with `ignoreErrorHandler`)', async () => { + await workingCopy.resolve(); + + let error: Error | undefined = undefined; + try { + accessor.fileService.writeShouldThrowError = new FileOperationError('write error', FileOperationResult.FILE_PERMISSION_DENIED); + + await workingCopy.save({ force: true, ignoreErrorHandler: true }); + } catch (e) { + error = e; + } finally { + accessor.fileService.writeShouldThrowError = undefined; + } + + assert.ok(error); + }); + test('revert', async () => { await workingCopy.resolve(); workingCopy.model?.updateContents('hello revert'); @@ -609,34 +632,34 @@ suite('FileWorkingCopy', function () { }); test('state', async () => { - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), true); await workingCopy.resolve({ contents: bufferToStream(VSBuffer.fromString('hello state')) }); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), true); const savePromise = workingCopy.save(); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), true); await savePromise; - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), false); }); test('joinState', async () => { await workingCopy.resolve({ contents: bufferToStream(VSBuffer.fromString('hello state')) }); workingCopy.save(); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), true); - await workingCopy.joinState(FileWorkingCopyState.PENDING_SAVE); + await workingCopy.joinState(StoredFileWorkingCopyState.PENDING_SAVE); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.DIRTY), false); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.SAVED), true); - assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.PENDING_SAVE), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.DIRTY), false); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.SAVED), true); + assert.strictEqual(workingCopy.hasState(StoredFileWorkingCopyState.PENDING_SAVE), false); }); test('isReadonly, isResolved, dispose, isDisposed', async () => { diff --git a/src/vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopyManager.test.ts b/src/vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopyManager.test.ts new file mode 100644 index 00000000000..87afff8dd9e --- /dev/null +++ b/src/vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopyManager.test.ts @@ -0,0 +1,504 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from 'vs/base/common/uri'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { workbenchInstantiationService, TestServiceAccessor, TestWillShutdownEvent } from 'vs/workbench/test/browser/workbenchTestServices'; +import { StoredFileWorkingCopyManager, IStoredFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopyManager'; +import { IStoredFileWorkingCopy, IStoredFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy'; +import { bufferToStream, VSBuffer } from 'vs/base/common/buffer'; +import { FileChangesEvent, FileChangeType, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; +import { timeout } from 'vs/base/common/async'; +import { TestStoredFileWorkingCopyModel, TestStoredFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopy.test'; +import { CancellationToken } from 'vs/base/common/cancellation'; + +suite('StoredFileWorkingCopyManager', () => { + + let instantiationService: IInstantiationService; + let accessor: TestServiceAccessor; + + let manager: IStoredFileWorkingCopyManager; + + setup(() => { + instantiationService = workbenchInstantiationService(); + accessor = instantiationService.createInstance(TestServiceAccessor); + + manager = new StoredFileWorkingCopyManager( + 'testStoredFileWorkingCopyType', + new TestStoredFileWorkingCopyModelFactory(), + accessor.fileService, accessor.lifecycleService, accessor.labelService, accessor.logService, + accessor.workingCopyFileService, accessor.workingCopyBackupService, accessor.uriIdentityService, + accessor.textFileService, accessor.filesConfigurationService, accessor.workingCopyService, accessor.notificationService, + accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService + ); + }); + + teardown(() => { + manager.dispose(); + }); + + test('resolve', async () => { + const resource = URI.file('/test.html'); + + const events: IStoredFileWorkingCopy[] = []; + const listener = manager.onDidCreate(workingCopy => { + events.push(workingCopy); + }); + + const resolvePromise = manager.resolve(resource); + assert.ok(manager.get(resource)); // working copy known even before resolved() + assert.strictEqual(manager.workingCopies.length, 1); + + const workingCopy1 = await resolvePromise; + assert.ok(workingCopy1); + assert.ok(workingCopy1.model); + assert.strictEqual(workingCopy1.typeId, 'testStoredFileWorkingCopyType'); + assert.strictEqual(workingCopy1.resource.toString(), resource.toString()); + assert.strictEqual(manager.get(resource), workingCopy1); + + const workingCopy2 = await manager.resolve(resource); + assert.strictEqual(workingCopy2, workingCopy1); + assert.strictEqual(manager.workingCopies.length, 1); + workingCopy1.dispose(); + + const workingCopy3 = await manager.resolve(resource); + assert.notStrictEqual(workingCopy3, workingCopy2); + assert.strictEqual(manager.workingCopies.length, 1); + assert.strictEqual(manager.get(resource), workingCopy3); + workingCopy3.dispose(); + + assert.strictEqual(manager.workingCopies.length, 0); + + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].resource.toString(), workingCopy1.resource.toString()); + assert.strictEqual(events[1].resource.toString(), workingCopy2.resource.toString()); + + listener.dispose(); + + workingCopy1.dispose(); + workingCopy2.dispose(); + workingCopy3.dispose(); + }); + + test('resolve async', async () => { + const resource = URI.file('/path/index.txt'); + + const workingCopy = await manager.resolve(resource); + + let didResolve = false; + const onDidResolve = new Promise(resolve => { + manager.onDidResolve(() => { + if (workingCopy.resource.toString() === resource.toString()) { + didResolve = true; + resolve(); + } + }); + }); + + manager.resolve(resource, { reload: { async: true } }); + + await onDidResolve; + + assert.strictEqual(didResolve, true); + }); + + test('resolve with initial contents', async () => { + const resource = URI.file('/test.html'); + + const workingCopy = await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); + assert.strictEqual(workingCopy.model?.contents, 'Hello World'); + assert.strictEqual(workingCopy.isDirty(), true); + + await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('More Changes')) }); + assert.strictEqual(workingCopy.model?.contents, 'More Changes'); + assert.strictEqual(workingCopy.isDirty(), true); + + workingCopy.dispose(); + }); + + test('multiple resolves execute in sequence (same resources)', async () => { + const resource = URI.file('/test.html'); + + const firstPromise = manager.resolve(resource); + const secondPromise = manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); + const thirdPromise = manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('More Changes')) }); + + await firstPromise; + await secondPromise; + const workingCopy = await thirdPromise; + + assert.strictEqual(workingCopy.model?.contents, 'More Changes'); + assert.strictEqual(workingCopy.isDirty(), true); + + workingCopy.dispose(); + }); + + test('multiple resolves execute in parallel (different resources)', async () => { + const resource1 = URI.file('/test1.html'); + const resource2 = URI.file('/test2.html'); + const resource3 = URI.file('/test3.html'); + + const firstPromise = manager.resolve(resource1); + const secondPromise = manager.resolve(resource2); + const thirdPromise = manager.resolve(resource3); + + const [workingCopy1, workingCopy2, workingCopy3] = await Promise.all([firstPromise, secondPromise, thirdPromise]); + + assert.strictEqual(manager.workingCopies.length, 3); + assert.strictEqual(workingCopy1.resource.toString(), resource1.toString()); + assert.strictEqual(workingCopy2.resource.toString(), resource2.toString()); + assert.strictEqual(workingCopy3.resource.toString(), resource3.toString()); + + workingCopy1.dispose(); + workingCopy2.dispose(); + workingCopy3.dispose(); + }); + + test('removed from cache when working copy or model gets disposed', async () => { + const resource = URI.file('/test.html'); + + let workingCopy = await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); + + assert.strictEqual(manager.get(URI.file('/test.html')), workingCopy); + + workingCopy.dispose(); + assert(!manager.get(URI.file('/test.html'))); + + workingCopy = await manager.resolve(resource, { contents: bufferToStream(VSBuffer.fromString('Hello World')) }); + + assert.strictEqual(manager.get(URI.file('/test.html')), workingCopy); + + workingCopy.model?.dispose(); + assert(!manager.get(URI.file('/test.html'))); + }); + + test('events', async () => { + const resource1 = URI.file('/path/index.txt'); + const resource2 = URI.file('/path/other.txt'); + + let createdCounter = 0; + let resolvedCounter = 0; + let gotDirtyCounter = 0; + let gotNonDirtyCounter = 0; + let revertedCounter = 0; + let savedCounter = 0; + let saveErrorCounter = 0; + + manager.onDidCreate(workingCopy => { + createdCounter++; + }); + + manager.onDidResolve(workingCopy => { + if (workingCopy.resource.toString() === resource1.toString()) { + resolvedCounter++; + } + }); + + manager.onDidChangeDirty(workingCopy => { + if (workingCopy.resource.toString() === resource1.toString()) { + if (workingCopy.isDirty()) { + gotDirtyCounter++; + } else { + gotNonDirtyCounter++; + } + } + }); + + manager.onDidRevert(workingCopy => { + if (workingCopy.resource.toString() === resource1.toString()) { + revertedCounter++; + } + }); + + manager.onDidSave(({ workingCopy }) => { + if (workingCopy.resource.toString() === resource1.toString()) { + savedCounter++; + } + }); + + manager.onDidSaveError(workingCopy => { + if (workingCopy.resource.toString() === resource1.toString()) { + saveErrorCounter++; + } + }); + + const workingCopy1 = await manager.resolve(resource1); + assert.strictEqual(resolvedCounter, 1); + assert.strictEqual(createdCounter, 1); + + accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: resource1, type: FileChangeType.DELETED }], false)); + accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource: resource1, type: FileChangeType.ADDED }], false)); + + const workingCopy2 = await manager.resolve(resource2); + assert.strictEqual(resolvedCounter, 2); + assert.strictEqual(createdCounter, 2); + + workingCopy1.model?.updateContents('changed'); + + await workingCopy1.revert(); + workingCopy1.model?.updateContents('changed again'); + + await workingCopy1.save(); + + try { + accessor.fileService.writeShouldThrowError = new FileOperationError('write error', FileOperationResult.FILE_PERMISSION_DENIED); + + await workingCopy1.save({ force: true }); + } finally { + accessor.fileService.writeShouldThrowError = undefined; + } + + workingCopy1.dispose(); + workingCopy2.dispose(); + + await workingCopy1.revert(); + assert.strictEqual(gotDirtyCounter, 3); + assert.strictEqual(gotNonDirtyCounter, 2); + assert.strictEqual(revertedCounter, 1); + assert.strictEqual(savedCounter, 1); + assert.strictEqual(saveErrorCounter, 1); + assert.strictEqual(createdCounter, 2); + + workingCopy1.dispose(); + workingCopy2.dispose(); + }); + + test('resolve registers as working copy and dispose clears', async () => { + const resource1 = URI.file('/test1.html'); + const resource2 = URI.file('/test2.html'); + const resource3 = URI.file('/test3.html'); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + + const firstPromise = manager.resolve(resource1); + const secondPromise = manager.resolve(resource2); + const thirdPromise = manager.resolve(resource3); + + await Promise.all([firstPromise, secondPromise, thirdPromise]); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); + assert.strictEqual(manager.workingCopies.length, 3); + + manager.dispose(); + + assert.strictEqual(manager.workingCopies.length, 0); + + // dispose does not remove from working copy service, only `destroy` should + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); + }); + + test('destroy', async () => { + const resource1 = URI.file('/test1.html'); + const resource2 = URI.file('/test2.html'); + const resource3 = URI.file('/test3.html'); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + + const firstPromise = manager.resolve(resource1); + const secondPromise = manager.resolve(resource2); + const thirdPromise = manager.resolve(resource3); + + await Promise.all([firstPromise, secondPromise, thirdPromise]); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); + assert.strictEqual(manager.workingCopies.length, 3); + + await manager.destroy(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + assert.strictEqual(manager.workingCopies.length, 0); + }); + + test('destroy saves dirty working copies', async () => { + const resource = URI.file('/path/source.txt'); + + const workingCopy = await manager.resolve(resource); + + let saved = false; + workingCopy.onDidSave(() => { + saved = true; + }); + + workingCopy.model?.updateContents('hello create'); + assert.strictEqual(workingCopy.isDirty(), true); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 1); + assert.strictEqual(manager.workingCopies.length, 1); + + await manager.destroy(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + assert.strictEqual(manager.workingCopies.length, 0); + + assert.strictEqual(saved, true); + }); + + test('destroy falls back to using backup when save fails', async () => { + const resource = URI.file('/path/source.txt'); + + const workingCopy = await manager.resolve(resource); + workingCopy.model?.setThrowOnSnapshot(); + + let unexpectedSave = false; + workingCopy.onDidSave(() => { + unexpectedSave = true; + }); + + workingCopy.model?.updateContents('hello create'); + assert.strictEqual(workingCopy.isDirty(), true); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 1); + assert.strictEqual(manager.workingCopies.length, 1); + + assert.strictEqual(accessor.workingCopyBackupService.resolved.has(workingCopy), true); + + await manager.destroy(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + assert.strictEqual(manager.workingCopies.length, 0); + + assert.strictEqual(unexpectedSave, false); + }); + + test('file change event triggers working copy resolve', async () => { + const resource = URI.file('/path/index.txt'); + + const workingCopy = await manager.resolve(resource); + + let didResolve = false; + const onDidResolve = new Promise(resolve => { + manager.onDidResolve(() => { + if (workingCopy.resource.toString() === resource.toString()) { + didResolve = true; + resolve(); + } + }); + }); + + accessor.fileService.fireFileChanges(new FileChangesEvent([{ resource, type: FileChangeType.UPDATED }], false)); + + await onDidResolve; + + assert.strictEqual(didResolve, true); + }); + + test('working copy file event handling: create', async () => { + const resource = URI.file('/path/source.txt'); + + const workingCopy = await manager.resolve(resource); + workingCopy.model?.updateContents('hello create'); + assert.strictEqual(workingCopy.isDirty(), true); + + await accessor.workingCopyFileService.create([{ resource }], CancellationToken.None); + assert.strictEqual(workingCopy.isDirty(), false); + }); + + test('working copy file event handling: move', () => { + return testMoveCopyFileWorkingCopy(true); + }); + + test('working copy file event handling: copy', () => { + return testMoveCopyFileWorkingCopy(false); + }); + + async function testMoveCopyFileWorkingCopy(move: boolean) { + const source = URI.file('/path/source.txt'); + const target = URI.file('/path/other.txt'); + + const sourceWorkingCopy = await manager.resolve(source); + sourceWorkingCopy.model?.updateContents('hello move or copy'); + assert.strictEqual(sourceWorkingCopy.isDirty(), true); + + if (move) { + await accessor.workingCopyFileService.move([{ file: { source, target } }], CancellationToken.None); + } else { + await accessor.workingCopyFileService.copy([{ file: { source, target } }], CancellationToken.None); + } + + const targetWorkingCopy = await manager.resolve(target); + assert.strictEqual(targetWorkingCopy.isDirty(), true); + assert.strictEqual(targetWorkingCopy.model?.contents, 'hello move or copy'); + } + + test('working copy file event handling: delete', async () => { + const resource = URI.file('/path/source.txt'); + + const workingCopy = await manager.resolve(resource); + workingCopy.model?.updateContents('hello delete'); + assert.strictEqual(workingCopy.isDirty(), true); + + await accessor.workingCopyFileService.delete([{ resource }], CancellationToken.None); + assert.strictEqual(workingCopy.isDirty(), false); + }); + + test('working copy file event handling: move to same resource', async () => { + const source = URI.file('/path/source.txt'); + + const sourceWorkingCopy = await manager.resolve(source); + sourceWorkingCopy.model?.updateContents('hello move'); + assert.strictEqual(sourceWorkingCopy.isDirty(), true); + + await accessor.workingCopyFileService.move([{ file: { source, target: source } }], CancellationToken.None); + + assert.strictEqual(sourceWorkingCopy.isDirty(), true); + assert.strictEqual(sourceWorkingCopy.model?.contents, 'hello move'); + }); + + test('canDispose with dirty working copy', async () => { + const resource = URI.file('/path/index_something.txt'); + + const workingCopy = await manager.resolve(resource); + workingCopy.model?.updateContents('make dirty'); + + let canDisposePromise = manager.canDispose(workingCopy); + assert.ok(canDisposePromise instanceof Promise); + + let canDispose = false; + (async () => { + canDispose = await canDisposePromise; + })(); + + assert.strictEqual(canDispose, false); + workingCopy.revert({ soft: true }); + + await timeout(0); + + assert.strictEqual(canDispose, true); + + let canDispose2 = manager.canDispose(workingCopy); + assert.strictEqual(canDispose2, true); + }); + + test('pending saves join on shutdown', async () => { + const resource1 = URI.file('/path/index_something1.txt'); + const resource2 = URI.file('/path/index_something2.txt'); + + const workingCopy1 = await manager.resolve(resource1); + workingCopy1.model?.updateContents('make dirty'); + + const workingCopy2 = await manager.resolve(resource2); + workingCopy2.model?.updateContents('make dirty'); + + let saved1 = false; + workingCopy1.save().then(() => { + saved1 = true; + }); + + let saved2 = false; + workingCopy2.save().then(() => { + saved2 = true; + }); + + const event = new TestWillShutdownEvent(); + accessor.lifecycleService.fireWillShutdown(event); + + assert.ok(event.value.length > 0); + await Promise.all(event.value); + + assert.strictEqual(saved1, true); + assert.strictEqual(saved2, true); + }); +}); diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts new file mode 100644 index 00000000000..16219cd5a69 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts @@ -0,0 +1,312 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { VSBufferReadableStream, newWriteableBufferStream, VSBuffer, streamToBuffer, bufferToStream } from 'vs/base/common/buffer'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { Schemas } from 'vs/base/common/network'; +import { basename } from 'vs/base/common/resources'; +import { consumeReadable, consumeStream, isReadableStream } from 'vs/base/common/stream'; +import { URI } from 'vs/base/common/uri'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelContentChangedEvent, IUntitledFileWorkingCopyModelFactory, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; +import { TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; + +export class TestUntitledFileWorkingCopyModel extends Disposable implements IUntitledFileWorkingCopyModel { + + private readonly _onDidChangeContent = this._register(new Emitter()); + readonly onDidChangeContent = this._onDidChangeContent.event; + + private readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose = this._onWillDispose.event; + + constructor(readonly resource: URI, public contents: string) { + super(); + } + + fireContentChangeEvent(event: IUntitledFileWorkingCopyModelContentChangedEvent): void { + this._onDidChangeContent.fire(event); + } + + updateContents(newContents: string): void { + this.doUpdate(newContents); + } + + private throwOnSnapshot = false; + setThrowOnSnapshot(): void { + this.throwOnSnapshot = true; + } + + async snapshot(token: CancellationToken): Promise { + if (this.throwOnSnapshot) { + throw new Error('Fail'); + } + + const stream = newWriteableBufferStream(); + stream.end(VSBuffer.fromString(this.contents)); + + return stream; + } + + async update(contents: VSBufferReadableStream, token: CancellationToken): Promise { + this.doUpdate((await streamToBuffer(contents)).toString()); + } + + private doUpdate(newContents: string): void { + this.contents = newContents; + + this.versionId++; + + this._onDidChangeContent.fire({ isEmpty: newContents.length === 0 }); + } + + versionId = 0; + + pushedStackElement = false; + + pushStackElement(): void { + this.pushedStackElement = true; + } + + override dispose(): void { + this._onWillDispose.fire(); + + super.dispose(); + } +} + +export class TestUntitledFileWorkingCopyModelFactory implements IUntitledFileWorkingCopyModelFactory { + + async createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise { + return new TestUntitledFileWorkingCopyModel(resource, (await streamToBuffer(contents)).toString()); + } +} + +suite('UntitledFileWorkingCopy', () => { + + const factory = new TestUntitledFileWorkingCopyModelFactory(); + + let resource = URI.from({ scheme: Schemas.untitled, path: 'Untitled-1' }); + let instantiationService: IInstantiationService; + let accessor: TestServiceAccessor; + let workingCopy: UntitledFileWorkingCopy; + + function createWorkingCopy(uri: URI = resource, hasAssociatedFilePath = false, initialValue = '') { + return new UntitledFileWorkingCopy( + 'testUntitledWorkingCopyType', + uri, + basename(uri), + hasAssociatedFilePath, + initialValue.length > 0 ? bufferToStream(VSBuffer.fromString(initialValue)) : undefined, + factory, + async workingCopy => { await workingCopy.revert(); return true; }, + accessor.workingCopyService, + accessor.workingCopyBackupService, + accessor.logService + ); + } + + setup(() => { + instantiationService = workbenchInstantiationService(); + accessor = instantiationService.createInstance(TestServiceAccessor); + + workingCopy = createWorkingCopy(); + }); + + teardown(() => { + workingCopy.dispose(); + }); + + test('registers with working copy service', async () => { + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 1); + + workingCopy.dispose(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + }); + + test('requires good untitled URI', async () => { + assert.throws(() => createWorkingCopy(URI.from({ scheme: 'unknown', path: 'somePath' }))); + }); + + test('dirty', async () => { + assert.strictEqual(workingCopy.isDirty(), false); + + let changeDirtyCounter = 0; + workingCopy.onDidChangeDirty(() => { + changeDirtyCounter++; + }); + + let contentChangeCounter = 0; + workingCopy.onDidChangeContent(() => { + contentChangeCounter++; + }); + + await workingCopy.resolve(); + assert.strictEqual(workingCopy.isResolved(), true); + + // Dirty from: Model content change + workingCopy.model?.updateContents('hello dirty'); + assert.strictEqual(contentChangeCounter, 1); + + assert.strictEqual(workingCopy.isDirty(), true); + assert.strictEqual(changeDirtyCounter, 1); + + await workingCopy.save(); + + assert.strictEqual(workingCopy.isDirty(), false); + assert.strictEqual(changeDirtyCounter, 2); + }); + + test('dirty - cleared when content event signals isEmpty', async () => { + assert.strictEqual(workingCopy.isDirty(), false); + + await workingCopy.resolve(); + + workingCopy.model?.updateContents('hello dirty'); + assert.strictEqual(workingCopy.isDirty(), true); + + workingCopy.model?.fireContentChangeEvent({ isEmpty: true }); + + assert.strictEqual(workingCopy.isDirty(), false); + }); + + test('dirty - not cleared when content event signals isEmpty when associated resource', async () => { + workingCopy.dispose(); + workingCopy = createWorkingCopy(resource, true); + + await workingCopy.resolve(); + + workingCopy.model?.updateContents('hello dirty'); + assert.strictEqual(workingCopy.isDirty(), true); + + workingCopy.model?.fireContentChangeEvent({ isEmpty: true }); + + assert.strictEqual(workingCopy.isDirty(), true); + }); + + test('revert', async () => { + let revertCounter = 0; + workingCopy.onDidRevert(() => { + revertCounter++; + }); + + let disposeCounter = 0; + workingCopy.onWillDispose(() => { + disposeCounter++; + }); + + await workingCopy.resolve(); + + workingCopy.model?.updateContents('hello dirty'); + assert.strictEqual(workingCopy.isDirty(), true); + + await workingCopy.revert(); + + assert.strictEqual(revertCounter, 1); + assert.strictEqual(disposeCounter, 1); + assert.strictEqual(workingCopy.isDirty(), false); + }); + + test('dispose', async () => { + let disposeCounter = 0; + workingCopy.onWillDispose(() => { + disposeCounter++; + }); + + await workingCopy.resolve(); + workingCopy.dispose(); + + assert.strictEqual(disposeCounter, 1); + }); + + test('backup', async () => { + assert.strictEqual((await workingCopy.backup(CancellationToken.None)).content, undefined); + + await workingCopy.resolve(); + + workingCopy.model?.updateContents('Hello Backup'); + const backup = await workingCopy.backup(CancellationToken.None); + + let backupContents: string | undefined = undefined; + if (isReadableStream(backup.content)) { + backupContents = (await consumeStream(backup.content, chunks => VSBuffer.concat(chunks))).toString(); + } else if (backup.content) { + backupContents = consumeReadable(backup.content, chunks => VSBuffer.concat(chunks)).toString(); + } + + assert.strictEqual(backupContents, 'Hello Backup'); + }); + + test('resolve - without contents', async () => { + assert.strictEqual(workingCopy.isResolved(), false); + assert.strictEqual(workingCopy.hasAssociatedFilePath, false); + assert.strictEqual(workingCopy.model, undefined); + + await workingCopy.resolve(); + + assert.strictEqual(workingCopy.isResolved(), true); + assert.ok(workingCopy.model); + }); + + test('resolve - with initial contents', async () => { + workingCopy.dispose(); + + workingCopy = createWorkingCopy(resource, false, 'Hello Initial'); + + let contentChangeCounter = 0; + workingCopy.onDidChangeContent(() => { + contentChangeCounter++; + }); + + await workingCopy.resolve(); + + assert.strictEqual(workingCopy.isDirty(), true); + assert.strictEqual(workingCopy.model?.contents, 'Hello Initial'); + assert.strictEqual(contentChangeCounter, 1); + + workingCopy.model.updateContents('Changed contents'); + + await workingCopy.resolve(); // second resolve should be ignored + assert.strictEqual(workingCopy.model?.contents, 'Changed contents'); + }); + + test('resolve - with associated resource', async () => { + workingCopy.dispose(); + workingCopy = createWorkingCopy(resource, true); + + await workingCopy.resolve(); + + assert.strictEqual(workingCopy.isDirty(), true); + assert.strictEqual(workingCopy.hasAssociatedFilePath, true); + }); + + test('resolve - with backup', async () => { + await workingCopy.resolve(); + workingCopy.model?.updateContents('Hello Backup'); + + const backup = await workingCopy.backup(CancellationToken.None); + await accessor.workingCopyBackupService.backup(workingCopy, backup.content, undefined, backup.meta); + + assert.strictEqual(accessor.workingCopyBackupService.hasBackupSync(workingCopy), true); + + workingCopy.dispose(); + + workingCopy = createWorkingCopy(); + + let contentChangeCounter = 0; + workingCopy.onDidChangeContent(() => { + contentChangeCounter++; + }); + + await workingCopy.resolve(); + + assert.strictEqual(workingCopy.isDirty(), true); + assert.strictEqual(workingCopy.model?.contents, 'Hello Backup'); + assert.strictEqual(contentChangeCounter, 1); + }); +}); diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts new file mode 100644 index 00000000000..0838e0c74d3 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { bufferToStream, VSBuffer } from 'vs/base/common/buffer'; +import { Schemas } from 'vs/base/common/network'; +import { URI } from 'vs/base/common/uri'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { FileWorkingCopyManager, IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; +import { WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; +import { TestStoredFileWorkingCopyModel, TestStoredFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/storedFileWorkingCopy.test'; +import { TestUntitledFileWorkingCopyModel, TestUntitledFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test'; +import { TestInMemoryFileSystemProvider, TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; + +suite('UntitledFileWorkingCopyManager', () => { + + let instantiationService: IInstantiationService; + let accessor: TestServiceAccessor; + + let manager: IFileWorkingCopyManager; + + setup(() => { + instantiationService = workbenchInstantiationService(); + accessor = instantiationService.createInstance(TestServiceAccessor); + + accessor.fileService.registerProvider(Schemas.file, new TestInMemoryFileSystemProvider()); + accessor.fileService.registerProvider(Schemas.vscodeRemote, new TestInMemoryFileSystemProvider()); + + manager = new FileWorkingCopyManager( + 'testUntitledFileWorkingCopyType', + new TestStoredFileWorkingCopyModelFactory(), + new TestUntitledFileWorkingCopyModelFactory(), + accessor.fileService, accessor.lifecycleService, accessor.labelService, accessor.logService, + accessor.workingCopyFileService, accessor.workingCopyBackupService, accessor.uriIdentityService, accessor.fileDialogService, + accessor.textFileService, accessor.filesConfigurationService, accessor.workingCopyService, accessor.notificationService, + accessor.workingCopyEditorService, accessor.editorService, accessor.elevatedFileService, accessor.pathService, + accessor.environmentService, accessor.dialogService + ); + }); + + teardown(() => { + manager.dispose(); + }); + + test('basics', async () => { + let createCounter = 0; + manager.untitled.onDidCreate(e => { + createCounter++; + }); + + let disposeCounter = 0; + manager.untitled.onWillDispose(e => { + disposeCounter++; + }); + + let dirtyCounter = 0; + manager.untitled.onDidChangeDirty(e => { + dirtyCounter++; + }); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + assert.strictEqual(manager.untitled.workingCopies.length, 0); + + assert.strictEqual(manager.untitled.get(URI.file('/some/invalidPath')), undefined); + assert.strictEqual(manager.untitled.get(URI.file('/some/invalidPath').with({ scheme: Schemas.untitled })), undefined); + + const workingCopy1 = await manager.untitled.resolve(); + const workingCopy2 = await manager.untitled.resolve(); + + assert.strictEqual(workingCopy1.typeId, 'testUntitledFileWorkingCopyType'); + assert.strictEqual(workingCopy1.resource.scheme, Schemas.untitled); + + assert.strictEqual(createCounter, 2); + + assert.strictEqual(manager.untitled.get(workingCopy1.resource), workingCopy1); + assert.strictEqual(manager.untitled.get(workingCopy2.resource), workingCopy2); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 2); + assert.strictEqual(manager.untitled.workingCopies.length, 2); + + assert.notStrictEqual(workingCopy1.resource.toString(), workingCopy2.resource.toString()); + + for (const workingCopy of [workingCopy1, workingCopy2]) { + assert.strictEqual(workingCopy.capabilities, WorkingCopyCapabilities.Untitled); + assert.strictEqual(workingCopy.isDirty(), false); + assert.ok(workingCopy.model); + } + + workingCopy1.model?.updateContents('Hello World'); + + assert.strictEqual(workingCopy1.isDirty(), true); + assert.strictEqual(dirtyCounter, 1); + + workingCopy1.model?.updateContents(''); // change to empty clears dirty flag + assert.strictEqual(workingCopy1.isDirty(), false); + assert.strictEqual(dirtyCounter, 2); + + workingCopy2.model?.fireContentChangeEvent({ isEmpty: false }); + assert.strictEqual(workingCopy2.isDirty(), true); + assert.strictEqual(dirtyCounter, 3); + + workingCopy1.dispose(); + + assert.strictEqual(manager.untitled.workingCopies.length, 1); + assert.strictEqual(manager.untitled.get(workingCopy1.resource), undefined); + + workingCopy2.dispose(); + + assert.strictEqual(manager.untitled.workingCopies.length, 0); + assert.strictEqual(manager.untitled.get(workingCopy2.resource), undefined); + + assert.strictEqual(disposeCounter, 2); + }); + + test('resolve - with initial value', async () => { + let dirtyCounter = 0; + manager.untitled.onDidChangeDirty(e => { + dirtyCounter++; + }); + + const workingCopy = await manager.untitled.resolve({ contents: bufferToStream(VSBuffer.fromString('Hello World')) }); + + assert.strictEqual(workingCopy.isDirty(), true); + assert.strictEqual(dirtyCounter, 1); + assert.strictEqual(workingCopy.model?.contents, 'Hello World'); + + workingCopy.dispose(); + }); + + test('resolve - existing', async () => { + let createCounter = 0; + manager.untitled.onDidCreate(e => { + createCounter++; + }); + + const workingCopy1 = await manager.untitled.resolve(); + assert.strictEqual(createCounter, 1); + + const workingCopy2 = await manager.untitled.resolve({ untitledResource: workingCopy1.resource }); + assert.strictEqual(workingCopy1, workingCopy2); + assert.strictEqual(createCounter, 1); + + const workingCopy3 = await manager.untitled.resolve({ untitledResource: URI.file('/invalid/untitled') }); + assert.strictEqual(workingCopy3.resource.scheme, Schemas.untitled); + + workingCopy1.dispose(); + workingCopy2.dispose(); + workingCopy3.dispose(); + }); + + test('resolve - untitled resource used for new working copy', async () => { + const invalidUntitledResource = URI.file('my/untitled.txt'); + const validUntitledResource = invalidUntitledResource.with({ scheme: Schemas.untitled }); + + const workingCopy1 = await manager.untitled.resolve({ untitledResource: invalidUntitledResource }); + assert.notStrictEqual(workingCopy1.resource.toString(), invalidUntitledResource.toString()); + + const workingCopy2 = await manager.untitled.resolve({ untitledResource: validUntitledResource }); + assert.strictEqual(workingCopy2.resource.toString(), validUntitledResource.toString()); + + workingCopy1.dispose(); + workingCopy2.dispose(); + }); + + test('resolve - with associated resource', async () => { + const workingCopy = await manager.untitled.resolve({ associatedResource: { path: '/some/associated.txt' } }); + + assert.strictEqual(workingCopy.hasAssociatedFilePath, true); + assert.strictEqual(workingCopy.resource.path, '/some/associated.txt'); + + workingCopy.dispose(); + }); + + test('save - without associated resource', async () => { + const workingCopy = await manager.untitled.resolve(); + workingCopy.model?.updateContents('Simple Save'); + + accessor.fileDialogService.setPickFileToSave(URI.file('simple/file.txt')); + + const result = await workingCopy.save(); + assert.ok(result); + + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('save - with associated resource', async () => { + const workingCopy = await manager.untitled.resolve({ associatedResource: { path: '/some/associated.txt' } }); + workingCopy.model?.updateContents('Simple Save with associated resource'); + + accessor.fileService.notExistsSet.set(URI.from({ scheme: Schemas.vscodeRemote, path: '/some/associated.txt' }), true); + + const result = await workingCopy.save(); + assert.ok(result); + + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('save - with associated resource (asks to overwrite)', async () => { + const workingCopy = await manager.untitled.resolve({ associatedResource: { path: '/some/associated.txt' } }); + workingCopy.model?.updateContents('Simple Save with associated resource'); + + let result = await workingCopy.save(); + assert.ok(!result); // not confirmed + + assert.strictEqual(manager.untitled.get(workingCopy.resource), workingCopy); + + accessor.dialogService.setConfirmResult({ confirmed: true }); + + result = await workingCopy.save(); + assert.ok(result); // confirmed + + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('destroy', async () => { + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + + await manager.untitled.resolve(); + await manager.untitled.resolve(); + await manager.untitled.resolve(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); + assert.strictEqual(manager.untitled.workingCopies.length, 3); + + await manager.untitled.destroy(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + assert.strictEqual(manager.untitled.workingCopies.length, 0); + }); +}); diff --git a/src/vs/workbench/services/workingCopy/test/browser/workingCopyBackupTracker.test.ts b/src/vs/workbench/services/workingCopy/test/browser/workingCopyBackupTracker.test.ts index dc5ff60e956..29149d216d3 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/workingCopyBackupTracker.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/workingCopyBackupTracker.test.ts @@ -30,6 +30,8 @@ import { IWorkingCopyEditorHandler, IWorkingCopyEditorService } from 'vs/workben import { bufferToReadable, VSBuffer } from 'vs/base/common/buffer'; import { isWindows } from 'vs/base/common/platform'; import { Schemas } from 'vs/base/common/network'; +import { IWorkspaceTrustRequestService } from 'vs/platform/workspace/common/workspaceTrust'; +import { TestWorkspaceTrustRequestService } from 'vs/workbench/services/workspaces/test/common/testWorkspaceTrustService'; suite('WorkingCopyBackupTracker (browser)', function () { let accessor: TestServiceAccessor; @@ -93,6 +95,7 @@ suite('WorkingCopyBackupTracker (browser)', function () { disposables.add(registerTestResourceEditor()); instantiationService.stub(IEditorGroupsService, part); + instantiationService.stub(IWorkspaceTrustRequestService, new TestWorkspaceTrustRequestService(false)); const editorService: EditorService = instantiationService.createInstance(EditorService); instantiationService.stub(IEditorService, editorService); @@ -199,6 +202,7 @@ suite('WorkingCopyBackupTracker (browser)', function () { const part = await createEditorPart(instantiationService, disposables); instantiationService.stub(IEditorGroupsService, part); + instantiationService.stub(IWorkspaceTrustRequestService, new TestWorkspaceTrustRequestService(false)); const editorService: EditorService = instantiationService.createInstance(EditorService); instantiationService.stub(IEditorService, editorService); diff --git a/src/vs/workbench/services/workingCopy/test/browser/workingCopyEditorService.test.ts b/src/vs/workbench/services/workingCopy/test/browser/workingCopyEditorService.test.ts index 80c239947d4..66cd9ae27e5 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/workingCopyEditorService.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/workingCopyEditorService.test.ts @@ -6,10 +6,12 @@ import * as assert from 'assert'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; +import { IWorkspaceTrustRequestService } from 'vs/platform/workspace/common/workspaceTrust'; import { EditorService } from 'vs/workbench/services/editor/browser/editorService'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { UntitledTextEditorInput } from 'vs/workbench/services/untitled/common/untitledTextEditorInput'; import { IWorkingCopyEditorHandler, WorkingCopyEditorService } from 'vs/workbench/services/workingCopy/common/workingCopyEditorService'; +import { TestWorkspaceTrustRequestService } from 'vs/workbench/services/workspaces/test/common/testWorkspaceTrustService'; import { createEditorPart, registerTestResourceEditor, TestEditorService, TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; import { TestWorkingCopy } from 'vs/workbench/test/common/workbenchTestServices'; @@ -52,6 +54,7 @@ suite('WorkingCopyEditorService', () => { const instantiationService = workbenchInstantiationService(); const part = await createEditorPart(instantiationService, disposables); instantiationService.stub(IEditorGroupsService, part); + instantiationService.stub(IWorkspaceTrustRequestService, new TestWorkspaceTrustRequestService(false)); const editorService = instantiationService.createInstance(EditorService); const accessor = instantiationService.createInstance(TestServiceAccessor); diff --git a/src/vs/workbench/services/workingCopy/test/common/workingCopyService.test.ts b/src/vs/workbench/services/workingCopy/test/common/workingCopyService.test.ts index e06a9ba987e..01896acf907 100644 --- a/src/vs/workbench/services/workingCopy/test/common/workingCopyService.test.ts +++ b/src/vs/workbench/services/workingCopy/test/common/workingCopyService.test.ts @@ -35,6 +35,7 @@ suite('WorkingCopyService', () => { const resource1 = URI.file('/some/folder/file.txt'); assert.strictEqual(service.has(resource1), false); assert.strictEqual(service.has({ resource: resource1, typeId: 'testWorkingCopyType' }), false); + assert.strictEqual(service.get({ resource: resource1, typeId: 'testWorkingCopyType' }), undefined); const copy1 = new TestWorkingCopy(resource1); const unregister1 = service.registerWorkingCopy(copy1); @@ -46,6 +47,7 @@ suite('WorkingCopyService', () => { assert.strictEqual(service.isDirty(resource1), false); assert.strictEqual(service.has(resource1), true); assert.strictEqual(service.has(copy1), true); + assert.strictEqual(service.get(copy1), copy1); assert.strictEqual(service.hasDirty, false); copy1.setDirty(true); diff --git a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts index b3e9213dfaa..534c8927890 100644 --- a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts +++ b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts @@ -10,9 +10,9 @@ import { createHash } from 'crypto'; import { insert } from 'vs/base/common/arrays'; import { hash } from 'vs/base/common/hash'; import { isEqual } from 'vs/base/common/resources'; -import { promises, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, join } from 'vs/base/common/path'; -import { readdirSync, rimraf, writeFile } from 'vs/base/node/pfs'; +import { Promises, readdirSync, rimraf, writeFile } from 'vs/base/node/pfs'; import { URI } from 'vs/base/common/uri'; import { WorkingCopyBackupsModel, hashIdentifier } from 'vs/workbench/services/workingCopy/common/workingCopyBackupService'; import { createTextModel } from 'vs/editor/test/common/editorTestUtils'; @@ -149,7 +149,7 @@ suite('WorkingCopyBackupService', () => { service = new NodeTestWorkingCopyBackupService(testDir, workspaceBackupPath); - await promises.mkdir(backupHome, { recursive: true }); + await Promises.mkdir(backupHome, { recursive: true }); return writeFile(workspacesJsonPath, ''); }); @@ -990,7 +990,7 @@ suite('WorkingCopyBackupService', () => { const sourceDir = getPathFromAmdModule(require, './fixtures'); - const buffer = await promises.readFile(join(sourceDir, 'binary.txt')); + const buffer = await Promises.readFile(join(sourceDir, 'binary.txt')); const hash = createHash('md5').update(buffer).digest('base64'); await service.backup(identifier, bufferToReadable(VSBuffer.wrap(buffer)), undefined, { binaryTest: 'true' }); @@ -1067,7 +1067,7 @@ suite('WorkingCopyBackupService', () => { test('create', async () => { const fooBackupPath = join(workspaceBackupPath, fooFile.scheme, hashIdentifier(toUntypedWorkingCopyId(fooFile))); - await promises.mkdir(dirname(fooBackupPath), { recursive: true }); + await Promises.mkdir(dirname(fooBackupPath), { recursive: true }); writeFileSync(fooBackupPath, 'foo'); const model = await WorkingCopyBackupsModel.create(URI.file(workspaceBackupPath), service.fileService); diff --git a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupTracker.test.ts b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupTracker.test.ts index 20ae4e5902e..0cbfa6c9876 100644 --- a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupTracker.test.ts +++ b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupTracker.test.ts @@ -6,9 +6,8 @@ import * as assert from 'assert'; import { isMacintosh, isWindows } from 'vs/base/common/platform'; import { tmpdir } from 'os'; -import { promises } from 'fs'; import { join } from 'vs/base/common/path'; -import { rimraf, writeFile } from 'vs/base/node/pfs'; +import { Promises, rimraf, writeFile } from 'vs/base/node/pfs'; import { URI } from 'vs/base/common/uri'; import { flakySuite, getRandomTestPath } from 'vs/base/test/node/testUtils'; import { hash } from 'vs/base/common/hash'; @@ -105,8 +104,8 @@ flakySuite('WorkingCopyBackupTracker (native)', function () { disposables.add(registerTestFileEditor()); - await promises.mkdir(backupHome, { recursive: true }); - await promises.mkdir(workspaceBackupPath, { recursive: true }); + await Promises.mkdir(backupHome, { recursive: true }); + await Promises.mkdir(workspaceBackupPath, { recursive: true }); return writeFile(workspacesJsonPath, ''); }); diff --git a/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts b/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts index 282d4872fbb..41b2a0def5c 100644 --- a/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts +++ b/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts @@ -258,7 +258,7 @@ export abstract class AbstractWorkspaceEditingService implements IWorkspaceEditi await this.textFileService.create([{ resource: targetConfigPathURI, value: newRawWorkspaceContents, options: { overwrite: true } }]); // Set trust for the workspace file - this.trustWorkspaceConfiguration(targetConfigPathURI); + await this.trustWorkspaceConfiguration(targetConfigPathURI); } protected async saveWorkspace(workspace: IWorkspaceIdentifier): Promise { @@ -359,9 +359,9 @@ export abstract class AbstractWorkspaceEditingService implements IWorkspaceEditi return this.jsonEditingService.write(toWorkspace.configPath, [{ path: ['settings'], value: targetWorkspaceConfiguration }], true); } - private trustWorkspaceConfiguration(configPathURI: URI): void { + private async trustWorkspaceConfiguration(configPathURI: URI): Promise { if (this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && this.workspaceTrustManagementService.isWorkpaceTrusted()) { - this.workspaceTrustManagementService.setUrisTrust([configPathURI], true); + await this.workspaceTrustManagementService.setUrisTrust([configPathURI], true); } } diff --git a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts index 36823d3132e..00ece8fe9b5 100644 --- a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts +++ b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts @@ -3,26 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from 'vs/base/common/codicons'; import { Emitter } from 'vs/base/common/event'; +import { MarkdownString } from 'vs/base/common/htmlContent'; import { splitName } from 'vs/base/common/labels'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { LinkedList } from 'vs/base/common/linkedList'; import { Schemas } from 'vs/base/common/network'; import { isWeb } from 'vs/base/common/platform'; +import Severity from 'vs/base/common/severity'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; -import { WorkspaceTrustRequestOptions, IWorkspaceTrustManagementService, IWorkspaceTrustInfo, IWorkspaceTrustUriInfo, IWorkspaceTrustRequestService, IWorkspaceTrustTransitionParticipant } from 'vs/platform/workspace/common/workspaceTrust'; +import { WorkspaceTrustRequestOptions, IWorkspaceTrustManagementService, IWorkspaceTrustInfo, IWorkspaceTrustUriInfo, IWorkspaceTrustRequestService, IWorkspaceTrustTransitionParticipant, WorkspaceTrustUriResponse } from 'vs/platform/workspace/common/workspaceTrust'; import { isSingleFolderWorkspaceIdentifier, isUntitledWorkspace, toWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; +import { Memento, MementoObject } from 'vs/workbench/common/memento'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; export const WORKSPACE_TRUST_ENABLED = 'security.workspace.trust.enabled'; export const WORKSPACE_TRUST_STARTUP_PROMPT = 'security.workspace.trust.startupPrompt'; +export const WORKSPACE_TRUST_UNTRUSTED_FILES = 'security.workspace.trust.untrustedFiles'; +export const WORKSPACE_TRUST_EMPTY_WINDOW = 'security.workspace.trust.emptyWindow'; export const WORKSPACE_TRUST_EXTENSION_SUPPORT = 'extensions.supportUntrustedWorkspaces'; export const WORKSPACE_TRUST_STORAGE_KEY = 'content.trust.model.key'; @@ -50,9 +57,9 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork private readonly _onDidChangeTrustedFolders = this._register(new Emitter()); readonly onDidChangeTrustedFolders = this._onDidChangeTrustedFolders.event; - private _isWorkspaceTrusted: boolean = false; private _trustStateInfo: IWorkspaceTrustInfo; + private readonly _trustState: WorkspaceTrustState; private readonly _trustTransitionManager: WorkspaceTrustTransitionManager; constructor( @@ -64,11 +71,12 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork ) { super(); - this._trustStateInfo = this.loadTrustInfo(); - this._isWorkspaceTrusted = this.calculateWorkspaceTrust(); - + this._trustState = new WorkspaceTrustState(this.storageService); this._trustTransitionManager = this._register(new WorkspaceTrustTransitionManager()); + this._trustStateInfo = this.loadTrustInfo(); + this._trustState.isTrusted = this.calculateWorkspaceTrust(); + this.registerListeners(); } @@ -129,7 +137,9 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork } if (this.workspaceService.getWorkbenchState() === WorkbenchState.EMPTY) { - return true; + // Use memento if present, otherwise default to restricted mode + // Workspace may transition to trusted based on the opened editors + return this._trustState.isTrusted ?? false; } const workspaceUris = this.getWorkspaceUris(); @@ -162,12 +172,15 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork return workspaceUris; } - private async updateWorkspaceTrust(): Promise { - const trusted = this.calculateWorkspaceTrust(); + private async updateWorkspaceTrust(trusted?: boolean): Promise { + if (trusted === undefined) { + trusted = this.calculateWorkspaceTrust(); + } + if (this.isWorkpaceTrusted() === trusted) { return; } // Update workspace trust - this._isWorkspaceTrusted = trusted; + this._trustState.isTrusted = trusted; // Run workspace trust transition participants await this._trustTransitionManager.participate(trusted); @@ -176,6 +189,14 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork this._onDidChangeTrust.fire(trusted); } + get acceptsOutOfWorkspaceFiles(): boolean { + return this._trustState.acceptsOutOfWorkspaceFiles; + } + + set acceptsOutOfWorkspaceFiles(value: boolean) { + this._trustState.acceptsOutOfWorkspaceFiles = value; + } + addWorkspaceTrustTransitionParticipant(participant: IWorkspaceTrustTransitionParticipant): IDisposable { return this._trustTransitionManager.addWorkspaceTrustTransitionParticipant(participant); } @@ -227,7 +248,7 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork canSetWorkspaceTrust(): boolean { // Empty workspace if (this.workspaceService.getWorkbenchState() === WorkbenchState.EMPTY) { - return false; + return true; } // Untrusted workspace @@ -266,19 +287,25 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork } isWorkpaceTrusted(): boolean { - return this._isWorkspaceTrusted; + return this._trustState.isTrusted ?? false; } - setParentFolderTrust(trusted: boolean): void { + async setParentFolderTrust(trusted: boolean): Promise { const workspaceIdentifier = toWorkspaceIdentifier(this.workspaceService.getWorkspace()); if (isSingleFolderWorkspaceIdentifier(workspaceIdentifier) && workspaceIdentifier.uri.scheme === Schemas.file) { const { parentPath } = splitName(workspaceIdentifier.uri.fsPath); - this.setUrisTrust([URI.file(parentPath)], trusted); + await this.setUrisTrust([URI.file(parentPath)], trusted); } } async setWorkspaceTrust(trusted: boolean): Promise { + // Empty workspace + if (this.workspaceService.getWorkbenchState() === WorkbenchState.EMPTY) { + await this.updateWorkspaceTrust(trusted); + return; + } + const workspaceFolders = this.getWorkspaceUris(); await this.setUrisTrust(workspaceFolders, trusted); } @@ -327,7 +354,10 @@ export class WorkspaceTrustRequestService extends Disposable implements IWorkspa constructor( @IContextKeyService contextKeyService: IContextKeyService, - @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IDialogService private readonly dialogService: IDialogService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService ) { super(); @@ -347,6 +377,14 @@ export class WorkspaceTrustRequestService extends Disposable implements IWorkspa this._ctxWorkspaceTrustState.set(trusted); } + private get untrustedFilesSetting(): 'prompt' | 'open' | 'newWindow' { + return this.configurationService.getValue(WORKSPACE_TRUST_UNTRUSTED_FILES); + } + + private set untrustedFilesSetting(value: 'prompt' | 'open' | 'newWindow') { + this.configurationService.updateValue(WORKSPACE_TRUST_UNTRUSTED_FILES, value); + } + private resolveRequest(trusted?: boolean): void { if (this._modalTrustRequestResolver) { this._modalTrustRequestResolver(trusted ?? this.trusted); @@ -376,6 +414,81 @@ export class WorkspaceTrustRequestService extends Disposable implements IWorkspa this.resolveRequest(trusted); } + async requestOpenUris(uris: URI[]): Promise { + // If workspace is untrusted, there is no conflict + if (!this.trusted) { + return WorkspaceTrustUriResponse.Open; + } + + const allTrusted = uris.map(uri => { + return this.workspaceTrustManagementService.getUriTrustInfo(uri).trusted; + }).every(trusted => trusted); + + // If all uris are trusted, there is no conflict + if (allTrusted) { + return WorkspaceTrustUriResponse.Open; + } + + // If user has setting, don't need to ask + if (this.untrustedFilesSetting !== 'prompt') { + if (this.untrustedFilesSetting === 'newWindow') { + return WorkspaceTrustUriResponse.OpenInNewWindow; + } + + if (this.untrustedFilesSetting === 'open') { + return WorkspaceTrustUriResponse.Open; + } + } + + // If we already asked the user, don't need to ask again + if (this.workspaceTrustManagementService.acceptsOutOfWorkspaceFiles) { + return WorkspaceTrustUriResponse.Open; + } + + const markdownDetails = [ + this.workspaceService.getWorkbenchState() !== WorkbenchState.EMPTY ? + localize('openLooseFileWorkspaceDetails', "You are trying to open untrusted files in a workspace which is trusted.") : + localize('openLooseFileWindowDetails', "You are trying to open untrusted files in a window which is trusted."), + localize('openLooseFileLearnMore', "If you don't trust the authors of these files, we recommend to open them in Restricted Mode in a new window as the files may be malicious. See [our docs](https://aka.ms/vscode-workspace-trust) to learn more.") + ]; + + const result = await this.dialogService.show(Severity.Info, localize('openLooseFileMesssage', "Do you trust the authors of these files?"), [localize('open', "Open"), localize('newWindow', "Open in Restricted Mode"), localize('cancel', "Cancel")], { + cancelId: 2, + checkbox: { + label: localize('openLooseFileWorkspaceCheckbox', "Remember my decision for all workspaces"), + checked: false + }, + custom: { + icon: Codicon.shield, + markdownDetails: markdownDetails.map(md => { return { markdown: new MarkdownString(md) }; }) + } + }); + + const saveResponseIfChecked = (response: WorkspaceTrustUriResponse, checked: boolean) => { + if (checked) { + if (response === WorkspaceTrustUriResponse.Open) { + this.untrustedFilesSetting = 'open'; + } + + if (response === WorkspaceTrustUriResponse.OpenInNewWindow) { + this.untrustedFilesSetting = 'newWindow'; + } + } + + return response; + }; + + switch (result.choice) { + case 0: + this.workspaceTrustManagementService.acceptsOutOfWorkspaceFiles = true; + return saveResponseIfChecked(WorkspaceTrustUriResponse.Open, !!result.checkboxChecked); + case 1: + return saveResponseIfChecked(WorkspaceTrustUriResponse.OpenInNewWindow, !!result.checkboxChecked); + default: + return WorkspaceTrustUriResponse.Cancel; + } + } + async requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Promise { // Trusted workspace if (this.trusted) { @@ -398,7 +511,7 @@ export class WorkspaceTrustRequestService extends Disposable implements IWorkspa } } -export class WorkspaceTrustTransitionManager extends Disposable { +class WorkspaceTrustTransitionManager extends Disposable { private readonly participants = new LinkedList(); @@ -418,4 +531,39 @@ export class WorkspaceTrustTransitionManager extends Disposable { } } +class WorkspaceTrustState { + private readonly _memento: Memento; + private readonly _mementoObject: MementoObject; + + private readonly _acceptsOutOfWorkspaceFilesKey = 'acceptsOutOfWorkspaceFiles'; + private readonly _isTrustedKey = 'isTrusted'; + + constructor(storageService: IStorageService) { + this._memento = new Memento('workspaceTrust', storageService); + this._mementoObject = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + get acceptsOutOfWorkspaceFiles(): boolean { + return this._mementoObject[this._acceptsOutOfWorkspaceFilesKey] ?? false; + } + + set acceptsOutOfWorkspaceFiles(value: boolean) { + this._mementoObject[this._acceptsOutOfWorkspaceFilesKey] = value; + this._memento.saveMemento(); + } + + get isTrusted(): boolean | undefined { + return this._mementoObject[this._isTrustedKey]; + } + + set isTrusted(value: boolean | undefined) { + this._mementoObject[this._isTrustedKey] = value; + if (!value) { + this._mementoObject[this._acceptsOutOfWorkspaceFilesKey] = value; + } + + this._memento.saveMemento(); + } +} + registerSingleton(IWorkspaceTrustRequestService, WorkspaceTrustRequestService); diff --git a/src/vs/workbench/services/workspaces/test/common/testWorkspaceTrustService.ts b/src/vs/workbench/services/workspaces/test/common/testWorkspaceTrustService.ts index 15a8259c97a..6e6e68772ce 100644 --- a/src/vs/workbench/services/workspaces/test/common/testWorkspaceTrustService.ts +++ b/src/vs/workbench/services/workspaces/test/common/testWorkspaceTrustService.ts @@ -6,7 +6,7 @@ import { Emitter } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; -import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, IWorkspaceTrustTransitionParticipant, IWorkspaceTrustUriInfo, WorkspaceTrustRequestOptions } from 'vs/platform/workspace/common/workspaceTrust'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, IWorkspaceTrustTransitionParticipant, IWorkspaceTrustUriInfo, WorkspaceTrustRequestOptions, WorkspaceTrustUriResponse } from 'vs/platform/workspace/common/workspaceTrust'; export class TestWorkspaceTrustManagementService implements IWorkspaceTrustManagementService { @@ -24,6 +24,14 @@ export class TestWorkspaceTrustManagementService implements IWorkspaceTrustManag this.trusted = trusted; } + get acceptsOutOfWorkspaceFiles(): boolean { + throw new Error('Method not implemented.'); + } + + set acceptsOutOfWorkspaceFiles(value: boolean) { + throw new Error('Method not implemented.'); + } + addWorkspaceTrustTransitionParticipant(participant: IWorkspaceTrustTransitionParticipant): IDisposable { throw new Error('Method not implemented.'); } @@ -32,7 +40,7 @@ export class TestWorkspaceTrustManagementService implements IWorkspaceTrustManag throw new Error('Method not implemented.'); } - setParentFolderTrust(trusted: boolean): void { + setParentFolderTrust(trusted: boolean): Promise { throw new Error('Method not implemented.'); } @@ -79,6 +87,14 @@ export class TestWorkspaceTrustRequestService implements IWorkspaceTrustRequestS constructor(private readonly _trusted: boolean) { } + requestOpenUrisHandler = async (uris: URI[]) => { + return WorkspaceTrustUriResponse.Open; + }; + + requestOpenUris(uris: URI[]): Promise { + return this.requestOpenUrisHandler(uris); + } + cancelRequest(): void { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/test/browser/api/extHostWebview.test.ts b/src/vs/workbench/test/browser/api/extHostWebview.test.ts index 447a1009a0c..075e47d631a 100644 --- a/src/vs/workbench/test/browser/api/extHostWebview.test.ts +++ b/src/vs/workbench/test/browser/api/extHostWebview.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { Schemas } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import { mock } from 'vs/base/test/common/mock'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; @@ -14,6 +15,7 @@ import { NullApiDeprecationService } from 'vs/workbench/api/common/extHostApiDep import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { ExtHostWebviews } from 'vs/workbench/api/common/extHostWebview'; import { ExtHostWebviewPanels } from 'vs/workbench/api/common/extHostWebviewPanels'; +import { webviewResourceBaseHost } from 'vs/workbench/api/common/shared/webview'; import { EditorGroupColumn } from 'vs/workbench/common/editor'; import type * as vscode from 'vscode'; import { SingleProxyRPCProtocol } from './testRPCProtocol'; @@ -30,12 +32,7 @@ suite('ExtHostWebview', () => { test('Cannot register multiple serializers for the same view type', async () => { const viewType = 'view.type'; - const extHostWebviews = new ExtHostWebviews(rpcProtocol!, { - webviewCspSource: '', - webviewResourceRoot: '', - isExtensionDevelopmentDebug: false, - remote: { authority: undefined }, - }, undefined, new NullLogService(), NullApiDeprecationService); + const extHostWebviews = new ExtHostWebviews(rpcProtocol!, { remote: { authority: undefined, isRemote: false } }, undefined, new NullLogService(), NullApiDeprecationService); const extHostWebviewPanels = new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined); @@ -79,56 +76,71 @@ suite('ExtHostWebview', () => { assert.strictEqual(lastInvokedDeserializer, serializerB); }); - test('asWebviewUri for web endpoint', () => { - const extHostWebviews = new ExtHostWebviews(rpcProtocol!, { - webviewCspSource: '', - webviewResourceRoot: `https://{{uuid}}.webview.contoso.com/commit/{{resource}}`, - isExtensionDevelopmentDebug: false, - remote: { - authority: 'remote' - }, - }, undefined, new NullLogService(), NullApiDeprecationService); - - const extHostWebviewPanels = new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined); - - const webview = extHostWebviewPanels.createWebviewPanel({} as any, 'type', 'title', 1, {}); - - function stripEndpointUuid(input: string) { - return input.replace(/^https:\/\/[^\.]+?\./, ''); - } + test('asWebviewUri for local file paths', () => { + const webview = createWebview(rpcProtocol, /* remoteAuthority */undefined); assert.strictEqual( - stripEndpointUuid(webview.webview.asWebviewUri(URI.parse('file:///Users/codey/file.html')).toString()), - 'webview.contoso.com/commit/remote/file//Users/codey/file.html', + (webview.webview.asWebviewUri(URI.parse('file:///Users/codey/file.html')).toString()), + `https://file%2B.vscode-resource.${webviewResourceBaseHost}/Users/codey/file.html`, 'Unix basic' ); assert.strictEqual( - stripEndpointUuid(webview.webview.asWebviewUri(URI.parse('file:///Users/codey/file.html#frag')).toString()), - 'webview.contoso.com/commit/remote/file//Users/codey/file.html#frag', + (webview.webview.asWebviewUri(URI.parse('file:///Users/codey/file.html#frag')).toString()), + `https://file%2B.vscode-resource.${webviewResourceBaseHost}/Users/codey/file.html#frag`, 'Unix should preserve fragment' ); assert.strictEqual( - stripEndpointUuid(webview.webview.asWebviewUri(URI.parse('file:///Users/codey/f%20ile.html')).toString()), - 'webview.contoso.com/commit/remote/file//Users/codey/f%20ile.html', + (webview.webview.asWebviewUri(URI.parse('file:///Users/codey/f%20ile.html')).toString()), + `https://file%2B.vscode-resource.${webviewResourceBaseHost}/Users/codey/f%20ile.html`, 'Unix with encoding' ); assert.strictEqual( - stripEndpointUuid(webview.webview.asWebviewUri(URI.parse('file://localhost/Users/codey/file.html')).toString()), - 'webview.contoso.com/commit/remote/file/localhost/Users/codey/file.html', + (webview.webview.asWebviewUri(URI.parse('file://localhost/Users/codey/file.html')).toString()), + `https://file%2Blocalhost.vscode-resource.${webviewResourceBaseHost}/Users/codey/file.html`, 'Unix should preserve authority' ); assert.strictEqual( - stripEndpointUuid(webview.webview.asWebviewUri(URI.parse('file:///c:/codey/file.txt')).toString()), - 'webview.contoso.com/commit/remote/file//c%3A/codey/file.txt', + (webview.webview.asWebviewUri(URI.parse('file:///c:/codey/file.txt')).toString()), + `https://file%2B.vscode-resource.${webviewResourceBaseHost}/c%3A/codey/file.txt`, 'Windows C drive' ); }); + + test('asWebviewUri for remote file paths', () => { + const webview = createWebview(rpcProtocol, /* remoteAuthority */ 'remote'); + + assert.strictEqual( + (webview.webview.asWebviewUri(URI.parse('file:///Users/codey/file.html')).toString()), + `https://vscode-remote%2Bremote.vscode-resource.${webviewResourceBaseHost}/Users/codey/file.html`, + 'Unix basic' + ); + }); }); +function createWebview(rpcProtocol: (IExtHostRpcService & IExtHostContext) | undefined, remoteAuthority: string | undefined) { + const extHostWebviews = new ExtHostWebviews(rpcProtocol!, { + remote: { + authority: remoteAuthority, + isRemote: !!remoteAuthority, + }, + }, undefined, new NullLogService(), NullApiDeprecationService); + + const extHostWebviewPanels = new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined); + + const webview = extHostWebviewPanels.createWebviewPanel({ + extensionLocation: URI.from({ + scheme: remoteAuthority ? Schemas.vscodeRemote : Schemas.file, + authority: remoteAuthority, + path: '/ext/path', + }) + } as IExtensionDescription, 'type', 'title', 1, {}); + return webview; +} + function createNoopMainThreadWebviews() { return new class extends mock() { diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 6a23b7a9bf0..16d6a5a907a 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -123,8 +123,8 @@ import { TextResourceEditorInput } from 'vs/workbench/common/editor/textResource import { UntitledTextEditorInput } from 'vs/workbench/services/untitled/common/untitledTextEditorInput'; import { SideBySideEditor } from 'vs/workbench/browser/parts/editor/sideBySideEditor'; import { IEnterWorkspaceResult, IRecent, IRecentlyOpened, IWorkspaceFolderCreationData, IWorkspaceIdentifier, IWorkspacesService } from 'vs/platform/workspaces/common/workspaces'; -import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; -import { TestWorkspaceTrustManagementService } from 'vs/workbench/services/workspaces/test/common/testWorkspaceTrustService'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from 'vs/platform/workspace/common/workspaceTrust'; +import { TestWorkspaceTrustManagementService, TestWorkspaceTrustRequestService } from 'vs/workbench/services/workspaces/test/common/testWorkspaceTrustService'; import { ILocalTerminalService, IShellLaunchConfig, ITerminalChildProcess, ITerminalProfile, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, TerminalShellType } from 'vs/platform/terminal/common/terminal'; import { IProcessDetails, ISetTerminalLayoutInfoArgs } from 'vs/platform/terminal/common/terminalProcess'; import { ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; @@ -138,6 +138,7 @@ import { IElevatedFileService } from 'vs/workbench/services/files/common/elevate import { BrowserElevatedFileService } from 'vs/workbench/services/files/browser/elevatedFileService'; import { IDiffComputationResult, IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService'; import { TextEdit, IInplaceReplaceSupportResult } from 'vs/editor/common/modes'; +import { ResourceMap } from 'vs/base/common/map'; export function createFileEditorInput(instantiationService: IInstantiationService, resource: URI): FileEditorInput { return instantiationService.createInstance(FileEditorInput, resource, undefined, undefined, undefined, undefined, undefined); @@ -261,8 +262,11 @@ export class TestServiceAccessor { @IModelService public modelService: ModelServiceImpl, @IFileService public fileService: TestFileService, @IFileDialogService public fileDialogService: TestFileDialogService, + @IDialogService public dialogService: TestDialogService, @IWorkingCopyService public workingCopyService: IWorkingCopyService, @IEditorService public editorService: TestEditorService, + @IWorkbenchEnvironmentService public environmentService: IWorkbenchEnvironmentService, + @IPathService public pathService: IPathService, @IEditorGroupsService public editorGroupService: IEditorGroupsService, @IEditorOverrideService public editorOverrideService: IEditorOverrideService, @IModeService public modeService: IModeService, @@ -279,7 +283,8 @@ export class TestServiceAccessor { @INotificationService public notificationService: INotificationService, @IWorkingCopyEditorService public workingCopyEditorService: IWorkingCopyEditorService, @IInstantiationService public instantiationService: IInstantiationService, - @IElevatedFileService public elevatedFileService: IElevatedFileService + @IElevatedFileService public elevatedFileService: IElevatedFileService, + @IWorkspaceTrustRequestService public workspaceTrustRequestService: TestWorkspaceTrustRequestService ) { } } @@ -537,6 +542,7 @@ export class TestLayoutService implements IWorkbenchLayoutService { isStatusBarHidden(): boolean { return false; } isActivityBarHidden(): boolean { return false; } setActivityBarHidden(_hidden: boolean): void { } + setBannerHidden(_hidden: boolean): void { } isSideBarHidden(): boolean { return false; } async setEditorHidden(_hidden: boolean): Promise { } async setSideBarHidden(_hidden: boolean): Promise { } @@ -867,7 +873,7 @@ export class TestFileService implements IFileService { return stats.map(stat => ({ stat, success: true })); } - readonly notExistsSet = new Set(); + readonly notExistsSet = new ResourceMap(); async exists(_resource: URI): Promise { return !this.notExistsSet.has(_resource); } @@ -1606,7 +1612,7 @@ export class TestLocalTerminalService implements ILocalTerminalService { async reduceConnectionGraceTime(): Promise { throw new Error('Method not implemented.'); } processBinary(id: number, data: string): Promise { throw new Error('Method not implemented.'); } updateTitle(id: number, title: string): Promise { throw new Error('Method not implemented.'); } - updateIcon(id: number, icon: string, color?: string): Promise { throw new Error('Method not implemented.'); } + updateIcon(id: number, icon: URI | { light: URI; dark: URI } | { id: string, color?: { id: string } }, color?: string): Promise { throw new Error('Method not implemented.'); } } class TestTerminalChildProcess implements ITerminalChildProcess { diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index d43993d9425..b2ddf026545 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -145,7 +145,7 @@ export class TestWorkingCopy extends Disposable implements IWorkingCopy { private dirty = false; - constructor(public readonly resource: URI, isDirty = false, public readonly typeId = 'testWorkingCopyType') { + constructor(readonly resource: URI, isDirty = false, readonly typeId = 'testWorkingCopyType') { super(); this.dirty = isDirty; diff --git a/src/vs/workbench/test/electron-browser/colorRegistry.releaseTest.ts b/src/vs/workbench/test/electron-browser/colorRegistry.releaseTest.ts index b0c5af9bbdb..6f04faa6df3 100644 --- a/src/vs/workbench/test/electron-browser/colorRegistry.releaseTest.ts +++ b/src/vs/workbench/test/electron-browser/colorRegistry.releaseTest.ts @@ -6,7 +6,6 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { IColorRegistry, Extensions, ColorContribution } from 'vs/platform/theme/common/colorRegistry'; import { asText } from 'vs/platform/request/common/request'; -import * as fs from 'fs'; import * as pfs from 'vs/base/node/pfs'; import * as path from 'vs/base/common/path'; import * as assert from 'assert'; @@ -106,7 +105,7 @@ async function getColorsFromExtension(): Promise<{ [id: string]: string }> { let result: { [id: string]: string } = Object.create(null); for (let folder of extFolders) { try { - let packageJSON = JSON.parse((await fs.promises.readFile(path.join(extPath, folder, 'package.json'))).toString()); + let packageJSON = JSON.parse((await pfs.Promises.readFile(path.join(extPath, folder, 'package.json'))).toString()); let contributes = packageJSON['contributes']; if (contributes) { let colors = contributes['colors']; diff --git a/src/vs/workbench/workbench.web.api.ts b/src/vs/workbench/workbench.web.api.ts index 0605863599d..f6df1c777f2 100644 --- a/src/vs/workbench/workbench.web.api.ts +++ b/src/vs/workbench/workbench.web.api.ts @@ -321,6 +321,11 @@ interface IWorkbenchConstructionOptions { */ readonly staticExtensions?: readonly IStaticExtension[]; + /** + * Filter for built-in extensions. + */ + readonly builtinExtensionsFilter?: (extensionId: string) => boolean; + /** * [TEMPORARY]: This will be removed soon. * Enable inlined extensions. diff --git a/test/automation/src/search.ts b/test/automation/src/search.ts index ec0078d11ba..247c714ab6b 100644 --- a/test/automation/src/search.ts +++ b/test/automation/src/search.ts @@ -43,6 +43,11 @@ export class Search extends Viewlet { await this.waitForInputFocus(INPUT); } + async getSearchTooltip(): Promise { + const icon = await this.code.waitForElement(`.activitybar .action-label.codicon.codicon-search-view-icon`, (el) => !!el?.attributes?.['title']); + return icon.attributes['title']; + } + async searchFor(text: string): Promise { await this.waitForInputFocus(INPUT); await this.code.waitForSetValue(INPUT, text); diff --git a/test/smoke/src/areas/search/search.test.ts b/test/smoke/src/areas/search/search.test.ts index 7b864b8bc74..72ec498f988 100644 --- a/test/smoke/src/areas/search/search.test.ts +++ b/test/smoke/src/areas/search/search.test.ts @@ -15,6 +15,15 @@ export function setup() { cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }); }); + // https://github.com/microsoft/vscode/issues/124146 + it('has a tooltp with a keybinding', async function () { + const app = this.app as Application; + const tooltip: string = await app.workbench.search.getSearchTooltip(); + if (!/Search \(.+\)/.test(tooltip)) { + throw Error(`Expected search tooltip to contain keybinding but got ${tooltip}`); + } + }); + it('searches for body & checks for correct result number', async function () { const app = this.app as Application; await app.workbench.search.openSearchViewlet(); diff --git a/yarn.lock b/yarn.lock index 02c65997a24..8359b7fa542 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8692,9 +8692,9 @@ sparkles@^1.0.0: integrity sha512-dSO0DDYUahUt/0/pD/Is3VIm5TGJjludZ0HVymmhYF6eNA53PVLhnUk0znSYbH8IYBuJdCE+1luR22jNLMaQdw== spdlog@^0.13.0: - version "0.13.4" - resolved "https://registry.yarnpkg.com/spdlog/-/spdlog-0.13.4.tgz#7393d436f077fca1d07500741e50cbf8928a838a" - integrity sha512-tdzk9ysc640emskx+pE/A2JdJ5IAr440ZIsNjRlD9aPK6U6IQ94VUGpl7u0NHamAB8O1H7RxLgtHyXT32V+RaA== + version "0.13.5" + resolved "https://registry.yarnpkg.com/spdlog/-/spdlog-0.13.5.tgz#a31027dcccbe032e9a53579f42cb45428af08bad" + integrity sha512-D1xA5tRXw7eZOoFBCAnOxCxLN3JpHVDjpPJG/xjJ0nFZvtfOUTAzK66MVxJCDht/ZFwjLcBAltvzjfz4JTuSEw== dependencies: bindings "^1.5.0" mkdirp "^0.5.5"