Skip to content

Commit f75b565

Browse files
committed
Copy widget scripts to extension folder (#11082)
* Copy widget scripts to extension folder * Fix test
1 parent c7379ec commit f75b565

File tree

10 files changed

+118
-37
lines changed

10 files changed

+118
-37
lines changed

package-lock.json

Lines changed: 20 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3007,6 +3007,7 @@
30073007
"request": "^2.87.0",
30083008
"request-progress": "^3.0.0",
30093009
"rxjs": "^5.5.9",
3010+
"sanitize-filename": "^1.6.3",
30103011
"semver": "^5.5.0",
30113012
"stack-trace": "0.0.10",
30123013
"string-argv": "^0.3.1",
@@ -3207,7 +3208,6 @@
32073208
"requirejs": "^2.3.6",
32083209
"rewiremock": "^3.13.0",
32093210
"rimraf": "^3.0.2",
3210-
"sanitize-filename": "^1.6.3",
32113211
"sass-loader": "^7.1.0",
32123212
"serialize-javascript": "^2.1.2",
32133213
"shortid": "^2.2.8",

src/client/common/application/webPanels/webPanel.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ export class WebPanel implements IWebPanel {
2828
private disposableRegistry: IDisposableRegistry,
2929
private port: number | undefined,
3030
private token: string | undefined,
31-
private options: IWebPanelOptions
31+
private options: IWebPanelOptions,
32+
additionalRootPaths: Uri[] = []
3233
) {
3334
const webViewOptions: WebviewOptions = {
3435
enableScripts: true,
35-
localResourceRoots: [Uri.file(this.options.rootPath), Uri.file(this.options.cwd)],
36+
localResourceRoots: [Uri.file(this.options.rootPath), Uri.file(this.options.cwd), ...additionalRootPaths],
3637
portMapping: port ? [{ webviewPort: RemappedPort, extensionHostPort: port }] : undefined
3738
};
3839
if (options.webViewPanel) {

src/client/common/application/webPanels/webPanelProvider.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
// Licensed under the MIT License.
33
'use strict';
44
import { inject, injectable } from 'inversify';
5+
import * as path from 'path';
56
import * as portfinder from 'portfinder';
67
import * as uuid from 'uuid/v4';
7-
8+
import { Uri } from 'vscode';
89
import { IFileSystem } from '../../platform/types';
9-
import { IDisposableRegistry } from '../../types';
10+
import { IDisposableRegistry, IExtensionContext } from '../../types';
1011
import { IWebPanel, IWebPanelOptions, IWebPanelProvider } from '../types';
1112
import { WebPanel } from './webPanel';
1213

@@ -16,17 +17,27 @@ export class WebPanelProvider implements IWebPanelProvider {
1617
private token: string | undefined;
1718

1819
constructor(
19-
@inject(IDisposableRegistry) private disposableRegistry: IDisposableRegistry,
20-
@inject(IFileSystem) private fs: IFileSystem
20+
@inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry,
21+
@inject(IFileSystem) private readonly fs: IFileSystem,
22+
@inject(IExtensionContext) private readonly context: IExtensionContext
2123
) {}
2224

2325
// tslint:disable-next-line:no-any
2426
public async create(options: IWebPanelOptions): Promise<IWebPanel> {
2527
const serverData = options.startHttpServer
2628
? await this.ensureServerIsRunning()
2729
: { port: undefined, token: undefined };
28-
29-
return new WebPanel(this.fs, this.disposableRegistry, serverData.port, serverData.token, options);
30+
// Allow loading resources from the `<extension folder>/tmp` folder when in webiviews.
31+
// Used by widgets to place files that are not otherwise accessible.
32+
const additionalRootPaths = [Uri.file(path.join(this.context.extensionPath, 'tmp'))];
33+
return new WebPanel(
34+
this.fs,
35+
this.disposableRegistry,
36+
serverData.port,
37+
serverData.token,
38+
options,
39+
additionalRootPaths
40+
);
3041
}
3142

3243
private async ensureServerIsRunning(): Promise<{ port: number; token: string }> {

src/client/datascience/ipywidgets/ipyWidgetScriptSource.ts

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44
'use strict';
55
import type * as jupyterlabService from '@jupyterlab/services';
66
import type * as serialize from '@jupyterlab/services/lib/kernel/serialize';
7+
import { sha256 } from 'hash.js';
78
import { inject, injectable } from 'inversify';
89
import { IDisposable } from 'monaco-editor';
10+
import * as path from 'path';
911
import { Event, EventEmitter, Uri } from 'vscode';
1012
import type { Data as WebSocketData } from 'ws';
1113
import { IApplicationShell, IWorkspaceService } from '../../common/application/types';
12-
import { traceError } from '../../common/logger';
14+
import { traceError, traceInfo } from '../../common/logger';
1315
import { IFileSystem } from '../../common/platform/types';
14-
import { IConfigurationService, IDisposableRegistry, IHttpClient, IPersistentStateFactory } from '../../common/types';
16+
import {
17+
IConfigurationService,
18+
IDisposableRegistry,
19+
IExtensionContext,
20+
IHttpClient,
21+
IPersistentStateFactory
22+
} from '../../common/types';
1523
import { createDeferred, Deferred } from '../../common/utils/async';
1624
import { IInterpreterService, PythonInterpreter } from '../../interpreter/contracts';
1725
import { sendTelemetryEvent } from '../../telemetry';
@@ -30,6 +38,8 @@ import {
3038
} from '../types';
3139
import { IPyWidgetScriptSourceProvider } from './ipyWidgetScriptSourceProvider';
3240
import { WidgetScriptSource } from './types';
41+
// tslint:disable: no-var-requires no-require-imports
42+
const sanitize = require('sanitize-filename');
3343

3444
@injectable()
3545
export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocalResourceUriConverter {
@@ -41,6 +51,14 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
4151
public get postInternalMessage(): Event<{ message: string; payload: any }> {
4252
return this.postInternalMessageEmitter.event;
4353
}
54+
private get deserialize(): typeof serialize.deserialize {
55+
if (!this.jupyterSerialize) {
56+
// tslint:disable-next-line: no-require-imports
57+
this.jupyterSerialize = require('@jupyterlab/services/lib/kernel/serialize') as typeof serialize;
58+
}
59+
return this.jupyterSerialize.deserialize;
60+
}
61+
private readonly resourcesMappedToExtensionFolder = new Map<string, Promise<Uri>>();
4462
private notebookIdentity?: Uri;
4563
private postEmitter = new EventEmitter<{
4664
message: string;
@@ -64,14 +82,9 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
6482
*/
6583
private pendingModuleRequests = new Map<string, string>();
6684
private jupyterSerialize?: typeof serialize;
67-
private get deserialize(): typeof serialize.deserialize {
68-
if (!this.jupyterSerialize) {
69-
// tslint:disable-next-line: no-require-imports
70-
this.jupyterSerialize = require('@jupyterlab/services/lib/kernel/serialize') as typeof serialize;
71-
}
72-
return this.jupyterSerialize.deserialize;
73-
}
7485
private readonly uriConversionPromises = new Map<string, Deferred<Uri>>();
86+
private readonly targetWidgetScriptsFolder: string;
87+
private readonly createTargetWidgetScriptsFolder: Promise<string>;
7588
constructor(
7689
@inject(IDisposableRegistry) disposables: IDisposableRegistry,
7790
@inject(INotebookProvider) private readonly notebookProvider: INotebookProvider,
@@ -81,8 +94,18 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
8194
@inject(IHttpClient) private readonly httpClient: IHttpClient,
8295
@inject(IApplicationShell) private readonly appShell: IApplicationShell,
8396
@inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService,
84-
@inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory
97+
@inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory,
98+
@inject(IExtensionContext) extensionContext: IExtensionContext
8599
) {
100+
this.targetWidgetScriptsFolder = path.join(extensionContext.extensionPath, 'tmp', 'nbextensions');
101+
this.createTargetWidgetScriptsFolder = this.fs
102+
.directoryExists(this.targetWidgetScriptsFolder)
103+
.then(async (exists) => {
104+
if (!exists) {
105+
await this.fs.createDirectory(this.targetWidgetScriptsFolder);
106+
}
107+
return this.targetWidgetScriptsFolder;
108+
});
86109
disposables.push(this);
87110
this.notebookProvider.onNotebookCreated(
88111
(e) => {
@@ -94,7 +117,39 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
94117
this.disposables
95118
);
96119
}
97-
public asWebviewUri(localResource: Uri): Promise<Uri> {
120+
/**
121+
* This method is called to convert a Uri to a format such that it can be used in a webview.
122+
* WebViews only allow files that are part of extension and the same directory where notebook lives.
123+
* To ensure widgets can find the js files, we copy the script file to a into the extensionr folder `tmp/nbextensions`.
124+
* (storing files in `tmp/nbextensions` is relatively safe as this folder gets deleted when ever a user updates to a new version of VSC).
125+
* Hence we need to copy for every version of the extension.
126+
* Copying into global workspace folder would also work, but over time this folder size could grow (in an unmanaged way).
127+
*/
128+
public async asWebviewUri(localResource: Uri): Promise<Uri> {
129+
if (this.notebookIdentity && !this.resourcesMappedToExtensionFolder.has(localResource.fsPath)) {
130+
const deferred = createDeferred<Uri>();
131+
this.resourcesMappedToExtensionFolder.set(localResource.fsPath, deferred.promise);
132+
try {
133+
// Create a file name such that it will be unique and consistent across VSC reloads.
134+
// Only if original file has been modified should we create a new copy of the sam file.
135+
const fileHash: string = await this.fs.getFileHash(localResource.fsPath);
136+
const uniqueFileName = sanitize(sha256().update(`${localResource.fsPath}${fileHash}`).digest('hex'));
137+
const targetFolder = await this.createTargetWidgetScriptsFolder;
138+
const mappedResource = Uri.file(
139+
path.join(targetFolder, `${uniqueFileName}${path.basename(localResource.fsPath)}`)
140+
);
141+
if (!(await this.fs.fileExists(mappedResource.fsPath))) {
142+
await this.fs.copyFile(localResource.fsPath, mappedResource.fsPath);
143+
}
144+
traceInfo(`Widget Script file ${localResource.fsPath} mapped to ${mappedResource.fsPath}`);
145+
deferred.resolve(mappedResource);
146+
} catch (ex) {
147+
traceError(`Failed to map widget Script file ${localResource.fsPath}`);
148+
deferred.reject(ex);
149+
}
150+
}
151+
localResource = await this.resourcesMappedToExtensionFolder.get(localResource.fsPath)!;
152+
98153
const key = localResource.toString();
99154
if (!this.uriConversionPromises.has(key)) {
100155
this.uriConversionPromises.set(key, createDeferred<Uri>());

src/client/datascience/ipywidgets/localWidgetScriptSourceProvider.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ export class LocalWidgetScriptSourceProvider implements IWidgetScriptSourceProvi
6868
const parts = file.split(path.sep);
6969
const moduleName = parts[0];
7070

71-
// Drop the `.js`.
72-
const fileUri = Uri.file(path.join(nbextensionsPath, moduleName, 'index'));
71+
const fileUri = Uri.file(path.join(nbextensionsPath, file));
7372
const scriptUri = (await this.localResourceUriConverter.asWebviewUri(fileUri)).toString();
7473
// tslint:disable-next-line: no-unnecessary-local-variable
7574
const widgetScriptSource: WidgetScriptSource = { moduleName, scriptUri, source: 'local' };

src/client/datascience/ipywidgets/remoteWidgetScriptSourceProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export class RemoteWidgetScriptSourceProvider implements IWidgetScriptSourceProv
1919
// Noop.
2020
}
2121
public async getWidgetScriptSource(moduleName: string, moduleVersion: string): Promise<WidgetScriptSource> {
22-
const scriptUri = `${this.connection.baseUrl}nbextensions/${moduleName}/index`;
23-
const exists = await this.getUrlForWidget(`${scriptUri}.js`);
22+
const scriptUri = `${this.connection.baseUrl}nbextensions/${moduleName}/index.js`;
23+
const exists = await this.getUrlForWidget(scriptUri);
2424
if (exists) {
2525
return { moduleName, scriptUri, source: 'cdn' };
2626
}

src/datascience-ui/ipywidgets/requirejsRegistry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,12 @@ function registerScriptsInRequireJs(scripts: NonPartial<WidgetScriptSource>[]) {
5555
};
5656
scripts.forEach((script) => {
5757
scriptsAlreadyRegisteredInRequireJs.set(script.moduleName, script.scriptUri);
58+
// Drop the `.js` from the scriptUri.
59+
const scriptUri = script.scriptUri.toLowerCase().endsWith('.js')
60+
? script.scriptUri.substring(0, script.scriptUri.length - 3)
61+
: script.scriptUri;
5862
// Register the script source into requirejs so it gets loaded via requirejs.
59-
config.paths[script.moduleName] = script.scriptUri;
63+
config.paths[script.moduleName] = scriptUri;
6064
});
6165

6266
requirejs.config(config);

src/ipywidgets/src/manager.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,7 @@ export const WIDGET_MIMETYPE = 'application/vnd.jupyter.widget-view+json';
1717
// Source borrowed from https://github.com/jupyter-widgets/ipywidgets/blob/master/examples/web3/src/manager.ts
1818

1919
// These widgets can always be loaded from requirejs (as it is bundled).
20-
const widgetsRegisteredInRequireJs = [
21-
'@jupyter-widgets/controls',
22-
'@jupyter-widgets/base',
23-
'@jupyter-widgets/output',
24-
'azureml_widgets'
25-
];
20+
const widgetsRegisteredInRequireJs = ['@jupyter-widgets/controls', '@jupyter-widgets/base', '@jupyter-widgets/output'];
2621

2722
export class WidgetManager extends jupyterlab.WidgetManager {
2823
public kernel: Kernel.IKernelConnection;

src/test/datascience/ipywidgets/localWidgetScriptSourceProvider.unit.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ suite('Data Science - ipywidget - Local Widget Script Source', () => {
121121
assert.deepEqual(value, {
122122
moduleName: 'widget2',
123123
source: 'local',
124-
scriptUri: asVSCodeUri(Uri.file(path.join(searchDirectory, 'widget2', 'index')))
124+
scriptUri: asVSCodeUri(Uri.file(path.join(searchDirectory, 'widget2', 'index.js')))
125125
});
126126
const value1 = await scriptSourceProvider.getWidgetScriptSource('widget2', '1');
127127
assert.deepEqual(value1, value);
@@ -149,7 +149,7 @@ suite('Data Science - ipywidget - Local Widget Script Source', () => {
149149
assert.deepEqual(value, {
150150
moduleName: 'widget1',
151151
source: 'local',
152-
scriptUri: asVSCodeUri(Uri.file(path.join(searchDirectory, 'widget1', 'index')))
152+
scriptUri: asVSCodeUri(Uri.file(path.join(searchDirectory, 'widget1', 'index.js')))
153153
});
154154

155155
// Ensure we look for the right things in the right place.

0 commit comments

Comments
 (0)