From 66fa5c41b3afd35f1cc28dbf9aab017c422b5b0f Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 12 May 2021 19:32:57 -0700 Subject: [PATCH 01/63] First cut at unifying notebook renderers apis This implements the api described in #123540. Major points: - Instead of having the `markdown-it` renderer pull it its dependencies, instead the dependencies can call `getRenderer` to import the object returned by the `markdown-it` renderer - We try to detect if a renderer is using the old or new api. Old renderers are still run as globals while new ones are loaded with `import` - I have only hooked up the new API for markdown renderers so far --- .../notebook/index.ts | 24 +- .../notebook/emoji.ts | 10 +- .../notebook/katex.ts | 37 +-- .../view/renderers/backLayerWebView.ts | 37 +-- .../browser/view/renderers/webviewPreloads.ts | 231 ++++++++++++------ 5 files changed, 207 insertions(+), 132 deletions(-) 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/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/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts index 7fc1c3f8bf3..bfa244d863d 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,10 @@ 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 { 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'; @@ -453,7 +453,7 @@ export class BackLayerWebView extends Disposable { this.element.style.position = 'absolute'; } private generateContent(coreDependencies: string, baseUrl: string) { - const markupRenderer = this.getMarkdownRenderer(); + const renderersData = this.getRendererData(); const outputWidth = `calc(100% - ${this.options.leftMargin + this.options.rightMargin + this.options.runGutter}px)`; const outputMarginLeft = `${this.options.leftMargin + this.options.runGutter}px`; return html` @@ -707,36 +707,19 @@ 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) || [], + dependencies: Array.from(renderer.dependencies.values()) }; }); } 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 54e0ed72b83..9f0e468f5c6 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,68 @@ 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; + } + + function createRendererContext(rendererId: string): RendererContext { + const api = acquireNotebookRendererApi(rendererId); + return { + getState: api.getState.bind(api), + setState: api.setState.bind(api), + getRenderer: (id: string) => renderers.getRenderer(id), + }; + } + + 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: () => { + return invokeSourceWithGlobals(text, kernelPreloadGlobals); } }; }; + 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 { + activate: () => { + const globals = { + acquireNotebookRendererApi: () => acquireNotebookRendererApi(rendererId) + }; + return invokeSourceWithGlobals(text, globals); + } + }; + } + }; + const dimensionUpdater = new class { private readonly pending = new Map(); @@ -389,7 +425,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv // the dontEmit symbol to skip emission. function mapEmitter(emitter: EmitterLike, mapFn: (data: T) => R | typeof dontEmit) { let listener: IDisposable; - const mapped = createEmitter(listeners => { + const mapped = createEmitter(listeners => { if (listeners.size && !listener) { listener = emitter.event(data => { const v = mapFn(data); @@ -407,7 +443,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv interface ICreateCellInfo { element: HTMLElement; - outputId: string; + outputId?: string; mime: string; value: unknown; @@ -422,7 +458,14 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv const onDidCreateOutput = createEmitter<{ rendererId: string, info: ICreateCellInfo }>(); const onDidReceiveKernelMessage = createEmitter(); - const acquireNotebookRendererApi = (id: string) => ({ + interface GlobalNotebookRendererApi { + setState: (newState: T) => void; + getState(): T | undefined; + readonly onWillDestroyOutput: Event; + readonly onDidCreateOutput: Event; + } + + const acquireNotebookRendererApi = (id: string): GlobalNotebookRendererApi => ({ setState(newState: T) { vscode.setState({ ...vscode.getState(), [id]: newState }); }, @@ -632,6 +675,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv cellOutputContainer.appendChild(outputContainer); outputContainer.appendChild(outputNode); } else { + // TODO: this should go through renderers instead onDidCreateOutput.fire({ rendererId: data.rendererId!, info: { @@ -754,21 +798,32 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv 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; - })); + if (source === 'kernel') { + const promise = runPreload(uri, originalUri); + queue = queue.then(() => promise.then(async module => { + try { + await module.activate(); + return { state: PreloadState.Ok }; + } catch (error) { + console.error(error); + return { state: PreloadState.Error, error: error.toString() }; + } + })); + } else { + queue = queue.then(async () => { + try { + await renderers.load(source.rendererId); + return { state: PreloadState.Ok }; + } catch (error) { + console.error(error); + return { state: PreloadState.Error, error: error.toString() }; + } + }); + } + preloadPromises.set(uri, queue); } break; @@ -789,51 +844,88 @@ 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; } - const markupRenderers = new class { + class Renderer { + constructor( + public readonly data: RendererMetadata, + private readonly loadDependency: (id: string) => Promise, + ) { } - private readonly mimeTypesToRenderers = new Map Promise; - }>(); + private _loadPromise: Promise | undefined; + private _api: RendererApi | undefined; + + public get api() { return this._api; } + + public load(): Promise { + if (!this._loadPromise) { + this._loadPromise = Promise.all(this.data.dependencies.map(dependencyId => this.loadDependency(dependencyId))) + .then(() => runRenderScript(this.data.entrypoint, this.data.id)) + .then(module => { + if (module) { + const api = module.activate(createRendererContext(this.data.id)); + this._api = api; + return api; + } + return undefined; + }); + } + return this._loadPromise; + } + } + + 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 (dependencyId) => { + const parent = this._renderers.get(dependencyId); + if (!parent) { + throw new Error(`Could not find renderer dependency: ${dependencyId}`); } - } + await parent.load(); + })); } } - async renderMarkdown(element: HTMLElement, content: string): Promise { - const entry = this.mimeTypesToRenderers.get('text/markdown'); - if (!entry) { + public getRenderer(id: string): RendererApi | undefined { + return this._renderers.get(id)?.api; + } + + public 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 async renderMarkdown(id: string, element: HTMLElement, content: string): Promise { + const markdownRenderers = Array.from(this._renderers.values()) + .filter(renderer => renderer.data.mimeTypes.includes('text/markdown')); + + if (!markdownRenderers.length) { + throw new Error('Could not find renderer'); + } + + await Promise.all(markdownRenderers.map(x => x.load())); + + const renderer = Array.from(this._renderers.values()).find(x => x.data.mimeTypes.includes('text/markdown')); + renderer?.api?.renderCell(id, { + element, + value: content, + mime: 'text/markdown', + metadata: undefined, + outputId: undefined, + }); } }(); - vscode.postMessage({ __vscode_notebook_message: true, type: 'initialized' @@ -961,7 +1053,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'); @@ -1060,13 +1152,14 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Webv }(); } -export interface WebviewPreloadRenderer { +export interface RendererMetadata { + readonly id: string; readonly entrypoint: string; + readonly dependencies: readonly string[] readonly mimeTypes: readonly string[]; - readonly dependencies: ReadonlyArray<{ entrypoint: string }>; } -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 ` From 616e0fd99270e5a59a0762849a5085268743de4c Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 18 May 2021 16:59:09 -0700 Subject: [PATCH 02/63] refactor: polish - Fix mis-used dependencies. Add the roughly proposed 'extends' model for notebook renderers. Keep it out of contribution schema for now until we can work it more. - Made new loading work with JS modules. - Consolidated the 'old style' code in the renderer so that we can just delete it when the time comes. - Removed duplicated code and sharp edges from the 'queue' mechaism. --- .../notebook-markdown-extensions/package.json | 22 +- .../notebook/browser/extensionPoint.ts | 25 +- .../view/renderers/backLayerWebView.ts | 58 +-- .../browser/view/renderers/webviewPreloads.ts | 378 ++++++++++-------- .../contrib/notebook/common/notebookCommon.ts | 3 + .../notebook/common/notebookOutputRenderer.ts | 18 +- 6 files changed, 264 insertions(+), 240 deletions(-) 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/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts b/src/vs/workbench/contrib/notebook/browser/extensionPoint.ts index de640c7926e..959e70bf586 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'; @@ -37,7 +37,7 @@ 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[]; } @@ -130,8 +130,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', 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 8826726a081..a48496cf9e4 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -203,7 +203,7 @@ export interface ICreationRequestMessage { cellTop: number; outputOffset: number; left: number; - requiredPreloads: ReadonlyArray; + requiredPreloads: ReadonlyArray; readonly initiallyHidden?: boolean; rendererId?: string | undefined; } @@ -263,17 +263,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 { @@ -376,7 +374,7 @@ export type ToWebviewMessage = | IClearOutputRequestMessage | IHideOutputMessage | IShowOutputMessage - | IUpdatePreloadResourceMessage + | IUpdateControllerPreloadsMessage | IUpdateDecorationsMessage | ICustomKernelMessage | ICreateMarkdownMessage @@ -767,7 +765,7 @@ export class BackLayerWebView extends Disposable { id: renderer.id, entrypoint, mimeTypes: renderer.mimeTypes, - dependencies: Array.from(renderer.dependencies.values()) + extends: renderer.extends, }; }); } @@ -1194,7 +1192,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) }); @@ -1469,7 +1466,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, @@ -1600,13 +1596,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()); } } @@ -1618,43 +1614,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/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index 48ce5768e1d..e1df7bf0485 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -129,10 +129,12 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend } function createRendererContext(rendererId: string): RendererContext { - const api = acquireNotebookRendererApi(rendererId); return { - getState: api.getState.bind(api), - setState: api.setState.bind(api), + setState: newState => vscode.setState({ ...vscode.getState(), [rendererId]: newState }), + getState: () => { + const state = vscode.getState(); + return typeof state === 'object' && state ? state[rendererId] as T : undefined; + }, getRenderer: (id: string) => renderers.getRenderer(id), }; } @@ -162,16 +164,39 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend if (isModule) { return __import(url); } else { + return createBackCompatModule(rendererId, text); + } + }; + + const createBackCompatModule = (rendererId: string, scriptText: string): ScriptModule => ({ + activate: (): RendererApi => { + const onDidCreateOutput = createEmitter(); + const onWillDestroyOutput = createEmitter(); + + const globals = { + 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 { - activate: () => { - const globals = { - acquireNotebookRendererApi: () => acquireNotebookRendererApi(rendererId) - }; - return invokeSourceWithGlobals(text, globals); + 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(); @@ -388,8 +413,6 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend focusTrackers.set(outputId, new FocusTracker(element, outputId)); } - const dontEmit = Symbol('dontEmit'); - function createEmitter(listenerChange: (listeners: Set>) => void = () => undefined): EmitterLike { const listeners = new Set>(); return { @@ -421,24 +444,16 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend }; } - // 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 { @@ -454,10 +469,9 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend outputId: string; } - const onWillDestroyOutput = createEmitter<'all' | { rendererId: string, info: IDestroyCellInfo }>(); - const onDidCreateOutput = createEmitter<{ rendererId: string, info: ICreateCellInfo }>(); const onDidReceiveKernelMessage = createEmitter(); + /** @deprecated */ interface GlobalNotebookRendererApi { setState: (newState: T) => void; getState(): T | undefined; @@ -465,65 +479,12 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend readonly onDidCreateOutput: Event; } - const acquireNotebookRendererApi = (id: string): GlobalNotebookRendererApi => ({ - 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), - }); - const kernelPreloadGlobals = { acquireVsCodeApi, onDidReceiveKernelMessage: onDidReceiveKernelMessage.event, 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, @@ -605,10 +566,15 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend } } 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; } @@ -658,38 +624,26 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend 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 { - // TODO: this should go through renderers instead - 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); const clientHeight = outputNode.clientHeight; @@ -714,6 +668,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend cellOutputContainer.style.visibility = data.initiallyHidden ? 'hidden' : 'visible'; }); break; + } case 'view-scroll': { // const date = new Date(); @@ -740,8 +695,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend break; } case 'clear': - queuedOuputActions.clear(); // stop all loading outputs - onWillDestroyOutput.fire('all'); + renderers.clearAll(); document.getElementById('container')!.innerText = ''; focusTrackers.forEach(ft => { @@ -753,26 +707,29 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend 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'; @@ -784,6 +741,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend } }); break; + } case 'ack-dimension': { const { outputId, height } = event.data; @@ -796,35 +754,8 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend } case 'preload': const resources = event.data.resources; - let queue: Promise = Promise.resolve({ state: PreloadState.Ok }); - for (const { uri, originalUri, source } of resources) { - // create the promise so that the scripts download in parallel, but - // only invoke them in series within the queue - - if (source === 'kernel') { - const promise = runPreload(uri, originalUri); - queue = queue.then(() => promise.then(async module => { - try { - await module.activate(); - return { state: PreloadState.Ok }; - } catch (error) { - console.error(error); - return { state: PreloadState.Error, error: error.toString() }; - } - })); - } else { - queue = queue.then(async () => { - try { - await renderers.load(source.rendererId); - return { state: PreloadState.Ok }; - } catch (error) { - console.error(error); - return { state: PreloadState.Error, error: error.toString() }; - } - }); - } - - preloadPromises.set(uri, queue); + for (const { uri, originalUri } of resources) { + kernelPreloads.load(uri, originalUri); } break; case 'focus-output': @@ -863,12 +794,13 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend interface RendererApi { renderCell: (id: string, context: ICreateCellInfo) => void; + destroyCell?: (id?: string) => void; } class Renderer { constructor( public readonly data: RendererMetadata, - private readonly loadDependency: (id: string) => Promise, + private readonly loadExtension: (id: string) => Promise, ) { } private _loadPromise: Promise | undefined; @@ -878,33 +810,116 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend public load(): Promise { if (!this._loadPromise) { - this._loadPromise = Promise.all(this.data.dependencies.map(dependencyId => this.loadDependency(dependencyId))) - .then(() => runRenderScript(this.data.entrypoint, this.data.id)) - .then(module => { - if (module) { - const api = module.activate(createRendererContext(this.data.id)); - this._api = api; - return api; - } - return undefined; - }); + this._loadPromise = this._load(); } + return this._loadPromise; } + + /** 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(createRendererContext(this.data.id)); + 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 renderers = new class { + 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) { - this._renderers.set(renderer.id, new Renderer(renderer, async (dependencyId) => { - const parent = this._renderers.get(dependencyId); - if (!parent) { - throw new Error(`Could not find renderer dependency: ${dependencyId}`); + 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 parent.load(); + + await ext.load(); })); } } @@ -913,7 +928,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend return this._renderers.get(id)?.api; } - public load(id: string) { + public async load(id: string) { const renderer = this._renderers.get(id); if (!renderer) { throw new Error('Could not find renderer'); @@ -922,9 +937,31 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend 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')); + .filter(renderer => renderer.data.mimeTypes.includes('text/markdown') && !renderer.data.extends); if (!markdownRenderers.length) { throw new Error('Could not find renderer'); @@ -932,8 +969,7 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend await Promise.all(markdownRenderers.map(x => x.load())); - const renderer = Array.from(this._renderers.values()).find(x => x.data.mimeTypes.includes('text/markdown')); - renderer?.api?.renderCell(id, { + markdownRenderers[0].api?.renderCell(id, { element, value: content, mime: 'text/markdown', @@ -1172,8 +1208,8 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend export interface RendererMetadata { readonly id: string; readonly entrypoint: string; - readonly dependencies: readonly string[] readonly mimeTypes: readonly string[]; + readonly extends: string | undefined; } export function preloadsScriptStr(styleValues: PreloadStyles, renderers: readonly RendererMetadata[]) { diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index 816f3dbaf20..354e926d95b 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 @@ -132,6 +134,7 @@ export const enum NotebookRendererMatch { export interface INotebookRendererInfo { id: string; displayName: string; + extends?: string; entrypoint: URI; preloads: ReadonlyArray; extensionLocation: URI; diff --git a/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts index b698cd5b3ee..5025db38aeb 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookOutputRenderer.ts @@ -8,7 +8,7 @@ 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 } from 'vs/workbench/contrib/notebook/common/notebookCommon'; class DependencyList { private readonly value: ReadonlySet; @@ -34,6 +34,7 @@ class DependencyList { export class NotebookOutputRendererInfo implements INotebookRendererInfo { readonly id: string; + readonly extends?: string; readonly entrypoint: URI; readonly displayName: string; readonly extensionLocation: URI; @@ -49,7 +50,7 @@ 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; @@ -58,7 +59,14 @@ export class NotebookOutputRendererInfo implements INotebookRendererInfo { 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)); @@ -103,6 +111,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); } } From df308a9a81b4acd6ac8b40fdfde97cb3261820f7 Mon Sep 17 00:00:00 2001 From: Nick Rayburn Date: Wed, 5 May 2021 23:21:36 -0500 Subject: [PATCH 03/63] add color customizations for inline debug values --- .../debug/browser/debugEditorContribution.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index edec9fd5d18..02c88afc9e4 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,20 @@ 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('debugInline.decorationForeground', { + dark: '#ffffff80', + light: '#00000080', + hc: '#ffffff80' +}, nls.localize('debug.inline.decorationForeground', "Color for the inline debug text.")); + +export const debugInlineBackground = registerColor('debugInline.decorationBackground', { + dark: '#ffc80033', + light: '#ffc80033', + hc: '#ffc80033' +}, nls.localize('debug.inline.decorationBackground', "Color for the inline debug background.")); + + + class InlineSegment { constructor(public column: number, public text: string) { } @@ -73,18 +89,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) } } }; From 0e4159cb7aab4f5585a23c587be884f3cd958628 Mon Sep 17 00:00:00 2001 From: Nick Rayburn Date: Wed, 5 May 2021 23:33:40 -0500 Subject: [PATCH 04/63] fix localization key/description for debugInline colors --- .../contrib/debug/browser/debugEditorContribution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index 02c88afc9e4..efab2dfda5f 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts @@ -58,13 +58,13 @@ export const debugInlineForeground = registerColor('debugInline.decorationForegr dark: '#ffffff80', light: '#00000080', hc: '#ffffff80' -}, nls.localize('debug.inline.decorationForeground', "Color for the inline debug text.")); +}, nls.localize('debugInline.decorationForeground', "Color for the debug inline value text.")); export const debugInlineBackground = registerColor('debugInline.decorationBackground', { dark: '#ffc80033', light: '#ffc80033', hc: '#ffc80033' -}, nls.localize('debug.inline.decorationBackground', "Color for the inline debug background.")); +}, nls.localize('debugInline.decorationBackground', "Color for the debug inline value background.")); From 677f2a3be1267173d3f148898b49da8f608fe2d4 Mon Sep 17 00:00:00 2001 From: Nick Rayburn Date: Thu, 6 May 2021 22:35:59 -0500 Subject: [PATCH 05/63] remove excess whitespace --- .../workbench/contrib/debug/browser/debugEditorContribution.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index efab2dfda5f..6c9807f41bb 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts @@ -66,8 +66,6 @@ export const debugInlineBackground = registerColor('debugInline.decorationBackgr hc: '#ffc80033' }, nls.localize('debugInline.decorationBackground', "Color for the debug inline value background.")); - - class InlineSegment { constructor(public column: number, public text: string) { } From b1349b64a64addf2829d84f076ba63346742a6f6 Mon Sep 17 00:00:00 2001 From: Nick Rayburn Date: Tue, 18 May 2021 15:56:37 -0500 Subject: [PATCH 06/63] update color registry names for inline values --- .../contrib/debug/browser/debugEditorContribution.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index 6c9807f41bb..979f3ef9d94 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts @@ -54,17 +54,17 @@ 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('debugInline.decorationForeground', { +export const debugInlineForeground = registerColor('editor.inlineValuesForeground', { dark: '#ffffff80', light: '#00000080', hc: '#ffffff80' -}, nls.localize('debugInline.decorationForeground', "Color for the debug inline value text.")); +}, nls.localize('editor.inlineValuesForeground', "Color for the debug inline value text.")); -export const debugInlineBackground = registerColor('debugInline.decorationBackground', { +export const debugInlineBackground = registerColor('editor.inlineValuesBackground', { dark: '#ffc80033', light: '#ffc80033', hc: '#ffc80033' -}, nls.localize('debugInline.decorationBackground', "Color for the debug inline value background.")); +}, nls.localize('editor.inlineValuesBackground', "Color for the debug inline value background.")); class InlineSegment { constructor(public column: number, public text: string) { From 825f6c7ab84d281acb5416031474a0916054c916 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 19 May 2021 09:17:29 +0200 Subject: [PATCH 07/63] valide URIs created via from, https://github.com/microsoft/vscode/issues/121198 --- src/vs/base/common/uri.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; } /** From 4173ced659cbf169f5e48b719ccc4fef7296f580 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 17 May 2021 11:44:00 +0200 Subject: [PATCH 08/63] untitled file working copy - first cut --- .../common/untitledTextEditorModel.ts | 75 ++--- .../test/browser/untitledTextEditor.test.ts | 16 - .../workingCopy/common/fileWorkingCopy.ts | 10 +- .../common/untitledFileWorkingCopy.ts | 281 ++++++++++++++++++ 4 files changed, 319 insertions(+), 63 deletions(-) create mode 100644 src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts diff --git a/src/vs/workbench/services/untitled/common/untitledTextEditorModel.ts b/src/vs/workbench/services/untitled/common/untitledTextEditorModel.ts index 732985245f5..7f4557bdde7 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,13 +110,8 @@ 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, @@ -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/fileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts index 5d573428ee8..ba40ccd1df1 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts @@ -445,6 +445,10 @@ export class FileWorkingCopy extends ResourceWo private lastResolvedFileStat: IFileStatWithMetadata | undefined; + isResolved(): this is IResolvedFileWorkingCopy { + return !!this.model; + } + async resolve(options?: IFileWorkingCopyResolveOptions): Promise { this.trace('[file working copy] resolve() - enter'); @@ -1172,6 +1176,8 @@ export class FileWorkingCopy extends ResourceWo return; // ignore if not resolved or not dirty and not enforced } + this.trace('[file working copy] revert()'); + // Unset flags const wasDirty = this.dirty; const undoSetDirty = this.doSetDirty(false); @@ -1235,10 +1241,6 @@ export class FileWorkingCopy extends ResourceWo //#region Utilities - isResolved(): this is IResolvedFileWorkingCopy { - return !!this.model; - } - isReadonly(): boolean { return this.lastResolvedFileStat?.readonly || this.fileService.hasCapability(this.resource, FileSystemProviderCapabilities.Readonly); } 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..02ed75d2d8e --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IFileWorkingCopyModel, IFileWorkingCopyModelContentChangedEvent, IFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { IWorkingCopy, IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; +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'; + +export interface IUntitledFileWorkingCopyModelFactory extends IFileWorkingCopyModelFactory { } + +export interface IUntitledFileWorkingCopyModel extends IFileWorkingCopyModel { + readonly onDidChangeContent: Event; +} + +export interface IUntitledFileWorkingCopyModelContentChangedEvent extends IFileWorkingCopyModelContentChangedEvent { + + /** + * 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 IWorkingCopy { + + /** + * Emits an event when this untitled model is reverted. + */ + readonly onDidRevert: Event; + + /** + * Provides access to the underlying model of this untitled + * file based working copy. As long as the untitled file working + * copy has not been resolved, the model is `undefined`. + */ + readonly model: T | undefined; + + /** + * Whether this untitled file working copy model has an associated file path. + */ + readonly hasAssociatedFilePath: boolean; + + /** + * Resolves an untitled file working copy. + */ + resolve(): Promise; +} + +export interface IResolvedUntitledFileWorkingCopy extends IUntitledFileWorkingCopy { + + /** + * A resolved untitled file working copy has a resolved model `T`. + */ + readonly model: T; +} + +export class UntitledFileWorkingCopy extends Disposable implements IUntitledFileWorkingCopy { + + readonly capabilities: WorkingCopyCapabilities = WorkingCopyCapabilities.Untitled; + + 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 _onDidChangeDirty = this._register(new Emitter()); + readonly onDidChangeDirty = this._onDidChangeDirty.event; + + private readonly _onDidRevert = this._register(new Emitter()); + readonly onDidRevert = this._onDidRevert.event; + + //#endregion + + constructor( + readonly typeId: string, + readonly resource: URI, + readonly name: string, + readonly hasAssociatedFilePath: boolean, + private readonly initialValue: VSBufferReadableStream | undefined, + private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, + @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: IUntitledFileWorkingCopyModel): 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() - enter'); + + // TODO needs to extract the code for bringing up save dialog + // or use the associated file path as target + // Also, who is disposing the untitled after save and open the + // new editor? + + throw new Error('Method not implemented.'); + } + + //#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()'); + + super.dispose(); + } + + private trace(msg: string): void { + this.logService.trace(msg, this.resource.toString(true), this.typeId); + } +} From 9f990bbb6ecdb0d9fbcabb3d8a160d708d2c66f9 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 17 May 2021 13:59:48 +0200 Subject: [PATCH 09/63] untitled file working copy - first cut manager --- .../common/fileWorkingCopyManager.ts | 11 +- .../common/untitledFileWorkingCopy.ts | 14 +- .../common/untitledFileWorkingCopyManager.ts | 308 ++++++++++++++++++ 3 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index a3ba3b249dc..72a7e3f3c5e 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -472,10 +472,17 @@ export class FileWorkingCopyManager extends Dis else { didCreateWorkingCopy = true; - const newWorkingCopy = workingCopy = this.instantiationService.createInstance(FileWorkingCopy, this.workingCopyTypeId, resource, this.labelService.getUriBasenameLabel(resource), this.modelFactory) as unknown as IFileWorkingCopy; + 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); + this.registerWorkingCopy(workingCopy); } // Store pending resolve to avoid race conditions diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts index 02ed75d2d8e..670517c603f 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -7,7 +7,7 @@ import { Event, Emitter } from 'vs/base/common/event'; import { VSBufferReadableStream } from 'vs/base/common/buffer'; import { IFileWorkingCopyModel, IFileWorkingCopyModelContentChangedEvent, IFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; import { IWorkingCopy, IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } 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'; @@ -35,13 +35,18 @@ export interface IUntitledFileWorkingCopyModelContentChangedEvent extends IFileW readonly isEmpty: boolean; } -export interface IUntitledFileWorkingCopy extends IWorkingCopy { +export interface IUntitledFileWorkingCopy extends IWorkingCopy, IDisposable { /** * Emits an event when this untitled model is reverted. */ readonly onDidRevert: Event; + /** + * An event for when the file working copy has been disposed. + */ + readonly onWillDispose: Event; + /** * Provides access to the underlying model of this untitled * file based working copy. As long as the untitled file working @@ -86,6 +91,9 @@ export class UntitledFileWorkingCopy ex 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( @@ -272,6 +280,8 @@ export class UntitledFileWorkingCopy ex override dispose(): void { this.trace('[untitled file working copy] dispose()'); + this._onWillDispose.fire(); + super.dispose(); } 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..3a5c980a50d --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -0,0 +1,308 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { IUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelFactory, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; +import { Event, Emitter } from 'vs/base/common/event'; +import { ResourceMap } from 'vs/base/common/map'; +import { Schemas } from 'vs/base/common/network'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { Promises } from 'vs/base/common/async'; +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'; + +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. + */ + initialValue?: 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 IExistingUntitledFileWorkingCopyOptions 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; +} + +/** + * 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 IDisposable { + + /** + * 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>; + + /** + * Access to all known untitled file working copies within the manager. + */ + readonly workingCopies: readonly IUntitledFileWorkingCopy[]; + + /** + * Returns an existing untitled file working copy if already created before + * or `undefined` otherwise. + */ + get(resource: URI): IUntitledFileWorkingCopy | undefined; + + /** + * Resolves an untitled file working copy from the provided options. + */ + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + + /** + * Resolves an untitled file working copy from the provided options + * unless an existing working copy already exists with that resource. + */ + resolve(options?: IExistingUntitledFileWorkingCopyOptions): 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; +} + +type IInternalUntitledFileWorkingCopyOptions = IExistingUntitledFileWorkingCopyOptions & INewUntitledFileWorkingCopyWithAssociatedResourceOptions; + +export class UntitledFileWorkingCopyManager extends Disposable 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 mapResourceToWorkingCopy = new ResourceMap>(); + + constructor( + private readonly workingCopyTypeId: string, + private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILabelService private readonly labelService: ILabelService, + @ILogService private readonly logService: ILogService, + @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, + @IFileService private readonly fileService: IFileService + ) { + super(); + } + + //#region Get / Get all + + get workingCopies(): IUntitledFileWorkingCopy[] { + return [...this.mapResourceToWorkingCopy.values()]; + } + + get(resource: URI): IUntitledFileWorkingCopy | undefined { + return this.mapResourceToWorkingCopy.get(resource); + } + + //#endregion + + //#region Resolve / Create + + 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 && this.mapResourceToWorkingCopy.has(massagedOptions.untitledResource)) { + return this.mapResourceToWorkingCopy.get(massagedOptions.untitledResource)!; + } + + // Create new instance otherwise + return this.doCreate(massagedOptions); + } + + private massageOptions(options: IInternalUntitledFileWorkingCopyOptions): IInternalUntitledFileWorkingCopyOptions { + const massagedOptions: IInternalUntitledFileWorkingCopyOptions = Object.create(null); + + // Figure out associated and untitled 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; + } else { + if (options.untitledResource?.scheme === Schemas.untitled) { + massagedOptions.untitledResource = options.untitledResource; + } + } + + // Take over initial value + massagedOptions.initialValue = options.initialValue; + + 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.mapResourceToWorkingCopy.has(untitledResource)); + } + + // Create new working copy with provided options + const workingCopy = this.instantiationService.createInstance( + UntitledFileWorkingCopy, + this.workingCopyTypeId, + untitledResource, + this.labelService.getUriBasenameLabel(untitledResource), + !!options.associatedResource, + options.initialValue, + this.modelFactory + ) as unknown as IUntitledFileWorkingCopy; + + this.registerWorkingCopy(workingCopy); + + return workingCopy; + } + + private registerWorkingCopy(workingCopy: IUntitledFileWorkingCopy): void { + + // Install working copy listeners + const listeners = new DisposableStore(); + listeners.add(workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(workingCopy))); + listeners.add(workingCopy.onWillDispose(() => this._onWillDispose.fire(workingCopy))); + + // Remove from cache on dispose + Event.once(workingCopy.onWillDispose)(() => { + + // Registry + this.mapResourceToWorkingCopy.delete(workingCopy.resource); + + // Listeners + listeners.dispose(); + }); + + // Add to cache + this.mapResourceToWorkingCopy.set(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); + } + } + + //#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(); + } + + async destroy(): Promise { + const workingCopies = Array.from(this.mapResourceToWorkingCopy.values()); + + // Make sure all dirty working copies are saved to disk + try { + await Promises.settled(workingCopies.map(async workingCopy => { + if (workingCopy.isDirty()) { + await this.saveWithFallback(workingCopy); + } + })); + } catch (error) { + this.logService.error(error); + } + + // Dispose all working copies + dispose(workingCopies); + + // Finally dispose manager + this.dispose(); + } + + private async saveWithFallback(workingCopy: IUntitledFileWorkingCopy): 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 + } +} From 103ba104a56f31a20fe5ee4694f89a86b6dc9365 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 17 May 2021 15:37:17 +0200 Subject: [PATCH 10/63] untitled file working copy - tests --- .../common/untitledFileWorkingCopy.ts | 6 +- .../common/untitledFileWorkingCopyManager.ts | 4 +- .../test/browser/fileWorkingCopy.test.ts | 8 + .../browser/untitledFileWorkingCopy.test.ts | 311 ++++++++++++++++++ .../untitledFileWorkingCopyManager.test.ts | 146 ++++++++ 5 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts create mode 100644 src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts index 670517c603f..0d59f0fa23d 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -244,7 +244,7 @@ export class UntitledFileWorkingCopy ex //#region Save - save(options?: ISaveOptions): Promise { + async save(options?: ISaveOptions): Promise { this.trace('[untitled file working copy] save() - enter'); // TODO needs to extract the code for bringing up save dialog @@ -252,7 +252,9 @@ export class UntitledFileWorkingCopy ex // Also, who is disposing the untitled after save and open the // new editor? - throw new Error('Method not implemented.'); + await this.revert(); + + return true; } //#endregion diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts index 3a5c980a50d..3585096df18 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -38,7 +38,7 @@ export interface INewUntitledFileWorkingCopyWithAssociatedResourceOptions extend * 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; } + associatedResource: { authority?: string; path?: string; query?: string; fragment?: string; } } export interface IExistingUntitledFileWorkingCopyOptions extends INewUntitledFileWorkingCopyOptions { @@ -145,7 +145,7 @@ export class UntitledFileWorkingCopyManager> { const workingCopy = this.doCreateOrGet(options); diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts index 3c0028841b7..47ab58bb6a7 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts @@ -112,6 +112,14 @@ 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' }))); }); 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..8c7da14566a --- /dev/null +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * 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({ isRedoing: false, isUndoing: false, 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, + 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, isUndoing: false, isRedoing: false }); + + 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, isUndoing: false, isRedoing: false }); + + 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..417fad0cfbe --- /dev/null +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IUntitledFileWorkingCopyManager, UntitledFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager'; +import { WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; +import { TestUntitledFileWorkingCopyModel, TestUntitledFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test'; +import { TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; + +suite('UntitledFileWorkingCopyManager', () => { + + let instantiationService: IInstantiationService; + let accessor: TestServiceAccessor; + + let manager: IUntitledFileWorkingCopyManager; + + setup(() => { + instantiationService = workbenchInstantiationService(); + accessor = instantiationService.createInstance(TestServiceAccessor); + + const factory = new TestUntitledFileWorkingCopyModelFactory(); + manager = new UntitledFileWorkingCopyManager('testUntitledWorkingCopyType', factory, instantiationService, accessor.labelService, accessor.logService, accessor.workingCopyBackupService, accessor.fileService); + }); + + teardown(() => { + manager.dispose(); + }); + + test('basics', async () => { + let disposeCounter = 0; + manager.onWillDispose(e => { + disposeCounter++; + }); + + let dirtyCounter = 0; + manager.onDidChangeDirty(e => { + dirtyCounter++; + }); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + assert.strictEqual(manager.workingCopies.length, 0); + + assert.strictEqual(manager.get(URI.file('/some/invalidPath')), undefined); + assert.strictEqual(manager.get(URI.file('/some/invalidPath').with({ scheme: Schemas.untitled })), undefined); + + const workingCopy1 = await manager.resolve(); + const workingCopy2 = await manager.resolve(); + + assert.strictEqual(manager.get(workingCopy1.resource), workingCopy1); + assert.strictEqual(manager.get(workingCopy2.resource), workingCopy2); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 2); + assert.strictEqual(manager.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, isRedoing: false, isUndoing: false }); + assert.strictEqual(workingCopy2.isDirty(), true); + assert.strictEqual(dirtyCounter, 3); + + workingCopy1.dispose(); + + assert.strictEqual(manager.workingCopies.length, 1); + assert.strictEqual(manager.get(workingCopy1.resource), undefined); + + workingCopy2.dispose(); + + assert.strictEqual(manager.workingCopies.length, 0); + assert.strictEqual(manager.get(workingCopy2.resource), undefined); + + assert.strictEqual(disposeCounter, 2); + }); + + test('resolve - with initial value', async () => { + let dirtyCounter = 0; + manager.onDidChangeDirty(e => { + dirtyCounter++; + }); + + const workingCopy = await manager.resolve({ initialValue: 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 () => { + const workingCopy1 = await manager.resolve(); + + const workingCopy2 = await manager.resolve({ untitledResource: workingCopy1.resource }); + assert.strictEqual(workingCopy1, workingCopy2); + + const workingCopy3 = await manager.resolve({ untitledResource: URI.file('/invalid/untitled') }); + assert.strictEqual(workingCopy3.resource.scheme, Schemas.untitled); + + workingCopy1.dispose(); + workingCopy2.dispose(); + workingCopy3.dispose(); + }); + + test('resolve - with associated resource', async () => { + const workingCopy = await manager.resolve({ associatedResource: { path: '/some/associated.txt' } }); + + assert.strictEqual(workingCopy.hasAssociatedFilePath, true); + assert.strictEqual(workingCopy.resource.path, '/some/associated.txt'); + }); + + test('destroy', async () => { + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + + await manager.resolve(); + await manager.resolve(); + await manager.resolve(); + + 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); + }); +}); From 44dec56af8c59e75436a596a8b25651e774c6a4d Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 17 May 2021 16:32:14 +0200 Subject: [PATCH 11/63] untitled file working copy - :lipstick: --- .../workingCopy/common/untitledFileWorkingCopyManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts index 3585096df18..674cca7295a 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -158,8 +158,11 @@ export class UntitledFileWorkingCopyManager Date: Tue, 18 May 2021 07:58:10 +0200 Subject: [PATCH 12/63] untitled file working copy - extract reusable interfaces --- .../common/abstractFileWorkingCopy.ts | 99 +++++++++++++++++++ .../workingCopy/common/fileWorkingCopy.ts | 82 ++------------- .../common/untitledFileWorkingCopy.ts | 48 ++++----- .../browser/untitledFileWorkingCopy.test.ts | 6 +- .../untitledFileWorkingCopyManager.test.ts | 2 +- 5 files changed, 129 insertions(+), 108 deletions(-) create mode 100644 src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts new file mode 100644 index 00000000000..36b4f2ac181 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from 'vs/base/common/lifecycle'; +import { Event } from 'vs/base/common/event'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { VSBufferReadableStream } from 'vs/base/common/buffer'; +import { URI } from 'vs/base/common/uri'; +import { IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopy'; + +export interface IBaseFileWorkingCopyModelFactory { + + /** + * Create a model 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; +} + +/** + * A generic file working copy model to be reused by untitled + * and titled file working copies. + */ +export interface IBaseFileWorkingCopyModel extends IDisposable { + + /** + * 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 `IBaseFileWorkingCopyModel#update` is invoked with contents + * that are different from the current contents + * + * 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; + + /** + * An event emitted right before disposing the model. + */ + readonly onWillDispose: Event; + + /** + * Snapshots the model's current content for writing. This must include + * any changes that were made to the model that are in memory. + * + * @param token support for cancellation + */ + snapshot(token: CancellationToken): Promise; + + /** + * Updates the model with the provided contents. The implementation should + * behave in a similar fashion as `IBaseFileWorkingCopyModelFactory#createModel` + * except that here the model already exists and just needs to update to + * the provided contents. + * + * Note: it is expected that the model fires a `onDidChangeContent` event + * as part of the update. + * + * @param the contents to use for the model + * @param token support for cancellation + */ + update(contents: VSBufferReadableStream, token: CancellationToken): Promise; +} + +export interface IBaseFileWorkingCopy extends IWorkingCopy, IDisposable { + + /** + * An event for when the file working copy has been reverted. + */ + readonly onDidRevert: Event; + + /** + * An event for when the file working copy has been disposed. + */ + 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; + + /** + * Resolves the file working copy and thus makes the `model` + * available. + */ + resolve(): Promise; +} diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts index ba40ccd1df1..5a7e140a6d8 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopy.ts @@ -7,7 +7,6 @@ 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'; @@ -28,23 +27,12 @@ import { IWorkingCopyEditorService } from 'vs/workbench/services/workingCopy/com 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 { IBaseFileWorkingCopy, IBaseFileWorkingCopyModel, IBaseFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopy'; -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 - * - * @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; -} +/** + * File specific working copy model factory. + */ +export interface IFileWorkingCopyModelFactory extends IBaseFileWorkingCopyModelFactory { } /** * The underlying model of a file working copy provides some @@ -52,30 +40,10 @@ export interface IFileWorkingCopyModelFactory { * typically only available after the working copy has been * resolved via it's `resolve()` method. */ -export interface IFileWorkingCopyModel extends IDisposable { +export interface IFileWorkingCopyModel extends IBaseFileWorkingCopyModel { - /** - * This event signals ANY changes to the contents of the file - * working copy model, 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 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; - /** - * 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 @@ -91,28 +59,6 @@ export interface IFileWorkingCopyModel extends IDisposable { */ 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. - * - * @param token support for cancellation - */ - snapshot(token: CancellationToken): Promise; - - /** - * Updates the model with the provided contents. The implementation should - * behave in a similar fashion as `IFileWorkingCopyModelFactory#createModel` - * except that here the model already exists and just needs to update to - * the provided contents. - * - * Note: it is expected that the model fires a `onDidChangeContent` event - * as part of the update. - * - * @param the contents to use for the model - * @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. @@ -143,7 +89,7 @@ export interface IFileWorkingCopyModelContentChangedEvent { * of functionality can be built on top, such as saving in * a secure way to prevent data loss. */ -export interface IFileWorkingCopy extends IResourceWorkingCopy { +export interface IFileWorkingCopy extends IResourceWorkingCopy, IBaseFileWorkingCopy { /** * An event for when a file working copy was resolved. @@ -160,23 +106,11 @@ export interface IFileWorkingCopy extends IReso */ readonly onDidSaveError: Event; - /** - * An event for when the file working copy was reverted. - */ - readonly onDidRevert: Event; - /** * An event for when the readonly state of the file working copy changes. */ readonly onDidChangeReadonly: 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; - /** * Resolves a file working copy. */ @@ -217,7 +151,7 @@ export interface IFileWorkingCopy extends IReso 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; } diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts index 0d59f0fa23d..77a33c99997 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -5,9 +5,9 @@ import { Event, Emitter } from 'vs/base/common/event'; import { VSBufferReadableStream } from 'vs/base/common/buffer'; -import { IFileWorkingCopyModel, IFileWorkingCopyModelContentChangedEvent, IFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; -import { IWorkingCopy, IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; -import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { IWorkingCopyBackup, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; +import { IBaseFileWorkingCopy, IBaseFileWorkingCopyModel, IBaseFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopy'; +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'; @@ -18,13 +18,23 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; import { emptyStream } from 'vs/base/common/stream'; -export interface IUntitledFileWorkingCopyModelFactory extends IFileWorkingCopyModelFactory { } +/** + * Untitled file specific working copy model factory. + */ +export interface IUntitledFileWorkingCopyModelFactory extends IBaseFileWorkingCopyModelFactory { } + +/** + * 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 IBaseFileWorkingCopyModel { -export interface IUntitledFileWorkingCopyModel extends IFileWorkingCopyModel { readonly onDidChangeContent: Event; } -export interface IUntitledFileWorkingCopyModelContentChangedEvent extends IFileWorkingCopyModelContentChangedEvent { +export interface IUntitledFileWorkingCopyModelContentChangedEvent { /** * Flag that indicates that the content change @@ -35,40 +45,18 @@ export interface IUntitledFileWorkingCopyModelContentChangedEvent extends IFileW readonly isEmpty: boolean; } -export interface IUntitledFileWorkingCopy extends IWorkingCopy, IDisposable { - - /** - * Emits an event when this untitled model is reverted. - */ - readonly onDidRevert: Event; - - /** - * An event for when the file working copy has been disposed. - */ - readonly onWillDispose: Event; - - /** - * Provides access to the underlying model of this untitled - * file based working copy. As long as the untitled file working - * copy has not been resolved, the model is `undefined`. - */ - readonly model: T | undefined; +export interface IUntitledFileWorkingCopy extends IBaseFileWorkingCopy { /** * Whether this untitled file working copy model has an associated file path. */ readonly hasAssociatedFilePath: boolean; - - /** - * Resolves an untitled file working copy. - */ - resolve(): Promise; } export interface IResolvedUntitledFileWorkingCopy extends IUntitledFileWorkingCopy { /** - * A resolved untitled file working copy has a resolved model `T`. + * A resolved untitled file working copy has a resolved model. */ readonly model: T; } diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts index 8c7da14566a..d4ed35dd986 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts @@ -61,7 +61,7 @@ export class TestUntitledFileWorkingCopyModel extends Disposable implements IUnt this.versionId++; - this._onDidChangeContent.fire({ isRedoing: false, isUndoing: false, isEmpty: newContents.length === 0 }); + this._onDidChangeContent.fire({ isEmpty: newContents.length === 0 }); } versionId = 0; @@ -169,7 +169,7 @@ suite('UntitledFileWorkingCopy', () => { workingCopy.model?.updateContents('hello dirty'); assert.strictEqual(workingCopy.isDirty(), true); - workingCopy.model?.fireContentChangeEvent({ isEmpty: true, isUndoing: false, isRedoing: false }); + workingCopy.model?.fireContentChangeEvent({ isEmpty: true }); assert.strictEqual(workingCopy.isDirty(), false); }); @@ -183,7 +183,7 @@ suite('UntitledFileWorkingCopy', () => { workingCopy.model?.updateContents('hello dirty'); assert.strictEqual(workingCopy.isDirty(), true); - workingCopy.model?.fireContentChangeEvent({ isEmpty: true, isUndoing: false, isRedoing: false }); + workingCopy.model?.fireContentChangeEvent({ isEmpty: true }); assert.strictEqual(workingCopy.isDirty(), true); }); diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts index 417fad0cfbe..1dcc6163294 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts @@ -75,7 +75,7 @@ suite('UntitledFileWorkingCopyManager', () => { assert.strictEqual(workingCopy1.isDirty(), false); assert.strictEqual(dirtyCounter, 2); - workingCopy2.model?.fireContentChangeEvent({ isEmpty: false, isRedoing: false, isUndoing: false }); + workingCopy2.model?.fireContentChangeEvent({ isEmpty: false }); assert.strictEqual(workingCopy2.isDirty(), true); assert.strictEqual(dirtyCounter, 3); From a3ee06b3e030dbbfc15c1f9d87fc9938a764429f Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 18 May 2021 08:30:01 +0200 Subject: [PATCH 13/63] untitled file working copy - extract common super type for manager --- .../common/abstractFileWorkingCopyManager.ts | 133 +++++++++++++ .../common/fileWorkingCopyManager.ts | 109 ++--------- .../common/untitledFileWorkingCopyManager.ts | 178 ++++-------------- 3 files changed, 187 insertions(+), 233 deletions(-) create mode 100644 src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts 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..42773b88bf1 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { IBaseFileWorkingCopy, IBaseFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopy'; + +export interface IBaseFileWorkingCopyManager> extends IDisposable { + + /** + * 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 mapResourceToWorkingCopy = new ResourceMap(); + + constructor( + @IFileService protected readonly fileService: IFileService, + @ILogService protected readonly logService: ILogService, + @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService + ) { + super(); + } + + protected has(resource: URI): boolean { + return this.mapResourceToWorkingCopy.has(resource); + } + + protected add(resource: URI, workingCopy: W): void { + this.mapResourceToWorkingCopy.set(resource, workingCopy); + } + + protected remove(resource: URI): void { + 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(); + } + + 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/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index 72a7e3f3c5e..c1bc7d08104 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -3,7 +3,7 @@ * 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 { 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'; @@ -22,13 +22,14 @@ import { IWorkingCopyFileService, WorkingCopyFileEvent } from 'vs/workbench/serv 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'; /** * 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. @@ -60,17 +61,6 @@ export interface IFileWorkingCopyManager extend */ 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 @@ -119,18 +109,6 @@ export interface IFileWorkingCopyManager extend * 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; } export interface IFileWorkingCopySaveEvent { @@ -180,7 +158,7 @@ export interface IFileWorkingCopySaveAsOptions extends IFileWorkingCopySaveOptio suggestedTarget?: URI; } -export class FileWorkingCopyManager extends Disposable implements IFileWorkingCopyManager { +export class FileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IFileWorkingCopyManager { //#region Events @@ -204,7 +182,6 @@ export class FileWorkingCopyManager extends Dis //#endregion - private readonly mapResourceToWorkingCopy = new ResourceMap>(); private readonly mapResourceToWorkingCopyListeners = new ResourceMap(); private readonly mapResourceToDisposeListener = new ResourceMap(); private readonly mapResourceToPendingWorkingCopyResolve = new ResourceMap>(); @@ -214,17 +191,17 @@ export class FileWorkingCopyManager extends Dis constructor( private readonly workingCopyTypeId: string, private readonly modelFactory: IFileWorkingCopyModelFactory, - @IFileService private readonly fileService: IFileService, + @IFileService fileService: IFileService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @ILabelService private readonly labelService: ILabelService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @ILogService private readonly logService: ILogService, + @ILogService logService: ILogService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService, - @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, + @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService ) { - super(); + super(fileService, logService, workingCopyBackupService); this.registerListeners(); } @@ -411,18 +388,6 @@ export class FileWorkingCopyManager extends Dis //#endregion - //#region Get / Get all - - get workingCopies(): IFileWorkingCopy[] { - return [...this.mapResourceToWorkingCopy.values()]; - } - - get(resource: URI): IFileWorkingCopy | undefined { - return this.mapResourceToWorkingCopy.get(resource); - } - - //#endregion - //#region Resolve async resolve(resource: URI, options?: IFileWorkingCopyResolveOptions): Promise> { @@ -554,8 +519,8 @@ export class FileWorkingCopyManager extends Dis this.mapResourceToWorkingCopyListeners.set(workingCopy.resource, workingCopyListeners); } - private add(resource: URI, workingCopy: IFileWorkingCopy): void { - const knownWorkingCopy = this.mapResourceToWorkingCopy.get(resource); + protected override add(resource: URI, workingCopy: IFileWorkingCopy): void { + const knownWorkingCopy = this.get(resource); if (knownWorkingCopy === workingCopy) { return; // already cached } @@ -567,12 +532,12 @@ export class FileWorkingCopyManager extends Dis } // Store in cache but remove when working copy gets disposed - this.mapResourceToWorkingCopy.set(resource, workingCopy); + super.add(resource, workingCopy); this.mapResourceToDisposeListener.set(resource, workingCopy.onWillDispose(() => this.remove(resource))); } - private remove(resource: URI): void { - this.mapResourceToWorkingCopy.delete(resource); + protected override remove(resource: URI): void { + super.remove(resource); const disposeListener = this.mapResourceToDisposeListener.get(resource); if (disposeListener) { @@ -723,15 +688,6 @@ export class FileWorkingCopyManager extends Dis 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 @@ -743,44 +699,5 @@ export class FileWorkingCopyManager extends Dis 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 }); - } - } - } - //#endregion } diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts index 674cca7295a..3b2e2d4e9d4 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -4,18 +4,47 @@ *--------------------------------------------------------------------------------------------*/ import { VSBufferReadableStream } from 'vs/base/common/buffer'; -import { Disposable, DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { IUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelFactory, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; import { Event, Emitter } from 'vs/base/common/event'; -import { ResourceMap } from 'vs/base/common/map'; import { Schemas } from 'vs/base/common/network'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; -import { Promises } from 'vs/base/common/async'; 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'; + +/** + * 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>; + + /** + * Resolves an untitled file working copy from the provided options. + */ + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + + /** + * Resolves an untitled file working copy from the provided options + * unless an existing working copy already exists with that resource. + */ + resolve(options?: IExistingUntitledFileWorkingCopyOptions): Promise>; +} export interface INewUntitledFileWorkingCopyOptions { @@ -52,62 +81,9 @@ export interface IExistingUntitledFileWorkingCopyOptions extends INewUntitledFil untitledResource: URI; } -/** - * 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 IDisposable { - - /** - * 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>; - - /** - * Access to all known untitled file working copies within the manager. - */ - readonly workingCopies: readonly IUntitledFileWorkingCopy[]; - - /** - * Returns an existing untitled file working copy if already created before - * or `undefined` otherwise. - */ - get(resource: URI): IUntitledFileWorkingCopy | undefined; - - /** - * Resolves an untitled file working copy from the provided options. - */ - resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; - resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; - - /** - * Resolves an untitled file working copy from the provided options - * unless an existing working copy already exists with that resource. - */ - resolve(options?: IExistingUntitledFileWorkingCopyOptions): 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; -} - type IInternalUntitledFileWorkingCopyOptions = IExistingUntitledFileWorkingCopyOptions & INewUntitledFileWorkingCopyWithAssociatedResourceOptions; -export class UntitledFileWorkingCopyManager extends Disposable implements IUntitledFileWorkingCopyManager { +export class UntitledFileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IUntitledFileWorkingCopyManager { //#region Events @@ -119,32 +95,18 @@ export class UntitledFileWorkingCopyManager>(); - constructor( private readonly workingCopyTypeId: string, private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILabelService private readonly labelService: ILabelService, - @ILogService private readonly logService: ILogService, - @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, - @IFileService private readonly fileService: IFileService + @ILogService logService: ILogService, + @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, + @IFileService fileService: IFileService ) { - super(); + super(fileService, logService, workingCopyBackupService); } - //#region Get / Get all - - get workingCopies(): IUntitledFileWorkingCopy[] { - return [...this.mapResourceToWorkingCopy.values()]; - } - - get(resource: URI): IUntitledFileWorkingCopy | undefined { - return this.mapResourceToWorkingCopy.get(resource); - } - - //#endregion - //#region Resolve async resolve(options?: IInternalUntitledFileWorkingCopyOptions): Promise> { @@ -159,7 +121,7 @@ export class UntitledFileWorkingCopyManager { // Registry - this.mapResourceToWorkingCopy.delete(workingCopy.resource); + this.remove(workingCopy.resource); // Listeners listeners.dispose(); }); // Add to cache - this.mapResourceToWorkingCopy.set(workingCopy.resource, workingCopy); + this.add(workingCopy.resource, workingCopy); // If the working copy is dirty right from the beginning, // make sure to emit this as an event @@ -250,62 +212,4 @@ export class UntitledFileWorkingCopyManager { - const workingCopies = Array.from(this.mapResourceToWorkingCopy.values()); - - // Make sure all dirty working copies are saved to disk - try { - await Promises.settled(workingCopies.map(async workingCopy => { - if (workingCopy.isDirty()) { - await this.saveWithFallback(workingCopy); - } - })); - } catch (error) { - this.logService.error(error); - } - - // Dispose all working copies - dispose(workingCopies); - - // Finally dispose manager - this.dispose(); - } - - private async saveWithFallback(workingCopy: IUntitledFileWorkingCopy): 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 - } } From 8173bd132fb14278e9b2573e44de61db0658d4a9 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 18 May 2021 10:22:03 +0200 Subject: [PATCH 14/63] untitled file working copy - add workingcopyservice#get --- .../services/textfile/browser/textFileService.ts | 2 +- .../services/workingCopy/common/workingCopyService.ts | 10 ++++++++++ .../workingCopy/test/common/workingCopyService.test.ts | 2 ++ src/vs/workbench/test/browser/workbenchTestServices.ts | 3 +++ 4 files changed, 16 insertions(+), 1 deletion(-) 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/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/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/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 6a23b7a9bf0..31a0a9caab2 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -261,8 +261,11 @@ export class TestServiceAccessor { @IModelService public modelService: ModelServiceImpl, @IFileService public fileService: TestFileService, @IFileDialogService public fileDialogService: TestFileDialogService, + @IDialogService public dialogService: IDialogService, @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, From 1edef157350d401c232afe80eb6af54b310ea3da Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 18 May 2021 11:05:26 +0200 Subject: [PATCH 15/63] untitled file working copy - wire in save support --- .../notebook/common/notebookEditorModel.ts | 6 +- .../common/abstractFileWorkingCopy.ts | 13 + .../common/abstractFileWorkingCopyManager.ts | 253 +++++++++++++++++- .../common/fileWorkingCopyManager.ts | 129 ++------- .../common/untitledFileWorkingCopy.ts | 18 +- .../common/untitledFileWorkingCopyManager.ts | 37 ++- .../test/browser/fileWorkingCopy.test.ts | 2 +- .../browser/fileWorkingCopyManager.test.ts | 30 ++- .../browser/untitledFileWorkingCopy.test.ts | 1 + .../untitledFileWorkingCopyManager.test.ts | 17 +- 10 files changed, 373 insertions(+), 133 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts index 511261f2ada..03cb38b4b63 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts @@ -28,7 +28,7 @@ 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 { IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; import { filter } from 'vs/base/common/objects'; //#region --- complex content provider @@ -491,8 +491,8 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE 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; } diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts index 36b4f2ac181..aa49c394e09 100644 --- a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts @@ -96,4 +96,17 @@ export interface IBaseFileWorkingCopy exten * available. */ resolve(): Promise; + + /** + * Whether we have a resolved model or not. + */ + isResolved(): this is IBaseResolvedFileWorkingCopy; +} + +export interface IBaseResolvedFileWorkingCopy extends IBaseFileWorkingCopy { + + /** + * A resolved file working copy has a resolved model. + */ + readonly model: T; } diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts index 42773b88bf1..e9021cb612d 100644 --- a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from 'vs/nls'; import { Disposable, dispose, IDisposable } from 'vs/base/common/lifecycle'; import { ResourceMap } from 'vs/base/common/map'; import { Promises } from 'vs/base/common/async'; @@ -11,6 +12,19 @@ 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 { IBaseFileWorkingCopy, IBaseFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopy'; +import { FileWorkingCopy } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; +import { IConfirmation, IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { basename, dirname, isEqual, joinPath, toLocalResource } from 'vs/base/common/resources'; +import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { VSBufferReadableStream } from 'vs/base/common/buffer'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { IPathService } from 'vs/workbench/services/path/common/pathService'; +import { isValidBasename } from 'vs/base/common/extpath'; +import { ISaveOptions } from 'vs/workbench/common/editor'; export interface IBaseFileWorkingCopyManager> extends IDisposable { @@ -38,14 +52,31 @@ export interface IBaseFileWorkingCopyManager; } +export interface IBaseFileWorkingCopySaveAsOptions extends ISaveOptions { + + /** + * Optional target resource to suggest to the user in case + * no taget resource is provided to save to. + */ + suggestedTarget?: URI; +} + export abstract class BaseFileWorkingCopyManager> extends Disposable implements IBaseFileWorkingCopyManager { private readonly mapResourceToWorkingCopy = new ResourceMap(); constructor( + protected readonly workingCopyTypeId: string, @IFileService protected readonly fileService: IFileService, @ILogService protected readonly logService: ILogService, - @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService + @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IUriIdentityService protected readonly uriIdentityService: IUriIdentityService, + @IWorkingCopyFileService protected readonly workingCopyFileService: IWorkingCopyFileService, + @IDialogService private readonly dialogService: IDialogService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IPathService private readonly pathService: IPathService ) { super(); } @@ -72,6 +103,226 @@ export abstract class BaseFileWorkingCopyManager { + const workingCopy = this.get(resource); + + // Untitled + if (workingCopy instanceof UntitledFileWorkingCopy) { + let targetUri: URI | undefined; + + // Untitled with associated file path is taken as is + if (workingCopy.hasAssociatedFilePath) { + targetUri = await this.suggestSavePath(resource); + } + + // Otherwise ask user for a target path to save to + else { + targetUri = await this.fileDialogService.pickFileToSave(await this.suggestSavePath(resource), options?.availableFileSystems); + } + + // Save as if target provided + if (targetUri) { + return this.saveAs(resource, targetUri, options); + } + } + + // File + else if (workingCopy) { + const success = await workingCopy.save(options); + if (success) { + return workingCopy; + } + } + + return undefined; + } + + async saveAs(source: URI, target?: URI, options?: IBaseFileWorkingCopySaveAsOptions): Promise { + + // Get to target resource + if (!target) { + 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 + if (isEqual(source, target)) { + return this.save(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))) { + 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. + let targetWorkingCopy = await this.save(source, options); + if (!targetWorkingCopy) { + targetWorkingCopy = await this.save(target, options); + } + + return targetWorkingCopy; + } + + // Do it + return this.doSaveAs(source, target, options); + } + + private async doSaveAs(source: URI, target: URI, options?: IBaseFileWorkingCopySaveAsOptions): Promise { + let sourceContents: VSBufferReadableStream; + + // If the source is an existing file working copy, we can directly + // use that to copy the contents to the target destination + const sourceWorkingCopy = this.get(source); + if (sourceWorkingCopy?.isResolved()) { + sourceContents = await sourceWorkingCopy.model.snapshot(CancellationToken.None); + } + + // Otherwise we resolve the contents from the underlying file + else { + 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, sourceWorkingCopy, sourceContents, target, options); + } + + private async doSaveAsWorkingCopy(source: URI, sourceWorkingCopy: W | undefined, sourceContents: VSBufferReadableStream, target: URI, options?: IBaseFileWorkingCopySaveAsOptions): Promise { + + // Prefer an existing file working copy if it is already resolved + // for the given target resource + let targetFileExists = false; + let targetFileWorkingCopy = this.getFile(target); + if (targetFileWorkingCopy?.isResolved()) { + targetFileExists = true; + } + + // Otherwise create the target working copy empty if + // it does not exist already and resolve it from there + else { + targetFileExists = await this.fileService.exists(target); + + // Create target file adhoc if it does not exist yet + if (!targetFileExists) { + await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); + } + + // At this point we need to resolve the target working copy + // 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 + // would potentially introduce a + if (this.uriIdentityService.extUri.isEqual(source, target) && this.get(source)) { + targetFileWorkingCopy = await this.doResolve(source); + } else { + targetFileWorkingCopy = await this.doResolve(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 targetFileWorkingCopy.model?.update(sourceContents, CancellationToken.None); + + // Save target + await targetFileWorkingCopy.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 targetFileWorkingCopy; + } + + protected abstract doResolve(resource: URI): Promise; + + 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 { + + // Just take the resource as is if the file service can handle it + if (this.fileService.canHandleResource(resource)) { + return resource; + } + + const remoteAuthority = this.environmentService.remoteAuthority; + const workingCopy = this.get(resource); + + // Otherwise try to suggest a path that can be saved + let suggestedFilename: string | undefined = undefined; + if (workingCopy instanceof UntitledFileWorkingCopy) { + + // Untitled with associated file path + if (workingCopy.hasAssociatedFilePath) { + return toLocalResource(resource, remoteAuthority, this.pathService.defaultUriScheme); + } + + // Untitled without associated file path: use name + // of untitled working copy if it is a valid path name + let untitledName = workingCopy.name; + if (!isValidBasename(untitledName)) { + untitledName = basename(resource); + } + + suggestedFilename = untitledName; + } + + // Fallback to basename of resource + if (!suggestedFilename) { + suggestedFilename = basename(resource); + } + + // Try to place where last active file was if any + // Otherwise fallback to user home + return joinPath(await this.fileDialogService.defaultFilePath(), suggestedFilename); + } + //#endregion //#region Lifecycle diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index c1bc7d08104..d1222647321 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -16,13 +16,16 @@ 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 { IDialogService, 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 { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; -import { BaseFileWorkingCopyManager, IBaseFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; +import { BaseFileWorkingCopyManager, IBaseFileWorkingCopyManager, IBaseFileWorkingCopySaveAsOptions } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { IPathService } from 'vs/workbench/services/path/common/pathService'; /** * The only one that should be dealing with `IFileWorkingCopy` and handle all @@ -101,7 +104,7 @@ export interface IFileWorkingCopyManager extend * cancellation */ saveAs(source: URI, target: URI, options?: IFileWorkingCopySaveOptions): Promise | undefined>; - saveAs(source: URI, target: undefined, options?: IFileWorkingCopySaveAsOptions): Promise | undefined>; + saveAs(source: URI, target: undefined, options?: IBaseFileWorkingCopySaveAsOptions): Promise | undefined>; /** * Waits for the file working copy to be ready to be disposed. There may be @@ -149,15 +152,6 @@ export interface IFileWorkingCopyResolveOptions { }; } -export interface IFileWorkingCopySaveAsOptions extends IFileWorkingCopySaveOptions { - - /** - * Optional target resource to suggest to the user in case - * no taget resource is provided to save to. - */ - suggestedTarget?: URI; -} - export class FileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IFileWorkingCopyManager { //#region Events @@ -189,19 +183,23 @@ export class FileWorkingCopyManager extends Bas private readonly workingCopyResolveQueue = this._register(new ResourceQueue()); constructor( - private readonly workingCopyTypeId: string, + workingCopyTypeId: string, private readonly modelFactory: IFileWorkingCopyModelFactory, @IFileService fileService: IFileService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @ILabelService private readonly labelService: ILabelService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService logService: ILogService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService, + @IFileDialogService fileDialogService: IFileDialogService, + @IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService, @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService + @IUriIdentityService uriIdentityService: IUriIdentityService, + @IDialogService dialogService: IDialogService, + @IWorkingCopyService workingCopyService: IWorkingCopyService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IPathService pathService: IPathService ) { - super(fileService, logService, workingCopyBackupService); + super(workingCopyTypeId, fileService, logService, workingCopyBackupService, fileDialogService, uriIdentityService, workingCopyFileService, dialogService, workingCopyService, environmentService, pathService); this.registerListeners(); } @@ -496,6 +494,10 @@ export class FileWorkingCopyManager extends Bas } } + protected doResolve(resource: URI): Promise> { + return this.resolve(resource); + } + private joinPendingResolve(resource: URI): Promise | undefined { const pendingWorkingCopyResolve = this.mapResourceToPendingWorkingCopyResolve.get(resource); if (pendingWorkingCopyResolve) { @@ -554,99 +556,6 @@ export class FileWorkingCopyManager extends Bas //#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> { - let sourceContents: VSBufferReadableStream; - - // If the source is an existing file working copy, we can directly - // use that to copy the contents to the target destination - const sourceWorkingCopy = this.get(source); - if (sourceWorkingCopy?.isResolved()) { - sourceContents = await sourceWorkingCopy.model.snapshot(CancellationToken.None); - } - - // Otherwise we resolve the contents from the underlying file - else { - 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); - } - - private async doSaveAsWorkingCopy(source: URI, sourceContents: VSBufferReadableStream, target: URI, options?: IFileWorkingCopySaveAsOptions): Promise> { - - // Prefer an existing 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; - } - - // 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); - - // Create target file adhoc if it does not exist yet - if (!targetExists) { - await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); - } - - // At this point we need to resolve the target working copy - // 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 - // would potentially introduce a - if (this.uriIdentityService.extUri.isEqual(source, target) && this.get(source)) { - targetWorkingCopy = await this.resolve(source); - } else { - targetWorkingCopy = await this.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; - } - - private async doRevert(resource: URI): Promise { - const workingCopy = this.get(resource); - if (!workingCopy) { - return undefined; - } - - return workingCopy.revert(); - } - - //#endregion - //#region Lifecycle canDispose(workingCopy: IFileWorkingCopy): true | Promise { diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts index 77a33c99997..21891effe76 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -61,6 +61,14 @@ export interface IResolvedUntitledFileWorkingCopy { + + /** + * 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; @@ -91,6 +99,7 @@ export class UntitledFileWorkingCopy ex 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 @@ -235,14 +244,7 @@ export class UntitledFileWorkingCopy ex async save(options?: ISaveOptions): Promise { this.trace('[untitled file working copy] save() - enter'); - // TODO needs to extract the code for bringing up save dialog - // or use the associated file path as target - // Also, who is disposing the untitled after save and open the - // new editor? - - await this.revert(); - - return true; + return this.saveDelegate(this, options); } //#endregion diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts index 3b2e2d4e9d4..344c038662f 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -15,6 +15,12 @@ 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 { IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +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 { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; +import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; /** * The only one that should be dealing with `IUntitledFileWorkingCopy` and @@ -81,7 +87,7 @@ export interface IExistingUntitledFileWorkingCopyOptions extends INewUntitledFil untitledResource: URI; } -type IInternalUntitledFileWorkingCopyOptions = IExistingUntitledFileWorkingCopyOptions & INewUntitledFileWorkingCopyWithAssociatedResourceOptions; +type IInternalUntitledFileWorkingCopyOptions = INewUntitledFileWorkingCopyOptions & INewUntitledFileWorkingCopyWithAssociatedResourceOptions & IExistingUntitledFileWorkingCopyOptions; export class UntitledFileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IUntitledFileWorkingCopyManager { @@ -96,19 +102,29 @@ export class UntitledFileWorkingCopyManager, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IFileService fileService: IFileService, @ILabelService private readonly labelService: ILabelService, + @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService logService: ILogService, + @IFileDialogService fileDialogService: IFileDialogService, + @IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService, @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, - @IFileService fileService: IFileService + @IUriIdentityService uriIdentityService: IUriIdentityService, + @IDialogService dialogService: IDialogService, + @IWorkingCopyService workingCopyService: IWorkingCopyService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IPathService pathService: IPathService ) { - super(fileService, logService, workingCopyBackupService); + super(workingCopyTypeId, fileService, logService, workingCopyBackupService, fileDialogService, uriIdentityService, workingCopyFileService, dialogService, workingCopyService, environmentService, pathService); } //#region Resolve + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + resolve(options?: IExistingUntitledFileWorkingCopyOptions): Promise>; async resolve(options?: IInternalUntitledFileWorkingCopyOptions): Promise> { const workingCopy = this.doCreateOrGet(options); await workingCopy.resolve(); @@ -116,6 +132,10 @@ export class UntitledFileWorkingCopyManager> { + return this.resolve({ untitledResource: resource }); + } + private doCreateOrGet(options: IInternalUntitledFileWorkingCopyOptions = Object.create(null)): IUntitledFileWorkingCopy { const massagedOptions = this.massageOptions(options); @@ -176,7 +196,12 @@ export class UntitledFileWorkingCopyManager (async () => { + const result = await this.save(workingCopy.resource, options); + + return result ? true : false; + })() ) as unknown as IUntitledFileWorkingCopy; this.registerWorkingCopy(workingCopy); diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts index 47ab58bb6a7..81ef604cc9e 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts @@ -98,7 +98,7 @@ suite('FileWorkingCopy', function () { let workingCopy: FileWorkingCopy; 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 FileWorkingCopy('testFileWorkingCopyType', 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(() => { 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..d730021d380 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts @@ -27,7 +27,23 @@ suite('FileWorkingCopyManager', () => { 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); + manager = new FileWorkingCopyManager( + 'testFileWorkingCopyType', + factory, + accessor.fileService, + accessor.lifecycleService, + accessor.labelService, + instantiationService, + accessor.logService, + accessor.fileDialogService, + accessor.workingCopyFileService, + accessor.workingCopyBackupService, + accessor.uriIdentityService, + accessor.dialogService, + accessor.workingCopyService, + accessor.environmentService, + accessor.pathService + ); }); teardown(() => { @@ -49,7 +65,7 @@ suite('FileWorkingCopyManager', () => { const workingCopy1 = await resolvePromise; assert.ok(workingCopy1); assert.ok(workingCopy1.model); - assert.strictEqual(workingCopy1.typeId, 'testWorkingCopyType'); + assert.strictEqual(workingCopy1.typeId, 'testFileWorkingCopyType'); assert.strictEqual(manager.get(resource), workingCopy1); const workingCopy2 = await manager.resolve(resource); @@ -527,7 +543,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) { diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts index d4ed35dd986..39596dcfffa 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts @@ -103,6 +103,7 @@ suite('UntitledFileWorkingCopy', () => { hasAssociatedFilePath, initialValue.length > 0 ? bufferToStream(VSBuffer.fromString(initialValue)) : undefined, factory, + async (workingCopy, options) => { return true; }, accessor.workingCopyService, accessor.workingCopyBackupService, accessor.logService diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts index 1dcc6163294..424426b8760 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts @@ -25,7 +25,22 @@ suite('UntitledFileWorkingCopyManager', () => { accessor = instantiationService.createInstance(TestServiceAccessor); const factory = new TestUntitledFileWorkingCopyModelFactory(); - manager = new UntitledFileWorkingCopyManager('testUntitledWorkingCopyType', factory, instantiationService, accessor.labelService, accessor.logService, accessor.workingCopyBackupService, accessor.fileService); + manager = new UntitledFileWorkingCopyManager( + 'testUntitledWorkingCopyType', + factory, + accessor.fileService, + accessor.labelService, + instantiationService, + accessor.logService, + accessor.fileDialogService, + accessor.workingCopyFileService, + accessor.workingCopyBackupService, + accessor.uriIdentityService, + accessor.dialogService, + accessor.workingCopyService, + accessor.environmentService, + accessor.pathService + ); }); teardown(() => { From a47fc96766a4a064fbac096edcc459dc9a0dec56 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 18 May 2021 11:11:23 +0200 Subject: [PATCH 16/63] untitled file working copy - fix tests --- .../services/workingCopy/common/untitledFileWorkingCopy.ts | 2 +- .../workingCopy/test/browser/untitledFileWorkingCopy.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts index 21891effe76..ef9b390531f 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -241,7 +241,7 @@ export class UntitledFileWorkingCopy ex //#region Save - async save(options?: ISaveOptions): Promise { + save(options?: ISaveOptions): Promise { this.trace('[untitled file working copy] save() - enter'); return this.saveDelegate(this, options); diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts index 39596dcfffa..16219cd5a69 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test.ts @@ -103,7 +103,7 @@ suite('UntitledFileWorkingCopy', () => { hasAssociatedFilePath, initialValue.length > 0 ? bufferToStream(VSBuffer.fromString(initialValue)) : undefined, factory, - async (workingCopy, options) => { return true; }, + async workingCopy => { await workingCopy.revert(); return true; }, accessor.workingCopyService, accessor.workingCopyBackupService, accessor.logService From 5835fbc9f1d63c1057ee43a1f74f64b7f4bb81c1 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 18 May 2021 14:52:15 +0200 Subject: [PATCH 17/63] untitled file working copy - some code :lipstick: --- .../common/abstractFileWorkingCopyManager.ts | 151 +++++++++--------- .../common/fileWorkingCopyManager.ts | 27 +--- .../common/untitledFileWorkingCopy.ts | 7 +- 3 files changed, 86 insertions(+), 99 deletions(-) diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts index e9021cb612d..f2f143e813e 100644 --- a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts @@ -39,6 +39,29 @@ export interface IBaseFileWorkingCopyManager; + saveAs(source: URI, target: undefined, options?: IBaseFileWorkingCopySaveAsOptions): 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 @@ -119,7 +142,7 @@ export abstract class BaseFileWorkingCopyManager { const workingCopy = this.get(resource); - // Untitled + // Untitled: is always a "Save As" if (workingCopy instanceof UntitledFileWorkingCopy) { let targetUri: URI | undefined; @@ -139,7 +162,7 @@ export abstract class BaseFileWorkingCopyManager { - - // Prefer an existing file working copy if it is already resolved - // for the given target resource - let targetFileExists = false; - let targetFileWorkingCopy = this.getFile(target); - if (targetFileWorkingCopy?.isResolved()) { - targetFileExists = true; - } - - // Otherwise create the target working copy empty if - // it does not exist already and resolve it from there - else { - targetFileExists = await this.fileService.exists(target); - - // Create target file adhoc if it does not exist yet - if (!targetFileExists) { - await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); - } - - // At this point we need to resolve the target working copy - // 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 - // would potentially introduce a - if (this.uriIdentityService.extUri.isEqual(source, target) && this.get(source)) { - targetFileWorkingCopy = await this.doResolve(source); - } else { - targetFileWorkingCopy = await this.doResolve(target); - } - } + // Resolve target + const { targetFileExists, targetFileWorkingCopy } = 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. @@ -270,6 +256,42 @@ export abstract class BaseFileWorkingCopyManager { + + // Prefer an existing file working copy if it is already resolved + // for the given target resource + let targetFileExists = false; + let targetFileWorkingCopy = this.getFile(target); + if (targetFileWorkingCopy?.isResolved()) { + targetFileExists = true; + } + + // Otherwise create the target working copy empty if + // it does not exist already and resolve it from there + else { + targetFileExists = await this.fileService.exists(target); + + // Create target file adhoc if it does not exist yet + if (!targetFileExists) { + await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); + } + + // At this point we need to resolve the target working copy + // 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. Otherwise we + // would potentially introduce a + if (this.uriIdentityService.extUri.isEqual(source, target) && this.has(source)) { + targetFileWorkingCopy = await this.doResolve(source); + } else { + targetFileWorkingCopy = await this.doResolve(target); + } + } + + return { targetFileExists, targetFileWorkingCopy }; + } + protected abstract doResolve(resource: URI): Promise; private async confirmOverwrite(resource: URI): Promise { @@ -286,41 +308,24 @@ export abstract class BaseFileWorkingCopyManager { - // Just take the resource as is if the file service can handle it + // 1.) Just take the resource as is if the file service can handle it if (this.fileService.canHandleResource(resource)) { return resource; } - const remoteAuthority = this.environmentService.remoteAuthority; + // 2.) Pick the associated file path for untitled working copies if any const workingCopy = this.get(resource); - - // Otherwise try to suggest a path that can be saved - let suggestedFilename: string | undefined = undefined; - if (workingCopy instanceof UntitledFileWorkingCopy) { - - // Untitled with associated file path - if (workingCopy.hasAssociatedFilePath) { - return toLocalResource(resource, remoteAuthority, this.pathService.defaultUriScheme); - } - - // Untitled without associated file path: use name - // of untitled working copy if it is a valid path name - let untitledName = workingCopy.name; - if (!isValidBasename(untitledName)) { - untitledName = basename(resource); - } - - suggestedFilename = untitledName; + if (workingCopy instanceof UntitledFileWorkingCopy && workingCopy.hasAssociatedFilePath) { + return toLocalResource(resource, this.environmentService.remoteAuthority, this.pathService.defaultUriScheme); } - // Fallback to basename of resource - if (!suggestedFilename) { - suggestedFilename = basename(resource); + // 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); } - // Try to place where last active file was if any - // Otherwise fallback to user home - return joinPath(await this.fileDialogService.defaultFilePath(), suggestedFilename); + // 4.) Finally fallback to the name of the resource joined with default path + return joinPath(await this.fileDialogService.defaultFilePath(), basename(resource)); } //#endregion diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index d1222647321..80e427d8867 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -5,7 +5,7 @@ import { 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 { FileWorkingCopy, FileWorkingCopyState, IFileWorkingCopy, IFileWorkingCopyModel, IFileWorkingCopyModelFactory } 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'; @@ -22,7 +22,7 @@ import { IWorkingCopyFileService, WorkingCopyFileEvent } from 'vs/workbench/serv 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, IBaseFileWorkingCopySaveAsOptions } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; +import { BaseFileWorkingCopyManager, IBaseFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IPathService } from 'vs/workbench/services/path/common/pathService'; @@ -83,29 +83,6 @@ export interface IFileWorkingCopyManager extend */ resolve(resource: URI, options?: IFileWorkingCopyResolveOptions): Promise>; - /** - * Implements "Save As" for file based working copies. The API is `URI` based - * because it works even without resolved file working copies. If a file working - * copy exists for any given `URI`, the implementation will deal with them properly - * (e.g. dirty contents of the source will be written to the target and the source - * will be reverted). - * - * Note: it is possible that the returned file working copy has a different `URI` - * than the `target` that was passed in. Based on URI identity, the file working - * copy may chose to return an existing file working copy with different casing - * to respect file systems that are case insensitive. - * - * Note: Callers must `dispose` the working copy when no longer needed. - * - * @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 - * cancellation - */ - saveAs(source: URI, target: URI, options?: IFileWorkingCopySaveOptions): Promise | undefined>; - saveAs(source: URI, target: undefined, options?: IBaseFileWorkingCopySaveAsOptions): 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 diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts index ef9b390531f..536517a7431 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -51,6 +51,11 @@ export interface IUntitledFileWorkingCopy; } export interface IResolvedUntitledFileWorkingCopy extends IUntitledFileWorkingCopy { @@ -242,7 +247,7 @@ export class UntitledFileWorkingCopy ex //#region Save save(options?: ISaveOptions): Promise { - this.trace('[untitled file working copy] save() - enter'); + this.trace('[untitled file working copy] save()'); return this.saveDelegate(this, options); } From 37fbfa6125109387676266b2a823c12161baabf7 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 18 May 2021 15:35:56 +0200 Subject: [PATCH 18/63] untitled file working copy - set visibility --- .../workingCopy/common/abstractFileWorkingCopyManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts index f2f143e813e..5ee9c15ead3 100644 --- a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts @@ -139,7 +139,7 @@ export abstract class BaseFileWorkingCopyManager { + protected async save(resource: URI, options?: ISaveOptions): Promise { const workingCopy = this.get(resource); // Untitled: is always a "Save As" From 0d92cb9dd88877cecb8d30150fc7c06251ceb773 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 18 May 2021 18:28:35 +0200 Subject: [PATCH 19/63] untitled file working copy - poperly resolve target --- .../dialogs/test/common/testDialogService.ts | 23 ++- .../common/abstractFileWorkingCopyManager.ts | 84 +++++------ .../common/fileWorkingCopyManager.ts | 27 +++- .../common/untitledFileWorkingCopyManager.ts | 23 ++- .../test/browser/fileWorkingCopy.test.ts | 8 +- .../browser/fileWorkingCopyManager.test.ts | 3 +- .../test/browser/resourceWorkingCopyTest.ts | 2 +- .../untitledFileWorkingCopyManager.test.ts | 132 +++++++++++++++++- .../test/browser/workbenchTestServices.ts | 5 +- 9 files changed, 231 insertions(+), 76 deletions(-) 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/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts index 5ee9c15ead3..337afbf2dae 100644 --- a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts @@ -12,7 +12,8 @@ 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 { IBaseFileWorkingCopy, IBaseFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopy'; -import { FileWorkingCopy } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { FileWorkingCopy, IFileWorkingCopy, IFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { IFileWorkingCopyResolver } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; import { UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; import { IConfirmation, IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { basename, dirname, isEqual, joinPath, toLocalResource } from 'vs/base/common/resources'; @@ -59,8 +60,8 @@ export interface IBaseFileWorkingCopyManager; - saveAs(source: URI, target: undefined, options?: IBaseFileWorkingCopySaveAsOptions): Promise; + saveAs(source: URI, target: URI, options?: ISaveOptions): Promise | undefined>; + saveAs(source: URI, target: undefined, options?: IBaseFileWorkingCopySaveAsOptions): Promise | undefined>; /** * Disposes all working copies of the manager and disposes the manager. This @@ -90,6 +91,7 @@ export abstract class BaseFileWorkingCopyManager | undefined { const workingCopy = this.workingCopyService.get({ resource, typeId: this.workingCopyTypeId }); if (workingCopy instanceof FileWorkingCopy) { - return workingCopy as unknown as W; + return workingCopy; } return undefined; @@ -139,45 +141,16 @@ export abstract class BaseFileWorkingCopyManager { - const workingCopy = this.get(resource); - - // Untitled: is always a "Save As" - if (workingCopy instanceof UntitledFileWorkingCopy) { - let targetUri: URI | undefined; - - // Untitled with associated file path is taken as is - if (workingCopy.hasAssociatedFilePath) { - targetUri = await this.suggestSavePath(resource); - } - - // Otherwise ask user for a target path to save to - else { - targetUri = await this.fileDialogService.pickFileToSave(await this.suggestSavePath(resource), options?.availableFileSystems); - } - - // Save as if target provided - if (targetUri) { - return this.saveAs(resource, targetUri, options); - } - } - - // File: via save method of working copy - else if (workingCopy) { - const success = await workingCopy.save(options); - if (success) { - return workingCopy; - } - } - - return undefined; - } - - async saveAs(source: URI, target?: URI, options?: IBaseFileWorkingCopySaveAsOptions): Promise { + async saveAs(source: URI, target?: URI, options?: IBaseFileWorkingCopySaveAsOptions): Promise | undefined> { // Get to target resource if (!target) { - target = await this.fileDialogService.pickFileToSave(await this.suggestSavePath(options?.suggestedTarget ?? source), options?.availableFileSystems); + 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) { @@ -187,7 +160,7 @@ export abstract class BaseFileWorkingCopyManager { + private async doSave(resource: URI, options?: ISaveOptions): Promise | undefined> { + + // Save is only possible with file working copies, + // any other have to go via `saveAs` flow. + const fileWorkingCopy = this.getFile(resource); + if (fileWorkingCopy) { + const success = await fileWorkingCopy.save(options); + if (success) { + return fileWorkingCopy; + } + } + + return undefined; + } + + private async doSaveAs(source: URI, target: URI, options?: IBaseFileWorkingCopySaveAsOptions): Promise | undefined> { let sourceContents: VSBufferReadableStream; // If the source is an existing file working copy, we can directly @@ -256,7 +244,7 @@ export abstract class BaseFileWorkingCopyManager { + private async doResolveSaveTarget(source: URI, target: URI): Promise<{ targetFileExists: boolean, targetFileWorkingCopy: IFileWorkingCopy }> { // Prefer an existing file working copy if it is already resolved // for the given target resource @@ -283,17 +271,15 @@ export abstract class BaseFileWorkingCopyManager; - private async confirmOverwrite(resource: URI): Promise { const confirm: IConfirmation = { message: localize('confirmOverwrite', "'{0}' already exists. Do you want to replace it?", basename(resource)), diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index 80e427d8867..ebbbb29a82e 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -129,6 +129,14 @@ export interface IFileWorkingCopyResolveOptions { }; } +export interface IFileWorkingCopyResolver { + + /** + * A delegate to resolve a file working copy. + */ + (resource: URI): Promise>; +} + export class FileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IFileWorkingCopyManager { //#region Events @@ -176,7 +184,20 @@ export class FileWorkingCopyManager extends Bas @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IPathService pathService: IPathService ) { - super(workingCopyTypeId, fileService, logService, workingCopyBackupService, fileDialogService, uriIdentityService, workingCopyFileService, dialogService, workingCopyService, environmentService, pathService); + super( + workingCopyTypeId, + resource => this.resolve(resource), + fileService, + logService, + workingCopyBackupService, + fileDialogService, + uriIdentityService, + workingCopyFileService, + dialogService, + workingCopyService, + environmentService, + pathService + ); this.registerListeners(); } @@ -471,10 +492,6 @@ export class FileWorkingCopyManager extends Bas } } - protected doResolve(resource: URI): Promise> { - return this.resolve(resource); - } - private joinPendingResolve(resource: URI): Promise | undefined { const pendingWorkingCopyResolve = this.mapResourceToPendingWorkingCopyResolve.get(resource); if (pendingWorkingCopyResolve) { diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts index 344c038662f..72f230c634d 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -21,6 +21,7 @@ import { IPathService } from 'vs/workbench/services/path/common/pathService'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IFileWorkingCopyResolver } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; /** * The only one that should be dealing with `IUntitledFileWorkingCopy` and @@ -104,6 +105,7 @@ export class UntitledFileWorkingCopyManager, + fileWorkingCopyResolver: IFileWorkingCopyResolver, @IFileService fileService: IFileService, @ILabelService private readonly labelService: ILabelService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -117,7 +119,20 @@ export class UntitledFileWorkingCopyManager fileWorkingCopyResolver(resource), + fileService, + logService, + workingCopyBackupService, + fileDialogService, + uriIdentityService, + workingCopyFileService, + dialogService, + workingCopyService, + environmentService, + pathService + ); } //#region Resolve @@ -132,10 +147,6 @@ export class UntitledFileWorkingCopyManager> { - return this.resolve({ untitledResource: resource }); - } - private doCreateOrGet(options: IInternalUntitledFileWorkingCopyOptions = Object.create(null)): IUntitledFileWorkingCopy { const massagedOptions = this.massageOptions(options); @@ -198,7 +209,7 @@ export class UntitledFileWorkingCopyManager (async () => { - const result = await this.save(workingCopy.resource, options); + const result = await this.saveAs(workingCopy.resource, undefined, options); return result ? true : false; })() diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts index 81ef604cc9e..e01b543faa1 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts @@ -128,7 +128,7 @@ suite('FileWorkingCopy', function () { assert.strictEqual(workingCopy.hasState(FileWorkingCopyState.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; @@ -291,7 +291,7 @@ 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; @@ -318,7 +318,7 @@ 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; @@ -464,7 +464,7 @@ 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; 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 d730021d380..cbc543d430a 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts @@ -26,10 +26,9 @@ suite('FileWorkingCopyManager', () => { instantiationService = workbenchInstantiationService(); accessor = instantiationService.createInstance(TestServiceAccessor); - const factory = new TestFileWorkingCopyModelFactory(); manager = new FileWorkingCopyManager( 'testFileWorkingCopyType', - factory, + new TestFileWorkingCopyModelFactory(), accessor.fileService, accessor.lifecycleService, accessor.labelService, 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/untitledFileWorkingCopyManager.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts index 424426b8760..28119fb7798 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts @@ -8,10 +8,12 @@ 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 { IUntitledFileWorkingCopyManager, UntitledFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager'; import { WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; +import { TestFileWorkingCopyModel, TestFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test'; import { TestUntitledFileWorkingCopyModel, TestUntitledFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test'; -import { TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { TestInMemoryFileSystemProvider, TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; suite('UntitledFileWorkingCopyManager', () => { @@ -19,15 +21,37 @@ suite('UntitledFileWorkingCopyManager', () => { let accessor: TestServiceAccessor; let manager: IUntitledFileWorkingCopyManager; + let fileManager: IFileWorkingCopyManager; setup(() => { instantiationService = workbenchInstantiationService(); accessor = instantiationService.createInstance(TestServiceAccessor); - const factory = new TestUntitledFileWorkingCopyModelFactory(); + accessor.fileService.registerProvider(Schemas.file, new TestInMemoryFileSystemProvider()); + accessor.fileService.registerProvider(Schemas.vscodeRemote, new TestInMemoryFileSystemProvider()); + + fileManager = new FileWorkingCopyManager( + 'testFileWorkingCopyType', + new TestFileWorkingCopyModelFactory(), + accessor.fileService, + accessor.lifecycleService, + accessor.labelService, + instantiationService, + accessor.logService, + accessor.fileDialogService, + accessor.workingCopyFileService, + accessor.workingCopyBackupService, + accessor.uriIdentityService, + accessor.dialogService, + accessor.workingCopyService, + accessor.environmentService, + accessor.pathService + ); + manager = new UntitledFileWorkingCopyManager( 'testUntitledWorkingCopyType', - factory, + new TestUntitledFileWorkingCopyModelFactory(), + resource => fileManager.resolve(resource), accessor.fileService, accessor.labelService, instantiationService, @@ -141,6 +165,108 @@ suite('UntitledFileWorkingCopyManager', () => { 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.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.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('save - with associated resource', async () => { + const workingCopy = await manager.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.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('save - with associated resource (asks to overwrite)', async () => { + const workingCopy = await manager.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.get(workingCopy.resource), workingCopy); + + accessor.dialogService.setConfirmResult({ confirmed: true }); + + result = await workingCopy.save(); + assert.ok(result); // confirmed + + assert.strictEqual(manager.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('saveAs - without associated resource', async () => { + const workingCopy = await manager.resolve(); + workingCopy.model?.updateContents('Simple Save As'); + + const target = URI.file('simple/file.txt'); + accessor.fileDialogService.setPickFileToSave(target); + + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result?.resource.toString(), target.toString()); + + assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As'); + + assert.strictEqual(manager.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('saveAs - with associated resource', async () => { + const workingCopy = await manager.resolve({ associatedResource: { path: '/some/associated.txt' } }); + workingCopy.model?.updateContents('Simple Save As with associated resource'); + + const target = URI.from({ scheme: Schemas.vscodeRemote, path: '/some/associated.txt' }); + + accessor.fileService.notExistsSet.set(target, true); + + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result?.resource.toString(), target.toString()); + + assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As with associated resource'); + + assert.strictEqual(manager.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + test('saveAs - 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 fileManager.resolve(target); + accessor.fileDialogService.setPickFileToSave(target); + + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result, targetFileWorkingCopy); + + assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As'); + + assert.strictEqual(manager.get(workingCopy.resource), undefined); + + workingCopy.dispose(); }); test('destroy', async () => { diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 31a0a9caab2..6ea06def96d 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -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,7 +262,7 @@ export class TestServiceAccessor { @IModelService public modelService: ModelServiceImpl, @IFileService public fileService: TestFileService, @IFileDialogService public fileDialogService: TestFileDialogService, - @IDialogService public dialogService: IDialogService, + @IDialogService public dialogService: TestDialogService, @IWorkingCopyService public workingCopyService: IWorkingCopyService, @IEditorService public editorService: TestEditorService, @IWorkbenchEnvironmentService public environmentService: IWorkbenchEnvironmentService, @@ -870,7 +871,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); } From bc37b284431575c5b74755c19e7aca4aa11441d9 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 19 May 2021 07:35:54 +0200 Subject: [PATCH 20/63] untitled file working copy - shared dispose handling --- .../common/abstractFileWorkingCopy.ts | 2 +- .../common/abstractFileWorkingCopyManager.ts | 24 ++++++++++ .../common/fileWorkingCopyManager.ts | 30 +------------ .../common/untitledFileWorkingCopyManager.ts | 45 +++++++++++++------ 4 files changed, 59 insertions(+), 42 deletions(-) diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts index aa49c394e09..3568f1ca66b 100644 --- a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopy.ts @@ -24,7 +24,7 @@ export interface IBaseFileWorkingCopyModelFactory> extends Disposable implements IBaseFileWorkingCopyManager { private readonly mapResourceToWorkingCopy = new ResourceMap(); + private readonly mapResourceToDisposeListener = new ResourceMap(); constructor( protected readonly workingCopyTypeId: string, @@ -111,10 +112,29 @@ export abstract class BaseFileWorkingCopyManager this.remove(resource))); } 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); } @@ -330,6 +350,10 @@ export abstract class BaseFileWorkingCopyManager { diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index ebbbb29a82e..309f5e80655 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -162,7 +162,6 @@ export class FileWorkingCopyManager extends Bas //#endregion private readonly mapResourceToWorkingCopyListeners = new ResourceMap(); - private readonly mapResourceToDisposeListener = new ResourceMap(); private readonly mapResourceToPendingWorkingCopyResolve = new ResourceMap>(); private readonly workingCopyResolveQueue = this._register(new ResourceQueue()); @@ -515,32 +514,10 @@ export class FileWorkingCopyManager extends Bas this.mapResourceToWorkingCopyListeners.set(workingCopy.resource, workingCopyListeners); } - protected override add(resource: URI, workingCopy: IFileWorkingCopy): void { - const knownWorkingCopy = this.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 - super.add(resource, workingCopy); - this.mapResourceToDisposeListener.set(resource, workingCopy.onWillDispose(() => this.remove(resource))); - } - protected override remove(resource: URI): void { super.remove(resource); - const disposeListener = this.mapResourceToDisposeListener.get(resource); - if (disposeListener) { - dispose(disposeListener); - this.mapResourceToDisposeListener.delete(resource); - } - + // Dispose any exsting working copy listeners const workingCopyListener = this.mapResourceToWorkingCopyListeners.get(resource); if (workingCopyListener) { dispose(workingCopyListener); @@ -591,12 +568,9 @@ export class FileWorkingCopyManager extends Bas override dispose(): void { super.dispose(); + // Clear pending working copy resolves 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(); diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts index 72f230c634d..28ee5986867 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { VSBufferReadableStream } from 'vs/base/common/buffer'; -import { DisposableStore } from 'vs/base/common/lifecycle'; +import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { IUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelFactory, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; import { Event, Emitter } from 'vs/base/common/event'; @@ -22,6 +22,7 @@ import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/ur import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { IFileWorkingCopyResolver } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; +import { ResourceMap } from 'vs/base/common/map'; /** * The only one that should be dealing with `IUntitledFileWorkingCopy` and @@ -102,6 +103,8 @@ export class UntitledFileWorkingCopyManager(); + constructor( workingCopyTypeId: string, private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, @@ -223,19 +226,12 @@ export class UntitledFileWorkingCopyManager): void { // Install working copy listeners - const listeners = new DisposableStore(); - listeners.add(workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(workingCopy))); - listeners.add(workingCopy.onWillDispose(() => this._onWillDispose.fire(workingCopy))); + const workingCopyListeners = new DisposableStore(); + workingCopyListeners.add(workingCopy.onDidChangeDirty(() => this._onDidChangeDirty.fire(workingCopy))); + workingCopyListeners.add(workingCopy.onWillDispose(() => this._onWillDispose.fire(workingCopy))); - // Remove from cache on dispose - Event.once(workingCopy.onWillDispose)(() => { - - // Registry - this.remove(workingCopy.resource); - - // Listeners - listeners.dispose(); - }); + // Keep for disposal + this.mapResourceToWorkingCopyListeners.set(workingCopy.resource, workingCopyListeners); // Add to cache this.add(workingCopy.resource, workingCopy); @@ -247,5 +243,28 @@ export class UntitledFileWorkingCopyManager Date: Wed, 19 May 2021 09:00:39 +0200 Subject: [PATCH 21/63] untitled file working copy - add new manager that unifies file and untitled working copies --- .../common/abstractFileWorkingCopyManager.ts | 244 +----------- .../common/fileWorkingCopyManager.ts | 144 +++++-- .../common/fileWorkingCopyManager2.ts | 353 ++++++++++++++++++ .../common/untitledFileWorkingCopy.ts | 6 +- .../common/untitledFileWorkingCopyManager.ts | 43 +-- .../browser/fileWorkingCopyManager.test.ts | 16 +- .../untitledFileWorkingCopyManager.test.ts | 151 ++------ 7 files changed, 512 insertions(+), 445 deletions(-) create mode 100644 src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager2.ts diff --git a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts index 19ffab8a89b..0659e102304 100644 --- a/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from 'vs/nls'; import { Disposable, dispose, IDisposable } from 'vs/base/common/lifecycle'; import { ResourceMap } from 'vs/base/common/map'; import { Promises } from 'vs/base/common/async'; @@ -12,20 +11,6 @@ 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 { IBaseFileWorkingCopy, IBaseFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopy'; -import { FileWorkingCopy, IFileWorkingCopy, IFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; -import { IFileWorkingCopyResolver } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; -import { UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; -import { IConfirmation, IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { basename, dirname, isEqual, joinPath, toLocalResource } from 'vs/base/common/resources'; -import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; -import { VSBufferReadableStream } from 'vs/base/common/buffer'; -import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; -import { IPathService } from 'vs/workbench/services/path/common/pathService'; -import { isValidBasename } from 'vs/base/common/extpath'; -import { ISaveOptions } from 'vs/workbench/common/editor'; export interface IBaseFileWorkingCopyManager> extends IDisposable { @@ -40,29 +25,6 @@ export interface IBaseFileWorkingCopyManager | undefined>; - saveAs(source: URI, target: undefined, options?: IBaseFileWorkingCopySaveAsOptions): Promise | 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 @@ -76,33 +38,15 @@ export interface IBaseFileWorkingCopyManager; } -export interface IBaseFileWorkingCopySaveAsOptions extends ISaveOptions { - - /** - * Optional target resource to suggest to the user in case - * no taget resource is provided to save to. - */ - suggestedTarget?: URI; -} - export abstract class BaseFileWorkingCopyManager> extends Disposable implements IBaseFileWorkingCopyManager { private readonly mapResourceToWorkingCopy = new ResourceMap(); private readonly mapResourceToDisposeListener = new ResourceMap(); constructor( - protected readonly workingCopyTypeId: string, - private readonly fileWorkingCopyResolver: IFileWorkingCopyResolver, @IFileService protected readonly fileService: IFileService, @ILogService protected readonly logService: ILogService, - @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IUriIdentityService protected readonly uriIdentityService: IUriIdentityService, - @IWorkingCopyFileService protected readonly workingCopyFileService: IWorkingCopyFileService, - @IDialogService private readonly dialogService: IDialogService, - @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, - @IPathService private readonly pathService: IPathService + @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService ) { super(); } @@ -148,192 +92,6 @@ export abstract class BaseFileWorkingCopyManager | undefined { - const workingCopy = this.workingCopyService.get({ resource, typeId: this.workingCopyTypeId }); - if (workingCopy instanceof FileWorkingCopy) { - return workingCopy; - } - - return undefined; - } - - //#endregion - - //#region Save - - async saveAs(source: URI, target?: URI, options?: IBaseFileWorkingCopySaveAsOptions): 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); - } - - private async doSave(resource: URI, options?: ISaveOptions): Promise | undefined> { - - // Save is only possible with file working copies, - // any other have to go via `saveAs` flow. - const fileWorkingCopy = this.getFile(resource); - if (fileWorkingCopy) { - const success = await fileWorkingCopy.save(options); - if (success) { - return fileWorkingCopy; - } - } - - return undefined; - } - - private async doSaveAs(source: URI, target: URI, options?: IBaseFileWorkingCopySaveAsOptions): Promise | undefined> { - let sourceContents: VSBufferReadableStream; - - // If the source is an existing file working copy, we can directly - // use that to copy the contents to the target destination - const sourceWorkingCopy = this.get(source); - if (sourceWorkingCopy?.isResolved()) { - sourceContents = await sourceWorkingCopy.model.snapshot(CancellationToken.None); - } - - // Otherwise we resolve the contents from the underlying file - else { - sourceContents = (await this.fileService.readFileStream(source)).value; - } - - // Resolve target - const { targetFileExists, targetFileWorkingCopy } = 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 targetFileWorkingCopy.model?.update(sourceContents, CancellationToken.None); - - // Save target - await targetFileWorkingCopy.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 targetFileWorkingCopy; - } - - private async doResolveSaveTarget(source: URI, target: URI): Promise<{ targetFileExists: boolean, targetFileWorkingCopy: IFileWorkingCopy }> { - - // Prefer an existing file working copy if it is already resolved - // for the given target resource - let targetFileExists = false; - let targetFileWorkingCopy = this.getFile(target); - if (targetFileWorkingCopy?.isResolved()) { - targetFileExists = true; - } - - // Otherwise create the target working copy empty if - // it does not exist already and resolve it from there - else { - targetFileExists = await this.fileService.exists(target); - - // Create target file adhoc if it does not exist yet - if (!targetFileExists) { - await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); - } - - // At this point we need to resolve the target working copy - // 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. Otherwise we - // would potentially introduce a - if (this.uriIdentityService.extUri.isEqual(source, target) && this.has(source)) { - targetFileWorkingCopy = await this.fileWorkingCopyResolver(source); - } else { - targetFileWorkingCopy = await this.fileWorkingCopyResolver(target); - } - } - - return { targetFileExists, targetFileWorkingCopy }; - } - - 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; - } - - // 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 diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts index 309f5e80655..f1c7354fa8b 100644 --- a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager.ts @@ -16,16 +16,13 @@ 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 { IDialogService, 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 { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; import { BaseFileWorkingCopyManager, IBaseFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/abstractFileWorkingCopyManager'; -import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; -import { IPathService } from 'vs/workbench/services/path/common/pathService'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; /** * The only one that should be dealing with `IFileWorkingCopy` and handle all @@ -89,6 +86,13 @@ export interface IFileWorkingCopyManager extend * it is dirty. Once the promise is settled, it is safe to dispose. */ canDispose(workingCopy: IFileWorkingCopy): true | Promise; + + + /** + * @deprecated + */ + saveAs(source: URI, target: URI): Promise | undefined>; + saveAs(source: URI, target: undefined): Promise | undefined>; } export interface IFileWorkingCopySaveEvent { @@ -129,14 +133,6 @@ export interface IFileWorkingCopyResolveOptions { }; } -export interface IFileWorkingCopyResolver { - - /** - * A delegate to resolve a file working copy. - */ - (resource: URI): Promise>; -} - export class FileWorkingCopyManager extends BaseFileWorkingCopyManager> implements IFileWorkingCopyManager { //#region Events @@ -167,36 +163,19 @@ export class FileWorkingCopyManager extends Bas private readonly workingCopyResolveQueue = this._register(new ResourceQueue()); constructor( - workingCopyTypeId: string, + private readonly workingCopyTypeId: string, private readonly modelFactory: IFileWorkingCopyModelFactory, @IFileService fileService: IFileService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @ILabelService private readonly labelService: ILabelService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService logService: ILogService, - @IFileDialogService fileDialogService: IFileDialogService, - @IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService, + @IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService, @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, - @IUriIdentityService uriIdentityService: IUriIdentityService, - @IDialogService dialogService: IDialogService, - @IWorkingCopyService workingCopyService: IWorkingCopyService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, - @IPathService pathService: IPathService + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IFileDialogService private readonly fileDialogService: IFileDialogService ) { - super( - workingCopyTypeId, - resource => this.resolve(resource), - fileService, - logService, - workingCopyBackupService, - fileDialogService, - uriIdentityService, - workingCopyFileService, - dialogService, - workingCopyService, - environmentService, - pathService - ); + super(fileService, logService, workingCopyBackupService); this.registerListeners(); } @@ -577,4 +556,101 @@ export class FileWorkingCopyManager extends Bas } //#endregion + + + //#region Save As... + + /** + * @deprecated TODO@bpasero remove + */ + async saveAs(source: URI, target?: URI): Promise | undefined> { + + // If not provided, ask user for target + if (!target) { + target = await this.fileDialogService.pickFileToSave(source); + + if (!target) { + return undefined; // user canceled + } + } + + // Do it + return this.doSaveAs(source, target); + } + + private async doSaveAs(source: URI, target: URI): Promise | undefined> { + let sourceContents: VSBufferReadableStream; + + // If the source is an existing file working copy, we can directly + // use that to copy the contents to the target destination + const sourceWorkingCopy = this.get(source); + if (sourceWorkingCopy?.isResolved()) { + sourceContents = await sourceWorkingCopy.model.snapshot(CancellationToken.None); + } + + // Otherwise we resolve the contents from the underlying file + else { + 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); + } + + private async doSaveAsWorkingCopy(source: URI, sourceContents: VSBufferReadableStream, target: URI): Promise> { + + // Prefer an existing 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; + } + + // 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); + + // Create target file adhoc if it does not exist yet + if (!targetExists) { + await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); + } + + // At this point we need to resolve the target working copy + // 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 + // would potentially introduce a + if (this.uriIdentityService.extUri.isEqual(source, target) && this.get(source)) { + targetWorkingCopy = await this.resolve(source); + } else { + targetWorkingCopy = await this.resolve(target); + } + } + + // Take over content from source to target + await targetWorkingCopy.model?.update(sourceContents, CancellationToken.None); + + // Save target + await targetWorkingCopy.save({ 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; + } + + private async doRevert(resource: URI): Promise { + const workingCopy = this.get(resource); + if (!workingCopy) { + return undefined; + } + + return workingCopy.revert(); + } + + //#endregion } diff --git a/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager2.ts b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager2.ts new file mode 100644 index 00000000000..b6c1944af3a --- /dev/null +++ b/src/vs/workbench/services/workingCopy/common/fileWorkingCopyManager2.ts @@ -0,0 +1,353 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Promises } from 'vs/base/common/async'; +import { VSBufferReadableStream } from 'vs/base/common/buffer'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Disposable, IDisposable } 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 { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +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 { IFileWorkingCopy, IFileWorkingCopyModel, IFileWorkingCopyModelFactory, IFileWorkingCopyResolveOptions } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { FileWorkingCopyManager, IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; +import { IUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelFactory, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; +import { IExistingUntitledFileWorkingCopyOptions, 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'; + +export interface IFileWorkingCopyManager2 extends IDisposable { + + /** + * Provides access to the manager for titled file working copies. + */ + readonly files: IFileWorkingCopyManager; + + /** + * Provides access to the manager for untitled file working copies. + */ + readonly untitled: IUntitledFileWorkingCopyManager; + + /** + * Resolves an untitled file working copy from the provided options. + */ + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + + /** + * Resolves an untitled file working copy from the provided options + * unless an existing working copy already exists with that resource. + */ + resolve(options?: IExistingUntitledFileWorkingCopyOptions): Promise>; + + /** + * 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. + * + * Use the `IFileWorkingCopyResolveOptions.reload` option to control the + * behaviour for when a 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 + * case one is already known for this `URI`. + * @param options + */ + resolve(resource: URI, options?: IFileWorkingCopyResolveOptions): Promise>; + + /** + * Implements "Save As" for file based working copies. The API is `URI` based + * because it works even without resolved file working copies. If a file working + * copy exists for any given `URI`, the implementation will deal with them properly + * (e.g. dirty contents of the source will be written to the target and the source + * will be reverted). + * + * Note: it is possible that the returned file working copy has a different `URI` + * than the `target` that was passed in. Based on URI identity, the file working + * copy may chose to return an existing file working copy with different casing + * to respect file systems that are case insensitive. + * + * Note: Callers must `dispose` the working copy when no longer needed. + * + * @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 + * cancellation + */ + saveAs(source: URI, target: URI, options?: ISaveOptions): Promise | undefined>; + saveAs(source: URI, target: undefined, options?: IFileWorkingCopySaveAsOptions): Promise | 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 interface IFileWorkingCopySaveAsOptions extends ISaveOptions { + + /** + * Optional target resource to suggest to the user in case + * no taget resource is provided to save to. + */ + suggestedTarget?: URI; +} + +export class FileWorkingCopyManager2 extends Disposable implements IFileWorkingCopyManager2 { + + readonly files: IFileWorkingCopyManager; + readonly untitled: IUntitledFileWorkingCopyManager; + + constructor( + private readonly workingCopyTypeId: string, + private readonly fileModelFactory: IFileWorkingCopyModelFactory, + private readonly untitledFileModelFactory: IUntitledFileWorkingCopyModelFactory, + @IFileService private readonly fileService: IFileService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IWorkingCopyFileService private readonly workingCopyFileService: IWorkingCopyFileService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IDialogService private readonly dialogService: IDialogService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IPathService private readonly pathService: IPathService + ) { + super(); + + // File manager + this.files = this._register(this.instantiationService.createInstance(FileWorkingCopyManager, this.workingCopyTypeId, this.fileModelFactory)) as unknown as IFileWorkingCopyManager; + + // Untitled manager + this.untitled = this._register(this.instantiationService.createInstance(UntitledFileWorkingCopyManager, this.workingCopyTypeId, this.untitledFileModelFactory, async (workingCopy, options) => { + const result = await this.saveAs(workingCopy.resource, undefined, options); + + return result ? true : false; + })) as unknown as IUntitledFileWorkingCopyManager; + } + + //#region resolve + + resolve(options?: INewUntitledFileWorkingCopyOptions): Promise>; + resolve(options?: INewUntitledFileWorkingCopyWithAssociatedResourceOptions): Promise>; + resolve(options?: IExistingUntitledFileWorkingCopyOptions): Promise>; + resolve(resource: URI, options?: IFileWorkingCopyResolveOptions): Promise>; + resolve(arg1?: URI | INewUntitledFileWorkingCopyOptions | INewUntitledFileWorkingCopyWithAssociatedResourceOptions | IExistingUntitledFileWorkingCopyOptions, arg2?: IFileWorkingCopyResolveOptions): Promise | IFileWorkingCopy> { + if (URI.isUri(arg1)) { + return this.files.resolve(arg1, arg2); + } + + return this.untitled.resolve(arg1); + } + + //#endregion + + //#region Save + + 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); + } + + private async doSave(resource: URI, options?: ISaveOptions): Promise | undefined> { + + // Save is only possible with file working copies, + // any other have to go via `saveAs` flow. + const fileWorkingCopy = this.files.get(resource); + if (fileWorkingCopy) { + const success = await fileWorkingCopy.save(options); + if (success) { + return fileWorkingCopy; + } + } + + return 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 + // use that to copy the contents to the target destination + const sourceWorkingCopy = this.get(source); + if (sourceWorkingCopy?.isResolved()) { + sourceContents = await sourceWorkingCopy.model.snapshot(CancellationToken.None); + } + + // Otherwise we resolve the contents from the underlying file + else { + sourceContents = (await this.fileService.readFileStream(source)).value; + } + + // Resolve target + const { targetFileExists, targetFileWorkingCopy } = 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 targetFileWorkingCopy.model?.update(sourceContents, CancellationToken.None); + + // Save target + await targetFileWorkingCopy.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 targetFileWorkingCopy; + } + + private get(resource: URI): IUntitledFileWorkingCopy | IFileWorkingCopy | undefined { + return this.files.get(resource) ?? this.untitled.get(resource); + } + + private async doResolveSaveTarget(source: URI, target: URI): Promise<{ targetFileExists: boolean, targetFileWorkingCopy: IFileWorkingCopy }> { + + // Prefer an existing file working copy if it is already resolved + // for the given target resource + let targetFileExists = false; + let targetFileWorkingCopy = this.files.get(target); + if (targetFileWorkingCopy?.isResolved()) { + targetFileExists = true; + } + + // Otherwise create the target working copy empty if + // it does not exist already and resolve it from there + else { + targetFileExists = await this.fileService.exists(target); + + // Create target file adhoc if it does not exist yet + if (!targetFileExists) { + await this.workingCopyFileService.create([{ resource: target }], CancellationToken.None); + } + + // At this point we need to resolve the target working copy + // 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. Otherwise we + // would potentially introduce a + if (this.uriIdentityService.extUri.isEqual(source, target) && this.get(source)) { + targetFileWorkingCopy = await this.files.resolve(source); + } else { + targetFileWorkingCopy = await this.files.resolve(target); + } + } + + return { targetFileExists, targetFileWorkingCopy }; + } + + 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; + } + + // 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 + + async destroy(): Promise { + await Promises.settled([ + this.files.destroy(), + this.untitled.destroy() + ]); + } + + //#endregion +} diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts index 536517a7431..90d24089bbb 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopy.ts @@ -66,12 +66,12 @@ export interface IResolvedUntitledFileWorkingCopy { +export interface IUntitledFileWorkingCopySaveDelegate { /** * A delegate to enable saving of untitled file working copies. */ - (workingCopy: IUntitledFileWorkingCopy, options?: ISaveOptions): Promise; + (workingCopy: IUntitledFileWorkingCopy, options?: ISaveOptions): Promise; } export class UntitledFileWorkingCopy extends Disposable implements IUntitledFileWorkingCopy { @@ -104,7 +104,7 @@ export class UntitledFileWorkingCopy ex readonly hasAssociatedFilePath: boolean, private readonly initialValue: VSBufferReadableStream | undefined, private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, - private readonly saveDelegate: IUntitledFileWorkingCopySaveDelegate, + private readonly saveDelegate: IUntitledFileWorkingCopySaveDelegate, @IWorkingCopyService workingCopyService: IWorkingCopyService, @IWorkingCopyBackupService private readonly workingCopyBackupService: IWorkingCopyBackupService, @ILogService private readonly logService: ILogService diff --git a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts index 28ee5986867..09562661f3d 100644 --- a/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts +++ b/src/vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager.ts @@ -6,7 +6,7 @@ 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, UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; +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 { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -15,13 +15,6 @@ 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 { IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -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 { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; -import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -import { IFileWorkingCopyResolver } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; import { ResourceMap } from 'vs/base/common/map'; /** @@ -106,36 +99,16 @@ export class UntitledFileWorkingCopyManager(); constructor( - workingCopyTypeId: string, + private readonly workingCopyTypeId: string, private readonly modelFactory: IUntitledFileWorkingCopyModelFactory, - fileWorkingCopyResolver: IFileWorkingCopyResolver, + private readonly saveDelegate: IUntitledFileWorkingCopySaveDelegate, @IFileService fileService: IFileService, @ILabelService private readonly labelService: ILabelService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService logService: ILogService, - @IFileDialogService fileDialogService: IFileDialogService, - @IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService, - @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService, - @IUriIdentityService uriIdentityService: IUriIdentityService, - @IDialogService dialogService: IDialogService, - @IWorkingCopyService workingCopyService: IWorkingCopyService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, - @IPathService pathService: IPathService + @IWorkingCopyBackupService workingCopyBackupService: IWorkingCopyBackupService ) { - super( - workingCopyTypeId, - resource => fileWorkingCopyResolver(resource), - fileService, - logService, - workingCopyBackupService, - fileDialogService, - uriIdentityService, - workingCopyFileService, - dialogService, - workingCopyService, - environmentService, - pathService - ); + super(fileService, logService, workingCopyBackupService); } //#region Resolve @@ -211,11 +184,7 @@ export class UntitledFileWorkingCopyManager (async () => { - const result = await this.saveAs(workingCopy.resource, undefined, options); - - return result ? true : false; - })() + this.saveDelegate ) as unknown as IUntitledFileWorkingCopy; this.registerWorkingCopy(workingCopy); 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 cbc543d430a..9905e3ac630 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts @@ -34,14 +34,10 @@ suite('FileWorkingCopyManager', () => { accessor.labelService, instantiationService, accessor.logService, - accessor.fileDialogService, accessor.workingCopyFileService, accessor.workingCopyBackupService, accessor.uriIdentityService, - accessor.dialogService, - accessor.workingCopyService, - accessor.environmentService, - accessor.pathService + accessor.fileDialogService ); }); @@ -542,15 +538,7 @@ suite('FileWorkingCopyManager', () => { // the same in that case assert.strictEqual(source.toString(), result?.resource.toString()); } else { - 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()); - } - } + assert.strictEqual(target.toString(), result?.resource.toString()); } if (resolveSource) { diff --git a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts index 28119fb7798..8a7b56c90d3 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopyManager.test.ts @@ -8,8 +8,7 @@ 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 { IUntitledFileWorkingCopyManager, UntitledFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopyManager'; +import { FileWorkingCopyManager2, IFileWorkingCopyManager2 } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager2'; import { WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopy'; import { TestFileWorkingCopyModel, TestFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test'; import { TestUntitledFileWorkingCopyModel, TestUntitledFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test'; @@ -20,8 +19,7 @@ suite('UntitledFileWorkingCopyManager', () => { let instantiationService: IInstantiationService; let accessor: TestServiceAccessor; - let manager: IUntitledFileWorkingCopyManager; - let fileManager: IFileWorkingCopyManager; + let manager: IFileWorkingCopyManager2; setup(() => { instantiationService = workbenchInstantiationService(); @@ -30,38 +28,16 @@ suite('UntitledFileWorkingCopyManager', () => { accessor.fileService.registerProvider(Schemas.file, new TestInMemoryFileSystemProvider()); accessor.fileService.registerProvider(Schemas.vscodeRemote, new TestInMemoryFileSystemProvider()); - fileManager = new FileWorkingCopyManager( - 'testFileWorkingCopyType', + manager = new FileWorkingCopyManager2( + 'testFileWorkingCopyTypeUntitled', new TestFileWorkingCopyModelFactory(), - accessor.fileService, - accessor.lifecycleService, - accessor.labelService, - instantiationService, - accessor.logService, - accessor.fileDialogService, - accessor.workingCopyFileService, - accessor.workingCopyBackupService, - accessor.uriIdentityService, - accessor.dialogService, - accessor.workingCopyService, - accessor.environmentService, - accessor.pathService - ); - - manager = new UntitledFileWorkingCopyManager( - 'testUntitledWorkingCopyType', new TestUntitledFileWorkingCopyModelFactory(), - resource => fileManager.resolve(resource), accessor.fileService, - accessor.labelService, instantiationService, - accessor.logService, accessor.fileDialogService, accessor.workingCopyFileService, - accessor.workingCopyBackupService, accessor.uriIdentityService, accessor.dialogService, - accessor.workingCopyService, accessor.environmentService, accessor.pathService ); @@ -73,29 +49,29 @@ suite('UntitledFileWorkingCopyManager', () => { test('basics', async () => { let disposeCounter = 0; - manager.onWillDispose(e => { + manager.untitled.onWillDispose(e => { disposeCounter++; }); let dirtyCounter = 0; - manager.onDidChangeDirty(e => { + manager.untitled.onDidChangeDirty(e => { dirtyCounter++; }); assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - assert.strictEqual(manager.workingCopies.length, 0); + assert.strictEqual(manager.untitled.workingCopies.length, 0); - assert.strictEqual(manager.get(URI.file('/some/invalidPath')), undefined); - assert.strictEqual(manager.get(URI.file('/some/invalidPath').with({ scheme: Schemas.untitled })), undefined); + 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.resolve(); - const workingCopy2 = await manager.resolve(); + const workingCopy1 = await manager.untitled.resolve(); + const workingCopy2 = await manager.untitled.resolve(); - assert.strictEqual(manager.get(workingCopy1.resource), workingCopy1); - assert.strictEqual(manager.get(workingCopy2.resource), workingCopy2); + 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.workingCopies.length, 2); + assert.strictEqual(manager.untitled.workingCopies.length, 2); assert.notStrictEqual(workingCopy1.resource.toString(), workingCopy2.resource.toString()); @@ -120,24 +96,24 @@ suite('UntitledFileWorkingCopyManager', () => { workingCopy1.dispose(); - assert.strictEqual(manager.workingCopies.length, 1); - assert.strictEqual(manager.get(workingCopy1.resource), undefined); + assert.strictEqual(manager.untitled.workingCopies.length, 1); + assert.strictEqual(manager.untitled.get(workingCopy1.resource), undefined); workingCopy2.dispose(); - assert.strictEqual(manager.workingCopies.length, 0); - assert.strictEqual(manager.get(workingCopy2.resource), undefined); + 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.onDidChangeDirty(e => { + manager.untitled.onDidChangeDirty(e => { dirtyCounter++; }); - const workingCopy = await manager.resolve({ initialValue: bufferToStream(VSBuffer.fromString('Hello World')) }); + const workingCopy = await manager.untitled.resolve({ initialValue: bufferToStream(VSBuffer.fromString('Hello World')) }); assert.strictEqual(workingCopy.isDirty(), true); assert.strictEqual(dirtyCounter, 1); @@ -147,12 +123,12 @@ suite('UntitledFileWorkingCopyManager', () => { }); test('resolve - existing', async () => { - const workingCopy1 = await manager.resolve(); + const workingCopy1 = await manager.untitled.resolve(); - const workingCopy2 = await manager.resolve({ untitledResource: workingCopy1.resource }); + const workingCopy2 = await manager.untitled.resolve({ untitledResource: workingCopy1.resource }); assert.strictEqual(workingCopy1, workingCopy2); - const workingCopy3 = await manager.resolve({ untitledResource: URI.file('/invalid/untitled') }); + const workingCopy3 = await manager.untitled.resolve({ untitledResource: URI.file('/invalid/untitled') }); assert.strictEqual(workingCopy3.resource.scheme, Schemas.untitled); workingCopy1.dispose(); @@ -161,7 +137,7 @@ suite('UntitledFileWorkingCopyManager', () => { }); test('resolve - with associated resource', async () => { - const workingCopy = await manager.resolve({ associatedResource: { path: '/some/associated.txt' } }); + const workingCopy = await manager.untitled.resolve({ associatedResource: { path: '/some/associated.txt' } }); assert.strictEqual(workingCopy.hasAssociatedFilePath, true); assert.strictEqual(workingCopy.resource.path, '/some/associated.txt'); @@ -170,7 +146,7 @@ suite('UntitledFileWorkingCopyManager', () => { }); test('save - without associated resource', async () => { - const workingCopy = await manager.resolve(); + const workingCopy = await manager.untitled.resolve(); workingCopy.model?.updateContents('Simple Save'); accessor.fileDialogService.setPickFileToSave(URI.file('simple/file.txt')); @@ -178,13 +154,13 @@ suite('UntitledFileWorkingCopyManager', () => { const result = await workingCopy.save(); assert.ok(result); - assert.strictEqual(manager.get(workingCopy.resource), undefined); + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); workingCopy.dispose(); }); test('save - with associated resource', async () => { - const workingCopy = await manager.resolve({ associatedResource: { path: '/some/associated.txt' } }); + 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); @@ -192,79 +168,26 @@ suite('UntitledFileWorkingCopyManager', () => { const result = await workingCopy.save(); assert.ok(result); - assert.strictEqual(manager.get(workingCopy.resource), undefined); + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); workingCopy.dispose(); }); test('save - with associated resource (asks to overwrite)', async () => { - const workingCopy = await manager.resolve({ associatedResource: { path: '/some/associated.txt' } }); + 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.get(workingCopy.resource), workingCopy); + assert.strictEqual(manager.untitled.get(workingCopy.resource), workingCopy); accessor.dialogService.setConfirmResult({ confirmed: true }); result = await workingCopy.save(); assert.ok(result); // confirmed - assert.strictEqual(manager.get(workingCopy.resource), undefined); - - workingCopy.dispose(); - }); - - test('saveAs - without associated resource', async () => { - const workingCopy = await manager.resolve(); - workingCopy.model?.updateContents('Simple Save As'); - - const target = URI.file('simple/file.txt'); - accessor.fileDialogService.setPickFileToSave(target); - - const result = await manager.saveAs(workingCopy.resource, undefined); - assert.strictEqual(result?.resource.toString(), target.toString()); - - assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As'); - - assert.strictEqual(manager.get(workingCopy.resource), undefined); - - workingCopy.dispose(); - }); - - test('saveAs - with associated resource', async () => { - const workingCopy = await manager.resolve({ associatedResource: { path: '/some/associated.txt' } }); - workingCopy.model?.updateContents('Simple Save As with associated resource'); - - const target = URI.from({ scheme: Schemas.vscodeRemote, path: '/some/associated.txt' }); - - accessor.fileService.notExistsSet.set(target, true); - - const result = await manager.saveAs(workingCopy.resource, undefined); - assert.strictEqual(result?.resource.toString(), target.toString()); - - assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As with associated resource'); - - assert.strictEqual(manager.get(workingCopy.resource), undefined); - - workingCopy.dispose(); - }); - - test('saveAs - 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 fileManager.resolve(target); - accessor.fileDialogService.setPickFileToSave(target); - - const result = await manager.saveAs(workingCopy.resource, undefined); - assert.strictEqual(result, targetFileWorkingCopy); - - assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As'); - - assert.strictEqual(manager.get(workingCopy.resource), undefined); + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); workingCopy.dispose(); }); @@ -272,16 +195,16 @@ suite('UntitledFileWorkingCopyManager', () => { test('destroy', async () => { assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - await manager.resolve(); - await manager.resolve(); - await manager.resolve(); + await manager.untitled.resolve(); + await manager.untitled.resolve(); + await manager.untitled.resolve(); assert.strictEqual(accessor.workingCopyService.workingCopies.length, 3); - assert.strictEqual(manager.workingCopies.length, 3); + assert.strictEqual(manager.untitled.workingCopies.length, 3); - await manager.destroy(); + await manager.untitled.destroy(); assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); - assert.strictEqual(manager.workingCopies.length, 0); + assert.strictEqual(manager.untitled.workingCopies.length, 0); }); }); From 1cc2f71a1ff7ca80ce0e817e15b5fa3c90995627 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 19 May 2021 09:17:03 +0200 Subject: [PATCH 22/63] untitled file working copy - tests for new unified manager --- .../browser/fileWorkingCopyManager2.test.ts | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts new file mode 100644 index 00000000000..dfa89d1b333 --- /dev/null +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * 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, TestInMemoryFileSystemProvider } from 'vs/workbench/test/browser/workbenchTestServices'; +import { FileWorkingCopy, IFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; +import { bufferToStream, VSBuffer } from 'vs/base/common/buffer'; +import { TestFileWorkingCopyModel, TestFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test'; +import { Schemas } from 'vs/base/common/network'; +import { IFileWorkingCopyManager2, FileWorkingCopyManager2 } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager2'; +import { TestUntitledFileWorkingCopyModel, TestUntitledFileWorkingCopyModelFactory } from 'vs/workbench/services/workingCopy/test/browser/untitledFileWorkingCopy.test'; +import { UntitledFileWorkingCopy } from 'vs/workbench/services/workingCopy/common/untitledFileWorkingCopy'; + +suite('FileWorkingCopyManager2', () => { + + let instantiationService: IInstantiationService; + let accessor: TestServiceAccessor; + + let manager: IFileWorkingCopyManager2; + + setup(() => { + instantiationService = workbenchInstantiationService(); + accessor = instantiationService.createInstance(TestServiceAccessor); + + accessor.fileService.registerProvider(Schemas.file, new TestInMemoryFileSystemProvider()); + accessor.fileService.registerProvider(Schemas.vscodeRemote, new TestInMemoryFileSystemProvider()); + + manager = new FileWorkingCopyManager2( + 'testFileWorkingCopyType2', + new TestFileWorkingCopyModelFactory(), + new TestUntitledFileWorkingCopyModelFactory(), + accessor.fileService, + instantiationService, + accessor.fileDialogService, + accessor.workingCopyFileService, + accessor.uriIdentityService, + accessor.dialogService, + accessor.environmentService, + accessor.pathService + ); + }); + + teardown(() => { + manager.dispose(); + }); + + test('resolve', async () => { + const fileWorkingCopy = await manager.resolve(URI.file('/test.html')); + assert.ok(fileWorkingCopy instanceof FileWorkingCopy); + assert.strictEqual(await manager.files.resolve(fileWorkingCopy.resource), fileWorkingCopy); + + const untitledFileWorkingCopy = await manager.resolve(); + assert.ok(untitledFileWorkingCopy instanceof UntitledFileWorkingCopy); + assert.strictEqual(await manager.untitled.resolve({ untitledResource: untitledFileWorkingCopy.resource }), untitledFileWorkingCopy); + + fileWorkingCopy.dispose(); + untitledFileWorkingCopy.dispose(); + }); + + test('destroy', async () => { + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + + await manager.resolve(URI.file('/test.html')); + await manager.resolve({ initialValue: bufferToStream(VSBuffer.fromString('Hello Untitled')) }); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 2); + assert.strictEqual(manager.files.workingCopies.length, 1); + assert.strictEqual(manager.untitled.workingCopies.length, 1); + + await manager.destroy(); + + assert.strictEqual(accessor.workingCopyService.workingCopies.length, 0); + assert.strictEqual(manager.files.workingCopies.length, 0); + assert.strictEqual(manager.untitled.workingCopies.length, 0); + }); + + // saveAs: unresolved source, unresolved target + + test('saveAs - file (same target, unresolved source, unresolved target)', () => { + const source = URI.file('/path/source.txt'); + + return testSaveAsFile(source, source, false, false); + }); + + 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 testSaveAsFile(source, target, false, false); + }); + + 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 testSaveAsFile(source, target, false, false); + }); + + // saveAs: resolved source, unresolved target + + test('saveAs - file (same target, resolved source, unresolved target)', () => { + const source = URI.file('/path/source.txt'); + + return testSaveAsFile(source, source, true, false); + }); + + 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 testSaveAsFile(source, target, true, false); + }); + + 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 testSaveAsFile(source, target, true, false); + }); + + // saveAs: unresolved source, resolved target + + test('saveAs - file (same target, unresolved source, resolved target)', () => { + const source = URI.file('/path/source.txt'); + + return testSaveAsFile(source, source, false, true); + }); + + 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 testSaveAsFile(source, target, false, true); + }); + + 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 testSaveAsFile(source, target, false, true); + }); + + // saveAs: resolved source, resolved target + + test('saveAs - file (same target, resolved source, resolved target)', () => { + const source = URI.file('/path/source.txt'); + + return testSaveAsFile(source, source, true, true); + }); + + 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 testSaveAsFile(source, target, true, true); + }); + + async function testSaveAsFile(source: URI, target: URI, resolveSource: boolean, resolveTarget: boolean) { + let sourceWorkingCopy: IFileWorkingCopy | undefined = undefined; + if (resolveSource) { + sourceWorkingCopy = await manager.resolve(source); + sourceWorkingCopy.model?.updateContents('hello world'); + assert.ok(sourceWorkingCopy.isDirty()); + } + + let targetWorkingCopy: IFileWorkingCopy | undefined = undefined; + if (resolveTarget) { + targetWorkingCopy = await manager.resolve(target); + targetWorkingCopy.model?.updateContents('hello world'); + assert.ok(targetWorkingCopy.isDirty()); + } + + const result = await manager.saveAs(source, target); + if (accessor.uriIdentityService.extUri.isEqual(source, target) && resolveSource) { + // if the uris are considered equal (different case on macOS/Windows) + // and the source is to be resolved, the resulting working copy resource + // will be the source resource because we consider file working copies + // the same in that case + assert.strictEqual(source.toString(), result?.resource.toString()); + } else { + 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) { + assert.strictEqual(sourceWorkingCopy?.isDirty(), false); + } + + if (resolveTarget) { + assert.strictEqual(targetWorkingCopy?.isDirty(), false); + } + } + + test('saveAs - untitled (without associated resource)', async () => { + const workingCopy = await manager.resolve(); + workingCopy.model?.updateContents('Simple Save As'); + + const target = URI.file('simple/file.txt'); + accessor.fileDialogService.setPickFileToSave(target); + + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result?.resource.toString(), target.toString()); + + assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As'); + + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + 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 target = URI.from({ scheme: Schemas.vscodeRemote, path: '/some/associated.txt' }); + + accessor.fileService.notExistsSet.set(target, true); + + const result = await manager.saveAs(workingCopy.resource, undefined); + assert.strictEqual(result?.resource.toString(), target.toString()); + + assert.strictEqual((result?.model as TestFileWorkingCopyModel).contents, 'Simple Save As with associated resource'); + + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); + + 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 TestFileWorkingCopyModel).contents, 'Simple Save As'); + + assert.strictEqual(manager.untitled.get(workingCopy.resource), undefined); + + workingCopy.dispose(); + }); +}); From 06ee4764a4c917d17ba26407d9b94645eb7b9611 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 19 May 2021 09:37:10 +0200 Subject: [PATCH 23/63] untitled file working copy - test :lipstick: --- .../test/browser/fileWorkingCopy.test.ts | 19 +++++++++++++++-- .../browser/fileWorkingCopyManager.test.ts | 21 +++++++++++++++++-- .../browser/fileWorkingCopyManager2.test.ts | 8 ------- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts index e01b543faa1..cfbfbdfc664 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopy.test.ts @@ -495,8 +495,6 @@ 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; } @@ -559,6 +557,23 @@ suite('FileWorkingCopy', function () { 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'); 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 9905e3ac630..a0a4199ff0b 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager.test.ts @@ -10,7 +10,7 @@ import { workbenchInstantiationService, TestServiceAccessor, TestWillShutdownEve import { FileWorkingCopyManager, IFileWorkingCopyManager } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; import { IFileWorkingCopy, IFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/fileWorkingCopy'; import { bufferToStream, VSBuffer } from 'vs/base/common/buffer'; -import { FileChangesEvent, FileChangeType } from 'vs/platform/files/common/files'; +import { FileChangesEvent, FileChangeType, FileOperationError, FileOperationResult } 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'; @@ -189,6 +189,7 @@ suite('FileWorkingCopyManager', () => { let gotNonDirtyCounter = 0; let revertedCounter = 0; let savedCounter = 0; + let saveErrorCounter = 0; manager.onDidCreate(workingCopy => { createdCounter++; @@ -222,6 +223,12 @@ suite('FileWorkingCopyManager', () => { } }); + manager.onDidSaveError(workingCopy => { + if (workingCopy.resource.toString() === resource1.toString()) { + saveErrorCounter++; + } + }); + const workingCopy1 = await manager.resolve(resource1); assert.strictEqual(resolvedCounter, 1); assert.strictEqual(createdCounter, 1); @@ -239,14 +246,24 @@ suite('FileWorkingCopyManager', () => { 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, 2); + 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(); diff --git a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts index dfa89d1b333..ae6a5510c04 100644 --- a/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts +++ b/src/vs/workbench/services/workingCopy/test/browser/fileWorkingCopyManager2.test.ts @@ -78,8 +78,6 @@ suite('FileWorkingCopyManager2', () => { assert.strictEqual(manager.untitled.workingCopies.length, 0); }); - // saveAs: unresolved source, unresolved target - test('saveAs - file (same target, unresolved source, unresolved target)', () => { const source = URI.file('/path/source.txt'); @@ -100,8 +98,6 @@ suite('FileWorkingCopyManager2', () => { return testSaveAsFile(source, target, false, false); }); - // saveAs: resolved source, unresolved target - test('saveAs - file (same target, resolved source, unresolved target)', () => { const source = URI.file('/path/source.txt'); @@ -122,8 +118,6 @@ suite('FileWorkingCopyManager2', () => { return testSaveAsFile(source, target, true, false); }); - // saveAs: unresolved source, resolved target - test('saveAs - file (same target, unresolved source, resolved target)', () => { const source = URI.file('/path/source.txt'); @@ -144,8 +138,6 @@ suite('FileWorkingCopyManager2', () => { return testSaveAsFile(source, target, false, true); }); - // saveAs: resolved source, resolved target - test('saveAs - file (same target, resolved source, resolved target)', () => { const source = URI.file('/path/source.txt'); From 99f3a3726eb8d24e8036ce5a6d7fbad122b3a3d8 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 18 May 2021 14:38:56 +0200 Subject: [PATCH 24/63] Additional details in the aria label --- .../contrib/workspace/browser/workspace.contribution.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index 148c23121e1..a41c0bdbd69 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -202,6 +202,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben return { id: BANNER_RESTRICTED_MODE, icon: shieldIcon, + ariaLabel: localize('restrictedModeBannerAriaLabel', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features. Use left, right navigation keys to access banner actions."), message: localize('restrictedModeBannerMessage', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features."), actions: [ { From e5bf21393bfce953cec372323458d27b71485f3f Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 19 May 2021 09:51:15 +0200 Subject: [PATCH 25/63] Additional navigation keys --- src/vs/workbench/browser/parts/banner/bannerPart.ts | 2 ++ .../contrib/workspace/browser/workspace.contribution.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/parts/banner/bannerPart.ts b/src/vs/workbench/browser/parts/banner/bannerPart.ts index 1665bb78406..4a042ca9d19 100644 --- a/src/vs/workbench/browser/parts/banner/bannerPart.ts +++ b/src/vs/workbench/browser/parts/banner/bannerPart.ts @@ -288,6 +288,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 +300,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/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index a41c0bdbd69..902e74d93d5 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -202,7 +202,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben return { id: BANNER_RESTRICTED_MODE, icon: shieldIcon, - ariaLabel: localize('restrictedModeBannerAriaLabel', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features. Use left, right navigation keys to access banner actions."), + ariaLabel: localize('restrictedModeBannerAriaLabel', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features. Use navigation keys to access banner actions."), message: localize('restrictedModeBannerMessage', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features."), actions: [ { From 08618458110ecad754f73d3607467c9c1854ce76 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 19 May 2021 10:03:43 +0200 Subject: [PATCH 26/63] fix ExtensionRecommendationsService test (for #124119) --- .../extensionRecommendationsService.ts | 10 +++++-- .../extensionRecommendationsService.test.ts | 30 +++++++++++-------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts index 56aff6ead14..b353d267df2 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts @@ -28,8 +28,6 @@ type IgnoreRecommendationClassification = { extensionId: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; }; -const WORKSPACE_RECOMMENDATION_DELAY = 5000; - export class ExtensionRecommendationsService extends Disposable implements IExtensionRecommendationsService { declare readonly _serviceBrand: undefined; @@ -235,13 +233,19 @@ 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(WORKSPACE_RECOMMENDATION_DELAY); // remote extensions might still being installed #124119 + await timeout(this.workbenchRecommendationDelay); await this.extensionRecommendationNotificationService.promptWorkspaceRecommendations(allowedRecommendations); } } 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); From 734b79dadae2caa8b6b3337faa31451d6e85c01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 19 May 2021 10:38:47 +0200 Subject: [PATCH 27/63] fix #124102 --- src/vs/workbench/contrib/update/browser/update.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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(); From 53352a295408aacce8994d35ebe587a9676cdf7f Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 19 May 2021 15:27:23 +0200 Subject: [PATCH 28/63] add API to open an untitled notebook, https://github.com/microsoft/vscode/issues/121974 --- .../notebook.document.test.ts | 24 +++++++ .../src/singlefolder-tests/notebook.test.ts | 4 +- src/vs/vscode.proposed.d.ts | 11 ++++ .../browser/mainThreadNotebookDocuments.ts | 32 ++++++++-- .../workbench/api/common/extHost.api.impl.ts | 13 +++- .../workbench/api/common/extHost.protocol.ts | 5 +- .../workbench/api/common/extHostNotebook.ts | 28 ++++----- .../api/common/extHostNotebookDocument.ts | 2 +- .../api/common/extHostTypeConverters.ts | 22 +++++++ .../notebook/common/notebookEditorModel.ts | 62 +++++++++++++------ .../notebookEditorModelResolverServiceImpl.ts | 12 ++-- 11 files changed, 164 insertions(+), 51 deletions(-) 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 cf4e029e497..0739e4e8afc 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 @@ -140,6 +140,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/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 147fcfb5ad6..16b6b7e85fd 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -1722,6 +1722,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. */ diff --git a/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts b/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts index 3d2785cd483..bad31cf5a11 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,38 @@ 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-Notebook-${counter}` }); + if (!this._notebookService.getNotebookTextModel(candidate)) { + uri = candidate; + break; + } + } + + const ref = await this._notebookEditorModelResolverService.resolve(uri, options.viewType); + this._modelReferenceCollection.add(ref.object.resource, ref); + 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/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index e0f69afce4c..71b0809eb0f 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1036,9 +1036,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); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index aee0a700928..3b12c7d8a37 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -888,8 +888,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; } diff --git a/src/vs/workbench/api/common/extHostNotebook.ts b/src/vs/workbench/api/common/extHostNotebook.ts index e7956730308..d505510df63 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/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/contrib/notebook/common/notebookEditorModel.ts b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts index 03cb38b4b63..5fc06a8afdb 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts @@ -28,8 +28,9 @@ 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 } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager'; import { filter } from 'vs/base/common/objects'; +import { IFileWorkingCopyManager2 } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager2'; +import { IResolvedUntitledFileWorkingCopy, IUntitledFileWorkingCopyModel, IUntitledFileWorkingCopyModelContentChangedEvent } 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?: IResolvedUntitledFileWorkingCopy | IResolvedFileWorkingCopy; private readonly _workingCopyListeners = new DisposableStore(); constructor( readonly resource: URI, readonly viewType: string, - private readonly _workingCopyManager: IFileWorkingCopyManager, + private readonly _workingCopyManager: IFileWorkingCopyManager2, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IFileService private readonly _fileService: IFileService ) { @@ -461,11 +462,17 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE } isOrphaned(): boolean { - return this._workingCopy?.hasState(FileWorkingCopyState.ORPHAN) ?? false; + return !!this._workingCopy && SimpleNotebookEditorModel._isFileWorkingCopy(this._workingCopy) && this._workingCopy.hasState(FileWorkingCopyState.ORPHAN); } isReadonly(): boolean { - return this._workingCopy?.isReadonly() || this._fileService.hasCapability(this.resource, FileSystemProviderCapabilities.Readonly); + if (this._fileService.hasCapability(this.resource, FileSystemProviderCapabilities.Readonly)) { + return true; + } else if (this._workingCopy && SimpleNotebookEditorModel._isFileWorkingCopy(this._workingCopy)) { + return this._workingCopy?.isReadonly(); + } else { + return false; + } } revert(options?: IRevertOptions): Promise { @@ -479,14 +486,26 @@ 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) { + const workingCopy = await this._workingCopyManager.resolve({ untitledResource: this.resource }); + this._workingCopy = >workingCopy; + } else { + const workingCopy = await this._workingCopyManager.resolve(this.resource, { forceReadFromFile: options?.forceReadFromFile }); + this._workingCopyListeners.add(workingCopy.onDidSave(() => this._onDidSave.fire())); + this._workingCopyListeners.add(workingCopy.onDidChangeOrphaned(() => this._onDidChangeOrphaned.fire())); + this._workingCopyListeners.add(workingCopy.onDidChangeReadonly(() => this._onDidChangeReadonly.fire())); + this._workingCopy = >workingCopy; + } + 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; } @@ -501,11 +520,15 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE // 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 _isFileWorkingCopy(candidate: IResolvedUntitledFileWorkingCopy | IResolvedFileWorkingCopy): candidate is IResolvedFileWorkingCopy { + return typeof (>candidate).hasState === 'function'; + } } -export class NotebookFileWorkingCopyModel implements IFileWorkingCopyModel { +export class NotebookFileWorkingCopyModel implements IFileWorkingCopyModel, IUntitledFileWorkingCopyModel { - private readonly _onDidChangeContent = new Emitter(); + private readonly _onDidChangeContent = new Emitter(); private readonly _changeListener: IDisposable; readonly onDidChangeContent = this._onDidChangeContent.event; @@ -525,10 +548,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,7 +608,9 @@ 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); @@ -606,7 +631,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..75f9ce0c647 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 { FileWorkingCopyManager2, IFileWorkingCopyManager2 } from 'vs/workbench/services/workingCopy/common/fileWorkingCopyManager2'; 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( - FileWorkingCopyManager, + const factory = new NotebookFileWorkingCopyModelFactory(viewType, this._notebookService); + workingCopyManager = >this._instantiationService.createInstance( + FileWorkingCopyManager2, workingCopyTypeId, - new NotebookFileWorkingCopyModelFactory(viewType, this._notebookService) + factory, + factory, ); this._workingCopyManagers.set(workingCopyTypeId, workingCopyManager); } From b63ad124d2e59c14ab75a0fc4bdcf28d24736173 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 19 May 2021 17:16:48 +0200 Subject: [PATCH 29/63] mark untitled notebooks as dirty by default, don't hold on to untitled notebooks eagerly, https://github.com/microsoft/vscode/issues/121974 --- .../src/singlefolder-tests/notebook.document.test.ts | 4 ---- .../api/browser/mainThreadNotebookDocuments.ts | 12 +++++++++++- .../notebook/browser/notebook.contribution.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) 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 0739e4e8afc..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 () { diff --git a/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts b/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts index bad31cf5a11..f557f5e253d 100644 --- a/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts +++ b/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts @@ -132,7 +132,17 @@ export class MainThreadNotebookDocuments implements MainThreadNotebookDocumentsS } const ref = await this._notebookEditorModelResolverService.resolve(uri, options.viewType); - this._modelReferenceCollection.add(ref.object.resource, ref); + + // 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, diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index 7142116ab99..f0051ae83d7 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -255,7 +255,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(); }); From 52c8fced3850349a7c1a8f4713c9c021092cd013 Mon Sep 17 00:00:00 2001 From: rebornix Date: Wed, 19 May 2021 09:16:33 -0700 Subject: [PATCH 30/63] focus indicator border or gutter --- .../notebook/browser/media/notebook.css | 40 -------- .../notebook/browser/notebookEditorWidget.ts | 96 ++++++++++++++++--- .../contrib/notebook/common/notebookCommon.ts | 1 + .../notebook/common/notebookOptions.ts | 18 +++- 4 files changed, 98 insertions(+), 57 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebook.css b/src/vs/workbench/contrib/notebook/browser/media/notebook.css index 33bfd31588f..8bf527a75ab 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebook.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebook.css @@ -347,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/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 2307875a22b..d2e55d45fba 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -367,7 +367,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this._updateForNotebookConfiguration(); } - if (e.compactView) { + if (e.compactView || e.focusIndicator) { this._styleElement?.remove(); this._createLayoutStyles(); this._webview?.updateOptions(this.notebookOptions.computeWebviewOptions()); @@ -555,7 +555,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor bottomCellToolbarGap, bottomCellToolbarHeight, collapsedIndicatorHeight, - compactView + compactView, + focusIndicator } = this._notebookOptions.getLayoutConfiguration(); const styleSheets: string[] = []; @@ -566,6 +567,84 @@ 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; }`); } + 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; + }`); + } + 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; }`); @@ -607,15 +686,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'); } @@ -2609,8 +2679,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/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index 354e926d95b..fc55f06bfef 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -897,6 +897,7 @@ 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 ExperimentalUndoRedoPerCell = 'notebook.experimental.undoRedoPerCell'; export const enum CellStatusbarAlignment { diff --git a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts index bd4cd916a29..7e5f68b509a 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts @@ -6,7 +6,7 @@ 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, ExperimentalFocusIndicator, ShowCellStatusBarKey } from 'vs/workbench/contrib/notebook/common/notebookCommon'; let EDITOR_TOP_PADDING = 12; const editorTopPaddingChangeEmitter = new Emitter(); @@ -45,6 +45,7 @@ export interface NotebookLayoutConfiguration { cellToolbarLocation: string | { [key: string]: string }; cellToolbarInteraction: string; compactView: boolean; + focusIndicator: 'border' | 'gutter'; } interface NotebookOptionsChangeEvent { @@ -53,6 +54,7 @@ interface NotebookOptionsChangeEvent { cellToolbarInteraction?: boolean; editorTopPadding?: boolean; compactView?: boolean; + focusIndicator?: boolean; } const defaultConfigConstants = { @@ -84,6 +86,7 @@ export class NotebookOptions { 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'; this._disposables = []; this._layoutConfiguration = { @@ -103,7 +106,8 @@ export class NotebookOptions { showCellStatusBar, cellToolbarLocation, cellToolbarInteraction, - compactView + compactView, + focusIndicator }; this._disposables.push(this.configurationService.onDidChangeConfiguration(e => { @@ -111,8 +115,9 @@ export class NotebookOptions { let cellToolbarLocation = e.affectsConfiguration(CellToolbarLocKey); let cellToolbarInteraction = e.affectsConfiguration(CellToolbarVisibility); let compactView = e.affectsConfiguration(ExperimentalCompactView); + let focusIndicator = e.affectsConfiguration(ExperimentalFocusIndicator); - if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView) { + if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView && !focusIndicator) { return; } @@ -130,6 +135,10 @@ export class NotebookOptions { configuration.cellToolbarInteraction = this.configurationService.getValue(CellToolbarVisibility); } + if (focusIndicator) { + configuration.focusIndicator = this.configurationService.getValue<'border' | 'gutter'>(ExperimentalFocusIndicator); + } + if (compactView) { const compactViewValue = this.configurationService.getValue('notebook.experimental.compactView'); configuration = Object.assign(configuration, { @@ -145,7 +154,8 @@ export class NotebookOptions { cellStatusBarVisibility: cellStatusBarVisibility, cellToolbarLocation: cellToolbarLocation, cellToolbarInteraction: cellToolbarInteraction, - compactView: compactView + compactView: compactView, + focusIndicator: focusIndicator }); })); From d553c21d5e0413f047f2e4e806ed539c7219e88a Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 19 May 2021 09:33:48 -0700 Subject: [PATCH 31/63] fixes #123869 --- .../contrib/preferences/browser/preferencesRenderers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts index 06e6fba025e..96eb0132f4a 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, height: 10 }; 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({ From 9716c27e068315251a6ed91ac5b60b9d8ee68e86 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 19 May 2021 18:43:56 +0200 Subject: [PATCH 32/63] slightly better uri for untitled notebooks, https://github.com/microsoft/vscode/issues/121974 --- src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts | 4 ++-- src/vs/workbench/api/common/extHost.protocol.ts | 2 +- src/vs/workbench/api/common/extHostNotebook.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts b/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts index f557f5e253d..a406d945ee0 100644 --- a/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts +++ b/src/vs/workbench/api/browser/mainThreadNotebookDocuments.ts @@ -119,12 +119,12 @@ export class MainThreadNotebookDocuments implements MainThreadNotebookDocumentsS }; } - async $tryCreateNotebook(options: { viewType?: string, content?: NotebookDataDto }): 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-Notebook-${counter}` }); + let candidate = URI.from({ scheme: Schemas.untitled, path: `Untitled-${counter}`, query: options.viewType }); if (!this._notebookService.getNotebookTextModel(candidate)) { uri = candidate; break; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 3b12c7d8a37..98368960f85 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -888,7 +888,7 @@ export interface MainThreadNotebookEditorsShape extends IDisposable { } export interface MainThreadNotebookDocumentsShape extends IDisposable { - $tryCreateNotebook(options: { viewType?: string, content?: NotebookDataDto }): Promise; + $tryCreateNotebook(options: { viewType: string, content?: NotebookDataDto }): Promise; $tryOpenNotebook(uriComponents: UriComponents): Promise; $trySaveNotebook(uri: UriComponents): Promise; $applyEdits(resource: UriComponents, edits: IImmediateCellEditOperation[], computeUndoRedo?: boolean): Promise; diff --git a/src/vs/workbench/api/common/extHostNotebook.ts b/src/vs/workbench/api/common/extHostNotebook.ts index d505510df63..1d2e22cd8ec 100644 --- a/src/vs/workbench/api/common/extHostNotebook.ts +++ b/src/vs/workbench/api/common/extHostNotebook.ts @@ -254,7 +254,7 @@ export class ExtHostNotebookController implements ExtHostNotebookShape { return new NotebookEditorDecorationType(this._notebookEditorsProxy, options).value; } - async createNotebookDocument(options: { viewType?: string, content?: vscode.NotebookData } = {}): Promise { + 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) From 283180b64b2af567a880280b4e47dca6d8cc1dae Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 19 May 2021 09:54:26 -0700 Subject: [PATCH 33/63] don't show action icons all the time wte --- .../workspace/browser/workspaceTrustEditor.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css index 63f05bb1fd4..63434c78d57 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css +++ b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css @@ -268,3 +268,14 @@ 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; +} From 49187c4e7854e427dac8fa0128622b935530cb83 Mon Sep 17 00:00:00 2001 From: Raymond Zhao Date: Wed, 19 May 2021 10:03:50 -0700 Subject: [PATCH 34/63] Replace flush with setFlushOn with loglevel info, fixes #123856 --- remote/yarn.lock | 6 +++--- src/vs/platform/log/node/spdlogLog.ts | 3 ++- src/vs/workbench/api/node/extHostOutputService.ts | 1 - yarn.lock | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) 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/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/workbench/api/node/extHostOutputService.ts b/src/vs/workbench/api/node/extHostOutputService.ts index 8daae98da4e..78a955a7330 100644 --- a/src/vs/workbench/api/node/extHostOutputService.ts +++ b/src/vs/workbench/api/node/extHostOutputService.ts @@ -32,7 +32,6 @@ class OutputAppender { append(content: string): void { this.appender.critical(content); - this.flush(); } flush(): void { 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" From 5442f154ef67d6930f778b6604f170339f493612 Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 19 May 2021 10:31:07 -0700 Subject: [PATCH 35/63] fix issue when not connected to remote #wt --- .../contrib/workspace/browser/workspaceTrustEditor.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts index 537356d50b7..db2d0416960 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts @@ -175,9 +175,12 @@ class WorkspaceTrustedUrisTable extends Disposable { } } + private get currentWorkspaceUri(): URI { + return this.workspaceService.getWorkspace().folders[0]?.uri || URI.file('/'); + } + private get trustedUriEntries(): ITrustedUriItem[] { const currentWorkspace = this.workspaceService.getWorkspace(); - const currentWorkspaceUri = currentWorkspace.folders[0]?.uri || URI.file('/'); const currentWorkspaceUris = currentWorkspace.folders.map(folder => folder.uri); if (currentWorkspace.configuration) { currentWorkspaceUris.push(currentWorkspace.configuration); @@ -196,7 +199,7 @@ class WorkspaceTrustedUrisTable extends Disposable { parentOfWorkspaceItem: relatedToCurrentWorkspace }; }); - entries.push({ uri: currentWorkspaceUri, entryType: TrustedUriItemType.Add, parentOfWorkspaceItem: false }); + entries.push({ uri: this.currentWorkspaceUri, entryType: TrustedUriItemType.Add, parentOfWorkspaceItem: false }); return entries; } @@ -233,7 +236,9 @@ class WorkspaceTrustedUrisTable extends Disposable { } async edit(item: ITrustedUriItem) { - if (item.uri.scheme === Schemas.file || item.uri.scheme === Schemas.vscodeRemote) { + 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, From 3804c98d6f7bf2250a39d55228c5d0fd0398bbfd Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 19 May 2021 10:35:58 -0700 Subject: [PATCH 36/63] fix padding for table #wt --- .../contrib/workspace/browser/workspaceTrustEditor.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css index 63434c78d57..b26d4cc9aa5 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css +++ b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.css @@ -217,8 +217,7 @@ } .workspace-trust-editor .workspace-trust-settings { - padding-top: 20px; - padding-bottom: 20px; + padding: 20px 14px; } .workspace-trust-editor .workspace-trust-settings .workspace-trusted-folders-title { From a796ebfb8b41db1890e9e55a1b8fdc34dbf99369 Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 19 May 2021 10:48:44 -0700 Subject: [PATCH 37/63] update actions column width #wt --- .../contrib/workspace/browser/workspaceTrustEditor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts index db2d0416960..82016b8c8d1 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspaceTrustEditor.ts @@ -119,8 +119,8 @@ class WorkspaceTrustedUrisTable extends Disposable { label: '', tooltip: '', weight: 0, - minimumWidth: 80, - maximumWidth: 80, + minimumWidth: 55, + maximumWidth: 55, templateId: TrustedUriActionsColumnRenderer.TEMPLATE_ID, project(row: ITrustedUriItem): ITrustedUriItem { return row; } }, From 134d9b187bbac1ef64be25ca805d210208a2c0aa Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 19 May 2021 13:52:32 -0400 Subject: [PATCH 38/63] Closes #122433 --- .../editor/browser/editorOverrideService.ts | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/services/editor/browser/editorOverrideService.ts b/src/vs/workbench/services/editor/browser/editorOverrideService.ts index 990a4acf6d9..45c897382db 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,8 +133,8 @@ 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); + // 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 @@ -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]?.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(); }); } From f4e05837d01f1bd2405ce0b6842e473a01c0264d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 19 May 2021 10:58:25 -0700 Subject: [PATCH 39/63] Remove custom height, use default from menu service #123869 --- .../contrib/preferences/browser/preferencesRenderers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts index 96eb0132f4a..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, height: 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({ From 25a12c75c5afae25e2c8c7b2f6993b6a8aa477a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 19 May 2021 20:40:03 +0200 Subject: [PATCH 40/63] use dummy uri --- extensions/github-authentication/src/githubServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/github-authentication/src/githubServer.ts b/extensions/github-authentication/src/githubServer.ts index a3124b7c64b..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); } From 2b30689ab00bfcc24e2072eb1dbbe3f2a582a359 Mon Sep 17 00:00:00 2001 From: Jackson Kearl Date: Wed, 19 May 2021 11:44:20 -0700 Subject: [PATCH 41/63] Close #123935 --- .github/commands.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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", From 40fbf5d915be79e03586153f06541ea307d201aa Mon Sep 17 00:00:00 2001 From: rebornix Date: Wed, 19 May 2021 12:03:52 -0700 Subject: [PATCH 42/63] insertToolbarPosition --- .../contrib/notebook/browser/constants.ts | 8 --- .../notebook/browser/contrib/coreActions.ts | 12 +++- .../notebook/browser/notebookEditorWidget.ts | 66 +++++++++++------- .../notebook/browser/view/notebookCellList.ts | 9 ++- .../browser/view/renderers/cellDnd.ts | 4 +- .../browser/view/renderers/cellRenderer.ts | 4 +- .../browser/viewModel/baseCellViewModel.ts | 2 +- .../browser/viewModel/codeCellViewModel.ts | 12 ++-- .../viewModel/markdownCellViewModel.ts | 6 +- .../contrib/notebook/common/notebookCommon.ts | 1 + .../notebook/common/notebookOptions.ts | 68 +++++++++++++++---- .../notebook/test/notebookCellList.test.ts | 21 +++--- 12 files changed, 140 insertions(+), 73 deletions(-) delete mode 100644 src/vs/workbench/contrib/notebook/browser/constants.ts 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 f8e5427ebdc..2a79d2bf867 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts @@ -1039,7 +1039,11 @@ MenuRegistry.appendMenuItem(MenuId.NotebookToolbar, { }, order: -5, group: 'navigation/add', - when: NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true) + 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, { @@ -1105,7 +1109,11 @@ MenuRegistry.appendMenuItem(MenuId.NotebookToolbar, { }, order: -5, group: 'navigation/add', - when: NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true) + 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, { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index d2e55d45fba..077b33d94c3 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -70,7 +70,6 @@ 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 { NotebookEditorToolbar } from 'vs/workbench/contrib/notebook/browser/notebookEditorToolbar'; @@ -213,7 +212,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[] = []; @@ -334,6 +333,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); @@ -367,11 +367,15 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor this._updateForNotebookConfiguration(); } - if (e.compactView || e.focusIndicator) { + if (e.compactView || e.focusIndicator || e.insertToolbarPosition) { this._styleElement?.remove(); this._createLayoutStyles(); this._webview?.updateOptions(this.notebookOptions.computeWebviewOptions()); } + + if (this._dimension && this._isVisible) { + this.layout(this._dimension); + } })); this.notebookEditorService.addNotebookEditor(this); @@ -552,11 +556,12 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor codeCellLeftMargin, markdownCellBottomMargin, markdownCellTopMargin, - bottomCellToolbarGap, - bottomCellToolbarHeight, + bottomToolbarGap: bottomCellToolbarGap, + bottomToolbarHeight: bottomCellToolbarHeight, collapsedIndicatorHeight, compactView, - focusIndicator + focusIndicator, + insertToolbarPosition } = this._notebookOptions.getLayoutConfiguration(); const styleSheets: string[] = []; @@ -567,6 +572,7 @@ 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, @@ -645,6 +651,24 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor }`); } + // 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(); + 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; }`); @@ -670,7 +694,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 }`); @@ -711,6 +734,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor 'NotebookCellList', this._overlayContainer, this._body, + this._viewContext, this._listDelegate, renderers, this.scopedContextKeyService, @@ -1093,8 +1117,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())]); @@ -1195,7 +1217,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._list.layout(this._dimension.height - topInserToolbarHeight, this._dimension.width); } else { this._list.layout(); } @@ -1423,16 +1446,18 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditor }; } + const topInserToolbarHeight = this._notebookOptions.computeTopInserToolbarHeight(); + this._dimension = new DOM.Dimension(dimension.width, dimension.height); DOM.size(this._body, dimension.width, dimension.height - (this._notebookTopToolbar?.useGlobalToolbar ? /** Toolbar height */ 26 : 0)); - if (this._list.getRenderHeight() < dimension.height - SCROLLABLE_ELEMENT_PADDING_TOP) { + 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'; @@ -2385,8 +2410,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; } } @@ -2460,7 +2485,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(); @@ -2606,12 +2630,6 @@ 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, diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts index f14f3fcca8d..eec47318496 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(); + 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/cellDnd.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts index fca5bd43ce6..7f9cd47a3cd 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); @@ -200,7 +200,7 @@ export class CellDragAndDropController extends Disposable { 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 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/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 c757fcc2671..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; } 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/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index fc55f06bfef..f3fd40adfee 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -898,6 +898,7 @@ 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 ExperimentalUndoRedoPerCell = 'notebook.experimental.undoRedoPerCell'; export const enum CellStatusbarAlignment { diff --git a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts index 7e5f68b509a..951b6b16b34 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, ExperimentalFocusIndicator, ShowCellStatusBarKey } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellToolbarLocKey, CellToolbarVisibility, ExperimentalCompactView, ExperimentalFocusIndicator, 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; @@ -46,6 +48,7 @@ export interface NotebookLayoutConfiguration { cellToolbarInteraction: string; compactView: boolean; focusIndicator: 'border' | 'gutter'; + insertToolbarPosition: 'betweenCells' | 'notebookToolbar' | 'both' | 'hidden'; } interface NotebookOptionsChangeEvent { @@ -55,6 +58,7 @@ interface NotebookOptionsChangeEvent { editorTopPadding?: boolean; compactView?: boolean; focusIndicator?: boolean; + insertToolbarPosition?: boolean; } const defaultConfigConstants = { @@ -63,7 +67,6 @@ const defaultConfigConstants = { markdownCellTopMargin: 8, markdownCellBottomMargin: 8, markdownCellLeftMargin: 32, - bottomCellToolbarGap: 18, }; const compactConfigConstants = { @@ -72,7 +75,6 @@ const compactConfigConstants = { markdownCellTopMargin: 6, markdownCellBottomMargin: 6, markdownCellLeftMargin: 32, - bottomCellToolbarGap: 12, }; export class NotebookOptions { @@ -87,6 +89,8 @@ export class NotebookOptions { 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 = { @@ -97,7 +101,8 @@ export class NotebookOptions { cellStatusBarHeight: 22, cellOutputPadding: 14, markdownPreviewPadding: 8, - bottomCellToolbarHeight: 22, + bottomToolbarHeight: bottomToolbarHeight, + bottomToolbarGap: bottomToolbarGap, editorToolbarHeight: 0, editorTopPadding: EDITOR_TOP_PADDING, editorBottomPadding: 4, @@ -107,7 +112,8 @@ export class NotebookOptions { cellToolbarLocation, cellToolbarInteraction, compactView, - focusIndicator + focusIndicator, + insertToolbarPosition }; this._disposables.push(this.configurationService.onDidChangeConfiguration(e => { @@ -116,8 +122,9 @@ export class NotebookOptions { let cellToolbarInteraction = e.affectsConfiguration(CellToolbarVisibility); let compactView = e.affectsConfiguration(ExperimentalCompactView); let focusIndicator = e.affectsConfiguration(ExperimentalFocusIndicator); + let insertToolbarPosition = e.affectsConfiguration(ExperimentalInsertToolbarPosition); - if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView && !focusIndicator) { + if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView && !focusIndicator && !insertToolbarPosition) { return; } @@ -147,6 +154,13 @@ 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; + } + this._layoutConfiguration = configuration; // trigger event @@ -155,7 +169,8 @@ export class NotebookOptions { cellToolbarLocation: cellToolbarLocation, cellToolbarInteraction: cellToolbarInteraction, compactView: compactView, - focusIndicator: focusIndicator + focusIndicator: focusIndicator, + insertToolbarPosition: insertToolbarPosition }); })); @@ -167,6 +182,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; } @@ -174,14 +206,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 { @@ -275,11 +307,19 @@ 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(): number { + if (this._layoutConfiguration.insertToolbarPosition === 'betweenCells' || this._layoutConfiguration.insertToolbarPosition === 'both') { + return SCROLLABLE_ELEMENT_PADDING_TOP; + } else { + return 0; + } + } + dispose() { this._disposables.forEach(d => d.dispose()); this._disposables = []; 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); From 05f83d95d2b51f6bdbeb99be47a40365d40a1e8b Mon Sep 17 00:00:00 2001 From: Nicholas Rayburn <52075362+nrayburn-tech@users.noreply.github.com> Date: Wed, 19 May 2021 14:31:42 -0500 Subject: [PATCH 43/63] Fix issue reporter not debounce submissions (#123340) --- .../electron-sandbox/issue/issueReporterMain.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 From c13c6d8e468dfda249f1f1a1f755f23ed9b46993 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 13 May 2021 11:27:17 -0700 Subject: [PATCH 44/63] Throw error when trying to reload/navigate within a webview This causes the webview document to get into an invalid state and is currently not supported. This new error message should make the issue more clear --- src/vs/workbench/contrib/webview/browser/pre/host.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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() { From c63ac2f638804572a6062f79899be7567037813c Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 13 May 2021 11:59:09 -0700 Subject: [PATCH 45/63] Use searchParams to get id --- .../contrib/webview/browser/pre/service-worker.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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..4a2d6b97462 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -318,16 +318,24 @@ async function processLocalhostRequest(event, requestUrl) { return promise.then(resolveRedirect); } +/** + * @param {Client} client + * @returns {string | undefined} + */ 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; }); } From f0ef8dfd913cd948db616266423f14e284384520 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 13 May 2021 12:11:50 -0700 Subject: [PATCH 46/63] Add explicit null typings --- .../webview/browser/pre/service-worker.js | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) 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 4a2d6b97462..b5f56258799 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -66,9 +66,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 = () => { @@ -205,11 +211,16 @@ 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); + if (!webviewId) { + console.error('Could not resolve webview id'); + return notFound(); + } + const resourcePath = requestUrl.pathname.startsWith(resourceRoot + '/') ? requestUrl.pathname.slice(resourceRoot.length) : requestUrl.pathname; /** @@ -229,18 +240,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) { @@ -273,8 +284,9 @@ 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); @@ -287,9 +299,10 @@ async function processLocalhostRequest(event, requestUrl) { 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); } @@ -320,7 +333,7 @@ async function processLocalhostRequest(event, requestUrl) { /** * @param {Client} client - * @returns {string | undefined} + * @returns {string | null} */ function getWebviewIdForClient(client) { const requesterClientUrl = new URL(client.url); From b8a0123cfc9a9330374904b8f3bbcfdbb270ebe6 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 13 May 2021 12:12:31 -0700 Subject: [PATCH 47/63] Fallback to fetch if localhost resolve fails --- .../contrib/webview/browser/pre/service-worker.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 b5f56258799..20576472cc5 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -162,7 +162,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); @@ -293,9 +292,14 @@ async function processLocalhostRequest(event, requestUrl) { 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; /** From 3c4176656435001add7614a8ab890a618c33e431 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 13 May 2021 12:46:17 -0700 Subject: [PATCH 48/63] Fix strict null errors in webview main --- .../contrib/webview/browser/pre/main.js | 114 +++++++++++++----- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js index a31f57f26ce..098abfbe8a1 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; + * resourceEndpoint: 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; + } } }); @@ -509,8 +554,11 @@ export async function createWebviewManager(host) { 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, endpointUrl.origin); + csp.setAttribute('content', newCsp); + } } catch (e) { console.error(`Could not rewrite csp: ${e}`); } @@ -563,7 +611,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 +634,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 +705,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 +742,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 +770,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 +786,11 @@ 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 => { e.preventDefault(); host.postMessage('did-context-menu', { clientX: e.clientX, @@ -760,7 +812,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 +828,7 @@ export async function createWebviewManager(host) { if (!target) { return; } - target.contentDocument.execCommand(data); + assertIsDefined(target.contentDocument).execCommand(data); }); trackFocus({ @@ -784,7 +836,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': From 40a268505754cc0ff4b7a9e514ccce57b2dc433e Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 13 May 2021 13:38:20 -0700 Subject: [PATCH 49/63] Fix shift for markdown cell selection only selecting current cell --- .../notebook/browser/notebookBrowser.ts | 2 +- .../notebook/browser/notebookEditorWidget.ts | 27 ++++++++++++++++--- .../view/renderers/backLayerWebView.ts | 4 +-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts index d188dea2b16..6d4bf6077d5 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -170,7 +170,7 @@ 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; diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 2307875a22b..c588c2a5290 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -1953,19 +1953,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; 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..1c6a07379d6 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -1025,8 +1025,8 @@ var requirejs = (function() { 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 }); From de1c8ad93a9842d2fab47b541dd5ed63fdd19b1e Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 13 May 2021 13:53:05 -0700 Subject: [PATCH 50/63] Give clearer names and add comment --- .../browser/view/renderers/cellDnd.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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..a3cdafe311d 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts @@ -344,16 +344,19 @@ export class CellDragAndDropController extends Disposable { return; } - const viewRect = this.notebookEditor.getDomNode().getBoundingClientRect(); + const notebookViewRect = this.notebookEditor.getDomNode().getBoundingClientRect(); const eventPositionInView = position.clientY - 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)); } } From d7f6d7e735d46b6e0a4a9a4c1c3da36b1ac0c8b9 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Tue, 18 May 2021 21:03:55 -0700 Subject: [PATCH 51/63] Fix markdown cell drag indiciator having wrong position in scrolled documents --- .../browser/diff/notebookTextDiffEditor.ts | 4 ++-- .../notebook/browser/notebookBrowser.ts | 6 +++--- .../notebook/browser/notebookEditorWidget.ts | 12 +++++------ .../view/renderers/backLayerWebView.ts | 18 ++++++---------- .../browser/view/renderers/cellDnd.ts | 21 +++++++++---------- .../browser/view/renderers/webviewPreloads.ts | 6 +++--- 6 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffEditor.ts index 9db388ec10e..75a9258c0ac 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 { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts index 6d4bf6077d5..b4dbbc4020b 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -177,9 +177,9 @@ export interface ICommonNotebookEditor { 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/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index c588c2a5290..4fcf323a763 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -2348,24 +2348,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); } } 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 1c6a07379d6..44fca9b4982 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -131,17 +131,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 +145,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 { @@ -1086,18 +1080,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, }); 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 a3cdafe311d..5f35d9efb21 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellDnd.ts @@ -195,7 +195,7 @@ 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; @@ -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); } @@ -345,7 +345,7 @@ export class CellDragAndDropController extends Disposable { } const notebookViewRect = this.notebookEditor.getDomNode().getBoundingClientRect(); - const eventPositionInView = position.clientY - this.list.scrollTop; + const eventPositionInView = dragOffsetY - this.list.scrollTop; // Percentage from the top/bottom of the screen where we start scrolling while dragging const notebookViewScrollMargins = 0.2; @@ -364,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/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index 282608bfea7..60003df8498 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -1025,7 +1025,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 +1041,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 +1053,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); }; From 15f772fae328e41554613bdbfcd8f2478fe58937 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 19 May 2021 12:53:36 -0700 Subject: [PATCH 52/63] notebook: include script url in back compat patch --- .../notebook/browser/view/renderers/webviewPreloads.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 e1df7bf0485..f70fb7c26c7 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -164,16 +164,17 @@ async function webviewPreloads(style: PreloadStyles, rendererData: readonly Rend if (isModule) { return __import(url); } else { - return createBackCompatModule(rendererId, text); + return createBackCompatModule(rendererId, url, text); } }; - const createBackCompatModule = (rendererId: string, scriptText: string): ScriptModule => ({ + 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, From 8c27c1f257b44acdf2c143df907eb975be89db35 Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Wed, 19 May 2021 13:13:29 -0700 Subject: [PATCH 53/63] Update list focus highlight color in standaloned editor Refs #123703 --- .../standalone/browser/quickInput/standaloneQuickInput.css | 2 +- src/vs/editor/standalone/common/themes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 -------------------------------- */ From 26194faa8be18e79603a65e99b25153a4c5ed9b9 Mon Sep 17 00:00:00 2001 From: rebornix Date: Wed, 19 May 2021 13:35:39 -0700 Subject: [PATCH 54/63] fix focus indicator default value. --- src/vs/workbench/contrib/notebook/common/notebookOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts index 951b6b16b34..c778ff425ba 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts @@ -143,7 +143,7 @@ export class NotebookOptions { } if (focusIndicator) { - configuration.focusIndicator = this.configurationService.getValue<'border' | 'gutter'>(ExperimentalFocusIndicator); + configuration.focusIndicator = this.configurationService.getValue<'border' | 'gutter'>(ExperimentalFocusIndicator) ?? 'border'; } if (compactView) { From 08f4a36de15c40ac3b6b4a65f1ca5f90ea3c2711 Mon Sep 17 00:00:00 2001 From: rebornix Date: Wed, 19 May 2021 13:49:46 -0700 Subject: [PATCH 55/63] notebook toolbar container display none by default. --- src/vs/workbench/contrib/notebook/browser/media/notebook.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebook.css b/src/vs/workbench/contrib/notebook/browser/media/notebook.css index 8bf527a75ab..cc070968973 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebook.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebook.css @@ -13,7 +13,7 @@ .monaco-workbench .notebookOverlay .notebook-toolbar-container { width: 100%; - display: flex; + display: none; margin-top: 2px; margin-bottom: 2px; } From c936add60132183432497fecc4c3fb819624e7be Mon Sep 17 00:00:00 2001 From: rebornix Date: Wed, 19 May 2021 14:07:13 -0700 Subject: [PATCH 56/63] hide kernel status bar item if it is already rendered in notebook toolbar. --- .../browser/contrib/status/editorStatus.ts | 7 +++++++ .../notebook/browser/notebookEditorToolbar.ts | 7 ++++--- .../contrib/notebook/common/notebookCommon.ts | 1 + .../contrib/notebook/common/notebookOptions.ts | 16 +++++++++++++--- 4 files changed, 25 insertions(+), 6 deletions(-) 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 f06c862be27..f6334bcde3f 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/status/editorStatus.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/status/editorStatus.ts @@ -242,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); @@ -254,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(); } diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorToolbar.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorToolbar.ts index 3fc2773c8bb..0d45a0ab1dd 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorToolbar.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorToolbar.ts @@ -23,6 +23,7 @@ import { INotebookEditor, NOTEBOOK_EDITOR_ID } from 'vs/workbench/contrib/notebo 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'; @@ -97,10 +98,10 @@ export class NotebookEditorToolbar extends Disposable { this._notebookGlobalActionsMenu = this._register(cellMenu.getNotebookToolbar(this.contextKeyService)); this._register(this._notebookGlobalActionsMenu); - this._useGlobalToolbar = this.configurationService.getValue('notebook.experimental.globalToolbar') ?? false; + this._useGlobalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar) ?? false; this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('notebook.experimental.globalToolbar')) { - this._useGlobalToolbar = this.configurationService.getValue('notebook.experimental.globalToolbar'); + if (e.affectsConfiguration(ExperimentalGlobalToolbar)) { + this._useGlobalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar); this._showNotebookActionsinEditorToolbar(); } })); diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index f3fd40adfee..d8fcf0068cb 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -899,6 +899,7 @@ export const ExperimentalUseMarkdownRenderer = 'notebook.experimental.useMarkdow 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 enum CellStatusbarAlignment { diff --git a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts index c778ff425ba..2dde37f10d1 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookOptions.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookOptions.ts @@ -6,7 +6,7 @@ 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, ExperimentalFocusIndicator, ExperimentalInsertToolbarPosition, ShowCellStatusBarKey } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellToolbarLocKey, CellToolbarVisibility, ExperimentalCompactView, ExperimentalFocusIndicator, ExperimentalGlobalToolbar, ExperimentalInsertToolbarPosition, ShowCellStatusBarKey } from 'vs/workbench/contrib/notebook/common/notebookCommon'; const SCROLLABLE_ELEMENT_PADDING_TOP = 18; @@ -49,6 +49,7 @@ export interface NotebookLayoutConfiguration { compactView: boolean; focusIndicator: 'border' | 'gutter'; insertToolbarPosition: 'betweenCells' | 'notebookToolbar' | 'both' | 'hidden'; + globalToolbar: boolean; } interface NotebookOptionsChangeEvent { @@ -59,6 +60,7 @@ interface NotebookOptionsChangeEvent { compactView?: boolean; focusIndicator?: boolean; insertToolbarPosition?: boolean; + globalToolbar?: boolean; } const defaultConfigConstants = { @@ -85,6 +87,7 @@ export class NotebookOptions { constructor(readonly configurationService: IConfigurationService) { const showCellStatusBar = this.configurationService.getValue(ShowCellStatusBarKey); + const globalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar) ?? false; const cellToolbarLocation = this.configurationService.getValue(CellToolbarLocKey); const cellToolbarInteraction = this.configurationService.getValue(CellToolbarVisibility); const compactView = this.configurationService.getValue(ExperimentalCompactView); @@ -109,6 +112,7 @@ export class NotebookOptions { editorBottomPaddingWithoutStatusBar: 12, collapsedIndicatorHeight: 24, showCellStatusBar, + globalToolbar, cellToolbarLocation, cellToolbarInteraction, compactView, @@ -123,8 +127,9 @@ export class NotebookOptions { let compactView = e.affectsConfiguration(ExperimentalCompactView); let focusIndicator = e.affectsConfiguration(ExperimentalFocusIndicator); let insertToolbarPosition = e.affectsConfiguration(ExperimentalInsertToolbarPosition); + let globalToolbar = e.affectsConfiguration(ExperimentalGlobalToolbar); - if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView && !focusIndicator && !insertToolbarPosition) { + if (!cellStatusBarVisibility && !cellToolbarLocation && !cellToolbarInteraction && !compactView && !focusIndicator && !insertToolbarPosition && !globalToolbar) { return; } @@ -161,6 +166,10 @@ export class NotebookOptions { configuration.bottomToolbarGap = bottomToolbarGap; } + if (globalToolbar) { + configuration.globalToolbar = this.configurationService.getValue(ExperimentalGlobalToolbar) ?? false; + } + this._layoutConfiguration = configuration; // trigger event @@ -170,7 +179,8 @@ export class NotebookOptions { cellToolbarInteraction: cellToolbarInteraction, compactView: compactView, focusIndicator: focusIndicator, - insertToolbarPosition: insertToolbarPosition + insertToolbarPosition: insertToolbarPosition, + globalToolbar: globalToolbar }); })); From d921cc41fc030271c699eb0d5b1c2cdfec304eda Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 19 May 2021 15:34:07 -0700 Subject: [PATCH 57/63] Update markdown grammar --- extensions/markdown-basics/cgmanifest.json | 2 +- .../syntaxes/markdown.tmLanguage.json | 106 +++++++++--------- 2 files changed, 54 insertions(+), 54 deletions(-) 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": { From f433b4781a0c719a17b1c23e67d1ed0161afd972 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 19 May 2021 15:47:35 -0700 Subject: [PATCH 58/63] Pick up TS 4.3.1-rc --- extensions/package.json | 2 +- extensions/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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" From 920c9a3a0d5d8bc3d6a4f63535052aea2aedccf7 Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 19 May 2021 15:57:52 -0700 Subject: [PATCH 59/63] improve banner for virtual workspace --- .../browser/parts/banner/bannerPart.ts | 12 +- .../browser/workspace.contribution.ts | 108 +++++++++++++++--- .../services/banner/browser/bannerService.ts | 3 +- 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/browser/parts/banner/bannerPart.ts b/src/vs/workbench/browser/parts/banner/bannerPart.ts index a894eafc722..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; @@ -211,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; diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index 4abb2b498c6..0dbac3b6736 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'; @@ -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 @@ -149,6 +155,7 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben private showModalOnStart(): void { if (this.workspaceTrustManagementService.isWorkpaceTrusted()) { + this.updateWorkbenchIndicators(true); return; } @@ -198,13 +205,52 @@ export class WorkspaceTrustRequestHandler extends Disposable implements IWorkben this.statusbarService.updateEntryVisibility(this.entryId, false); } - private getBannerItem(): IBannerItem { - return { - id: BANNER_RESTRICTED_MODE, - icon: shieldIcon, - ariaLabel: localize('restrictedModeBannerAriaLabel', "Restricted Mode is intended for safe code browsing. Trust this folder to enable all features. Use navigation keys to access banner actions."), - 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' @@ -214,7 +260,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); + } + } }; } @@ -245,10 +317,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); + } } } 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'); From f822083cb70dd6636727632f59d04c34699f87cf Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 19 May 2021 15:59:38 -0700 Subject: [PATCH 60/63] Check `defaultPrevented` before showing built-in webview context menu Fixes #123301 --- .../contrib/webview/browser/pre/main.js | 119 ++++++------------ 1 file changed, 36 insertions(+), 83 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js index 098abfbe8a1..7e429b1969b 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/main.js +++ b/src/vs/workbench/contrib/webview/browser/pre/main.js @@ -4,12 +4,10 @@ *--------------------------------------------------------------------------------------------*/ // @ts-check -/// - /** * @typedef {{ * postMessage: (channel: string, data?: any) => void, - * onMessage: (channel: string, handler: (event: MessageEvent, data: any) => void) => void, + * onMessage: (channel: string, handler: any) => void, * focusIframeOnCreate?: boolean, * ready?: Promise, * onIframeLoaded?: (iframe: HTMLIFrameElement) => void, @@ -58,18 +56,6 @@ 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'); @@ -216,9 +202,6 @@ 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; @@ -235,7 +218,7 @@ const workerReady = new Promise(async (resolve, reject) => { } }; navigator.serviceWorker.addEventListener('message', versionHandler); - assertIsDefined(registration.active).postMessage({ channel: 'version' }); + registration.active.postMessage({ channel: 'version' }); }, error => { reject(new Error(`Could not register service workers: ${error}.`)); @@ -248,7 +231,6 @@ const workerReady = new Promise(async (resolve, reject) => { export async function createWebviewManager(host) { // state let firstLoad = true; - /** @type {any} */ let loadTimeout; let styleVersion = 0; @@ -259,7 +241,7 @@ export async function createWebviewManager(host) { /** @type {number | undefined} */ initialScrollProgress: undefined, - /** @type {{ [key: string]: string } | undefined} */ + /** @type {{ [key: string]: string }} */ styles: undefined, /** @type {string | undefined} */ @@ -271,13 +253,13 @@ export async function createWebviewManager(host) { host.onMessage('did-load-resource', (_event, data) => { navigator.serviceWorker.ready.then(registration => { - assertIsDefined(registration.active).postMessage({ channel: 'did-load-resource', data }, data.data?.buffer ? [data.data.buffer] : []); + 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 => { - assertIsDefined(registration.active).postMessage({ channel: 'did-load-localhost', data }); + registration.active.postMessage({ channel: 'did-load-localhost', data }); }); }); @@ -300,9 +282,7 @@ export async function createWebviewManager(host) { if (body) { body.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast'); - if (initData.activeTheme) { - body.classList.add(initData.activeTheme); - } + body.classList.add(initData.activeTheme); body.dataset.vscodeThemeKind = initData.activeTheme; body.dataset.vscodeThemeName = initData.themeName || ''; @@ -455,9 +435,6 @@ export async function createWebviewManager(host) { let isHandlingScroll = false; - /** - * @param {WheelEvent} event - */ const handleWheel = (event) => { if (isHandlingScroll) { return; @@ -473,21 +450,15 @@ export async function createWebviewManager(host) { }); }; - /** - * @param {Event} event - */ const handleInnerScroll = (event) => { + if (!event.target || !event.target.body) { + return; + } if (isHandlingScroll) { return; } - 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; + const progress = event.currentTarget.scrollY / event.target.body.clientHeight; if (isNaN(progress)) { return; } @@ -504,19 +475,6 @@ export async function createWebviewManager(host) { }; /** - * @typedef {{ - * contents: string; - * options: { - * readonly allowScripts: boolean; - * readonly allowMultipleAPIAcquire: boolean; - * } - * state: any; - * resourceEndpoint: string; - * }} ContentUpdateData - */ - - /** - * @param {ContentUpdateData} data * @return {string} */ function toContentHtml(data) { @@ -526,10 +484,7 @@ export async function createWebviewManager(host) { newDocument.querySelectorAll('a').forEach(a => { if (!a.title) { - const href = a.getAttribute('href'); - if (typeof href === 'string') { - a.title = href; - } + a.title = a.getAttribute('href'); } }); @@ -554,11 +509,8 @@ export async function createWebviewManager(host) { try { // Attempt to rewrite CSPs that hardcode old-style resource endpoint const endpointUrl = new URL(data.resourceEndpoint); - const cspContent = csp.getAttribute('content'); - if (cspContent) { - const newCsp = cspContent.replace(/(vscode-webview-resource|vscode-resource):(?=(\s|;|$))/g, endpointUrl.origin); - csp.setAttribute('content', newCsp); - } + const newCsp = csp.getAttribute('content').replace(/(vscode-webview-resource|vscode-resource):(?=(\s|;|$))/g, endpointUrl.origin); + csp.setAttribute('content', newCsp); } catch (e) { console.error(`Could not rewrite csp: ${e}`); } @@ -611,7 +563,7 @@ export async function createWebviewManager(host) { // update iframe-contents let updateId = 0; - host.onMessage('content', async (_event, /** @type {ContentUpdateData} */ data) => { + host.onMessage('content', async (_event, data) => { const currentUpdateId = ++updateId; try { @@ -634,19 +586,18 @@ 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 (typeof initData.initialScrollProgress === 'number' && !isNaN(initData.initialScrollProgress)) { + if (!isNaN(initData.initialScrollProgress)) { if (window.scrollY === 0) { window.scroll(0, body.clientHeight * initData.initialScrollProgress); } } }; } else { - const scrollY = frame && frame.contentDocument && frame.contentDocument.body ? assertIsDefined(frame.contentWindow).scrollY : 0; + const scrollY = frame && frame.contentDocument && frame.contentDocument.body ? frame.contentWindow.scrollY : 0; setInitialScrollPosition = (body, window) => { if (window.scrollY === 0) { window.scroll(0, scrollY); @@ -705,16 +656,15 @@ export async function createWebviewManager(host) { return; } - const contentDocument = assertIsDefined(newFrame.contentDocument); - if (contentDocument.readyState !== 'loading') { + if (newFrame.contentDocument.readyState !== 'loading') { clearInterval(interval); - onFrameLoaded(contentDocument); + onFrameLoaded(newFrame.contentDocument); } }, 10); } else { - assertIsDefined(newFrame.contentWindow).addEventListener('DOMContentLoaded', e => { + newFrame.contentWindow.addEventListener('DOMContentLoaded', e => { const contentDocument = e.target ? (/** @type {HTMLDocument} */ (e.target)) : undefined; - onFrameLoaded(assertIsDefined(contentDocument)); + onFrameLoaded(contentDocument); }); } @@ -742,7 +692,7 @@ export async function createWebviewManager(host) { newFrame.setAttribute('id', 'active-frame'); newFrame.style.visibility = 'visible'; if (host.focusIframeOnCreate) { - assertIsDefined(newFrame.contentWindow).focus(); + newFrame.contentWindow.focus(); } contentWindow.addEventListener('scroll', handleInnerScroll); @@ -770,12 +720,10 @@ export async function createWebviewManager(host) { loadTimeout = setTimeout(() => { clearTimeout(loadTimeout); loadTimeout = undefined; - onLoad(assertIsDefined(newFrame.contentDocument), assertIsDefined(newFrame.contentWindow)); + onLoad(newFrame.contentDocument, newFrame.contentWindow); }, 200); - const contentWindow = assertIsDefined(newFrame.contentWindow); - - contentWindow.addEventListener('load', function (e) { + newFrame.contentWindow.addEventListener('load', function (e) { const contentDocument = /** @type {Document} */ (e.target); if (loadTimeout) { @@ -786,11 +734,11 @@ export async function createWebviewManager(host) { }); // Bubble out various events - contentWindow.addEventListener('click', handleInnerClick); - contentWindow.addEventListener('auxclick', handleAuxClick); - contentWindow.addEventListener('keydown', handleInnerKeydown); - contentWindow.addEventListener('keyup', handleInnerUp); - contentWindow.addEventListener('contextmenu', e => { + 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 => { e.preventDefault(); host.postMessage('did-context-menu', { clientX: e.clientX, @@ -812,7 +760,7 @@ export async function createWebviewManager(host) { if (!pending) { const target = getActiveFrame(); if (target) { - assertIsDefined(target.contentWindow).postMessage(data.message, '*', data.transfer); + target.contentWindow.postMessage(data.message, '*', data.transfer); return; } } @@ -828,7 +776,7 @@ export async function createWebviewManager(host) { if (!target) { return; } - assertIsDefined(target.contentDocument).execCommand(data); + target.contentDocument.execCommand(data); }); trackFocus({ @@ -836,13 +784,18 @@ export async function createWebviewManager(host) { onBlur: () => host.postMessage('did-blur') }); - (/** @type {any} */ (window))[vscodePostMessageFuncName] = (/** @type {string} */ command, /** @type {any} */ data) => { + (/** @type {any} */ (window))[vscodePostMessageFuncName] = (command, data, transfer) => { switch (command) { case 'onmessage': case 'do-update-state': host.postMessage(command, data); break; } + if (e.defaultPrevented) { + // Extension code has already handled this event + return; + } + }; // signal ready From a65d55e9c48f7629eae430cd924f357b6aff8236 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 19 May 2021 15:59:58 -0700 Subject: [PATCH 61/63] Strict null fixes in webview --- .../contrib/webview/browser/pre/main.js | 124 +++++++++++++----- 1 file changed, 88 insertions(+), 36 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js index 7e429b1969b..a5faa4d3316 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; + * resourceEndpoint: 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; + } } }); @@ -509,8 +554,11 @@ export async function createWebviewManager(host) { 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, endpointUrl.origin); + csp.setAttribute('content', newCsp); + } } catch (e) { console.error(`Could not rewrite csp: ${e}`); } @@ -563,7 +611,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 +634,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 +705,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 +742,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 +770,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 +786,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 +817,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 +833,7 @@ export async function createWebviewManager(host) { if (!target) { return; } - target.contentDocument.execCommand(data); + assertIsDefined(target.contentDocument).execCommand(data); }); trackFocus({ @@ -784,18 +841,13 @@ 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': host.postMessage(command, data); break; } - if (e.defaultPrevented) { - // Extension code has already handled this event - return; - } - }; // signal ready From 4fcc2720826658c53bf807a06a92a35eeded04ff Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 19 May 2021 16:01:20 -0700 Subject: [PATCH 62/63] use custom shield dialog #wt --- src/vs/workbench/services/workspaces/common/workspaceTrust.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts index c49d8f6fade..7f134cebfa1 100644 --- a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts +++ b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts @@ -3,6 +3,7 @@ * 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 { splitName } from 'vs/base/common/labels'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; @@ -389,6 +390,7 @@ export class WorkspaceTrustRequestService extends Disposable implements IWorkspa const result = await this.dialogService.show(Severity.Info, localize('openLooseFileMesssage', "Are you sure you want to open these files?"), [localize('open', "Open"), localize('newWindow', "Open in New Window"), localize('cancel', "Cancel")], { detail: localize('openLooseFileDetails', "You are trying to open untrusted files into the current window which is trusted. How would you like to continue?"), cancelId: 2, + custom: { icon: Codicon.shield } }); switch (result.choice) { From 40592a274c9f6dc24c00819099b2103962b0f092 Mon Sep 17 00:00:00 2001 From: rebornix Date: Wed, 19 May 2021 16:13:29 -0700 Subject: [PATCH 63/63] fix test suite. --- src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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),