@kispace-io/extension-notebook 0.8.0

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,1354 @@
1
+ import { css, html } from "lit";
2
+ import { customElement, property, state } from "lit/decorators.js";
3
+ import { Ref, createRef, ref } from "lit/directives/ref.js";
4
+ import { repeat } from "lit/directives/repeat.js";
5
+ import { styleMap } from "lit/directives/style-map.js";
6
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
7
+ import { marked } from "marked";
8
+ import * as monaco from 'monaco-editor';
9
+ import monacoStyles from "monaco-editor/min/vs/editor/editor.main.css?raw";
10
+ import type { EditorInput } from "@kispace-io/core";
11
+ import { File, workspaceService } from "@kispace-io/core";
12
+ import { PyEnv, pythonPackageManagerService } from "@kispace-io/extension-python-runtime";
13
+ import { KPart } from "@kispace-io/core";
14
+ import type { NotebookCell, NotebookData, NotebookEditorLike } from "./notebook-types";
15
+
16
+ @customElement('k-notebook-editor')
17
+ export class KNotebookEditor extends KPart implements NotebookEditorLike {
18
+ @property({ attribute: false })
19
+ public input?: EditorInput;
20
+
21
+ @state()
22
+ public notebook?: NotebookData;
23
+
24
+ @state()
25
+ private cellOutputs: Map<number, any> = new Map();
26
+
27
+ @state()
28
+ private executingCells: Set<number> = new Set();
29
+
30
+ @state()
31
+ private pyenv?: PyEnv;
32
+
33
+ @state()
34
+ private pyConnected: boolean = false;
35
+
36
+ @state()
37
+ private pyConnecting: boolean = false;
38
+
39
+ @state()
40
+ private pyVersion?: string;
41
+
42
+ @state()
43
+ private editingMarkdownCells: Set<number> = new Set();
44
+
45
+ @state()
46
+ private executionCounter: number = 0;
47
+
48
+ @state()
49
+ private isRunningAll: boolean = false;
50
+
51
+ @state()
52
+ private highlightedCellIndex: number = -1;
53
+
54
+ public focusedCellIndex: number = -1;
55
+ private cancelRunAll: boolean = false;
56
+
57
+ private monacoEditors: Map<number, monaco.editor.IStandaloneCodeEditor> = new Map();
58
+ private cellRefs: Map<number, Ref<HTMLElement>> = new Map();
59
+ private themeObserver?: MutationObserver;
60
+
61
+ protected doClose() {
62
+ this.input = undefined;
63
+ this.notebook = undefined;
64
+ this.cellOutputs.clear();
65
+ this.executingCells.clear();
66
+
67
+ // Dispose Monaco editors
68
+ this.monacoEditors.forEach(editor => editor.dispose());
69
+ this.monacoEditors.clear();
70
+ this.cellRefs.clear();
71
+ this.focusedCellIndex = -1;
72
+
73
+ if (this.themeObserver) {
74
+ this.themeObserver.disconnect();
75
+ this.themeObserver = undefined;
76
+ }
77
+
78
+ if (this.pyenv) {
79
+ this.pyenv.close();
80
+ this.pyenv = undefined;
81
+ }
82
+
83
+ this.pyConnected = false;
84
+ this.pyVersion = undefined;
85
+ }
86
+
87
+ async save() {
88
+ if (!this.notebook || !this.input) return;
89
+
90
+ try {
91
+ // Update cell contents from Monaco editors before saving
92
+ this.saveEditorContents();
93
+
94
+ this.notebook.cells.forEach((cell, index) => {
95
+ if (cell.cell_type === 'code') {
96
+ const output = this.cellOutputs.get(index);
97
+ cell.outputs = output ? this.convertOutputToJupyter(output, cell.execution_count) : [];
98
+ }
99
+ });
100
+
101
+ // Serialize notebook to JSON
102
+ const notebookJson = JSON.stringify(this.notebook, null, 2);
103
+
104
+ // Save to file
105
+ const file: File = this.input.data;
106
+ await file.saveContents(notebookJson);
107
+
108
+ // Mark as not dirty
109
+ this.markDirty(false);
110
+ } catch (error) {
111
+ console.error("Failed to save notebook:", error);
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ protected doBeforeUI() {
117
+ // Start loading notebook asynchronously
118
+ this.loadNotebook();
119
+ }
120
+
121
+ protected renderToolbar() {
122
+ const connectionTitle = this.pyConnecting
123
+ ? 'Connecting to Python...'
124
+ : this.pyConnected
125
+ ? 'Python Connected'
126
+ : 'Python Disconnected - Click to connect';
127
+
128
+ const connectionText = this.pyConnecting
129
+ ? 'Connecting...'
130
+ : this.pyConnected && this.pyVersion
131
+ ? `Python ${this.pyVersion}`
132
+ : 'Not connected';
133
+
134
+ const iconColor = this.pyConnected
135
+ ? "var(--wa-color-green-40)"
136
+ : this.pyConnecting
137
+ ? "var(--wa-color-warning-500)"
138
+ : "var(--wa-color-red-40)";
139
+
140
+ const runAllButton = this.isRunningAll ? html`
141
+ <wa-button size="small" appearance="plain" @click=${() => this.cancelAllCells()} title="Cancel running all cells">
142
+ <wa-icon name="stop" label="Stop"></wa-icon>
143
+ Cancel All
144
+ </wa-button>
145
+ ` : html`
146
+ <wa-button size="small" appearance="plain" @click=${() => this.runAllCells()} title="Run all code cells sequentially">
147
+ <wa-icon name="play" label="Run"></wa-icon>
148
+ Run All
149
+ </wa-button>
150
+ `;
151
+
152
+ return html`
153
+ <wa-button
154
+ appearance="plain"
155
+ size="small"
156
+ style="display: flex; align-items: center; gap: 0.5rem;"
157
+ ?disabled=${this.pyConnecting}
158
+ @click=${() => this.connectPython()}
159
+ title=${connectionTitle}>
160
+ <wa-icon
161
+ name="circle"
162
+ label="Python status"
163
+ style=${styleMap({ color: iconColor })}
164
+ ></wa-icon>
165
+ ${connectionText}
166
+ </wa-button>
167
+ ${runAllButton}
168
+ <wa-button
169
+ size="small"
170
+ appearance="plain"
171
+ @click=${() => this.clearAllOutputs()}
172
+ title="Clear all outputs and reset execution counter">
173
+ <wa-icon name="eraser" label="Clear"></wa-icon>
174
+ Clear Outputs
175
+ </wa-button>
176
+ <wa-button
177
+ size="small"
178
+ appearance="plain"
179
+ @click=${() => this.restartKernel()}
180
+ title="Restart Python kernel (clears all variables and state)"
181
+ ?disabled=${!this.pyConnected || this.pyConnecting}>
182
+ <wa-icon name="arrows-rotate" label="Restart"></wa-icon>
183
+ Restart Kernel
184
+ </wa-button>
185
+ <wa-button
186
+ size="small"
187
+ appearance="plain"
188
+ @click=${() => this.openPackageManager()}
189
+ title="Manage required packages for this notebook">
190
+ <wa-icon name="box" label="Packages"></wa-icon>
191
+ Packages
192
+ </wa-button>
193
+ `;
194
+ }
195
+
196
+ protected doInitUI() {
197
+ this.setupThemeObserver();
198
+ }
199
+
200
+ private async loadNotebook() {
201
+ const file: File = this.input!.data;
202
+ const contents = await file.getContents();
203
+
204
+ try {
205
+ this.notebook = JSON.parse(contents as string);
206
+ } catch (e) {
207
+ console.error("Failed to parse notebook:", e);
208
+ this.notebook = {
209
+ cells: [{
210
+ cell_type: 'markdown',
211
+ source: ['# Error\nFailed to parse notebook file.']
212
+ }]
213
+ };
214
+ }
215
+
216
+ // Initialize execution counter from existing cells
217
+ if (this.notebook?.cells) {
218
+ const maxCount = this.notebook.cells
219
+ .filter(cell => cell.cell_type === 'code')
220
+ .map(cell => cell.execution_count ?? 0)
221
+ .reduce((max, count) => Math.max(max, count), 0);
222
+ this.executionCounter = maxCount;
223
+
224
+ this.notebook.cells.forEach((cell, index) => {
225
+ if (cell.cell_type === 'code' && cell.outputs && cell.outputs.length > 0) {
226
+ const output = this.convertOutputFromJupyter(cell.outputs[0]);
227
+ if (output) {
228
+ this.cellOutputs.set(index, output);
229
+ }
230
+ }
231
+ });
232
+ }
233
+ }
234
+
235
+ private setupThemeObserver() {
236
+ const rootElement = document.documentElement;
237
+ let currentTheme = this.getMonacoTheme();
238
+
239
+ this.themeObserver = new MutationObserver(() => {
240
+ const newTheme = this.getMonacoTheme();
241
+ // Only update if theme actually changed
242
+ if (newTheme !== currentTheme) {
243
+ currentTheme = newTheme;
244
+ monaco.editor.setTheme(newTheme);
245
+ }
246
+ });
247
+
248
+ this.themeObserver.observe(rootElement, {
249
+ attributes: true,
250
+ attributeFilter: ['class']
251
+ });
252
+ }
253
+
254
+ private getCellSource(cell: NotebookCell): string {
255
+ return Array.isArray(cell.source) ? cell.source.join('') : cell.source;
256
+ }
257
+
258
+ private convertOutputToJupyter(output: any, executionCount: number | null | undefined): any[] {
259
+ if (output.type === 'execute_result') {
260
+ const data: any = {};
261
+ if (output.imageData) data['image/png'] = output.imageData;
262
+ if (output.data) data['text/plain'] = output.data;
263
+ return [{
264
+ output_type: 'execute_result',
265
+ data,
266
+ execution_count: executionCount,
267
+ metadata: {}
268
+ }];
269
+ } else if (output.type === 'error') {
270
+ return [{
271
+ output_type: 'error',
272
+ ename: 'Error',
273
+ evalue: output.data,
274
+ traceback: [output.data]
275
+ }];
276
+ }
277
+ return [];
278
+ }
279
+
280
+ private convertOutputFromJupyter(jupyterOutput: any): any | null {
281
+ if (jupyterOutput.output_type === 'execute_result' && jupyterOutput.data) {
282
+ return {
283
+ type: 'execute_result',
284
+ data: jupyterOutput.data['text/plain'] || '',
285
+ imageData: jupyterOutput.data['image/png'] || undefined
286
+ };
287
+ } else if (jupyterOutput.output_type === 'error') {
288
+ return {
289
+ type: 'error',
290
+ data: jupyterOutput.evalue || jupyterOutput.traceback?.join('\n') || 'Unknown error'
291
+ };
292
+ }
293
+ return null;
294
+ }
295
+
296
+ private renderHeaderActions(index: number, additionalButton?: any) {
297
+ return html`
298
+ <div class="cell-header-actions">
299
+ ${additionalButton || ''}
300
+ ${additionalButton ? html`<span class="divider"></span>` : ''}
301
+ <wa-button size="small" appearance="plain" @click=${() => this.addCell(index, 'code')} title="Add code cell before">
302
+ <wa-icon name="plus"></wa-icon>
303
+ <wa-icon name="code" label="Code"></wa-icon>
304
+ </wa-button>
305
+ <wa-button size="small" appearance="plain" @click=${() => this.addCell(index, 'markdown')} title="Add markdown cell before">
306
+ <wa-icon name="plus"></wa-icon>
307
+ <wa-icon name="font" label="Markdown"></wa-icon>
308
+ </wa-button>
309
+ <span class="divider"></span>
310
+ <wa-button size="small" appearance="plain" @click=${() => this.deleteCell(index)} title="Delete cell" ?disabled=${this.notebook!.cells.length <= 1}>
311
+ <wa-icon name="trash" label="Delete cell"></wa-icon>
312
+ </wa-button>
313
+ </div>
314
+ `;
315
+ }
316
+
317
+ private renderFooterActions(index: number) {
318
+ return html`
319
+ <div class="cell-footer">
320
+ <wa-button size="small" appearance="plain" @click=${() => this.addCell(index + 1, 'code')} title="Add code cell after">
321
+ <wa-icon name="code" label="Code"></wa-icon>
322
+ <wa-icon name="plus"></wa-icon>
323
+ </wa-button>
324
+ <wa-button size="small" appearance="plain" @click=${() => this.addCell(index + 1, 'markdown')} title="Add markdown cell after">
325
+ <wa-icon name="font" label="Markdown"></wa-icon>
326
+ <wa-icon name="plus"></wa-icon>
327
+ </wa-button>
328
+ </div>
329
+ `;
330
+ }
331
+
332
+ // Helper to convert string to Jupyter source format
333
+ private stringToSourceArray(content: string): string[] {
334
+ if (!content) return [''];
335
+ const lines = content.split('\n').map(line => line + '\n');
336
+ // Remove trailing newline from last line
337
+ if (lines.length > 0) {
338
+ lines[lines.length - 1] = lines[lines.length - 1].replace(/\n$/, '');
339
+ }
340
+ return lines;
341
+ }
342
+
343
+ // Helper to create a new cell
344
+ private createCell(cellType: 'code' | 'markdown'): NotebookCell {
345
+ const newCell: NotebookCell = {
346
+ cell_type: cellType,
347
+ source: [''],
348
+ metadata: {}
349
+ };
350
+
351
+ if (cellType === 'code') {
352
+ newCell.execution_count = null;
353
+ newCell.outputs = [];
354
+ }
355
+
356
+ return newCell;
357
+ }
358
+
359
+ private async initPyodide() {
360
+ if (!this.pyenv) {
361
+ this.pyenv = new PyEnv();
362
+ const workspace = await workspaceService.getWorkspace();
363
+ if (workspace) {
364
+ await this.pyenv.init(workspace);
365
+ this.pyConnected = true;
366
+
367
+ // Get Python version using sys.version
368
+ try {
369
+ const response = await this.pyenv.execCode('import sys; sys.version.split()[0]');
370
+ this.pyVersion = response?.result || 'Unknown';
371
+ } catch (error) {
372
+ console.error("Failed to get Python version:", error);
373
+ this.pyVersion = 'Unknown';
374
+ }
375
+
376
+ // Load required packages from notebook metadata
377
+ const requiredPackages = this.notebook?.metadata?.required_packages || [];
378
+ if (requiredPackages.length > 0) {
379
+ try {
380
+ await this.pyenv.loadPackages(requiredPackages);
381
+ } catch (error) {
382
+ console.error("Failed to load required packages:", error);
383
+ }
384
+ }
385
+
386
+ // Set up matplotlib backend and patch plt.show() if matplotlib is installed
387
+ try {
388
+ await this.pyenv.execCode(`
389
+ try:
390
+ import matplotlib
391
+ matplotlib.use('agg')
392
+
393
+ import matplotlib.pyplot as plt
394
+ import io
395
+ import base64
396
+
397
+ _original_show = plt.show
398
+ _display_data = None
399
+
400
+ def _patched_show(*args, **kwargs):
401
+ """Patched plt.show() that captures the current figure for notebook display."""
402
+ global _display_data
403
+ if plt.get_fignums():
404
+ fig = plt.gcf()
405
+ buffer = io.BytesIO()
406
+ fig.savefig(buffer, format='png', bbox_inches='tight', dpi=100)
407
+ buffer.seek(0)
408
+ _display_data = base64.b64encode(buffer.read()).decode('utf-8')
409
+ plt.close(fig)
410
+ # Don't call original show() as it would try to display in a window
411
+
412
+ plt.show = _patched_show
413
+ except ImportError:
414
+ # matplotlib not installed - skip configuration
415
+ pass
416
+ `);
417
+ } catch (error) {
418
+ console.error("Failed to configure matplotlib:", error);
419
+ }
420
+ }
421
+ }
422
+ }
423
+
424
+ public async executeCell(cellIndex: number) {
425
+ const cell = this.notebook!.cells[cellIndex];
426
+ if (cell.cell_type !== 'code') return;
427
+
428
+ this.executingCells.add(cellIndex);
429
+ this.requestUpdate();
430
+
431
+ try {
432
+ await this.initPyodide();
433
+
434
+ // Get code from Monaco editor if available, otherwise from cell source
435
+ const editor = this.monacoEditors.get(cellIndex);
436
+ let code = editor ? editor.getValue() : this.getCellSource(cell);
437
+
438
+ // PyEnv now runs in a worker and returns { result, console }
439
+ const response = await this.pyenv!.execCode(code);
440
+
441
+ // Build output from console output and result
442
+ const outputParts: string[] = [];
443
+
444
+ // Add console output (stdout/stderr) if present
445
+ if (response && typeof response === 'object' && 'console' in response) {
446
+ const consoleOutput = response.console;
447
+ if (Array.isArray(consoleOutput) && consoleOutput.length > 0) {
448
+ const filteredOutput = consoleOutput.filter(s => s && s.trim());
449
+ if (filteredOutput.length > 0) {
450
+ outputParts.push(...filteredOutput);
451
+ }
452
+ }
453
+ }
454
+
455
+ // Check if plt.show() was called (which stores data in _display_data)
456
+ let imageData: string | undefined = undefined;
457
+ try {
458
+ const checkDisplayData = await this.pyenv!.execCode('_display_data if "_display_data" in dir() else None');
459
+ const displayResult = checkDisplayData && typeof checkDisplayData === 'object' ?
460
+ checkDisplayData.result : checkDisplayData;
461
+
462
+ if (displayResult && String(displayResult) !== 'None' && String(displayResult) !== 'undefined') {
463
+ imageData = String(displayResult);
464
+ // Clear the display data for next execution
465
+ await this.pyenv!.execCode('_display_data = None');
466
+ }
467
+ } catch (e) {
468
+ // No display data, which is fine
469
+ console.debug('No display data to retrieve:', e);
470
+ }
471
+
472
+ // Add the return value if it exists, but only if we didn't capture a matplotlib figure
473
+ // (matplotlib functions return objects like Text, Line2D etc that aren't useful to display)
474
+ if (!imageData) {
475
+ const result = response && typeof response === 'object' ? response.result : response;
476
+ if (result !== undefined && result !== null && String(result) !== 'undefined') {
477
+ const resultStr = String(result);
478
+ if (resultStr && resultStr !== 'undefined') {
479
+ outputParts.push(resultStr);
480
+ }
481
+ }
482
+ }
483
+
484
+ this.cellOutputs.set(cellIndex, {
485
+ type: 'execute_result',
486
+ data: outputParts.length > 0 ? outputParts.join('\n') : undefined,
487
+ imageData: imageData
488
+ });
489
+
490
+ // Update execution count
491
+ this.executionCounter++;
492
+ cell.execution_count = this.executionCounter;
493
+ this.markDirty(true);
494
+
495
+ } catch (error) {
496
+ // Check if execution is still marked as running (not cancelled)
497
+ if (this.executingCells.has(cellIndex)) {
498
+ this.cellOutputs.set(cellIndex, {
499
+ type: 'error',
500
+ data: String(error)
501
+ });
502
+ }
503
+ } finally {
504
+ this.executingCells.delete(cellIndex);
505
+ this.requestUpdate();
506
+ }
507
+ }
508
+
509
+ private async cancelExecution(cellIndex: number) {
510
+ if (!this.pyenv) return;
511
+
512
+ // Check if graceful interrupt is available (requires SharedArrayBuffer)
513
+ if (this.pyenv.canInterrupt()) {
514
+ // Use interrupt buffer - Python will raise KeyboardInterrupt
515
+ this.pyenv.interrupt();
516
+ // The KeyboardInterrupt will be caught in executeCell's catch block
517
+ } else {
518
+ // SharedArrayBuffer not available - must terminate worker
519
+ this.cellOutputs.set(cellIndex, {
520
+ type: 'error',
521
+ data: 'Execution cancelled by user (worker terminated - SharedArrayBuffer not available for graceful interrupt)'
522
+ });
523
+
524
+ this.executingCells.delete(cellIndex);
525
+
526
+ // Terminate and reinitialize
527
+ this.pyenv.close();
528
+ this.pyenv = undefined;
529
+ this.pyConnected = false;
530
+ this.pyVersion = undefined;
531
+
532
+ // Reinitialize for future executions
533
+ try {
534
+ await this.initPyodide();
535
+ } catch (error) {
536
+ console.error("Failed to reinitialize Python after cancellation:", error);
537
+ }
538
+
539
+ this.requestUpdate();
540
+ }
541
+ }
542
+
543
+ private clearAllOutputs() {
544
+ // Clear all outputs
545
+ this.cellOutputs.clear();
546
+
547
+ // Reset execution counter
548
+ this.executionCounter = 0;
549
+
550
+ // Reset execution counts on all code cells
551
+ if (this.notebook?.cells) {
552
+ this.notebook.cells.forEach(cell => {
553
+ if (cell.cell_type === 'code') {
554
+ cell.execution_count = null;
555
+ cell.outputs = [];
556
+ }
557
+ });
558
+ }
559
+
560
+ // Mark as dirty since we've modified the notebook
561
+ this.markDirty(true);
562
+
563
+ // Force re-render
564
+ this.requestUpdate();
565
+ }
566
+
567
+ private async restartKernel() {
568
+ if (!this.pyenv || this.pyConnecting) return;
569
+
570
+ try {
571
+ this.pyConnecting = true;
572
+
573
+ // Close current environment
574
+ this.pyenv.close();
575
+ this.pyenv = undefined;
576
+ this.pyConnected = false;
577
+ this.pyVersion = undefined;
578
+
579
+ // Force re-render to show reconnecting state
580
+ this.requestUpdate();
581
+
582
+ // Reinitialize
583
+ await this.initPyodide();
584
+
585
+ // Force re-render to show connected state
586
+ this.requestUpdate();
587
+ } catch (error) {
588
+ console.error("Failed to restart kernel:", error);
589
+ } finally {
590
+ this.pyConnecting = false;
591
+ }
592
+ }
593
+
594
+ private async runAllCells() {
595
+ if (!this.notebook?.cells) return;
596
+
597
+ this.isRunningAll = true;
598
+ this.cancelRunAll = false;
599
+ this.requestUpdate();
600
+
601
+ try {
602
+ // Execute all code cells sequentially
603
+ for (let i = 0; i < this.notebook.cells.length; i++) {
604
+ // Check if cancellation was requested
605
+ if (this.cancelRunAll) {
606
+ break;
607
+ }
608
+
609
+ const cell = this.notebook.cells[i];
610
+ if (cell.cell_type === 'code') {
611
+ await this.executeCell(i);
612
+ }
613
+ }
614
+ } finally {
615
+ this.isRunningAll = false;
616
+ this.cancelRunAll = false;
617
+ this.requestUpdate();
618
+ }
619
+ }
620
+
621
+ private cancelAllCells() {
622
+ this.cancelRunAll = true;
623
+ }
624
+
625
+ private toggleMarkdownEdit(index: number) {
626
+ if (this.editingMarkdownCells.has(index)) {
627
+ this.editingMarkdownCells.delete(index);
628
+ } else {
629
+ this.editingMarkdownCells.add(index);
630
+ }
631
+ this.requestUpdate();
632
+ }
633
+
634
+ private saveMarkdownEdit(index: number, event: Event) {
635
+ const textarea = event.target as HTMLTextAreaElement;
636
+ const newContent = textarea.value;
637
+
638
+ // Update the cell source
639
+ if (this.notebook && this.notebook.cells[index]) {
640
+ const cell = this.notebook.cells[index];
641
+ const oldContent = this.getCellSource(cell);
642
+
643
+ cell.source = this.stringToSourceArray(newContent);
644
+
645
+ // Mark dirty if content changed
646
+ if (oldContent !== newContent) {
647
+ this.markDirty(true);
648
+ }
649
+ }
650
+
651
+ this.editingMarkdownCells.delete(index);
652
+ this.requestUpdate();
653
+ }
654
+
655
+ private renderMarkdownCell(cell: NotebookCell, index: number) {
656
+ const source = this.getCellSource(cell);
657
+ const isEmpty = !source || source.trim() === '';
658
+ const isEditing = this.editingMarkdownCells.has(index);
659
+
660
+ if (isEditing) {
661
+ const editButtons = html`
662
+ <wa-button
663
+ size="small"
664
+ appearance="plain"
665
+ @click=${(e: Event) => {
666
+ const textarea = (e.target as HTMLElement).closest('.markdown-cell')?.querySelector('textarea');
667
+ if (textarea) {
668
+ this.saveMarkdownEdit(index, { target: textarea } as any);
669
+ }
670
+ }}
671
+ title="Save changes">
672
+ <wa-icon name="check" label="Save"></wa-icon>
673
+ </wa-button>
674
+ <wa-button
675
+ size="small"
676
+ appearance="plain"
677
+ @click=${() => this.toggleMarkdownEdit(index)}
678
+ title="Cancel editing">
679
+ <wa-icon name="xmark" label="Cancel"></wa-icon>
680
+ </wa-button>
681
+ `;
682
+
683
+ return html`
684
+ <div class="cell-wrapper">
685
+ <wa-animation
686
+ name="bounce"
687
+ duration="1000"
688
+ iterations="1"
689
+ ?play=${this.highlightedCellIndex === index}
690
+ @wa-finish=${() => this.highlightedCellIndex = -1}>
691
+ <div class="cell markdown-cell editing">
692
+ <div class="cell-header">
693
+ ${this.renderHeaderActions(index, editButtons)}
694
+ <span class="cell-label">Markdown</span>
695
+ </div>
696
+ <textarea
697
+ class="markdown-editor"
698
+ .value=${source}
699
+ @blur=${(e: Event) => this.saveMarkdownEdit(index, e)}
700
+ placeholder="Enter markdown content here... (# for headings, ** for bold, etc.)"></textarea>
701
+ ${this.renderFooterActions(index)}
702
+ </div>
703
+ </wa-animation>
704
+ </div>
705
+ `;
706
+ }
707
+
708
+ const rendered = marked.parse(source) as string;
709
+
710
+ const editButton = html`
711
+ <wa-button
712
+ size="small"
713
+ appearance="plain"
714
+ @click=${() => this.toggleMarkdownEdit(index)}
715
+ title="Edit markdown">
716
+ <wa-icon name="pencil" label="Edit"></wa-icon>
717
+ </wa-button>
718
+ `;
719
+
720
+ return html`
721
+ <div class="cell-wrapper">
722
+ <wa-animation
723
+ name="bounce"
724
+ duration="1000"
725
+ iterations="1"
726
+ ?play=${this.highlightedCellIndex === index}
727
+ @wa-finish=${() => this.highlightedCellIndex = -1}>
728
+ <div class="cell markdown-cell ${isEmpty ? 'empty' : ''}" @dblclick=${() => this.toggleMarkdownEdit(index)}>
729
+ <div class="cell-header">
730
+ ${this.renderHeaderActions(index, editButton)}
731
+ <span class="cell-label"></span>
732
+ </div>
733
+ <div class="cell-content">
734
+ ${isEmpty ? html`
735
+ <div class="markdown-placeholder">
736
+ <wa-icon name="font" label="Markdown"></wa-icon>
737
+ <span>Double-click or click the pencil icon to edit markdown</span>
738
+ </div>
739
+ ` : unsafeHTML(rendered)}
740
+ </div>
741
+ ${this.renderFooterActions(index)}
742
+ </div>
743
+ </wa-animation>
744
+ </div>
745
+ `;
746
+ }
747
+
748
+ private renderCodeCell(cell: NotebookCell, index: number) {
749
+ const output = this.cellOutputs.get(index);
750
+ const isExecuting = this.executingCells.has(index);
751
+
752
+ // Create or get ref for this cell
753
+ if (!this.cellRefs.has(index)) {
754
+ this.cellRefs.set(index, createRef());
755
+ }
756
+ const cellRef = this.cellRefs.get(index)!;
757
+
758
+ const runButton = isExecuting ? html`
759
+ <wa-button
760
+ size="small"
761
+ appearance="plain"
762
+ @click=${() => this.cancelExecution(index)}
763
+ title="Stop execution">
764
+ <wa-icon name="stop" label="Stop" style="color: var(--wa-color-danger-500);"></wa-icon>
765
+ </wa-button>
766
+ ` : html`
767
+ <k-command
768
+ cmd="python"
769
+ icon="play"
770
+ title="Run cell"
771
+ size="small"
772
+ appearance="plain"
773
+ .params=${{ cellIndex: index }}>
774
+ </k-command>
775
+ `;
776
+
777
+ return html`
778
+ <div class="cell-wrapper">
779
+ <wa-animation
780
+ name="bounce"
781
+ duration="1000"
782
+ iterations="1"
783
+ ?play=${this.highlightedCellIndex === index}
784
+ @wa-finish=${() => this.highlightedCellIndex = -1}>
785
+ <div class="cell code-cell ${isExecuting ? 'executing' : ''}">
786
+ <div class="cell-header">
787
+ <span class="cell-label">
788
+ ${isExecuting ? html`
789
+ In [<wa-animation name="pulse" duration="1000" iterations="Infinity" ?play=${isExecuting}>
790
+ <span class="executing-indicator">*</span>
791
+ </wa-animation>]
792
+ ` : html`
793
+ In [${cell.execution_count ?? ' '}]
794
+ `}
795
+ </span>
796
+ ${this.renderHeaderActions(index, runButton)}
797
+ </div>
798
+ <div class="cell-input monaco-container" ${ref(cellRef)} data-cell-index="${index}"></div>
799
+ ${output ? html`
800
+ <div class="cell-output ${output.type === 'error' ? 'output-error' : ''}">
801
+ <div class="output-label">Out [${index + 1}]:</div>
802
+ ${output.imageData ? html`
803
+ <img src="data:image/png;base64,${output.imageData}" alt="Output image" class="output-image" />
804
+ ` : ''}
805
+ ${output.data ? html`<pre><code>${output.data}</code></pre>` : ''}
806
+ </div>
807
+ ` : ''}
808
+ ${this.renderFooterActions(index)}
809
+ </div>
810
+ </wa-animation>
811
+ </div>
812
+ `;
813
+ }
814
+
815
+ private renderCell(cell: NotebookCell, index: number) {
816
+ if (cell.cell_type === 'markdown') {
817
+ return this.renderMarkdownCell(cell, index);
818
+ } else if (cell.cell_type === 'code') {
819
+ return this.renderCodeCell(cell, index);
820
+ } else {
821
+ // raw cell
822
+ const source = this.getCellSource(cell);
823
+ return html`
824
+ <div class="cell raw-cell">
825
+ <pre><code>${source}</code></pre>
826
+ </div>
827
+ `;
828
+ }
829
+ }
830
+
831
+ private async connectPython() {
832
+ if (this.pyConnecting || this.pyConnected) {
833
+ return;
834
+ }
835
+
836
+ try {
837
+ this.pyConnecting = true;
838
+ await this.initPyodide();
839
+ } catch (error) {
840
+ console.error("Failed to initialize Pyodide:", error);
841
+ } finally {
842
+ this.pyConnecting = false;
843
+ }
844
+ }
845
+
846
+
847
+ private addCell(index: number, cellType: 'code' | 'markdown' = 'code') {
848
+ if (!this.notebook) return;
849
+
850
+ // Save editor contents BEFORE modifying the cells array
851
+ this.saveEditorContents();
852
+
853
+ this.shiftIndices(index, 'up');
854
+
855
+ this.notebook.cells.splice(index, 0, this.createCell(cellType));
856
+
857
+ // Automatically enter edit mode for new markdown cells
858
+ if (cellType === 'markdown') {
859
+ this.editingMarkdownCells.add(index);
860
+ }
861
+
862
+ this.resetCellState();
863
+
864
+ // Trigger animation and scroll to the new cell
865
+ this.highlightedCellIndex = index;
866
+ this.updateComplete.then(() => {
867
+ this.scrollToCell(index);
868
+ });
869
+ }
870
+
871
+ private scrollToCell(index: number) {
872
+ // Find the cell wrapper element
873
+ const cellWrapper = this.shadowRoot?.querySelectorAll('.cell-wrapper')[index];
874
+ if (!cellWrapper) return;
875
+
876
+ // Find the wa-scroller container (parent of this component)
877
+ const scroller = this.closest('wa-scroller');
878
+ if (!scroller) {
879
+ // Fallback to default scrollIntoView if no scroller found
880
+ cellWrapper.scrollIntoView({
881
+ behavior: 'smooth',
882
+ block: 'center',
883
+ inline: 'nearest'
884
+ });
885
+ return;
886
+ }
887
+
888
+ // Manually scroll the wa-scroller to the cell position
889
+ const scrollerRect = scroller.getBoundingClientRect();
890
+ const cellRect = cellWrapper.getBoundingClientRect();
891
+ const scrollTop = scroller.scrollTop;
892
+
893
+ // Calculate target scroll position (center the cell)
894
+ const targetScroll = scrollTop + (cellRect.top - scrollerRect.top) - (scrollerRect.height / 2) + (cellRect.height / 2);
895
+
896
+ scroller.scrollTo({
897
+ top: targetScroll,
898
+ behavior: 'smooth'
899
+ });
900
+ }
901
+
902
+ private saveEditorContents() {
903
+ // Update cell contents from Monaco editors
904
+ this.monacoEditors.forEach((editor, index) => {
905
+ const cell = this.notebook!.cells[index];
906
+ if (cell && cell.cell_type === 'code') {
907
+ cell.source = this.stringToSourceArray(editor.getValue());
908
+ }
909
+ });
910
+ }
911
+
912
+ private resetCellState() {
913
+ // Clear Monaco editors and refs since indices have changed
914
+ this.monacoEditors.forEach(editor => editor.dispose());
915
+ this.monacoEditors.clear();
916
+ this.cellRefs.clear();
917
+ this.markDirty(true);
918
+ }
919
+
920
+ private deleteCell(index: number) {
921
+ if (!this.notebook || this.notebook.cells.length <= 1) return;
922
+
923
+ // Save editor contents BEFORE modifying the cells array
924
+ this.saveEditorContents();
925
+
926
+ // Remove state for the deleted cell
927
+ this.cellOutputs.delete(index);
928
+ this.executingCells.delete(index);
929
+ this.editingMarkdownCells.delete(index);
930
+
931
+ this.notebook.cells.splice(index, 1);
932
+
933
+ this.shiftIndices(index, 'down');
934
+
935
+ this.resetCellState();
936
+ }
937
+
938
+ private shiftIndices(startIndex: number, direction: 'up' | 'down') {
939
+ const shift = direction === 'up' ? 1 : -1;
940
+ const filterFn = direction === 'up'
941
+ ? (idx: number) => idx >= startIndex
942
+ : (idx: number) => idx > startIndex;
943
+ const sortFn = direction === 'up'
944
+ ? (a: number, b: number) => b - a
945
+ : (a: number, b: number) => a - b;
946
+
947
+ const shiftMap = <T>(map: Map<number, T>) => {
948
+ const indices = Array.from(map.keys()).filter(filterFn).sort(sortFn);
949
+ indices.forEach(oldIndex => {
950
+ const value = map.get(oldIndex);
951
+ map.delete(oldIndex);
952
+ map.set(oldIndex + shift, value!);
953
+ });
954
+ };
955
+
956
+ const shiftSet = (set: Set<number>) => {
957
+ const indices = Array.from(set).filter(filterFn).sort(sortFn);
958
+ indices.forEach(oldIndex => {
959
+ set.delete(oldIndex);
960
+ set.add(oldIndex + shift);
961
+ });
962
+ };
963
+
964
+ shiftMap(this.cellOutputs);
965
+ shiftSet(this.executingCells);
966
+ shiftSet(this.editingMarkdownCells);
967
+ }
968
+
969
+ private initializeMonacoEditor(container: HTMLElement, cell: NotebookCell, index: number) {
970
+ const source = this.getCellSource(cell);
971
+ const lineCount = source.split('\n').length;
972
+ const lineHeight = 19;
973
+ const padding = 10;
974
+ const minHeight = 100;
975
+ const calculatedHeight = Math.max(minHeight, lineCount * lineHeight + padding);
976
+
977
+ container.style.height = `${calculatedHeight}px`;
978
+
979
+ const editor = monaco.editor.create(container, {
980
+ value: source,
981
+ language: 'python',
982
+ theme: this.getMonacoTheme(),
983
+ minimap: { enabled: false },
984
+ scrollBeyondLastLine: false,
985
+ lineNumbers: 'on',
986
+ renderLineHighlight: 'all',
987
+ automaticLayout: true,
988
+ fontSize: 14,
989
+ tabSize: 4,
990
+ wordWrap: 'on',
991
+ });
992
+
993
+ let isEditorFocused = false;
994
+ editor.onDidFocusEditorText(() => {
995
+ isEditorFocused = true;
996
+ this.focusedCellIndex = index;
997
+ });
998
+ editor.onDidBlurEditorText(() => {
999
+ isEditorFocused = false;
1000
+ if (this.focusedCellIndex === index) {
1001
+ this.focusedCellIndex = -1;
1002
+ }
1003
+ });
1004
+
1005
+ container.addEventListener('wheel', (e: WheelEvent) => {
1006
+ if (!isEditorFocused) {
1007
+ const scrollTop = editor.getScrollTop();
1008
+ const scrollHeight = editor.getScrollHeight();
1009
+ const contentHeight = editor.getContentHeight();
1010
+ const canScroll = scrollHeight > contentHeight;
1011
+ const atBoundary = (e.deltaY < 0 && scrollTop <= 0) ||
1012
+ (e.deltaY > 0 && scrollTop + contentHeight >= scrollHeight);
1013
+
1014
+ if (!canScroll || atBoundary) {
1015
+ e.stopImmediatePropagation();
1016
+ }
1017
+ }
1018
+ }, { passive: true, capture: true });
1019
+
1020
+ editor.onDidContentSizeChange(() => {
1021
+ const contentHeight = editor.getContentHeight();
1022
+ container.style.height = `${Math.max(minHeight, contentHeight + padding)}px`;
1023
+ editor.layout();
1024
+ });
1025
+
1026
+ editor.onDidChangeModelContent(() => {
1027
+ const currentValue = editor.getValue();
1028
+ const originalValue = this.getCellSource(cell);
1029
+ if (currentValue !== originalValue) {
1030
+ this.markDirty(true);
1031
+ }
1032
+ });
1033
+
1034
+ this.monacoEditors.set(index, editor);
1035
+ }
1036
+
1037
+ private getMonacoTheme(): string {
1038
+ const rootElement = document.documentElement;
1039
+ if (rootElement.classList.contains('wa-dark')) {
1040
+ return 'vs-dark';
1041
+ } else if (rootElement.classList.contains('wa-light')) {
1042
+ return 'vs';
1043
+ }
1044
+ return 'vs-dark';
1045
+ }
1046
+
1047
+ private openPackageManager() {
1048
+ const packages = this.notebook?.metadata?.required_packages || [];
1049
+
1050
+ pythonPackageManagerService.showPackageManager({
1051
+ packages,
1052
+ pyenv: this.pyenv,
1053
+ onPackageAdded: (packageName: string) => {
1054
+ if (!this.notebook) return;
1055
+ if (!this.notebook.metadata) {
1056
+ this.notebook.metadata = {};
1057
+ }
1058
+ if (!this.notebook.metadata.required_packages) {
1059
+ this.notebook.metadata.required_packages = [];
1060
+ }
1061
+
1062
+ if (!this.notebook.metadata.required_packages.includes(packageName)) {
1063
+ this.notebook.metadata.required_packages.push(packageName);
1064
+ this.markDirty(true);
1065
+ }
1066
+ },
1067
+ onPackageRemoved: (packageName: string) => {
1068
+ if (!this.notebook?.metadata?.required_packages) return;
1069
+
1070
+ const index = this.notebook.metadata.required_packages.indexOf(packageName);
1071
+ if (index > -1) {
1072
+ this.notebook.metadata.required_packages.splice(index, 1);
1073
+ this.markDirty(true);
1074
+ }
1075
+ }
1076
+ });
1077
+ }
1078
+
1079
+ protected updated(changedProperties: Map<string, any>) {
1080
+ super.updated(changedProperties);
1081
+
1082
+ // Update toolbar when state affecting toolbar changes
1083
+ if (changedProperties.has('pyConnected') ||
1084
+ changedProperties.has('pyConnecting') ||
1085
+ changedProperties.has('pyVersion') ||
1086
+ changedProperties.has('isRunningAll')) {
1087
+ this.updateToolbar();
1088
+ }
1089
+
1090
+ if (this.notebook?.cells) {
1091
+ this.notebook.cells.forEach((cell, index) => {
1092
+ if (cell.cell_type === 'code') {
1093
+ const ref = this.cellRefs.get(index);
1094
+ if (ref?.value && !this.monacoEditors.has(index)) {
1095
+ this.initializeMonacoEditor(ref.value, cell, index);
1096
+ }
1097
+ }
1098
+ });
1099
+ }
1100
+ }
1101
+
1102
+ protected render() {
1103
+ if (!this.notebook) {
1104
+ return html`<div class="loading">Loading notebook...</div>`;
1105
+ }
1106
+
1107
+ return html`
1108
+ <style>
1109
+ ${monacoStyles}
1110
+ </style>
1111
+ <div class="notebook-cells">
1112
+ ${repeat(
1113
+ this.notebook.cells,
1114
+ (_cell, index) => index,
1115
+ (cell, index) => this.renderCell(cell, index)
1116
+ )}
1117
+ </div>
1118
+ `;
1119
+ }
1120
+
1121
+ static styles = css`
1122
+ :host {
1123
+ display: block;
1124
+ width: 100%;
1125
+ }
1126
+
1127
+ .python-status {
1128
+ display: flex;
1129
+ align-items: center;
1130
+ gap: 0.5rem;
1131
+ }
1132
+
1133
+ .python-version {
1134
+ font-size: 0.9rem;
1135
+ opacity: 0.8;
1136
+ }
1137
+
1138
+ .notebook-cells {
1139
+ display: flex;
1140
+ flex-direction: column;
1141
+ gap: 3rem;
1142
+ max-width: 1200px;
1143
+ margin: 0 auto;
1144
+ padding: 2rem 1rem;
1145
+ width: 100%;
1146
+ box-sizing: border-box;
1147
+ }
1148
+
1149
+ .cell-wrapper {
1150
+ position: relative;
1151
+ }
1152
+
1153
+ .cell {
1154
+ border-radius: 4px;
1155
+ overflow: hidden;
1156
+ opacity: 0.9;
1157
+ position: relative;
1158
+ }
1159
+
1160
+ .cell-header-actions {
1161
+ display: flex;
1162
+ gap: 0.25rem;
1163
+ align-items: center;
1164
+ opacity: 0.5;
1165
+ transition: opacity 0.2s;
1166
+ }
1167
+
1168
+ .cell-header-actions .divider {
1169
+ width: 1px;
1170
+ height: 1rem;
1171
+ background: var(--wa-color-outline);
1172
+ margin: 0 0.25rem;
1173
+ opacity: 0.5;
1174
+ }
1175
+
1176
+ .cell-header:hover .cell-header-actions {
1177
+ opacity: 1;
1178
+ }
1179
+
1180
+ .cell-footer {
1181
+ display: flex;
1182
+ gap: 0.5rem;
1183
+ align-items: center;
1184
+ justify-content: flex-start;
1185
+ padding: 0.5rem;
1186
+ border-top: 1px solid var(--wa-color-outline);
1187
+ opacity: 0.5;
1188
+ transition: opacity 0.2s;
1189
+ }
1190
+
1191
+ .cell-footer:hover {
1192
+ opacity: 1;
1193
+ }
1194
+
1195
+ .markdown-cell {
1196
+ cursor: pointer;
1197
+ transition: opacity 0.2s;
1198
+ }
1199
+
1200
+ .markdown-cell:hover:not(.editing) {
1201
+ opacity: 0.9;
1202
+ }
1203
+
1204
+ .markdown-cell .cell-content {
1205
+ padding: 1rem;
1206
+ }
1207
+
1208
+ .markdown-cell.editing {
1209
+ cursor: default;
1210
+ padding: 0;
1211
+ }
1212
+
1213
+ .markdown-cell.editing .cell-actions {
1214
+ display: none;
1215
+ }
1216
+
1217
+ .markdown-editor {
1218
+ width: 100%;
1219
+ min-height: 200px;
1220
+ padding: 1rem;
1221
+ font-family: monospace;
1222
+ font-size: 0.95rem;
1223
+ line-height: 1.6;
1224
+ border: none;
1225
+ outline: none;
1226
+ resize: vertical;
1227
+ background: transparent;
1228
+ color: inherit;
1229
+ }
1230
+
1231
+ .code-cell {
1232
+ border-left: 3px solid var(--wa-color-primary-500);
1233
+ transition: all 0.3s ease;
1234
+ }
1235
+
1236
+ .code-cell.executing {
1237
+ border-left: 4px solid var(--wa-color-primary-500);
1238
+ box-shadow: 0 0 0 2px var(--wa-color-primary-500, rgba(59, 130, 246, 0.3));
1239
+ animation: pulse-cell 2s ease-in-out infinite;
1240
+ }
1241
+
1242
+ @keyframes pulse-cell {
1243
+ 0%, 100% {
1244
+ box-shadow: 0 0 0 2px var(--wa-color-primary-500, rgba(59, 130, 246, 0.3));
1245
+ opacity: 1;
1246
+ }
1247
+ 50% {
1248
+ box-shadow: 0 0 0 4px var(--wa-color-primary-500, rgba(59, 130, 246, 0.5));
1249
+ opacity: 0.95;
1250
+ }
1251
+ }
1252
+
1253
+ .cell-header {
1254
+ display: flex;
1255
+ align-items: center;
1256
+ justify-content: flex-start;
1257
+ gap: 0.5rem;
1258
+ padding: 0.5rem 1rem;
1259
+ flex-wrap: nowrap;
1260
+ }
1261
+
1262
+ .cell-label {
1263
+ font-family: monospace;
1264
+ font-weight: bold;
1265
+ flex-shrink: 0;
1266
+ }
1267
+
1268
+ .executing-indicator {
1269
+ display: inline-block;
1270
+ color: var(--wa-color-primary-500);
1271
+ font-weight: bold;
1272
+ font-size: 1.2em;
1273
+ }
1274
+
1275
+ .cell-input {
1276
+ margin: 0;
1277
+ }
1278
+
1279
+ .monaco-container {
1280
+ min-height: 100px;
1281
+ height: auto;
1282
+ width: 100%;
1283
+ }
1284
+
1285
+ .cell-output {
1286
+ padding: 1rem;
1287
+ }
1288
+
1289
+ .output-label {
1290
+ font-family: monospace;
1291
+ font-weight: bold;
1292
+ margin-bottom: 0.5rem;
1293
+ opacity: 0.7;
1294
+ }
1295
+
1296
+ .cell-output pre {
1297
+ margin: 0;
1298
+ overflow-x: auto;
1299
+ }
1300
+
1301
+ .cell-output code {
1302
+ font-family: 'Courier New', monospace;
1303
+ font-size: 0.9rem;
1304
+ line-height: 1.5;
1305
+ }
1306
+
1307
+ .output-image {
1308
+ max-width: 100%;
1309
+ height: auto;
1310
+ display: block;
1311
+ margin: 0.5rem 0;
1312
+ border-radius: 4px;
1313
+ }
1314
+
1315
+ .output-error {
1316
+ border-left: 3px solid var(--wa-color-danger-500);
1317
+ }
1318
+
1319
+ .raw-cell {
1320
+ padding: 1rem;
1321
+ }
1322
+
1323
+ .raw-cell pre {
1324
+ margin: 0;
1325
+ }
1326
+
1327
+ .loading {
1328
+ display: flex;
1329
+ align-items: center;
1330
+ justify-content: center;
1331
+ height: 100%;
1332
+ font-size: 1.2rem;
1333
+ }
1334
+
1335
+ .markdown-placeholder {
1336
+ display: flex;
1337
+ align-items: center;
1338
+ justify-content: center;
1339
+ gap: 0.75rem;
1340
+ padding: 3rem 1rem;
1341
+ opacity: 0.5;
1342
+ font-style: italic;
1343
+ transition: opacity 0.2s;
1344
+ }
1345
+
1346
+ .markdown-cell.empty:hover .markdown-placeholder {
1347
+ opacity: 0.8;
1348
+ }
1349
+
1350
+ .markdown-placeholder wa-icon {
1351
+ font-size: 1.5rem;
1352
+ }
1353
+ `;
1354
+ }