@@ -20,12 +20,14 @@ import {
2020 faColumns ,
2121 faTableList ,
2222 faBroom ,
23+ faPlus ,
24+ faFileLines ,
2325} from " @fortawesome/free-solid-svg-icons" ;
2426import { FontAwesomeIcon } from " @fortawesome/vue-fontawesome" ;
2527import KernelStatusPill from " ./KernelStatusPill.vue" ;
2628import MwctipyReferenceModal from " ./MwctipyReferenceModal.vue" ;
2729import OutputPanel from " ./OutputPanel.vue" ;
28- import { parseCells , findCellAtLine } from " ./cellDelimiterParser" ;
30+ import { parseCells , findCellAtLine , uuid } from " ./cellDelimiterParser" ;
2931
3032const props = defineProps ({
3133 notebookId: { type: Number , default: null },
@@ -76,14 +78,6 @@ const liveNotebook = computed(() => {
7678 );
7779});
7880
79- const liveSource = computed (() => {
80- // Prefer the editor model (so the panel reflects unsaved edits) over the
81- // store copy.
82- const model = models .get (currentNotebook .value ? .id );
83- if (model) return model .getValue ();
84- return liveNotebook .value ? .source || " " ;
85- });
86-
8781const liveCellOutputs = computed (() => liveNotebook .value ? .cell_outputs || {});
8882const liveCellMeta = computed (() => {
8983 if (! currentNotebook .value ) return {};
@@ -257,12 +251,16 @@ watch(
257251 { immediate: true },
258252);
259253
260- // Expose a tracked source string so the OutputPanel re-renders on edits.
261- // ` void _editTick .value ` forces a reactive subscription on the tick ref so
262- // computed re-runs whenever the model emits onDidChangeContent.
254+ // Source string the OutputPanel reads. The Monaco model is not reactive, so
255+ // we subscribe to ` ` _editTick` ` (bumped on every onDidChangeContent) and
256+ // call ` ` model.getValue()` ` inline — wrapping it in another computed would
257+ // hide the read behind a cached layer and stale-render markdown until the
258+ // next notebook switch.
263259const trackedSource = computed(() => {
264260 void _editTick.value;
265- return liveSource.value;
261+ const model = models.get(currentNotebook.value?.id);
262+ if (model) return model.getValue();
263+ return liveNotebook.value?.source || "";
266264});
267265
268266// ── Run cell / Run all / Interrupt / Restart ────────────────────────────
@@ -360,6 +358,46 @@ async function runCellById(cellId) {
360358 }
361359}
362360
361+ // Insert a new code or markdown cell after the cell at the cursor (or at the
362+ // end of the document if the cursor isn't inside a cell). Uses Monaco's edit
363+ // API so undo/redo and onDidChangeContent (auto-save) all behave correctly.
364+ function insertCell(type) {
365+ if (!currentNotebook.value || readOnly.value || isLibrary.value) return;
366+ if (!editorRef.value || !monaco.value) return;
367+ const model = models.get(currentNotebook.value.id);
368+ if (!model) return;
369+
370+ const cells = parseCells(model.getValue());
371+ const pos = editorRef.value.getPosition();
372+ let endLine = model.getLineCount();
373+ if (pos && cells.length > 0) {
374+ const cell = findCellAtLine(cells, pos.lineNumber);
375+ if (cell) endLine = cell.sourceEndLine;
376+ }
377+
378+ const lineLen = model.getLineContent(endLine).length;
379+ // Don't prepend a newline when the doc is a single empty line — otherwise
380+ // we'd seed a permanent leading blank line.
381+ const isEmptyDoc = endLine === 1 && lineLen === 0;
382+ const delimiter = ` # %% [id= ${uuid ()}] ${type}\n` ;
383+ const text = (isEmptyDoc ? "" : "\n ") + delimiter;
384+
385+ const range = new monaco.value.Range(
386+ endLine,
387+ lineLen + 1,
388+ endLine,
389+ lineLen + 1,
390+ );
391+ editorRef.value.executeEdits("insert-cell", [
392+ { range, text, forceMoveMarkers: true },
393+ ]);
394+
395+ const cursorLine = isEmptyDoc ? 2 : endLine + 2;
396+ editorRef.value.setPosition({ lineNumber: cursorLine, column: 1 });
397+ editorRef.value.revealLineInCenterIfOutsideViewport(cursorLine);
398+ editorRef.value.focus();
399+ }
400+
363401async function clearOutputs() {
364402 if (!currentNotebook.value) return;
365403 if (!confirm("Clear all outputs from this notebook?")) return;
@@ -617,9 +655,43 @@ const saveLabel = computed(() => {
617655 :class="{ 'editor-pane-split': showOutputs }"
618656 >
619657 <div
620- class="editor-pane-toolbar border-bottom px-2 py-1 d-flex align-items-center"
658+ class="editor-pane-toolbar border-bottom px-2 py-1 d-flex align-items-center gap-2 "
621659 >
622660 <small class="text-muted font-monospace me-auto">python</small>
661+ <div v-if="!readOnly" class="dropdown">
662+ <button
663+ type="button"
664+ class="btn btn-outline-secondary btn-sm dropdown-toggle"
665+ data-bs-toggle="dropdown"
666+ aria-expanded="false"
667+ title="Add a new cell after the current cell"
668+ >
669+ <FontAwesomeIcon :icon="faPlus" class="me-1" />
670+ Add
671+ </button>
672+ <ul class="dropdown-menu dropdown-menu-end">
673+ <li>
674+ <button
675+ type="button"
676+ class="dropdown-item"
677+ @click="insertCell('code')"
678+ >
679+ <FontAwesomeIcon :icon="faCode" class="me-2" />
680+ Code
681+ </button>
682+ </li>
683+ <li>
684+ <button
685+ type="button"
686+ class="dropdown-item"
687+ @click="insertCell('markdown')"
688+ >
689+ <FontAwesomeIcon :icon="faFileLines" class="me-2" />
690+ Markdown
691+ </button>
692+ </li>
693+ </ul>
694+ </div>
623695 <button
624696 class="btn btn-outline-secondary btn-sm"
625697 data-bs-toggle="modal"
0 commit comments