@jupyterlab/workspaces-extension 4.2.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,597 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+
4
+ import {
5
+ IRouter,
6
+ JupyterFrontEnd,
7
+ JupyterFrontEndPlugin
8
+ } from '@jupyterlab/application';
9
+ import {
10
+ Dialog,
11
+ InputDialog,
12
+ IWindowResolver,
13
+ showDialog
14
+ } from '@jupyterlab/apputils';
15
+
16
+ import { URLExt } from '@jupyterlab/coreutils';
17
+
18
+ import { FileDialog, IDefaultFileBrowser } from '@jupyterlab/filebrowser';
19
+ import { Contents, Workspace } from '@jupyterlab/services';
20
+ import { IStateDB } from '@jupyterlab/statedb';
21
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
22
+ import { ICommandPalette } from '@jupyterlab/apputils';
23
+ import { IWorkspaceCommands, IWorkspacesModel } from '@jupyterlab/workspaces';
24
+
25
+ namespace CommandIDs {
26
+ /**
27
+ * Open a workspace by identifier. When no identifier is supplied, falls back to a simple choice dialog.
28
+ */
29
+ export const open = 'workspace-ui:open';
30
+ /**
31
+ * Save the current workspace.
32
+ */
33
+ export const save = 'workspace-ui:save';
34
+ /**
35
+ * Save the current workspace under a different path.
36
+ */
37
+ export const saveAs = 'workspace-ui:save-as';
38
+ /**
39
+ * Create a new workspace.
40
+ */
41
+ export const createNew = 'workspace-ui:create-new';
42
+ /**
43
+ * Delete a workspace.
44
+ */
45
+ export const deleteWorkspace = 'workspace-ui:delete';
46
+ /**
47
+ * Clone a given workspace.
48
+ */
49
+ export const clone = 'workspace-ui:clone';
50
+ /**
51
+ * Rename a workspace.
52
+ */
53
+ export const rename = 'workspace-ui:rename';
54
+ /**
55
+ * Reset a workspace.
56
+ */
57
+ export const reset = 'workspace-ui:reset';
58
+ /**
59
+ * Import a workspace.
60
+ */
61
+ export const importWorkspace = 'workspace-ui:import';
62
+ /**
63
+ * Export a workspace.
64
+ */
65
+ export const exportWorkspace = 'workspace-ui:export';
66
+ }
67
+
68
+ const WORKSPACE_NAME = 'jupyterlab-workspace';
69
+ const WORKSPACE_EXT = '.' + WORKSPACE_NAME;
70
+ const LAST_SAVE_ID = 'workspace-ui:lastSave';
71
+
72
+ export const WORKSPACE_ITEM_CLASS = 'jp-mod-workspace';
73
+
74
+ /**
75
+ * The workspace commands
76
+ */
77
+ export const commandsPlugin: JupyterFrontEndPlugin<IWorkspaceCommands> = {
78
+ id: '@jupyterlab/workspaces:commands',
79
+ description: 'Add workspace commands.',
80
+ autoStart: true,
81
+ requires: [
82
+ IWorkspacesModel,
83
+ IDefaultFileBrowser,
84
+ IWindowResolver,
85
+ IStateDB,
86
+ ITranslator,
87
+ JupyterFrontEnd.IPaths
88
+ ],
89
+ provides: IWorkspaceCommands,
90
+ optional: [IRouter, ICommandPalette],
91
+ activate: (
92
+ app: JupyterFrontEnd,
93
+ model: IWorkspacesModel,
94
+ fileBrowser: IDefaultFileBrowser,
95
+ resolver: IWindowResolver,
96
+ state: IStateDB,
97
+ translator: ITranslator,
98
+ paths: JupyterFrontEnd.IPaths,
99
+ router: IRouter | null,
100
+ palette: ICommandPalette | null
101
+ ): IWorkspaceCommands => {
102
+ const trans = translator.load('jupyterlab');
103
+
104
+ const namingHintLabel = trans.__(
105
+ 'Naming the workspace will create a unique URL. The name may contain letters, numbers, hyphens (-), and underscores (_).'
106
+ );
107
+ const workspacesBase = URLExt.join(paths.urls.app, 'workspaces');
108
+ const resourcePrefix = workspacesBase + '/';
109
+ // `-` needs double backslashes to be escaped in `v` mode used for patterns
110
+ const namePattern = '[a-zA-Z0-9\\-_]+';
111
+
112
+ const getNameForWorkspace = async (
113
+ options: Omit<InputDialog.ITextOptions, 'prefix' | 'pattern' | 'label'>
114
+ ) => {
115
+ return InputDialog.getText({
116
+ label: namingHintLabel,
117
+ prefix: resourcePrefix,
118
+ pattern: namePattern,
119
+ required: true,
120
+ placeholder: trans.__('workspace-name'),
121
+ ...options
122
+ });
123
+ };
124
+
125
+ const test = (node: HTMLElement) =>
126
+ node.classList.contains(WORKSPACE_ITEM_CLASS);
127
+
128
+ app.commands.addCommand(CommandIDs.open, {
129
+ label: args => {
130
+ const workspaceId = args.workspace as string | undefined;
131
+ return workspaceId
132
+ ? trans.__('Open Workspace')
133
+ : trans.__('Open Workspace…');
134
+ },
135
+ execute: async args => {
136
+ let workspaceId = args.workspace as string | undefined;
137
+
138
+ if (!workspaceId) {
139
+ const result = await InputDialog.getItem({
140
+ title: trans.__('Choose Workspace To Open'),
141
+ label: trans.__('Choose an existing workspace to open.'),
142
+ items: model.identifiers,
143
+ okLabel: trans.__('Choose'),
144
+ prefix: resourcePrefix
145
+ });
146
+ if (!result.value || !result.button.accept) {
147
+ return;
148
+ }
149
+ workspaceId = result.value;
150
+ }
151
+
152
+ if (!workspaceId) {
153
+ return;
154
+ }
155
+
156
+ const url = URLExt.join(workspacesBase, workspaceId);
157
+ if (!url.startsWith(workspacesBase)) {
158
+ throw new Error('Can only be used for workspaces');
159
+ }
160
+ if (router) {
161
+ router.navigate(url, { hard: true });
162
+ } else {
163
+ document.location.href = url;
164
+ }
165
+ }
166
+ });
167
+
168
+ app.commands.addCommand(CommandIDs.deleteWorkspace, {
169
+ label: trans.__('Delete Workspace…'),
170
+ execute: async args => {
171
+ const node = app.contextMenuHitTest(test);
172
+ let workspaceId =
173
+ (args.workspace as string | undefined) ?? node?.dataset['context'];
174
+
175
+ if (!workspaceId) {
176
+ const result = await InputDialog.getItem({
177
+ title: trans.__('Choose Workspace To Delete'),
178
+ label: trans.__('Choose an existing workspace to delete.'),
179
+ items: model.identifiers,
180
+ okLabel: trans.__('Choose')
181
+ });
182
+ if (!result.value || !result.button.accept) {
183
+ return;
184
+ }
185
+ workspaceId = result.value;
186
+ }
187
+
188
+ if (!workspaceId) {
189
+ return;
190
+ }
191
+ const result = await showDialog({
192
+ title: trans.__('Delete workspace'),
193
+ body: trans.__(
194
+ 'Deleting workspace "%1" will also delete its URL. A deleted workspace cannot be recovered.',
195
+ workspaceId
196
+ ),
197
+ buttons: [
198
+ Dialog.cancelButton(),
199
+ Dialog.warnButton({ label: trans.__('Delete') })
200
+ ],
201
+ defaultButton: 0
202
+ });
203
+
204
+ if (result.button.accept) {
205
+ await model.remove(workspaceId);
206
+ }
207
+ }
208
+ });
209
+
210
+ app.commands.addCommand(CommandIDs.createNew, {
211
+ label: trans.__('Create New Workspace…'),
212
+ execute: async args => {
213
+ let workspaceId = args.workspace as string | undefined;
214
+
215
+ if (!workspaceId) {
216
+ const result = await getNameForWorkspace({
217
+ title: trans.__('Create New Workspace'),
218
+ okLabel: trans.__('Create')
219
+ });
220
+ if (!result.value || !result.button.accept) {
221
+ return;
222
+ }
223
+ workspaceId = result.value;
224
+ }
225
+ if (!workspaceId) {
226
+ return;
227
+ }
228
+ await model.create(workspaceId);
229
+ }
230
+ });
231
+
232
+ app.commands.addCommand(CommandIDs.clone, {
233
+ label: trans.__('Clone Workspace…'),
234
+ execute: async args => {
235
+ const node = app.contextMenuHitTest(test);
236
+ let workspaceId =
237
+ (args.workspace as string | undefined) ?? node?.dataset['context'];
238
+ if (!workspaceId) {
239
+ const result = await InputDialog.getItem({
240
+ title: trans.__('Choose Workspace To Clone'),
241
+ label: trans.__('Choose an existing workspace to clone.'),
242
+ items: model.identifiers,
243
+ okLabel: trans.__('Choose')
244
+ });
245
+ if (!result.value || !result.button.accept) {
246
+ return;
247
+ }
248
+ workspaceId = result.value;
249
+ }
250
+
251
+ const result = await getNameForWorkspace({
252
+ title: trans.__('Clone Workspace'),
253
+ text: trans.__('%1-clone', workspaceId),
254
+ okLabel: trans.__('Clone')
255
+ });
256
+
257
+ if (!result.button.accept || !result.value) {
258
+ return;
259
+ }
260
+
261
+ let newName = result.value;
262
+
263
+ await model.saveAs(workspaceId, newName);
264
+
265
+ if (workspaceId === resolver.name) {
266
+ // If the current workspace was cloned, open the cloned copy
267
+ return app.commands.execute(CommandIDs.open, { workspace: newName });
268
+ }
269
+ }
270
+ });
271
+
272
+ app.commands.addCommand(CommandIDs.rename, {
273
+ label: trans.__('Rename Workspace…'),
274
+ execute: async args => {
275
+ const node = app.contextMenuHitTest(test);
276
+ const workspaceId =
277
+ (args.workspace as string | undefined) ??
278
+ node?.dataset['context'] ??
279
+ resolver.name;
280
+
281
+ const oldName = workspaceId;
282
+ const result = await getNameForWorkspace({
283
+ title: trans.__('Rename Workspace'),
284
+ text: oldName,
285
+ okLabel: trans.__('Rename')
286
+ });
287
+
288
+ if (!result.button.accept || !result.value) {
289
+ return;
290
+ }
291
+
292
+ let newName = result.value;
293
+
294
+ await model.rename(workspaceId, newName);
295
+
296
+ if (workspaceId === resolver.name) {
297
+ // If the current workspace was renamed, reopen it to ensure consistent state
298
+ return app.commands.execute(CommandIDs.open, { workspace: newName });
299
+ }
300
+ }
301
+ });
302
+
303
+ app.commands.addCommand(CommandIDs.reset, {
304
+ label: trans.__('Reset Workspace…'),
305
+ execute: async args => {
306
+ const node = app.contextMenuHitTest(test);
307
+ const workspaceId =
308
+ (args.workspace as string | undefined) ??
309
+ node?.dataset['context'] ??
310
+ resolver.name;
311
+
312
+ const workspace =
313
+ await app.serviceManager.workspaces.fetch(workspaceId);
314
+ const tabs = (workspace.data['layout-restorer:data'] as any)?.main?.dock
315
+ ?.widgets?.length;
316
+
317
+ const result = await showDialog({
318
+ title: trans.__('Reset Workspace'),
319
+ body: trans._n(
320
+ 'Resetting workspace %2 will close its %1 tab and return to default layout.',
321
+ 'Resetting workspace %2 will close its %1 tabs and return to default layout.',
322
+ tabs,
323
+ workspaceId
324
+ ),
325
+ buttons: [
326
+ Dialog.cancelButton(),
327
+ Dialog.warnButton({ label: trans.__('Reset') })
328
+ ],
329
+ defaultButton: 0
330
+ });
331
+
332
+ if (!result.button.accept) {
333
+ return;
334
+ }
335
+
336
+ await model.reset(workspaceId);
337
+
338
+ if (workspaceId === resolver.name) {
339
+ // If the current workspace was reset, refresh it to take effect
340
+ return app.commands.execute(CommandIDs.open, {
341
+ workspace: workspaceId
342
+ });
343
+ } else {
344
+ await model.refresh();
345
+ }
346
+ }
347
+ });
348
+
349
+ /**
350
+ * Commands persisting (or reading from) Jupyter file system
351
+ */
352
+
353
+ app.commands.addCommand(CommandIDs.importWorkspace, {
354
+ label: trans.__('Import Workspace…'),
355
+ execute: async () => {
356
+ const { contents } = app.serviceManager;
357
+ const result = await FileDialog.getOpenFiles({
358
+ manager: fileBrowser.model.manager,
359
+ title: trans.__('Select Workspace Files to Import'),
360
+ filter: (model: Contents.IModel) =>
361
+ model.type === 'directory' || model.path.endsWith(WORKSPACE_EXT)
362
+ ? {}
363
+ : null,
364
+ label: trans.__(
365
+ 'You choose one or more workspace files to import. A Jupyter Workspace File has %1 extension.',
366
+ WORKSPACE_EXT
367
+ ),
368
+ translator
369
+ });
370
+ if (result.button.accept && result.value && result.value.length >= 1) {
371
+ for (const fileModel of result.value) {
372
+ // Get the file contents too
373
+ const fullFile = await contents.get(fileModel.path, {
374
+ content: true
375
+ });
376
+ const workspace = JSON.parse(
377
+ fullFile.content
378
+ ) as unknown as Workspace.IWorkspace;
379
+ await app.serviceManager.workspaces.save(
380
+ workspace.metadata.id,
381
+ workspace
382
+ );
383
+ }
384
+ await model.refresh();
385
+ }
386
+ }
387
+ });
388
+
389
+ app.commands.addCommand(CommandIDs.exportWorkspace, {
390
+ label: trans.__('Export Workspace…'),
391
+ execute: async args => {
392
+ const { contents } = app.serviceManager;
393
+
394
+ const node = app.contextMenuHitTest(test);
395
+ let workspaceId =
396
+ (args.workspace as string | undefined) ??
397
+ node?.dataset['context'] ??
398
+ resolver.name;
399
+
400
+ if (!workspaceId) {
401
+ // When invoked from main menu or command palette we need to ask which workspace to export
402
+ const result = await InputDialog.getItem({
403
+ title: trans.__('Choose Workspace To Export'),
404
+ label: trans.__('Choose an existing workspace to export.'),
405
+ items: model.identifiers,
406
+ okLabel: trans.__('Choose')
407
+ });
408
+ if (!result.value || !result.button.accept) {
409
+ return;
410
+ }
411
+ workspaceId = result.value;
412
+ }
413
+
414
+ const data = app.serviceManager.workspaces.fetch(workspaceId);
415
+
416
+ const result = await FileDialog.getExistingDirectory({
417
+ title: trans.__('Choose Workspace Export Directory'),
418
+ defaultPath: fileBrowser.model.path,
419
+ manager: fileBrowser.model.manager,
420
+ label: trans.__(
421
+ 'The "%1" workspace will be saved in the chosen directory as "%1%2".',
422
+ workspaceId,
423
+ WORKSPACE_EXT
424
+ ),
425
+ translator
426
+ });
427
+ if (
428
+ !result.button.accept ||
429
+ !result.value ||
430
+ result.value.length === 0
431
+ ) {
432
+ return;
433
+ }
434
+ if (result.value.length > 1) {
435
+ console.warn(
436
+ 'More than one directory was selected; the workspace will be exported to the first directory only'
437
+ );
438
+ }
439
+ const exportPath =
440
+ result.value[0].path + '/' + workspaceId + WORKSPACE_EXT;
441
+
442
+ if (exportPath) {
443
+ await Private.save(exportPath, contents, data, state, false);
444
+ }
445
+ }
446
+ });
447
+
448
+ app.commands.addCommand(CommandIDs.saveAs, {
449
+ label: trans.__('Save Current Workspace As…'),
450
+ execute: async () => {
451
+ const { contents } = app.serviceManager;
452
+ const data = app.serviceManager.workspaces.fetch(resolver.name);
453
+ await Private.saveAs(fileBrowser, contents, data, state, translator);
454
+ }
455
+ });
456
+
457
+ app.commands.addCommand(CommandIDs.save, {
458
+ label: trans.__('Save Current Workspace…'),
459
+ execute: async () => {
460
+ const { contents } = app.serviceManager;
461
+ const data = app.serviceManager.workspaces.fetch(resolver.name);
462
+ const lastSave = (await state.fetch(LAST_SAVE_ID)) as string;
463
+ if (lastSave === undefined) {
464
+ await Private.saveAs(fileBrowser, contents, data, state, translator);
465
+ } else {
466
+ await Private.save(lastSave, contents, data, state);
467
+ }
468
+ }
469
+ });
470
+
471
+ if (palette) {
472
+ const category = trans.__('Workspaces');
473
+ const commands = [
474
+ CommandIDs.open,
475
+ CommandIDs.save,
476
+ CommandIDs.saveAs,
477
+ CommandIDs.createNew,
478
+ CommandIDs.rename,
479
+ CommandIDs.clone,
480
+ CommandIDs.exportWorkspace,
481
+ CommandIDs.importWorkspace,
482
+ CommandIDs.reset,
483
+ CommandIDs.deleteWorkspace
484
+ ];
485
+ for (const command of commands) {
486
+ palette.addItem({
487
+ command,
488
+ category
489
+ });
490
+ }
491
+ }
492
+
493
+ return {
494
+ open: CommandIDs.open,
495
+ deleteWorkspace: CommandIDs.deleteWorkspace
496
+ };
497
+ }
498
+ };
499
+
500
+ namespace Private {
501
+ export function createNameFromPath(path: string): string {
502
+ let name = path.split('/').pop();
503
+ if (name === undefined) {
504
+ return 'unnamed-workspace';
505
+ }
506
+ // Remove the workspace suffix if present
507
+ if (name.endsWith(WORKSPACE_EXT)) {
508
+ name = name.slice(0, -WORKSPACE_EXT.length);
509
+ }
510
+ return name;
511
+ }
512
+
513
+ /**
514
+ * Save workspace to a user provided location
515
+ */
516
+ export async function save(
517
+ userPath: string,
518
+ contents: Contents.IManager,
519
+ data: Promise<Workspace.IWorkspace>,
520
+ state: IStateDB,
521
+ rememberAsLastSave = true
522
+ ): Promise<void> {
523
+ const name = createNameFromPath(userPath);
524
+
525
+ // Add extension if not provided
526
+ if (!userPath.endsWith(WORKSPACE_EXT)) {
527
+ userPath = userPath + WORKSPACE_EXT;
528
+ }
529
+
530
+ if (rememberAsLastSave) {
531
+ // Save last save location, for save button to work
532
+ await state.save(LAST_SAVE_ID, userPath);
533
+ }
534
+
535
+ const resolvedData = await data;
536
+ resolvedData.metadata.id = `${name}`;
537
+ await contents.save(userPath, {
538
+ type: 'file',
539
+ format: 'text',
540
+ content: JSON.stringify(resolvedData)
541
+ });
542
+ }
543
+
544
+ /**
545
+ * Ask user for location, and save workspace.
546
+ * Default location is the current directory in the file browser
547
+ */
548
+ export async function saveAs(
549
+ browser: IDefaultFileBrowser,
550
+ contents: Contents.IManager,
551
+ data: Promise<Workspace.IWorkspace>,
552
+ state: IStateDB,
553
+ translator?: ITranslator
554
+ ): Promise<void> {
555
+ translator = translator || nullTranslator;
556
+ const lastSave = await state.fetch(LAST_SAVE_ID);
557
+
558
+ let defaultName;
559
+ if (lastSave === undefined) {
560
+ defaultName = 'new-workspace';
561
+ } else {
562
+ defaultName = (lastSave as string).split('/').pop()?.split('.')[0];
563
+ }
564
+
565
+ const defaultPath = browser.model.path + '/' + defaultName + WORKSPACE_EXT;
566
+ const userPath = await getSavePath(defaultPath, translator);
567
+
568
+ if (userPath) {
569
+ await save(userPath, contents, data, state);
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Ask user for a path to save to.
575
+ * @param defaultPath Path already present when the dialog is shown
576
+ */
577
+ async function getSavePath(
578
+ defaultPath: string,
579
+ translator?: ITranslator
580
+ ): Promise<string | null> {
581
+ translator = translator || nullTranslator;
582
+ const trans = translator.load('jupyterlab');
583
+ const result = await InputDialog.getText({
584
+ title: trans.__('Save Current Workspace As…'),
585
+ text: defaultPath,
586
+ placeholder: trans.__('Path to save the workspace in'),
587
+ okLabel: trans.__('Save'),
588
+ // select the path without extension
589
+ selectionRange: defaultPath.length - WORKSPACE_EXT.length
590
+ });
591
+ if (result.button.accept) {
592
+ return result.value;
593
+ } else {
594
+ return null;
595
+ }
596
+ }
597
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ /* -----------------------------------------------------------------------------
2
+ | Copyright (c) Jupyter Development Team.
3
+ | Distributed under the terms of the Modified BSD License.
4
+ |----------------------------------------------------------------------------*/
5
+ /**
6
+ * @packageDocumentation
7
+ * @module workspaces-extension
8
+ */
9
+ import {
10
+ JupyterFrontEnd,
11
+ JupyterFrontEndPlugin
12
+ } from '@jupyterlab/application';
13
+ import {
14
+ IWorkspaceCommands,
15
+ IWorkspacesModel,
16
+ WorkspacesModel
17
+ } from '@jupyterlab/workspaces';
18
+ import { commandsPlugin } from './commands';
19
+ import { workspacesSidebar } from './sidebar';
20
+
21
+ /**
22
+ * The extension populating sidebar with workspaces list.
23
+ */
24
+ const workspacesModel: JupyterFrontEndPlugin<IWorkspacesModel> = {
25
+ id: '@jupyterlab/workspaces-extension:model',
26
+ description: 'Provides a model for available workspaces.',
27
+ provides: IWorkspacesModel,
28
+ autoStart: true,
29
+ activate: (app: JupyterFrontEnd) => {
30
+ return new WorkspacesModel({
31
+ manager: app.serviceManager.workspaces
32
+ });
33
+ }
34
+ };
35
+
36
+ /**
37
+ * The extension providing workspace sub-menu in the "File" main menu.
38
+ */
39
+ const workspacesMenu: JupyterFrontEndPlugin<void> = {
40
+ id: '@jupyterlab/workspaces-extension:menu',
41
+ description: 'Populates "File" main menu with Workspaces submenu.',
42
+ requires: [IWorkspaceCommands],
43
+ autoStart: true,
44
+ activate: () => {
45
+ // no-op - the menu items come from schema matching the name of the plugin
46
+ }
47
+ };
48
+
49
+ /**
50
+ * Export the plugins as default.
51
+ */
52
+ const plugins: JupyterFrontEndPlugin<any>[] = [
53
+ workspacesModel,
54
+ commandsPlugin,
55
+ workspacesSidebar,
56
+ workspacesMenu
57
+ ];
58
+ export default plugins;