@jupyterlab/fileeditor-extension 4.0.0-alpha.2 → 4.0.0-alpha.21

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,1388 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+ import { selectAll } from '@codemirror/commands';
4
+ import { findNext, gotoLine } from '@codemirror/search';
5
+ import { JupyterFrontEnd } from '@jupyterlab/application';
6
+ import {
7
+ Clipboard,
8
+ ICommandPalette,
9
+ ISessionContextDialogs,
10
+ MainAreaWidget,
11
+ sessionContextDialogs,
12
+ WidgetTracker
13
+ } from '@jupyterlab/apputils';
14
+ import {
15
+ CodeEditor,
16
+ CodeViewerWidget,
17
+ IEditorServices
18
+ } from '@jupyterlab/codeeditor';
19
+ import {
20
+ CodeMirrorEditor,
21
+ IEditorExtensionRegistry,
22
+ IEditorLanguageRegistry,
23
+ IEditorThemeRegistry
24
+ } from '@jupyterlab/codemirror';
25
+ import { ICompletionProviderManager } from '@jupyterlab/completer';
26
+ import { IConsoleTracker } from '@jupyterlab/console';
27
+ import { MarkdownCodeBlocks, PathExt } from '@jupyterlab/coreutils';
28
+ import { IDocumentWidget } from '@jupyterlab/docregistry';
29
+ import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
30
+ import { FileEditor, IEditorTracker } from '@jupyterlab/fileeditor';
31
+ import { ILauncher } from '@jupyterlab/launcher';
32
+ import { IMainMenu } from '@jupyterlab/mainmenu';
33
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
34
+ import {
35
+ ITranslator,
36
+ nullTranslator,
37
+ TranslationBundle
38
+ } from '@jupyterlab/translation';
39
+ import {
40
+ consoleIcon,
41
+ copyIcon,
42
+ cutIcon,
43
+ LabIcon,
44
+ markdownIcon,
45
+ pasteIcon,
46
+ redoIcon,
47
+ textEditorIcon,
48
+ undoIcon
49
+ } from '@jupyterlab/ui-components';
50
+ import { find } from '@lumino/algorithm';
51
+ import { CommandRegistry } from '@lumino/commands';
52
+ import {
53
+ JSONObject,
54
+ ReadonlyJSONObject,
55
+ ReadonlyPartialJSONObject
56
+ } from '@lumino/coreutils';
57
+
58
+ const autoClosingBracketsNotebook = 'notebook:toggle-autoclosing-brackets';
59
+ const autoClosingBracketsConsole = 'console:toggle-autoclosing-brackets';
60
+
61
+ /**
62
+ * The command IDs used by the fileeditor plugin.
63
+ */
64
+ export namespace CommandIDs {
65
+ export const createNew = 'fileeditor:create-new';
66
+
67
+ export const createNewMarkdown = 'fileeditor:create-new-markdown-file';
68
+
69
+ export const changeFontSize = 'fileeditor:change-font-size';
70
+
71
+ export const lineNumbers = 'fileeditor:toggle-line-numbers';
72
+
73
+ export const currentLineNumbers = 'fileeditor:toggle-current-line-numbers';
74
+
75
+ export const lineWrap = 'fileeditor:toggle-line-wrap';
76
+
77
+ export const currentLineWrap = 'fileeditor:toggle-current-line-wrap';
78
+
79
+ export const changeTabs = 'fileeditor:change-tabs';
80
+
81
+ export const matchBrackets = 'fileeditor:toggle-match-brackets';
82
+
83
+ export const currentMatchBrackets =
84
+ 'fileeditor:toggle-current-match-brackets';
85
+
86
+ export const autoClosingBrackets = 'fileeditor:toggle-autoclosing-brackets';
87
+
88
+ export const autoClosingBracketsUniversal =
89
+ 'fileeditor:toggle-autoclosing-brackets-universal';
90
+
91
+ export const createConsole = 'fileeditor:create-console';
92
+
93
+ export const replaceSelection = 'fileeditor:replace-selection';
94
+
95
+ export const restartConsole = 'fileeditor:restart-console';
96
+
97
+ export const runCode = 'fileeditor:run-code';
98
+
99
+ export const runAllCode = 'fileeditor:run-all';
100
+
101
+ export const markdownPreview = 'fileeditor:markdown-preview';
102
+
103
+ export const undo = 'fileeditor:undo';
104
+
105
+ export const redo = 'fileeditor:redo';
106
+
107
+ export const cut = 'fileeditor:cut';
108
+
109
+ export const copy = 'fileeditor:copy';
110
+
111
+ export const paste = 'fileeditor:paste';
112
+
113
+ export const selectAll = 'fileeditor:select-all';
114
+
115
+ export const invokeCompleter = 'completer:invoke-file';
116
+
117
+ export const selectCompleter = 'completer:select-file';
118
+
119
+ export const openCodeViewer = 'code-viewer:open';
120
+
121
+ export const changeTheme = 'fileeditor:change-theme';
122
+
123
+ export const changeLanguage = 'fileeditor:change-language';
124
+
125
+ export const find = 'fileeditor:find';
126
+
127
+ export const goToLine = 'fileeditor:go-to-line';
128
+ }
129
+
130
+ export interface IFileTypeData extends ReadonlyJSONObject {
131
+ fileExt: string;
132
+ iconName: string;
133
+ launcherLabel: string;
134
+ paletteLabel: string;
135
+ caption: string;
136
+ }
137
+
138
+ /**
139
+ * The name of the factory that creates editor widgets.
140
+ */
141
+ export const FACTORY = 'Editor';
142
+
143
+ /**
144
+ * A utility class for adding commands and menu items,
145
+ * for use by the File Editor extension or other Editor extensions.
146
+ */
147
+ export namespace Commands {
148
+ let config: Record<string, any> = {};
149
+ let scrollPastEnd = true;
150
+
151
+ /**
152
+ * Accessor function that returns the createConsole function for use by Create Console commands
153
+ */
154
+ function getCreateConsoleFunction(
155
+ commands: CommandRegistry,
156
+ languages: IEditorLanguageRegistry
157
+ ): (
158
+ widget: IDocumentWidget<FileEditor>,
159
+ args?: ReadonlyPartialJSONObject
160
+ ) => Promise<void> {
161
+ return async function createConsole(
162
+ widget: IDocumentWidget<FileEditor>,
163
+ args?: ReadonlyPartialJSONObject
164
+ ): Promise<void> {
165
+ const options = args || {};
166
+ const console = await commands.execute('console:create', {
167
+ activate: options['activate'],
168
+ name: widget.context.contentsModel?.name,
169
+ path: widget.context.path,
170
+ // Default value is an empty string -> using OR operator
171
+ preferredLanguage:
172
+ widget.context.model.defaultKernelLanguage ||
173
+ (languages.findByFileName(widget.context.path)?.name ?? ''),
174
+ ref: widget.id,
175
+ insertMode: 'split-bottom'
176
+ });
177
+
178
+ widget.context.pathChanged.connect((sender, value) => {
179
+ console.session.setPath(value);
180
+ console.session.setName(widget.context.contentsModel?.name);
181
+ });
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Update the setting values.
187
+ */
188
+ export function updateSettings(
189
+ settings: ISettingRegistry.ISettings,
190
+ commands: CommandRegistry
191
+ ): void {
192
+ config =
193
+ (settings.get('editorConfig').composite as Record<string, any>) ?? {};
194
+ scrollPastEnd = settings.get('scrollPasteEnd').composite as boolean;
195
+
196
+ // Trigger a refresh of the rendered commands
197
+ commands.notifyCommandChanged(CommandIDs.lineNumbers);
198
+ commands.notifyCommandChanged(CommandIDs.currentLineNumbers);
199
+ commands.notifyCommandChanged(CommandIDs.lineWrap);
200
+ commands.notifyCommandChanged(CommandIDs.currentLineWrap);
201
+ commands.notifyCommandChanged(CommandIDs.changeTabs);
202
+ commands.notifyCommandChanged(CommandIDs.matchBrackets);
203
+ commands.notifyCommandChanged(CommandIDs.currentMatchBrackets);
204
+ commands.notifyCommandChanged(CommandIDs.autoClosingBrackets);
205
+ commands.notifyCommandChanged(CommandIDs.changeLanguage);
206
+ }
207
+
208
+ /**
209
+ * Update the settings of the current tracker instances.
210
+ */
211
+ export function updateTracker(
212
+ tracker: WidgetTracker<IDocumentWidget<FileEditor>>
213
+ ): void {
214
+ tracker.forEach(widget => {
215
+ updateWidget(widget.content);
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Update the settings of a widget.
221
+ * Skip global settings for transient editor specific configs.
222
+ */
223
+ export function updateWidget(widget: FileEditor): void {
224
+ const editor = widget.editor;
225
+ editor.setOptions({ ...config, scrollPastEnd });
226
+ }
227
+
228
+ /**
229
+ * Wrapper function for adding the default File Editor commands
230
+ */
231
+ export function addCommands(
232
+ commands: CommandRegistry,
233
+ settingRegistry: ISettingRegistry,
234
+ trans: TranslationBundle,
235
+ id: string,
236
+ isEnabled: () => boolean,
237
+ tracker: WidgetTracker<IDocumentWidget<FileEditor>>,
238
+ defaultBrowser: IDefaultFileBrowser,
239
+ extensions: IEditorExtensionRegistry,
240
+ languages: IEditorLanguageRegistry,
241
+ themes: IEditorThemeRegistry,
242
+ consoleTracker: IConsoleTracker | null,
243
+ sessionDialogs: ISessionContextDialogs | null,
244
+ mainMenu: IMainMenu | null
245
+ ): void {
246
+ /**
247
+ * Add a command to change font size for File Editor
248
+ */
249
+ commands.addCommand(CommandIDs.changeFontSize, {
250
+ execute: args => {
251
+ const delta = Number(args['delta']);
252
+ if (Number.isNaN(delta)) {
253
+ console.error(
254
+ `${CommandIDs.changeFontSize}: delta arg must be a number`
255
+ );
256
+ return;
257
+ }
258
+ const style = window.getComputedStyle(document.documentElement);
259
+ const cssSize = parseInt(
260
+ style.getPropertyValue('--jp-code-font-size'),
261
+ 10
262
+ );
263
+ const currentSize =
264
+ (config['customStyles']['fontSize'] ??
265
+ extensions.baseConfiguration['customStyles']['fontSize']) ||
266
+ cssSize;
267
+ config.fontSize = currentSize + delta;
268
+ return settingRegistry
269
+ .set(id, 'editorConfig', config)
270
+ .catch((reason: Error) => {
271
+ console.error(`Failed to set ${id}: ${reason.message}`);
272
+ });
273
+ },
274
+ label: args => {
275
+ const delta = Number(args['delta']);
276
+ if (Number.isNaN(delta)) {
277
+ console.error(
278
+ `${CommandIDs.changeFontSize}: delta arg must be a number`
279
+ );
280
+ }
281
+ if (delta > 0) {
282
+ return args.isMenu
283
+ ? trans.__('Increase Text Editor Font Size')
284
+ : trans.__('Increase Font Size');
285
+ } else {
286
+ return args.isMenu
287
+ ? trans.__('Decrease Text Editor Font Size')
288
+ : trans.__('Decrease Font Size');
289
+ }
290
+ }
291
+ });
292
+
293
+ /**
294
+ * Add the Line Numbers command
295
+ */
296
+ commands.addCommand(CommandIDs.lineNumbers, {
297
+ execute: async () => {
298
+ config.lineNumbers = !(
299
+ config.lineNumbers ?? extensions.baseConfiguration.lineNumbers
300
+ );
301
+ try {
302
+ return await settingRegistry.set(id, 'editorConfig', config);
303
+ } catch (reason) {
304
+ console.error(`Failed to set ${id}: ${reason.message}`);
305
+ }
306
+ },
307
+ isEnabled,
308
+ isToggled: () =>
309
+ config.lineNumbers ?? extensions.baseConfiguration.lineNumbers,
310
+ label: trans.__('Show Line Numbers')
311
+ });
312
+
313
+ commands.addCommand(CommandIDs.currentLineNumbers, {
314
+ label: trans.__('Show Line Numbers'),
315
+ caption: trans.__('Show the line numbers for the current file.'),
316
+ execute: () => {
317
+ const widget = tracker.currentWidget;
318
+ if (!widget) {
319
+ return;
320
+ }
321
+ const lineNumbers = !widget.content.editor.getOption('lineNumbers');
322
+ widget.content.editor.setOption('lineNumbers', lineNumbers);
323
+ },
324
+ isEnabled,
325
+ isToggled: () => {
326
+ const widget = tracker.currentWidget;
327
+ return (
328
+ (widget?.content.editor.getOption('lineNumbers') as
329
+ | boolean
330
+ | undefined) ?? false
331
+ );
332
+ }
333
+ });
334
+
335
+ /**
336
+ * Add the Word Wrap command
337
+ */
338
+ commands.addCommand(CommandIDs.lineWrap, {
339
+ execute: async args => {
340
+ config.lineWrap = (args['mode'] as boolean) ?? false;
341
+ try {
342
+ return await settingRegistry.set(id, 'editorConfig', config);
343
+ } catch (reason) {
344
+ console.error(`Failed to set ${id}: ${reason.message}`);
345
+ }
346
+ },
347
+ isEnabled,
348
+ isToggled: args => {
349
+ const lineWrap = args['mode'] ?? false;
350
+ return (
351
+ lineWrap ===
352
+ (config.lineWrap ?? extensions.baseConfiguration.lineWrap)
353
+ );
354
+ },
355
+ label: trans.__('Word Wrap')
356
+ });
357
+
358
+ commands.addCommand(CommandIDs.currentLineWrap, {
359
+ label: trans.__('Wrap Words'),
360
+ caption: trans.__('Wrap words for the current file.'),
361
+ execute: () => {
362
+ const widget = tracker.currentWidget;
363
+ if (!widget) {
364
+ return;
365
+ }
366
+ const oldValue = widget.content.editor.getOption('lineWrap');
367
+ widget.content.editor.setOption('lineWrap', !oldValue);
368
+ },
369
+ isEnabled,
370
+ isToggled: () => {
371
+ const widget = tracker.currentWidget;
372
+ return (
373
+ (widget?.content.editor.getOption('lineWrap') as boolean) ?? false
374
+ );
375
+ }
376
+ });
377
+
378
+ /**
379
+ * Add command for changing tabs size or type in File Editor
380
+ */
381
+
382
+ commands.addCommand(CommandIDs.changeTabs, {
383
+ label: args => {
384
+ if (args.size) {
385
+ return trans.__('Spaces: %1', args.size ?? '');
386
+ } else {
387
+ return trans.__('Indent with Tab');
388
+ }
389
+ },
390
+ execute: async args => {
391
+ config.indentUnit =
392
+ args['size'] !== undefined
393
+ ? ((args['size'] as string) ?? '4').toString()
394
+ : 'Tab';
395
+ try {
396
+ return await settingRegistry.set(id, 'editorConfig', config);
397
+ } catch (reason) {
398
+ console.error(`Failed to set ${id}: ${reason.message}`);
399
+ }
400
+ },
401
+ isToggled: args => {
402
+ const currentIndentUnit =
403
+ config.indentUnit ?? extensions.baseConfiguration.indentUnit;
404
+ return args['size']
405
+ ? args['size'] === currentIndentUnit
406
+ : 'Tab' == currentIndentUnit;
407
+ }
408
+ });
409
+
410
+ /**
411
+ * Add the Match Brackets command
412
+ */
413
+ commands.addCommand(CommandIDs.matchBrackets, {
414
+ execute: async () => {
415
+ config.matchBrackets = !(
416
+ config.matchBrackets ?? extensions.baseConfiguration.matchBrackets
417
+ );
418
+ try {
419
+ return await settingRegistry.set(id, 'editorConfig', config);
420
+ } catch (reason) {
421
+ console.error(`Failed to set ${id}: ${reason.message}`);
422
+ }
423
+ },
424
+ label: trans.__('Match Brackets'),
425
+ isEnabled,
426
+ isToggled: () =>
427
+ config.matchBrackets ?? extensions.baseConfiguration.matchBrackets
428
+ });
429
+
430
+ commands.addCommand(CommandIDs.currentMatchBrackets, {
431
+ label: trans.__('Match Brackets'),
432
+ caption: trans.__('Change match brackets for the current file.'),
433
+ execute: () => {
434
+ const widget = tracker.currentWidget;
435
+ if (!widget) {
436
+ return;
437
+ }
438
+ const matchBrackets = !widget.content.editor.getOption('matchBrackets');
439
+ widget.content.editor.setOption('matchBrackets', matchBrackets);
440
+ },
441
+ isEnabled,
442
+ isToggled: () => {
443
+ const widget = tracker.currentWidget;
444
+ return (
445
+ (widget?.content.editor.getOption('matchBrackets') as
446
+ | boolean
447
+ | undefined) ?? false
448
+ );
449
+ }
450
+ });
451
+
452
+ /**
453
+ * Add the Auto Close Brackets for Text Editor command
454
+ */
455
+ commands.addCommand(CommandIDs.autoClosingBrackets, {
456
+ execute: async args => {
457
+ config.autoClosingBrackets = !!(
458
+ args['force'] ??
459
+ !(
460
+ config.autoClosingBrackets ??
461
+ extensions.baseConfiguration.autoClosingBrackets
462
+ )
463
+ );
464
+ try {
465
+ return await settingRegistry.set(id, 'editorConfig', config);
466
+ } catch (reason) {
467
+ console.error(`Failed to set ${id}: ${reason.message}`);
468
+ }
469
+ },
470
+ label: trans.__('Auto Close Brackets in Text Editor'),
471
+ isToggled: () =>
472
+ config.autoClosingBrackets ??
473
+ extensions.baseConfiguration.autoClosingBrackets
474
+ });
475
+
476
+ commands.addCommand(CommandIDs.autoClosingBracketsUniversal, {
477
+ execute: () => {
478
+ const anyToggled =
479
+ commands.isToggled(CommandIDs.autoClosingBrackets) ||
480
+ commands.isToggled(autoClosingBracketsNotebook) ||
481
+ commands.isToggled(autoClosingBracketsConsole);
482
+ // if any auto closing brackets options is toggled, toggle both off
483
+ if (anyToggled) {
484
+ void commands.execute(CommandIDs.autoClosingBrackets, {
485
+ force: false
486
+ });
487
+ void commands.execute(autoClosingBracketsNotebook, { force: false });
488
+ void commands.execute(autoClosingBracketsConsole, { force: false });
489
+ } else {
490
+ // both are off, turn them on
491
+ void commands.execute(CommandIDs.autoClosingBrackets, {
492
+ force: true
493
+ });
494
+ void commands.execute(autoClosingBracketsNotebook, { force: true });
495
+ void commands.execute(autoClosingBracketsConsole, { force: true });
496
+ }
497
+ },
498
+ label: trans.__('Auto Close Brackets'),
499
+ isToggled: () =>
500
+ commands.isToggled(CommandIDs.autoClosingBrackets) ||
501
+ commands.isToggled(autoClosingBracketsNotebook) ||
502
+ commands.isToggled(autoClosingBracketsConsole)
503
+ });
504
+
505
+ /**
506
+ * Create a menu for the editor.
507
+ */
508
+ commands.addCommand(CommandIDs.changeTheme, {
509
+ label: args =>
510
+ ((args.displayName ?? args.theme) as string) ??
511
+ config.theme ??
512
+ extensions.baseConfiguration.theme ??
513
+ trans.__('Editor Theme'),
514
+ execute: async args => {
515
+ config.theme = (args['theme'] as string) ?? config.theme;
516
+
517
+ try {
518
+ return await settingRegistry.set(id, 'editorConfig', config);
519
+ } catch (reason) {
520
+ console.error(`Failed to set theme - ${reason.message}`);
521
+ }
522
+ },
523
+ isToggled: args =>
524
+ args['theme'] === (config.theme ?? extensions.baseConfiguration.theme)
525
+ });
526
+
527
+ commands.addCommand(CommandIDs.find, {
528
+ label: trans.__('Find…'),
529
+ execute: () => {
530
+ const widget = tracker.currentWidget;
531
+ if (!widget) {
532
+ return;
533
+ }
534
+ const editor = widget.content.editor as CodeMirrorEditor;
535
+ editor.execCommand(findNext);
536
+ },
537
+ isEnabled
538
+ });
539
+
540
+ commands.addCommand(CommandIDs.goToLine, {
541
+ label: trans.__('Go to Line…'),
542
+ execute: args => {
543
+ const widget = tracker.currentWidget;
544
+ if (!widget) {
545
+ return;
546
+ }
547
+ const editor = widget.content.editor as CodeMirrorEditor;
548
+
549
+ const line = args['line'] as number | undefined;
550
+ const column = args['column'] as number | undefined;
551
+ if (line !== undefined || column !== undefined) {
552
+ editor.setCursorPosition({
553
+ line: (line ?? 1) - 1,
554
+ column: (column ?? 1) - 1
555
+ });
556
+ } else {
557
+ editor.execCommand(gotoLine);
558
+ }
559
+ },
560
+ isEnabled
561
+ });
562
+
563
+ commands.addCommand(CommandIDs.changeLanguage, {
564
+ label: args =>
565
+ ((args['displayName'] ?? args['name']) as string) ??
566
+ trans.__('Change editor language.'),
567
+ execute: args => {
568
+ const name = args['name'] as string;
569
+ const widget = tracker.currentWidget;
570
+ if (name && widget) {
571
+ const spec = languages.findByName(name);
572
+ if (spec) {
573
+ widget.content.model.mimeType = spec.mime as string;
574
+ }
575
+ }
576
+ },
577
+ isEnabled,
578
+ isToggled: args => {
579
+ const widget = tracker.currentWidget;
580
+ if (!widget) {
581
+ return false;
582
+ }
583
+ const mime = widget.content.model.mimeType;
584
+ const spec = languages.findByMIME(mime);
585
+ const name = spec && spec.name;
586
+ return args['name'] === name;
587
+ }
588
+ });
589
+
590
+ /**
591
+ * Add the replace selection for text editor command
592
+ */
593
+
594
+ commands.addCommand(CommandIDs.replaceSelection, {
595
+ execute: args => {
596
+ const text: string = (args['text'] as string) || '';
597
+ const widget = tracker.currentWidget;
598
+ if (!widget) {
599
+ return;
600
+ }
601
+ widget.content.editor.replaceSelection?.(text);
602
+ },
603
+ isEnabled,
604
+ label: trans.__('Replace Selection in Editor')
605
+ });
606
+
607
+ /**
608
+ * Add the Create Console for Editor command
609
+ */
610
+ commands.addCommand(CommandIDs.createConsole, {
611
+ execute: args => {
612
+ const widget = tracker.currentWidget;
613
+
614
+ if (!widget) {
615
+ return;
616
+ }
617
+
618
+ return getCreateConsoleFunction(commands, languages)(widget, args);
619
+ },
620
+ isEnabled,
621
+ icon: consoleIcon,
622
+ label: trans.__('Create Console for Editor')
623
+ });
624
+
625
+ /**
626
+ * Restart the Console Kernel linked to the current Editor
627
+ */
628
+ commands.addCommand(CommandIDs.restartConsole, {
629
+ execute: async () => {
630
+ const current = tracker.currentWidget?.content;
631
+
632
+ if (!current || consoleTracker === null) {
633
+ return;
634
+ }
635
+
636
+ const widget = consoleTracker.find(
637
+ widget => widget.sessionContext.session?.path === current.context.path
638
+ );
639
+ if (widget) {
640
+ return (sessionDialogs || sessionContextDialogs).restart(
641
+ widget.sessionContext
642
+ );
643
+ }
644
+ },
645
+ label: trans.__('Restart Kernel'),
646
+ isEnabled: () => consoleTracker !== null && isEnabled()
647
+ });
648
+
649
+ /**
650
+ * Add the Run Code command
651
+ */
652
+ commands.addCommand(CommandIDs.runCode, {
653
+ execute: () => {
654
+ // Run the appropriate code, taking into account a ```fenced``` code block.
655
+ const widget = tracker.currentWidget?.content;
656
+
657
+ if (!widget) {
658
+ return;
659
+ }
660
+
661
+ let code: string | undefined = '';
662
+ const editor = widget.editor;
663
+ const path = widget.context.path;
664
+ const extension = PathExt.extname(path);
665
+ const selection = editor.getSelection();
666
+ const { start, end } = selection;
667
+ let selected = start.column !== end.column || start.line !== end.line;
668
+
669
+ if (selected) {
670
+ // Get the selected code from the editor.
671
+ const start = editor.getOffsetAt(selection.start);
672
+ const end = editor.getOffsetAt(selection.end);
673
+
674
+ code = editor.model.sharedModel.getSource().substring(start, end);
675
+ } else if (MarkdownCodeBlocks.isMarkdown(extension)) {
676
+ const text = editor.model.sharedModel.getSource();
677
+ const blocks = MarkdownCodeBlocks.findMarkdownCodeBlocks(text);
678
+
679
+ for (const block of blocks) {
680
+ if (block.startLine <= start.line && start.line <= block.endLine) {
681
+ code = block.code;
682
+ selected = true;
683
+ break;
684
+ }
685
+ }
686
+ }
687
+
688
+ if (!selected) {
689
+ // no selection, submit whole line and advance
690
+ code = editor.getLine(selection.start.line);
691
+ const cursor = editor.getCursorPosition();
692
+ if (cursor.line + 1 === editor.lineCount) {
693
+ const text = editor.model.sharedModel.getSource();
694
+ editor.model.sharedModel.setSource(text + '\n');
695
+ }
696
+ editor.setCursorPosition({
697
+ line: cursor.line + 1,
698
+ column: cursor.column
699
+ });
700
+ }
701
+
702
+ const activate = false;
703
+ if (code) {
704
+ return commands.execute('console:inject', { activate, code, path });
705
+ } else {
706
+ return Promise.resolve(void 0);
707
+ }
708
+ },
709
+ isEnabled,
710
+ label: trans.__('Run Selected Code')
711
+ });
712
+
713
+ /**
714
+ * Add the Run All Code command
715
+ */
716
+ commands.addCommand(CommandIDs.runAllCode, {
717
+ execute: () => {
718
+ const widget = tracker.currentWidget?.content;
719
+
720
+ if (!widget) {
721
+ return;
722
+ }
723
+
724
+ let code = '';
725
+ const editor = widget.editor;
726
+ const text = editor.model.sharedModel.getSource();
727
+ const path = widget.context.path;
728
+ const extension = PathExt.extname(path);
729
+
730
+ if (MarkdownCodeBlocks.isMarkdown(extension)) {
731
+ // For Markdown files, run only code blocks.
732
+ const blocks = MarkdownCodeBlocks.findMarkdownCodeBlocks(text);
733
+ for (const block of blocks) {
734
+ code += block.code;
735
+ }
736
+ } else {
737
+ code = text;
738
+ }
739
+
740
+ const activate = false;
741
+ if (code) {
742
+ return commands.execute('console:inject', { activate, code, path });
743
+ } else {
744
+ return Promise.resolve(void 0);
745
+ }
746
+ },
747
+ isEnabled,
748
+ label: trans.__('Run All Code')
749
+ });
750
+
751
+ /**
752
+ * Add markdown preview command
753
+ */
754
+ commands.addCommand(CommandIDs.markdownPreview, {
755
+ execute: () => {
756
+ const widget = tracker.currentWidget;
757
+ if (!widget) {
758
+ return;
759
+ }
760
+ const path = widget.context.path;
761
+ return commands.execute('markdownviewer:open', {
762
+ path,
763
+ options: {
764
+ mode: 'split-right'
765
+ }
766
+ });
767
+ },
768
+ isVisible: () => {
769
+ const widget = tracker.currentWidget;
770
+ return (
771
+ (widget && PathExt.extname(widget.context.path) === '.md') || false
772
+ );
773
+ },
774
+ icon: markdownIcon,
775
+ label: trans.__('Show Markdown Preview')
776
+ });
777
+
778
+ /**
779
+ * Add the New File command
780
+ *
781
+ * Defaults to Text/.txt if file type data is not specified
782
+ */
783
+ commands.addCommand(CommandIDs.createNew, {
784
+ label: args => {
785
+ if (args.isPalette) {
786
+ return (args.paletteLabel as string) ?? trans.__('New Text File');
787
+ }
788
+ return (args.launcherLabel as string) ?? trans.__('Text File');
789
+ },
790
+ caption: args =>
791
+ (args.caption as string) ?? trans.__('Create a new text file'),
792
+ icon: args =>
793
+ args.isPalette
794
+ ? undefined
795
+ : LabIcon.resolve({
796
+ icon: (args.iconName as string) ?? textEditorIcon
797
+ }),
798
+ execute: args => {
799
+ const cwd = args.cwd || defaultBrowser.model.path;
800
+ return createNew(
801
+ commands,
802
+ cwd as string,
803
+ (args.fileExt as string) ?? 'txt'
804
+ );
805
+ }
806
+ });
807
+
808
+ /**
809
+ * Add the New Markdown File command
810
+ */
811
+ commands.addCommand(CommandIDs.createNewMarkdown, {
812
+ label: args =>
813
+ args['isPalette']
814
+ ? trans.__('New Markdown File')
815
+ : trans.__('Markdown File'),
816
+ caption: trans.__('Create a new markdown file'),
817
+ icon: args => (args['isPalette'] ? undefined : markdownIcon),
818
+ execute: args => {
819
+ const cwd = args['cwd'] || defaultBrowser.model.path;
820
+ return createNew(commands, cwd as string, 'md');
821
+ }
822
+ });
823
+
824
+ /**
825
+ * Add undo command
826
+ */
827
+ commands.addCommand(CommandIDs.undo, {
828
+ execute: () => {
829
+ const widget = tracker.currentWidget?.content;
830
+
831
+ if (!widget) {
832
+ return;
833
+ }
834
+
835
+ widget.editor.undo();
836
+ },
837
+ isEnabled: () => {
838
+ if (!isEnabled()) {
839
+ return false;
840
+ }
841
+
842
+ const widget = tracker.currentWidget?.content;
843
+
844
+ if (!widget) {
845
+ return false;
846
+ }
847
+ // Ideally enable it when there are undo events stored
848
+ // Reference issue #8590: Code mirror editor could expose the history of undo/redo events
849
+ return true;
850
+ },
851
+ icon: undoIcon.bindprops({ stylesheet: 'menuItem' }),
852
+ label: trans.__('Undo')
853
+ });
854
+
855
+ /**
856
+ * Add redo command
857
+ */
858
+ commands.addCommand(CommandIDs.redo, {
859
+ execute: () => {
860
+ const widget = tracker.currentWidget?.content;
861
+
862
+ if (!widget) {
863
+ return;
864
+ }
865
+
866
+ widget.editor.redo();
867
+ },
868
+ isEnabled: () => {
869
+ if (!isEnabled()) {
870
+ return false;
871
+ }
872
+
873
+ const widget = tracker.currentWidget?.content;
874
+
875
+ if (!widget) {
876
+ return false;
877
+ }
878
+ // Ideally enable it when there are redo events stored
879
+ // Reference issue #8590: Code mirror editor could expose the history of undo/redo events
880
+ return true;
881
+ },
882
+ icon: redoIcon.bindprops({ stylesheet: 'menuItem' }),
883
+ label: trans.__('Redo')
884
+ });
885
+
886
+ /**
887
+ * Add cut command
888
+ */
889
+ commands.addCommand(CommandIDs.cut, {
890
+ execute: () => {
891
+ const widget = tracker.currentWidget?.content;
892
+
893
+ if (!widget) {
894
+ return;
895
+ }
896
+
897
+ const editor = widget.editor as CodeMirrorEditor;
898
+ const text = getTextSelection(editor);
899
+
900
+ Clipboard.copyToSystem(text);
901
+ editor.replaceSelection && editor.replaceSelection('');
902
+ },
903
+ isEnabled: () => {
904
+ if (!isEnabled()) {
905
+ return false;
906
+ }
907
+
908
+ const widget = tracker.currentWidget?.content;
909
+
910
+ if (!widget) {
911
+ return false;
912
+ }
913
+
914
+ // Enable command if there is a text selection in the editor
915
+ return isSelected(widget.editor as CodeMirrorEditor);
916
+ },
917
+ icon: cutIcon.bindprops({ stylesheet: 'menuItem' }),
918
+ label: trans.__('Cut')
919
+ });
920
+
921
+ /**
922
+ * Add copy command
923
+ */
924
+ commands.addCommand(CommandIDs.copy, {
925
+ execute: () => {
926
+ const widget = tracker.currentWidget?.content;
927
+
928
+ if (!widget) {
929
+ return;
930
+ }
931
+
932
+ const editor = widget.editor as CodeMirrorEditor;
933
+ const text = getTextSelection(editor);
934
+
935
+ Clipboard.copyToSystem(text);
936
+ },
937
+ isEnabled: () => {
938
+ if (!isEnabled()) {
939
+ return false;
940
+ }
941
+
942
+ const widget = tracker.currentWidget?.content;
943
+
944
+ if (!widget) {
945
+ return false;
946
+ }
947
+
948
+ // Enable command if there is a text selection in the editor
949
+ return isSelected(widget.editor as CodeMirrorEditor);
950
+ },
951
+ icon: copyIcon.bindprops({ stylesheet: 'menuItem' }),
952
+ label: trans.__('Copy')
953
+ });
954
+
955
+ /**
956
+ * Add paste command
957
+ */
958
+ commands.addCommand(CommandIDs.paste, {
959
+ execute: async () => {
960
+ const widget = tracker.currentWidget?.content;
961
+
962
+ if (!widget) {
963
+ return;
964
+ }
965
+
966
+ const editor: CodeEditor.IEditor = widget.editor;
967
+
968
+ // Get data from clipboard
969
+ const clipboard = window.navigator.clipboard;
970
+ const clipboardData: string = await clipboard.readText();
971
+
972
+ if (clipboardData) {
973
+ // Paste data to the editor
974
+ editor.replaceSelection && editor.replaceSelection(clipboardData);
975
+ }
976
+ },
977
+ isEnabled: () => Boolean(isEnabled() && tracker.currentWidget?.content),
978
+ icon: pasteIcon.bindprops({ stylesheet: 'menuItem' }),
979
+ label: trans.__('Paste')
980
+ });
981
+
982
+ /**
983
+ * Add select all command
984
+ */
985
+ commands.addCommand(CommandIDs.selectAll, {
986
+ execute: () => {
987
+ const widget = tracker.currentWidget?.content;
988
+
989
+ if (!widget) {
990
+ return;
991
+ }
992
+
993
+ const editor = widget.editor as CodeMirrorEditor;
994
+ editor.execCommand(selectAll);
995
+ },
996
+ isEnabled: () => Boolean(isEnabled() && tracker.currentWidget?.content),
997
+ label: trans.__('Select All')
998
+ });
999
+ }
1000
+
1001
+ export function addCompleterCommands(
1002
+ commands: CommandRegistry,
1003
+ editorTracker: IEditorTracker,
1004
+ manager: ICompletionProviderManager,
1005
+ translator: ITranslator | null
1006
+ ): void {
1007
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
1008
+
1009
+ commands.addCommand(CommandIDs.invokeCompleter, {
1010
+ label: trans.__('Display the completion helper.'),
1011
+ execute: () => {
1012
+ const id =
1013
+ editorTracker.currentWidget && editorTracker.currentWidget.id;
1014
+ if (id) {
1015
+ return manager.invoke(id);
1016
+ }
1017
+ }
1018
+ });
1019
+
1020
+ commands.addCommand(CommandIDs.selectCompleter, {
1021
+ label: trans.__('Select the completion suggestion.'),
1022
+ execute: () => {
1023
+ const id =
1024
+ editorTracker.currentWidget && editorTracker.currentWidget.id;
1025
+ if (id) {
1026
+ return manager.select(id);
1027
+ }
1028
+ }
1029
+ });
1030
+
1031
+ commands.addKeyBinding({
1032
+ command: CommandIDs.selectCompleter,
1033
+ keys: ['Enter'],
1034
+ selector: '.jp-FileEditor .jp-mod-completer-active'
1035
+ });
1036
+ }
1037
+
1038
+ /**
1039
+ * Helper function to check if there is a text selection in the editor
1040
+ */
1041
+ function isSelected(editor: CodeMirrorEditor) {
1042
+ const selectionObj = editor.getSelection();
1043
+ const { start, end } = selectionObj;
1044
+ const selected = start.column !== end.column || start.line !== end.line;
1045
+
1046
+ return selected;
1047
+ }
1048
+
1049
+ /**
1050
+ * Helper function to get text selection from the editor
1051
+ */
1052
+ function getTextSelection(editor: CodeMirrorEditor) {
1053
+ const selectionObj = editor.getSelection();
1054
+ const start = editor.getOffsetAt(selectionObj.start);
1055
+ const end = editor.getOffsetAt(selectionObj.end);
1056
+ const text = editor.model.sharedModel.getSource().substring(start, end);
1057
+
1058
+ return text;
1059
+ }
1060
+
1061
+ /**
1062
+ * Function to create a new untitled text file, given the current working directory.
1063
+ */
1064
+ async function createNew(
1065
+ commands: CommandRegistry,
1066
+ cwd: string,
1067
+ ext: string = 'txt'
1068
+ ) {
1069
+ const model = await commands.execute('docmanager:new-untitled', {
1070
+ path: cwd,
1071
+ type: 'file',
1072
+ ext
1073
+ });
1074
+ if (model != undefined) {
1075
+ const widget = (await commands.execute('docmanager:open', {
1076
+ path: model.path,
1077
+ factory: FACTORY
1078
+ })) as unknown as IDocumentWidget;
1079
+ widget.isUntitled = true;
1080
+ return widget;
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Wrapper function for adding the default launcher items for File Editor
1086
+ */
1087
+ export function addLauncherItems(
1088
+ launcher: ILauncher,
1089
+ trans: TranslationBundle
1090
+ ): void {
1091
+ addCreateNewToLauncher(launcher, trans);
1092
+
1093
+ addCreateNewMarkdownToLauncher(launcher, trans);
1094
+ }
1095
+
1096
+ /**
1097
+ * Add Create New Text File to the Launcher
1098
+ */
1099
+ export function addCreateNewToLauncher(
1100
+ launcher: ILauncher,
1101
+ trans: TranslationBundle
1102
+ ): void {
1103
+ launcher.add({
1104
+ command: CommandIDs.createNew,
1105
+ category: trans.__('Other'),
1106
+ rank: 1
1107
+ });
1108
+ }
1109
+
1110
+ /**
1111
+ * Add Create New Markdown to the Launcher
1112
+ */
1113
+ export function addCreateNewMarkdownToLauncher(
1114
+ launcher: ILauncher,
1115
+ trans: TranslationBundle
1116
+ ): void {
1117
+ launcher.add({
1118
+ command: CommandIDs.createNewMarkdown,
1119
+ category: trans.__('Other'),
1120
+ rank: 2
1121
+ });
1122
+ }
1123
+
1124
+ /**
1125
+ * Add ___ File items to the Launcher for common file types associated with available kernels
1126
+ */
1127
+ export function addKernelLanguageLauncherItems(
1128
+ launcher: ILauncher,
1129
+ trans: TranslationBundle,
1130
+ availableKernelFileTypes: Iterable<IFileTypeData>
1131
+ ): void {
1132
+ for (let ext of availableKernelFileTypes) {
1133
+ launcher.add({
1134
+ command: CommandIDs.createNew,
1135
+ category: trans.__('Other'),
1136
+ rank: 3,
1137
+ args: ext
1138
+ });
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Wrapper function for adding the default items to the File Editor palette
1144
+ */
1145
+ export function addPaletteItems(
1146
+ palette: ICommandPalette,
1147
+ trans: TranslationBundle
1148
+ ): void {
1149
+ addChangeTabsCommandsToPalette(palette, trans);
1150
+
1151
+ addCreateNewCommandToPalette(palette, trans);
1152
+
1153
+ addCreateNewMarkdownCommandToPalette(palette, trans);
1154
+
1155
+ addChangeFontSizeCommandsToPalette(palette, trans);
1156
+ }
1157
+
1158
+ /**
1159
+ * Add commands to change the tab indentation to the File Editor palette
1160
+ */
1161
+ export function addChangeTabsCommandsToPalette(
1162
+ palette: ICommandPalette,
1163
+ trans: TranslationBundle
1164
+ ): void {
1165
+ const paletteCategory = trans.__('Text Editor');
1166
+ const args: JSONObject = {
1167
+ size: 4
1168
+ };
1169
+ const command = CommandIDs.changeTabs;
1170
+ palette.addItem({ command, args, category: paletteCategory });
1171
+
1172
+ for (const size of [1, 2, 4, 8]) {
1173
+ const args: JSONObject = {
1174
+ size
1175
+ };
1176
+ palette.addItem({ command, args, category: paletteCategory });
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * Add a Create New File command to the File Editor palette
1182
+ */
1183
+ export function addCreateNewCommandToPalette(
1184
+ palette: ICommandPalette,
1185
+ trans: TranslationBundle
1186
+ ): void {
1187
+ const paletteCategory = trans.__('Text Editor');
1188
+ palette.addItem({
1189
+ command: CommandIDs.createNew,
1190
+ args: { isPalette: true },
1191
+ category: paletteCategory
1192
+ });
1193
+ }
1194
+
1195
+ /**
1196
+ * Add a Create New Markdown command to the File Editor palette
1197
+ */
1198
+ export function addCreateNewMarkdownCommandToPalette(
1199
+ palette: ICommandPalette,
1200
+ trans: TranslationBundle
1201
+ ): void {
1202
+ const paletteCategory = trans.__('Text Editor');
1203
+ palette.addItem({
1204
+ command: CommandIDs.createNewMarkdown,
1205
+ args: { isPalette: true },
1206
+ category: paletteCategory
1207
+ });
1208
+ }
1209
+
1210
+ /**
1211
+ * Add commands to change the font size to the File Editor palette
1212
+ */
1213
+ export function addChangeFontSizeCommandsToPalette(
1214
+ palette: ICommandPalette,
1215
+ trans: TranslationBundle
1216
+ ): void {
1217
+ const paletteCategory = trans.__('Text Editor');
1218
+ const command = CommandIDs.changeFontSize;
1219
+
1220
+ let args = { delta: 1 };
1221
+ palette.addItem({ command, args, category: paletteCategory });
1222
+
1223
+ args = { delta: -1 };
1224
+ palette.addItem({ command, args, category: paletteCategory });
1225
+ }
1226
+
1227
+ /**
1228
+ * Add New ___ File commands to the File Editor palette for common file types associated with available kernels
1229
+ */
1230
+ export function addKernelLanguagePaletteItems(
1231
+ palette: ICommandPalette,
1232
+ trans: TranslationBundle,
1233
+ availableKernelFileTypes: Iterable<IFileTypeData>
1234
+ ): void {
1235
+ const paletteCategory = trans.__('Text Editor');
1236
+ for (let ext of availableKernelFileTypes) {
1237
+ palette.addItem({
1238
+ command: CommandIDs.createNew,
1239
+ args: { ...ext, isPalette: true },
1240
+ category: paletteCategory
1241
+ });
1242
+ }
1243
+ }
1244
+
1245
+ /**
1246
+ * Wrapper function for adding the default menu items for File Editor
1247
+ */
1248
+ export function addMenuItems(
1249
+ menu: IMainMenu,
1250
+ tracker: WidgetTracker<IDocumentWidget<FileEditor>>,
1251
+ consoleTracker: IConsoleTracker | null,
1252
+ isEnabled: () => boolean
1253
+ ): void {
1254
+ // Add undo/redo hooks to the edit menu.
1255
+ menu.editMenu.undoers.redo.add({
1256
+ id: CommandIDs.redo,
1257
+ isEnabled
1258
+ });
1259
+ menu.editMenu.undoers.undo.add({
1260
+ id: CommandIDs.undo,
1261
+ isEnabled
1262
+ });
1263
+
1264
+ // Add editor view options.
1265
+ menu.viewMenu.editorViewers.toggleLineNumbers.add({
1266
+ id: CommandIDs.currentLineNumbers,
1267
+ isEnabled
1268
+ });
1269
+ menu.viewMenu.editorViewers.toggleMatchBrackets.add({
1270
+ id: CommandIDs.currentMatchBrackets,
1271
+ isEnabled
1272
+ });
1273
+ menu.viewMenu.editorViewers.toggleWordWrap.add({
1274
+ id: CommandIDs.currentLineWrap,
1275
+ isEnabled
1276
+ });
1277
+
1278
+ // Add a console creator the the file menu.
1279
+ menu.fileMenu.consoleCreators.add({
1280
+ id: CommandIDs.createConsole,
1281
+ isEnabled
1282
+ });
1283
+
1284
+ // Add a code runner to the run menu.
1285
+ if (consoleTracker) {
1286
+ addCodeRunnersToRunMenu(menu, consoleTracker);
1287
+ }
1288
+ }
1289
+
1290
+ /**
1291
+ * Add Create New ___ File commands to the File menu for common file types associated with available kernels
1292
+ */
1293
+ export function addKernelLanguageMenuItems(
1294
+ menu: IMainMenu,
1295
+ availableKernelFileTypes: Iterable<IFileTypeData>
1296
+ ): void {
1297
+ for (let ext of availableKernelFileTypes) {
1298
+ menu.fileMenu.newMenu.addItem({
1299
+ command: CommandIDs.createNew,
1300
+ args: ext,
1301
+ rank: 31
1302
+ });
1303
+ }
1304
+ }
1305
+
1306
+ /**
1307
+ * Add a File Editor code runner to the Run menu
1308
+ */
1309
+ export function addCodeRunnersToRunMenu(
1310
+ menu: IMainMenu,
1311
+ consoleTracker: IConsoleTracker
1312
+ ): void {
1313
+ const isEnabled = (current: IDocumentWidget<FileEditor>) =>
1314
+ current.context &&
1315
+ !!consoleTracker.find(
1316
+ widget => widget.sessionContext.session?.path === current.context.path
1317
+ );
1318
+ menu.runMenu.codeRunners.restart.add({
1319
+ id: CommandIDs.restartConsole,
1320
+ isEnabled
1321
+ });
1322
+ menu.runMenu.codeRunners.run.add({
1323
+ id: CommandIDs.runCode,
1324
+ isEnabled
1325
+ });
1326
+ menu.runMenu.codeRunners.runAll.add({
1327
+ id: CommandIDs.runAllCode,
1328
+ isEnabled
1329
+ });
1330
+ }
1331
+
1332
+ export function addOpenCodeViewerCommand(
1333
+ app: JupyterFrontEnd,
1334
+ editorServices: IEditorServices,
1335
+ tracker: WidgetTracker<MainAreaWidget<CodeViewerWidget>>,
1336
+ trans: TranslationBundle
1337
+ ): void {
1338
+ const openCodeViewer = async (args: {
1339
+ content: string;
1340
+ label?: string;
1341
+ mimeType?: string;
1342
+ extension?: string;
1343
+ widgetId?: string;
1344
+ }): Promise<CodeViewerWidget> => {
1345
+ const func = editorServices.factoryService.newDocumentEditor;
1346
+ const factory: CodeEditor.Factory = options => {
1347
+ return func(options);
1348
+ };
1349
+
1350
+ // Derive mimetype from extension
1351
+ let mimetype = args.mimeType;
1352
+ if (!mimetype && args.extension) {
1353
+ mimetype = editorServices.mimeTypeService.getMimeTypeByFilePath(
1354
+ `temp.${args.extension.replace(/\\.$/, '')}`
1355
+ );
1356
+ }
1357
+
1358
+ const widget = CodeViewerWidget.createCodeViewer({
1359
+ factory,
1360
+ content: args.content,
1361
+ mimeType: mimetype
1362
+ });
1363
+ widget.title.label = args.label || trans.__('Code Viewer');
1364
+ widget.title.caption = widget.title.label;
1365
+
1366
+ // Get the fileType based on the mimetype to determine the icon
1367
+ const fileType = find(app.docRegistry.fileTypes(), fileType =>
1368
+ mimetype ? fileType.mimeTypes.includes(mimetype) : false
1369
+ );
1370
+ widget.title.icon = fileType?.icon ?? textEditorIcon;
1371
+
1372
+ if (args.widgetId) {
1373
+ widget.id = args.widgetId;
1374
+ }
1375
+ const main = new MainAreaWidget({ content: widget });
1376
+ await tracker.add(main);
1377
+ app.shell.add(main, 'main');
1378
+ return widget;
1379
+ };
1380
+
1381
+ app.commands.addCommand(CommandIDs.openCodeViewer, {
1382
+ label: trans.__('Open Code Viewer'),
1383
+ execute: (args: any) => {
1384
+ return openCodeViewer(args);
1385
+ }
1386
+ });
1387
+ }
1388
+ }