提交 4862602c 编写于 作者: M Matt Bierner

Align custom editor API proposal with notebook API

Fixes #95854
Fixes #95849
For #77131

- Move all editing functionality back onto the provider. This better matches the notebook API.

- Rename `CustomEditorProvider` to `CustomReadonlyEditorProvider`.  `CustomEditorProvider` is now how editable custom editors are implemented

- Give extension a full suggested backup path instead of just a folder
上级 37944c88
......@@ -13,7 +13,7 @@ import { BinarySizeStatusBarEntry } from './binarySizeStatusBarEntry';
const localize = nls.loadMessageBundle();
export class PreviewManager implements vscode.CustomEditorProvider {
export class PreviewManager implements vscode.CustomReadonlyEditorProvider {
public static readonly viewType = 'imagePreview.previewEditor';
......
......@@ -1239,11 +1239,17 @@ declare module 'vscode' {
}
/**
* Event triggered by extensions to signal to VS Code that an edit has occurred on an [`CustomEditableDocument`](#CustomEditableDocument).
* Event triggered by extensions to signal to VS Code that an edit has occurred on an [`CustomDocument`](#CustomDocument).
*
* @see [`CustomEditableDocument.onDidChange`](#CustomEditableDocument.onDidChange).
* @see [`CustomDocumentProvider.onDidChangeCustomDocument`](#CustomDocumentProvider.onDidChangeCustomDocument).
*/
interface CustomDocumentEditEvent {
/**
* The document that the edit is for.
*/
readonly document: CustomDocument;
/**
* Undo the edit operation.
*
......@@ -1267,17 +1273,20 @@ declare module 'vscode' {
}
/**
* Event triggered by extensions to signal to VS Code that the content of a [`CustomEditableDocument`](#CustomEditableDocument)
* Event triggered by extensions to signal to VS Code that the content of a [`CustomDocument`](#CustomDocument)
* has changed.
*
* @see [`CustomEditableDocument.onDidChange`](#CustomEditableDocument.onDidChange).
* @see [`CustomDocumentProvider.onDidChangeCustomDocument`](#CustomDocumentProvider.onDidChangeCustomDocument).
*/
interface CustomDocumentContentChangeEvent {
// marker interface
/**
* The document that the change is for.
*/
readonly document: CustomDocument;
}
/**
* A backup for an [`CustomEditableDocument`](#CustomEditableDocument).
* A backup for an [`CustomDocument`](#CustomDocument).
*/
interface CustomDocumentBackup {
/**
......@@ -1285,7 +1294,7 @@ declare module 'vscode' {
*
* This id is passed back to your extension in `openCustomDocument` when opening a custom editor from a backup.
*/
readonly backupId: string;
readonly id: string;
/**
* Delete the current backup.
......@@ -1301,21 +1310,76 @@ declare module 'vscode' {
*/
interface CustomDocumentBackupContext {
/**
* Uri of a workspace specific directory in which the extension can store backup data.
* Suggested file location to write the new backup.
*
* Note that your extension is free to ignore this and use its own strategy for backup.
*
* The directory might not exist on disk and creation is up to the extension.
* For editors for workspace resource, this destination will be in the workspace storage. The path may not
*/
readonly workspaceStorageUri: Uri | undefined;
readonly destination: Uri;
}
/**
* Represents an editable custom document used by a [`CustomEditorProvider`](#CustomEditorProvider).
* Additional information about the opening custom document.
*/
interface CustomDocumentOpenContext {
/**
* The id of the backup to restore the document from or `undefined` if there is no backup.
*
* If this is provided, your extension should restore the editor from the backup instead of reading the file
* the user's workspace.
*/
readonly backupId?: string;
}
/**
* Provider for custom editors that use a custom document model.
*
* `CustomEditableDocument` is how custom editors hook into standard VS Code operations such as save and undo. The
* document is also how custom editors notify VS Code that an edit has taken place.
* Custom editors use [`CustomDocument`](#CustomDocument) as their document model instead of a [`TextDocument`](#TextDocument).
* This gives extensions full control over actions such as edit, save, and backup.
*
* You should use this type of custom editor when dealing with binary files or more complex scenarios. For simple
* text based documents, use [`CustomTextEditorProvider`](#CustomTextEditorProvider) instead.
*
* @param T Type of the custom document returned by this provider.
*/
interface CustomEditableDocument extends CustomDocument {
export interface CustomReadonlyEditorProvider<T extends CustomDocument = CustomDocument> {
/**
* Create a new document for a given resource.
*
* `openCustomDocument` is called when the first editor for a given resource is opened, and the resolve document
* is passed to `resolveCustomEditor`. The resolved `CustomDocument` is re-used for subsequent editor opens.
* If all editors for a given resource are closed, the `CustomDocument` is disposed of. Opening an editor at
* this point will trigger another call to `openCustomDocument`.
*
* @param uri Uri of the document to open.
* @param openContext Additional information about the opening custom document.
* @param token A cancellation token that indicates the result is no longer needed.
*
* @return The custom document.
*/
openCustomDocument(uri: Uri, openContext: CustomDocumentOpenContext, token: CancellationToken): Thenable<T> | T;
/**
* Resolve a custom editor for a given resource.
*
* This is called whenever the user opens a new editor for this `CustomEditorProvider`.
*
* To resolve a custom editor, the provider must fill in its initial html content and hook up all
* the event listeners it is interested it. The provider can also hold onto the `WebviewPanel` to use later,
* for example in a command. See [`WebviewPanel`](#WebviewPanel) for additional details.
*
* @param document Document for the resource being resolved.
* @param webviewPanel Webview to resolve.
* @param token A cancellation token that indicates the result is no longer needed.
*
* @return Optional thenable indicating that the custom editor has been resolved.
*/
resolveCustomEditor(document: T, webviewPanel: WebviewPanel, token: CancellationToken): Thenable<void> | void;
}
export interface CustomEditorProvider<T extends CustomDocument = CustomDocument> extends CustomReadonlyEditorProvider<T> {
/**
* Signal that an edit has occurred inside a custom editor.
*
......@@ -1336,10 +1400,10 @@ declare module 'vscode' {
*
* An editor should only ever fire `CustomDocumentEditEvent` events, or only ever fire `CustomDocumentContentChangeEvent` events.
*/
readonly onDidChange: Event<CustomDocumentEditEvent> | Event<CustomDocumentContentChangeEvent>;
readonly onDidChangeCustomDocument: Event<CustomDocumentEditEvent> | Event<CustomDocumentContentChangeEvent>;
/**
* Save the resource for a custom editor.
* Save a custom document.
*
* This method is invoked by VS Code when the user saves a custom editor. This can happen when the user
* triggers save while the custom editor is active, by commands such as `save all`, or by auto save if enabled.
......@@ -1348,29 +1412,31 @@ declare module 'vscode' {
* file data for the custom document to disk. After `save` completes, any associated editor instances will
* no longer be marked as dirty.
*
* @param document Document to save.
* @param cancellation Token that signals the save is no longer required (for example, if another save was triggered).
*
* @return Thenable signaling that saving has completed.
*/
save(cancellation: CancellationToken): Thenable<void>;
saveCustomDocument(document: CustomDocument, cancellation: CancellationToken): Thenable<void>;
/**
* Save the resource for a custom editor to a different location.
* Save a custom document to a different location.
*
* This method is invoked by VS Code when the user triggers save as on a custom editor. The implementer must
* persist the custom editor to `targetResource`.
* This method is invoked by VS Code when the user triggers 'save as' on a custom editor. The implementer must
* persist the custom editor to `destination`.
*
* When the user accepts save as, the current editor is be replaced by an editor for the newly saved file.
*
* @param uri Location to save to.
* @param document Document to save.
* @param destination Location to save to.
* @param cancellation Token that signals the save is no longer required.
*
* @return Thenable signaling that saving has completed.
*/
saveAs(uri: Uri, cancellation: CancellationToken): Thenable<void>;
saveCustomDocumentAs(document: CustomDocument, destination: Uri, cancellation: CancellationToken): Thenable<void>;
/**
* Revert a custom editor to its last saved state.
* Revert a custom document to its last saved state.
*
* This method is invoked by VS Code when the user triggers `File: Revert File` in a custom editor. (Note that
* this is only used using VS Code's `File: Revert File` command and not on a `git revert` of the file).
......@@ -1382,14 +1448,15 @@ declare module 'vscode' {
* During `revert`, your extension should also clear any backups for the custom editor. Backups are only needed
* when there is a difference between an editor's state in VS Code and its save state on disk.
*
* @param document Document to revert.
* @param cancellation Token that signals the revert is no longer required.
*
* @return Thenable signaling that the change has completed.
*/
revert(cancellation: CancellationToken): Thenable<void>;
revertCustomDocument(document: CustomDocument, cancellation: CancellationToken): Thenable<void>;
/**
* Back up the resource in its current state.
* Back up a dirty custom document.
*
* Backups are used for hot exit and to prevent data loss. Your `backup` method should persist the resource in
* its current state, i.e. with the edits applied. Most commonly this means saving the resource to disk in
......@@ -1401,73 +1468,14 @@ declare module 'vscode' {
* made in quick succession, `backup` is only triggered after the last one. `backup` is not invoked when
* `auto save` is enabled (since auto save already persists resource ).
*
* @param document Document to backup.
* @param context Information that can be used to backup the document.
* @param cancellation Token that signals the current backup since a new backup is coming in. It is up to your
* extension to decided how to respond to cancellation. If for example your extension is backing up a large file
* in an operation that takes time to complete, your extension may decide to finish the ongoing backup rather
* than cancelling it to ensure that VS Code has some valid backup.
*/
backup(context: CustomDocumentBackupContext, cancellation: CancellationToken): Thenable<CustomDocumentBackup>;
}
/**
* Additional information about the opening custom document.
*/
interface CustomDocumentOpenContext {
/**
* The id of the backup to restore the document from or `undefined` if there is no backup.
*
* If this is provided, your extension should restore the editor from the backup instead of reading the file
* the user's workspace.
*/
readonly backupId?: string;
}
/**
* Provider for custom editors that use a custom document model.
*
* Custom editors use [`CustomDocument`](#CustomDocument) as their document model instead of a [`TextDocument`](#TextDocument).
* This gives extensions full control over actions such as edit, save, and backup.
*
* You should use this type of custom editor when dealing with binary files or more complex scenarios. For simple
* text based documents, use [`CustomTextEditorProvider`](#CustomTextEditorProvider) instead.
*
* @param T Type of the custom document returned by this provider.
*/
export interface CustomEditorProvider<T extends CustomDocument = CustomDocument> {
/**
* Create a new document for a given resource.
*
* `openCustomDocument` is called when the first editor for a given resource is opened, and the resolve document
* is passed to `resolveCustomEditor`. The resolved `CustomDocument` is re-used for subsequent editor opens.
* If all editors for a given resource are closed, the `CustomDocument` is disposed of. Opening an editor at
* this point will trigger another call to `openCustomDocument`.
*
* @param uri Uri of the document to open.
* @param openContext Additional information about the opening custom document.
* @param token A cancellation token that indicates the result is no longer needed.
*
* @return The custom document.
*/
openCustomDocument(uri: Uri, openContext: CustomDocumentOpenContext, token: CancellationToken): Thenable<T> | T;
/**
* Resolve a custom editor for a given resource.
*
* This is called whenever the user opens a new editor for this `CustomEditorProvider`.
*
* To resolve a custom editor, the provider must fill in its initial html content and hook up all
* the event listeners it is interested it. The provider can also hold onto the `WebviewPanel` to use later,
* for example in a command. See [`WebviewPanel`](#WebviewPanel) for additional details.
*
* @param document Document for the resource being resolved.
* @param webviewPanel Webview to resolve.
* @param token A cancellation token that indicates the result is no longer needed.
*
* @return Optional thenable indicating that the custom editor has been resolved.
*/
resolveCustomEditor(document: T, webviewPanel: WebviewPanel, token: CancellationToken): Thenable<void> | void;
backupCustomDocument(document: CustomDocument, context: CustomDocumentBackupContext, cancellation: CancellationToken): Thenable<CustomDocumentBackup>;
}
namespace window {
......@@ -1476,11 +1484,13 @@ declare module 'vscode' {
*/
export function registerCustomEditorProvider2(
viewType: string,
provider: CustomEditorProvider,
provider: CustomReadonlyEditorProvider | CustomEditorProvider,
options?: {
readonly webviewOptions?: WebviewPanelOptions;
/**
* Only applies to `CustomReadonlyEditorProvider | CustomEditorProvider`.
*
* Indicates that the provider allows multiple editor instances to be open at the same time for
* the same resource.
*
......
......@@ -588,7 +588,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
registerCustomEditorProvider: (viewType: string, provider: vscode.CustomTextEditorProvider, options: { webviewOptions?: vscode.WebviewPanelOptions } = {}) => {
return extHostWebviews.registerCustomEditorProvider(extension, viewType, provider, options);
},
registerCustomEditorProvider2: (viewType: string, provider: vscode.CustomEditorProvider, options: { webviewOptions?: vscode.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean } = {}) => {
registerCustomEditorProvider2: (viewType: string, provider: vscode.CustomReadonlyEditorProvider, options: { webviewOptions?: vscode.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean } = {}) => {
checkProposedApiEnabled(extension);
return extHostWebviews.registerCustomEditorProvider(extension, viewType, provider, options);
},
......
......@@ -5,7 +5,10 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { Emitter, Event } from 'vs/base/common/event';
import { hash } from 'vs/base/common/hash';
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { Schemas } from 'vs/base/common/network';
import { joinPath } from 'vs/base/common/resources';
import { URI, UriComponents } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import * as modes from 'vs/editor/common/modes';
......@@ -13,6 +16,7 @@ import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'
import { ILogService } from 'vs/platform/log/common/log';
import { IExtHostApiDeprecationService } from 'vs/workbench/api/common/extHostApiDeprecationService';
import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments';
import { IExtensionStoragePaths } from 'vs/workbench/api/common/extHostStoragePaths';
import * as typeConverters from 'vs/workbench/api/common/extHostTypeConverters';
import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace';
import { EditorViewColumn } from 'vs/workbench/api/common/shared/editor';
......@@ -21,7 +25,6 @@ import type * as vscode from 'vscode';
import { Cache } from './cache';
import * as extHostProtocol from './extHost.protocol';
import * as extHostTypes from './extHostTypes';
import { IExtensionStoragePaths } from 'vs/workbench/api/common/extHostStoragePaths';
type IconPath = URI | { light: URI, dark: URI };
......@@ -266,16 +269,17 @@ export class ExtHostWebviewEditor extends Disposable implements vscode.WebviewPa
class CustomDocumentStoreEntry {
private _backupCounter = 1;
constructor(
public readonly document: vscode.CustomDocument,
public readonly backupContext: vscode.CustomDocumentBackupContext,
private readonly _storagePath: string,
) { }
private readonly _edits = new Cache<vscode.CustomDocumentEditEvent>('custom documents');
private _backup?: vscode.CustomDocumentBackup;
addEdit(item: vscode.CustomDocumentEditEvent): number {
return this._edits.add([item]);
}
......@@ -300,6 +304,10 @@ class CustomDocumentStoreEntry {
}
}
getNewBackupUri(): URI {
return joinPath(URI.file(this._storagePath), hashPath(this.document.uri) + (this._backupCounter++));
}
updateBackup(backup: vscode.CustomDocumentBackup): void {
this._backup?.delete();
this._backup = backup;
......@@ -326,12 +334,12 @@ class CustomDocumentStore {
return this._documents.get(this.key(viewType, resource));
}
public add(viewType: string, document: vscode.CustomDocument, backupContext: vscode.CustomDocumentBackupContext): CustomDocumentStoreEntry {
public add(viewType: string, document: vscode.CustomDocument, storagePath: string): CustomDocumentStoreEntry {
const key = this.key(viewType, document.uri);
if (this._documents.has(key)) {
throw new Error(`Document already exists for viewType:${viewType} resource:${document.uri}`);
}
const entry = new CustomDocumentStoreEntry(document, backupContext);
const entry = new CustomDocumentStoreEntry(document, storagePath);
this._documents.set(key, entry);
return entry;
}
......@@ -359,7 +367,7 @@ type ProviderEntry = {
} | {
readonly extension: IExtensionDescription;
readonly type: WebviewEditorType.Custom;
readonly provider: vscode.CustomEditorProvider;
readonly provider: vscode.CustomReadonlyEditorProvider;
};
class EditorProviderStore {
......@@ -369,7 +377,7 @@ class EditorProviderStore {
return this.add(WebviewEditorType.Text, viewType, extension, provider);
}
public addCustomProvider(viewType: string, extension: IExtensionDescription, provider: vscode.CustomEditorProvider): vscode.Disposable {
public addCustomProvider(viewType: string, extension: IExtensionDescription, provider: vscode.CustomReadonlyEditorProvider): vscode.Disposable {
return this.add(WebviewEditorType.Custom, viewType, extension, provider);
}
......@@ -377,7 +385,7 @@ class EditorProviderStore {
return this._providers.get(viewType);
}
private add(type: WebviewEditorType, viewType: string, extension: IExtensionDescription, provider: vscode.CustomTextEditorProvider | vscode.CustomEditorProvider): vscode.Disposable {
private add(type: WebviewEditorType, viewType: string, extension: IExtensionDescription, provider: vscode.CustomTextEditorProvider | vscode.CustomReadonlyEditorProvider): vscode.Disposable {
if (this._providers.has(viewType)) {
throw new Error(`Provider for viewType:${viewType} already registered`);
}
......@@ -459,7 +467,7 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
public registerCustomEditorProvider(
extension: IExtensionDescription,
viewType: string,
provider: vscode.CustomEditorProvider | vscode.CustomTextEditorProvider,
provider: vscode.CustomReadonlyEditorProvider | vscode.CustomTextEditorProvider,
options: { webviewOptions?: vscode.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean },
): vscode.Disposable {
const disposables = new DisposableStore();
......@@ -470,6 +478,19 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
});
} else {
disposables.add(this._editorProviders.addCustomProvider(viewType, extension, provider));
if (this.supportEditing(provider)) {
disposables.add(provider.onDidChangeCustomDocument(e => {
const entry = this.getCustomDocumentEntry(viewType, e.document.uri);
if (isEditEvent(e)) {
const editId = entry.addEdit(e);
this._proxy.$onDidEdit(e.document.uri, viewType, editId, e.label);
} else {
this._proxy.$onContentChange(e.document.uri, viewType);
}
}));
}
this._proxy.$registerCustomEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, !!options.supportsMultipleEditorsPerDocument);
}
......@@ -570,23 +591,11 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
const revivedResource = URI.revive(resource);
const document = await entry.provider.openCustomDocument(revivedResource, { backupId }, cancellation);
const workspaceStoragePath = this._extensionStoragePaths?.workspaceValue(entry.extension);
const documentEntry = this._documents.add(viewType, document, {
workspaceStorageUri: workspaceStoragePath ? URI.file(workspaceStoragePath) : undefined,
});
if (this.isEditable(document)) {
document.onDidChange(e => {
if (isEditEvent(e)) {
const editId = documentEntry.addEdit(e);
this._proxy.$onDidEdit(document.uri, viewType, editId, e.label);
} else {
this._proxy.$onContentChange(document.uri, viewType);
}
});
}
const storageRoot = this._extensionStoragePaths?.workspaceValue(entry.extension) ?? this._extensionStoragePaths?.globalValue(entry.extension);
this._documents.add(viewType, document, storageRoot!);
return { editable: this.isEditable(document) };
return { editable: this.supportEditing(entry.provider) };
}
async $disposeCustomDocument(resource: UriComponents, viewType: string): Promise<void> {
......@@ -680,29 +689,33 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
async $revert(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const document = this.getCustomEditableDocument(viewType, resourceComponents);
await document.revert(cancellation);
const provider = this.getCustomEditorProvider(viewType);
await provider.revertCustomDocument(entry.document, cancellation);
entry.disposeBackup();
}
async $onSave(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const document = this.getCustomEditableDocument(viewType, resourceComponents);
await document.save(cancellation);
const provider = this.getCustomEditorProvider(viewType);
await provider.saveCustomDocument(entry.document, cancellation);
entry.disposeBackup();
}
async $onSaveAs(resourceComponents: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void> {
const document = this.getCustomEditableDocument(viewType, resourceComponents);
return document.saveAs(URI.revive(targetResource), cancellation);
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const provider = this.getCustomEditorProvider(viewType);
return provider.saveCustomDocumentAs(entry.document, URI.revive(targetResource), cancellation);
}
async $backup(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise<string> {
const entry = this.getCustomDocumentEntry(viewType, resourceComponents);
const document = this.getCustomEditableDocument(viewType, resourceComponents);
const backup = await document.backup(entry.backupContext, cancellation);
const provider = this.getCustomEditorProvider(viewType);
const backup = await provider.backupCustomDocument(entry.document, {
destination: entry.getNewBackupUri(),
}, cancellation);
entry.updateBackup(backup);
return backup.backupId;
return backup.id;
}
private getWebviewPanel(handle: extHostProtocol.WebviewPanelHandle): ExtHostWebviewEditor | undefined {
......@@ -717,16 +730,19 @@ export class ExtHostWebviews implements extHostProtocol.ExtHostWebviewsShape {
return entry;
}
private isEditable(document: vscode.CustomDocument): document is vscode.CustomEditableDocument {
return !!(document as vscode.CustomEditableDocument).onDidChange;
}
private getCustomEditableDocument(viewType: string, resource: UriComponents): vscode.CustomEditableDocument {
const { document } = this.getCustomDocumentEntry(viewType, resource);
if (!this.isEditable(document)) {
private getCustomEditorProvider(viewType: string): vscode.CustomEditorProvider {
const entry = this._editorProviders.get(viewType);
const provider = entry?.provider;
if (!provider || !this.supportEditing(provider)) {
throw new Error('Custom document is not editable');
}
return document;
return provider;
}
private supportEditing(
provider: vscode.CustomTextEditorProvider | vscode.CustomEditorProvider | vscode.CustomReadonlyEditorProvider
): provider is vscode.CustomEditorProvider {
return !!(provider as vscode.CustomEditorProvider).onDidChangeCustomDocument;
}
}
......@@ -759,3 +775,8 @@ function isEditEvent(e: vscode.CustomDocumentContentChangeEvent | vscode.CustomD
return typeof (e as vscode.CustomDocumentEditEvent).undo === 'function'
&& typeof (e as vscode.CustomDocumentEditEvent).redo === 'function';
}
function hashPath(resource: URI): string {
const str = resource.scheme === Schemas.file || resource.scheme === Schemas.untitled ? resource.fsPath : resource.toString();
return hash(str) + '';
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册