From 047e3d5850cf624a816c6035cd2d1445d61277ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Sch=C3=A4fer?= Date: Tue, 20 Oct 2026 16:00:19 +0200 Subject: [PATCH 1/3] Refresh the cells below a run, and notice runs the VS Code webview never renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a run the editor asked the kernel again for the diagnostics of the cell that ran, and of no other. A run changes what the cells after it can refer to, so a cell below kept the errors it had before, a name the run had just defined still marked as unknown, until the user clicked into it. Now the cell that ran and every cell below it are asked again, top to bottom and one at a time, so the cells nearest the run update first and a run started in the meantime waits behind at most one request. A cell whose answer still holds is skipped, and nothing is asked while a cell runs. Cells above the run are asked on their next focus, as before. In the VS Code webview not even the cell that ran was refreshed. A cell noticed its run end by seeing IsExecuting turn false, but the host's executing and executed notifications for a quick cell are both handled before the next render, so the cell was never rendered as executing. A cell now also counts a moved execution count as the end of a run. A count cleared by a restart does not count, so a restart still clears the markers without asking again. Signed-off-by: Jonathan Schäfer --- docs/architecture/front-ends.md | 4 +- docs/extensions/extension-interfaces.md | 2 +- .../Components/Editor/MonacoEditor.razor | 14 ++++ .../Components/Notebook/Cell.razor | 11 ++- .../wwwroot/js/monaco-interop.js | 77 ++++++++++++++++--- tests/Verso.Blazor.Shared.Tests/CellTests.cs | 37 ++++++++- .../MonacoEditorTests.cs | 2 + 7 files changed, 128 insertions(+), 19 deletions(-) diff --git a/docs/architecture/front-ends.md b/docs/architecture/front-ends.md index 30e3e72..6cbab03 100644 --- a/docs/architecture/front-ends.md +++ b/docs/architecture/front-ends.md @@ -41,10 +41,10 @@ The refresh policy is set by the expensive kernels, where a request is a full co - **Edit**: debounced, 500 ms after the last change the user makes. Text set from .NET (a reload, a tool editing cells) does not ask unless that editor has focus; the next focus asks instead. - **Focus**: when a cell's editor gains focus, since variable-dependent kernels (SQL, HTTP, HTML) change their answer when another cell runs, not when this text changes. Skipped when the text is the version last answered and nothing has happened since that could change the answer (a run ending, a restart, a language change). -- **Run**: after the cell's own run finishes. +- **Run**: after a cell's run finishes, for that cell and every cell below it, since the run may have changed what their code refers to. They are asked top to bottom and one at a time, so the cells nearest the run update first and a run started in the meantime waits behind at most one request; a cell whose answer still holds is skipped. Cells above the run are asked on their next focus. A cell counts as run when it stops executing or when its execution count moves; in the VS Code webview a quick cell is never rendered as executing, because its executing and executed notifications are both handled before the next render. - **Restart**: markers are cleared in every cell, whichever kernel restarted, not recomputed. -While any cell is running no request is issued (`NotebookCellContext.IsAnyExecuting`, passed to the editor as `DiagnosticsSuspended`). `Verso.Host` answers requests one at a time, so a request during a run would wait for the run, and some kernels answer empty while executing. A refresh asked for in that time runs once the run is over. One request is in flight per editor; a reply for text that has since changed is dropped; a failed request (null) keeps the markers that are showing, while an empty list clears them. Cells over 2,000 lines are not analyzed. +While any cell is running no request is issued (`NotebookCellContext.IsAnyExecuting`, passed to the editor as `DiagnosticsSuspended`). `Verso.Host` answers requests one at a time, so a request during a run would wait for the run, and some kernels answer empty while executing. A refresh asked for in that time runs once the run is over, in the same top-to-bottom order. Run All is one `execution/runAll` request, so in VS Code the cells are refreshed once it has finished, not one by one as it goes. One request is in flight per editor; a reply for text that has since changed is dropped; a failed request (null) keeps the markers that are showing, while an empty list clears them. Cells over 2,000 lines are not analyzed. Monaco's bundled TypeScript worker would otherwise mark JavaScript and TypeScript cells as if each were a standalone file, so its validation is turned off when Monaco loads; its completion and hover stay on. diff --git a/docs/extensions/extension-interfaces.md b/docs/extensions/extension-interfaces.md index 0b1fc8a..bb8c2d2 100644 --- a/docs/extensions/extension-interfaces.md +++ b/docs/extensions/extension-interfaces.md @@ -61,7 +61,7 @@ Executes code, provides completions, diagnostics, and hover information for a sp ### Diagnostics in the Editor -What `GetDiagnosticsAsync` returns is drawn in the cell editor as squiggles. The editor asks after the user pauses typing (about half a second), when the cell's editor gains focus, and after the cell runs. It does not ask while any cell in the notebook is running, and it clears every marker in the notebook when any kernel restarts. Diagnostics are advisory: if the kernel fails to initialize or `GetDiagnosticsAsync` throws, the request goes unanswered and the editor keeps the markers it already shows (none, for a kernel that never started); the failure is reported where it matters, when the cell runs. Return an empty list, not an exception, when there is nothing to report, so fixed problems are cleared. +What `GetDiagnosticsAsync` returns is drawn in the cell editor as squiggles. The editor asks after the user pauses typing (about half a second), when the cell's editor gains focus, and after a run, for the cell that ran and every cell below it, nearest first. It does not ask while any cell in the notebook is running, and it clears every marker in the notebook when any kernel restarts. Diagnostics are advisory: if the kernel fails to initialize or `GetDiagnosticsAsync` throws, the request goes unanswered and the editor keeps the markers it already shows (none, for a kernel that never started); the failure is reported where it matters, when the cell runs. Return an empty list, not an exception, when there is nothing to report, so fixed problems are cleared. Leading `#!` magic command lines are blanked before the code reaches the kernel, keeping the line count, so a kernel never has to recognize them and its positions still match the cell. At run time the pipeline removes those lines instead, so a kernel that reads a directive from the cell's first line (as SQL reads `--connection`) should skip leading blank lines when it looks for that line during diagnostics. diff --git a/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor b/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor index 541109f..1d4f298 100644 --- a/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor +++ b/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor @@ -91,6 +91,20 @@ catch (JSDisconnectedException) { } } + /// + /// The cell has finished running: ask again for this editor's diagnostics and for every + /// editor below it, nearest first, since the run may have changed what their code refers to. + /// + public async Task RefreshAfterRunAsync() + { + if (!_initialized) return; + try + { + await JS.InvokeVoidAsync("versoMonaco.refreshAfterRun", _elementId); + } + catch (JSDisconnectedException) { } + } + /// Remove every kernel diagnostic marker from the editor. public async Task ClearDiagnosticsAsync() { diff --git a/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor b/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor index d62b831..5712b2e 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor @@ -470,11 +470,16 @@ // boundary is holding no error, which is the ordinary case. The first execution this // component sees is not treated as a change: there is nothing yet to recover. var execution = (CellData.Id, CellData.ExecutionCount); - if (_lastExecution is { } previous && previous != execution) + var ranAgain = _lastExecution is { } previous && previous != execution; + if (ranAgain) _outputErrorBoundary?.Recover(); _lastExecution = execution; - if (_wasExecuting && !IsExecuting) + // The run has ended when the cell stops executing, or when its execution count moves + // without this component having seen it execute: in the VS Code webview the executing + // and executed notifications for a quick cell are handled before the next render, so + // IsExecuting is never seen true. A count cleared by a restart is not a run. + if ((_wasExecuting && !IsExecuting) || (ranAgain && execution.ExecutionCount is not null && !IsExecuting)) _pendingDiagnosticsRefresh = true; _wasExecuting = IsExecuting; @@ -599,7 +604,7 @@ { _pendingDiagnosticsRefresh = false; if (_editor is not null) - await _editor.RefreshDiagnosticsAsync(); + await _editor.RefreshAfterRunAsync(); } await ReconcileMenuPopoverAsync(); diff --git a/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js b/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js index 40d52f0..b11b014 100644 --- a/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js +++ b/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js @@ -240,16 +240,17 @@ window.versoMonaco = (function () { // onFocus is true for a refresh triggered by the editor gaining focus, which is skipped when // the last answer still holds. Edits, runs and explicit requests always go through. + // Returns a promise that settles when the request it sends does, or at once when it sends none. function refreshDiagnostics(elementId, onFocus) { const state = diagnosticsState[elementId]; const editor = editors[elementId]; - if (!state || !editor) return; + if (!state || !editor) return Promise.resolve(); if (onFocus && !state.pending && !state.timer) { const current = editor.getModel(); if (current && current.getVersionId() === state.answeredVersion && state.answeredEpoch === state.staleEpoch) { - return; + return Promise.resolve(); } } @@ -262,20 +263,20 @@ window.versoMonaco = (function () { // against state it is changing. Ask once the run is over instead. if (state.suspended || state.inFlight) { state.pending = true; - return; + return Promise.resolve(); } state.pending = false; const model = editor.getModel(); - if (!model) return; + if (!model) return Promise.resolve(); const ref = dotnetRefs[model.uri.toString()]; - if (!ref) return; + if (!ref) return Promise.resolve(); // A pathological pasted cell is not analyzed at all, and what it showed before no // longer applies. if (model.getLineCount() > DIAGNOSTICS_MAX_LINES) { setKernelMarkers(model, []); - return; + return Promise.resolve(); } const version = model.getVersionId(); @@ -283,7 +284,7 @@ window.versoMonaco = (function () { const epoch = state.staleEpoch; state.inFlight = true; - ref.invokeMethodAsync('GetDiagnostics', model.getValue()) + return ref.invokeMethodAsync('GetDiagnostics', model.getValue()) .then(function (result) { if (diagnosticsState[elementId] !== state || state.generation !== generation) return; if (model.isDisposed() || model.getVersionId() !== version) return; @@ -302,6 +303,56 @@ window.versoMonaco = (function () { }); } + // ── Refresh after a run ───────────────────────────────────────────── + // A run changes what the cells after it can refer to, so when a cell's run ends it and every + // cell below it are asked again. They go top to bottom and one at a time: the cells nearest + // the run update first, and a run started in the meantime waits behind at most one request. + // Nothing goes while a cell runs; the walk resumes when the run is over. A cell whose answer + // still holds for its text and kernel state is skipped. Cells above a run are asked on their + // next focus, as before. + const refreshAfterRunQueue = new Set(); // elementIds still to ask + let refreshAfterRunActive = false; + + function editorsInDocumentOrder() { + return Object.keys(diagnosticsState) + .filter(function (id) { return editors[id]; }) + .sort(function (a, b) { + const position = editors[a].getContainerDomNode() + .compareDocumentPosition(editors[b].getContainerDomNode()); + return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + } + + function queueRefreshAfterRun(elementId) { + const ordered = editorsInDocumentOrder(); + const start = ordered.indexOf(elementId); + (start >= 0 ? ordered.slice(start) : [elementId]).forEach(function (id) { + refreshAfterRunQueue.add(id); + }); + runRefreshAfterRunQueue(); + } + + function runRefreshAfterRunQueue() { + if (refreshAfterRunActive || refreshAfterRunQueue.size === 0) return; + refreshAfterRunActive = true; + + function step() { + const next = editorsInDocumentOrder().find(function (id) { return refreshAfterRunQueue.has(id); }); + const state = next && diagnosticsState[next]; + if (!state || state.suspended) { + // Done, or held until the run that is starting ends (setDiagnosticsSuspended). + refreshAfterRunActive = false; + if (!next) refreshAfterRunQueue.clear(); + return; + } + refreshAfterRunQueue.delete(next); + refreshDiagnostics(next, true).then(step, step); + } + + // On the next turn, so every editor a render lifts from suspension is lifted first. + setTimeout(step, 0); + } + function scheduleDiagnostics(elementId) { const state = diagnosticsState[elementId]; if (!state) return; @@ -696,6 +747,7 @@ window.versoMonaco = (function () { dispose: function (elementId) { clearDiagnostics(elementId); delete diagnosticsState[elementId]; + refreshAfterRunQueue.delete(elementId); const editor = editors[elementId]; if (editor) { const modelUri = editor.getModel()?.uri?.toString(); @@ -863,11 +915,16 @@ window.versoMonaco = (function () { } }, - // Ask the kernel for this editor's diagnostics now (after its cell ran, for example). + // Ask the kernel for this editor's diagnostics now. refreshDiagnostics: function (elementId) { refreshDiagnostics(elementId); }, + // This editor's cell has finished running: ask again for it and every cell below it. + refreshAfterRun: function (elementId) { + queueRefreshAfterRun(elementId); + }, + clearDiagnostics: function (elementId) { clearDiagnostics(elementId); }, @@ -881,8 +938,10 @@ window.versoMonaco = (function () { if (state.suspended && !suspended) markDiagnosticsStale(state); state.suspended = !!suspended; if (!state.suspended && state.pending) { - refreshDiagnostics(elementId); + // Joins the walk after a run, so the cells a run held back are asked in order. + refreshAfterRunQueue.add(elementId); } + if (!state.suspended) runRefreshAfterRunQueue(); }, setTheme: function (theme) { diff --git a/tests/Verso.Blazor.Shared.Tests/CellTests.cs b/tests/Verso.Blazor.Shared.Tests/CellTests.cs index 0cd6858..780a788 100644 --- a/tests/Verso.Blazor.Shared.Tests/CellTests.cs +++ b/tests/Verso.Blazor.Shared.Tests/CellTests.cs @@ -522,11 +522,39 @@ public void Diagnostics_SuspendedWhileNotebookExecutes() public void Diagnostics_RefreshedWhenOwnRunCompletes() { var cut = RenderCell(CreateCodeCell("var x = 1;"), isExecuting: true, editorCreated: true); - Assert.AreEqual(0, RefreshDiagnosticsCallCount()); + Assert.AreEqual(0, RefreshAfterRunCallCount()); cut.SetParametersAndRender(p => p.Add(c => c.IsExecuting, false)); - Assert.AreEqual(1, RefreshDiagnosticsCallCount()); + Assert.AreEqual(1, RefreshAfterRunCallCount()); + } + + [TestMethod] + public void Diagnostics_RefreshedWhenExecutionCountMovesUnseen() + { + // In the VS Code webview a quick cell's executing and executed notifications are both + // handled before the next render, so the cell is never rendered as executing. + var cell = CreateCodeCell("var x = 1;"); + var cut = RenderCell(cell, editorCreated: true); + Assert.AreEqual(0, RefreshAfterRunCallCount()); + + cell.ExecutionCount = 1; + cut.SetParametersAndRender(p => p.Add(c => c.CellData, cell)); + + Assert.AreEqual(1, RefreshAfterRunCallCount()); + } + + [TestMethod] + public void Diagnostics_NotRefreshedWhenARestartClearsTheCount() + { + var cell = CreateCodeCell("var x = 1;"); + cell.ExecutionCount = 3; + var cut = RenderCell(cell, editorCreated: true); + + cell.ExecutionCount = null; + cut.SetParametersAndRender(p => p.Add(c => c.CellData, cell)); + + Assert.AreEqual(0, RefreshAfterRunCallCount()); } [TestMethod] @@ -596,8 +624,8 @@ public async Task Diagnostics_MarkdownCell_IsNotSent() StringAssert.Contains(System.Text.Json.JsonSerializer.Serialize(result), "\"items\":[]"); } - private int RefreshDiagnosticsCallCount() - => TestContext!.JSInterop.Invocations.Count(i => i.Identifier == "versoMonaco.refreshDiagnostics"); + private int RefreshAfterRunCallCount() + => TestContext!.JSInterop.Invocations.Count(i => i.Identifier == "versoMonaco.refreshAfterRun"); private IRenderedComponent RenderCell( CellModel cell, @@ -616,6 +644,7 @@ private IRenderedComponent RenderCell( TestContext.JSInterop.SetupVoid("versoMonaco.setLanguage", _ => true); TestContext.JSInterop.SetupVoid("versoMonaco.dispose", _ => true); TestContext.JSInterop.SetupVoid("versoMonaco.refreshDiagnostics", _ => true); + TestContext.JSInterop.SetupVoid("versoMonaco.refreshAfterRun", _ => true); TestContext.JSInterop.SetupVoid("versoMonaco.clearDiagnostics", _ => true); TestContext.JSInterop.SetupVoid("versoMonaco.setDiagnosticsSuspended", _ => true); diff --git a/tests/Verso.Blazor.Shared.Tests/MonacoEditorTests.cs b/tests/Verso.Blazor.Shared.Tests/MonacoEditorTests.cs index 1d2b363..784cd8f 100644 --- a/tests/Verso.Blazor.Shared.Tests/MonacoEditorTests.cs +++ b/tests/Verso.Blazor.Shared.Tests/MonacoEditorTests.cs @@ -104,9 +104,11 @@ public async Task RefreshAndClearDiagnostics_CallInterop() var cut = Render(); await cut.InvokeAsync(() => cut.Instance.RefreshDiagnosticsAsync()); + await cut.InvokeAsync(() => cut.Instance.RefreshAfterRunAsync()); await cut.InvokeAsync(() => cut.Instance.ClearDiagnosticsAsync()); Assert.AreEqual(1, TestContext!.JSInterop.Invocations.Count(i => i.Identifier == "versoMonaco.refreshDiagnostics")); + Assert.AreEqual(1, TestContext.JSInterop.Invocations.Count(i => i.Identifier == "versoMonaco.refreshAfterRun")); Assert.AreEqual(1, TestContext.JSInterop.Invocations.Count(i => i.Identifier == "versoMonaco.clearDiagnostics")); } } From 90e145da219911bbd9eec0d2d6b5da5814378eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Sch=C3=A4fer?= Date: Wed, 21 Oct 2026 09:39:49 +0200 Subject: [PATCH 2/3] Give the line-limit note as long to appear as the editors have to lay out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the CI runners the note past a cell's line limit has taken many seconds to appear after the editors are measured, and twice not within the 10 seconds the test allowed. The editors themselves are given 60, so the note is given the same. Signed-off-by: Jonathan Schäfer --- tests/Verso.E2E.Tests/Serve/CellEditorHeightTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Verso.E2E.Tests/Serve/CellEditorHeightTests.cs b/tests/Verso.E2E.Tests/Serve/CellEditorHeightTests.cs index f9f3a4d..65f1067 100644 --- a/tests/Verso.E2E.Tests/Serve/CellEditorHeightTests.cs +++ b/tests/Verso.E2E.Tests/Serve/CellEditorHeightTests.cs @@ -73,7 +73,7 @@ public async Task CellPastTheLimit_SaysSoAndHowToRaiseIt() var notebook = _scratch.Write("tall.verso", Notebooks.VersoWithCellsOf(20, 60)); await OpenAsync(s_server, notebook); await MeasureEditorsAsync(".verso-cell", expected: 3); - await LineLimitNotes.First.WaitForAsync(ElementState.Visible, timeout: 10_000); + await LineLimitNotes.First.WaitForAsync(ElementState.Visible, timeout: 60_000); Assert.AreEqual(1, await LineLimitNotes.CountAsync(), "Only the cell past the limit should carry the note."); var note = await LineLimitNotes.First.InnerTextAsync(); From b94ec2ef18c459b01274d26d725f43e854eec095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20Sch=C3=A4fer?= Date: Fri, 23 Oct 2026 23:00:24 +0200 Subject: [PATCH 3/3] Ask again after a run only for the cells below that show markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run ended by asking again for the cell that ran and for every cell below it. For C# and F# each request is a full compilation, so in a long notebook every run was followed by one per cell below, one at a time. Now the cells below are asked only while they are showing kernel markers, which are the ones a run can visibly fix, such as a name the run has just defined. The other cells below pick up a change on their next focus, as the cells above already did. Signed-off-by: Jonathan Schäfer --- docs/architecture/front-ends.md | 2 +- docs/extensions/extension-interfaces.md | 2 +- .../Components/Editor/MonacoEditor.razor | 3 +- .../wwwroot/js/monaco-interop.js | 32 +++++++++++++------ .../Serve/EditorDiagnosticsTests.cs | 15 +++++++++ 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/architecture/front-ends.md b/docs/architecture/front-ends.md index 6cbab03..5ca5742 100644 --- a/docs/architecture/front-ends.md +++ b/docs/architecture/front-ends.md @@ -41,7 +41,7 @@ The refresh policy is set by the expensive kernels, where a request is a full co - **Edit**: debounced, 500 ms after the last change the user makes. Text set from .NET (a reload, a tool editing cells) does not ask unless that editor has focus; the next focus asks instead. - **Focus**: when a cell's editor gains focus, since variable-dependent kernels (SQL, HTTP, HTML) change their answer when another cell runs, not when this text changes. Skipped when the text is the version last answered and nothing has happened since that could change the answer (a run ending, a restart, a language change). -- **Run**: after a cell's run finishes, for that cell and every cell below it, since the run may have changed what their code refers to. They are asked top to bottom and one at a time, so the cells nearest the run update first and a run started in the meantime waits behind at most one request; a cell whose answer still holds is skipped. Cells above the run are asked on their next focus. A cell counts as run when it stops executing or when its execution count moves; in the VS Code webview a quick cell is never rendered as executing, because its executing and executed notifications are both handled before the next render. +- **Run**: after a cell's run finishes, for that cell and for every cell below it that is showing kernel markers, since the run may have changed what their code refers to and those are the markers it can visibly fix. They are asked top to bottom and one at a time, so the cells nearest the run update first and a run started in the meantime waits behind at most one request; a cell whose answer still holds is skipped. The other cells below, and the cells above, are asked on their next focus. A cell counts as run when it stops executing or when its execution count moves; in the VS Code webview a quick cell is never rendered as executing, because its executing and executed notifications are both handled before the next render. - **Restart**: markers are cleared in every cell, whichever kernel restarted, not recomputed. While any cell is running no request is issued (`NotebookCellContext.IsAnyExecuting`, passed to the editor as `DiagnosticsSuspended`). `Verso.Host` answers requests one at a time, so a request during a run would wait for the run, and some kernels answer empty while executing. A refresh asked for in that time runs once the run is over, in the same top-to-bottom order. Run All is one `execution/runAll` request, so in VS Code the cells are refreshed once it has finished, not one by one as it goes. One request is in flight per editor; a reply for text that has since changed is dropped; a failed request (null) keeps the markers that are showing, while an empty list clears them. Cells over 2,000 lines are not analyzed. diff --git a/docs/extensions/extension-interfaces.md b/docs/extensions/extension-interfaces.md index bb8c2d2..b576edc 100644 --- a/docs/extensions/extension-interfaces.md +++ b/docs/extensions/extension-interfaces.md @@ -61,7 +61,7 @@ Executes code, provides completions, diagnostics, and hover information for a sp ### Diagnostics in the Editor -What `GetDiagnosticsAsync` returns is drawn in the cell editor as squiggles. The editor asks after the user pauses typing (about half a second), when the cell's editor gains focus, and after a run, for the cell that ran and every cell below it, nearest first. It does not ask while any cell in the notebook is running, and it clears every marker in the notebook when any kernel restarts. Diagnostics are advisory: if the kernel fails to initialize or `GetDiagnosticsAsync` throws, the request goes unanswered and the editor keeps the markers it already shows (none, for a kernel that never started); the failure is reported where it matters, when the cell runs. Return an empty list, not an exception, when there is nothing to report, so fixed problems are cleared. +What `GetDiagnosticsAsync` returns is drawn in the cell editor as squiggles. The editor asks after the user pauses typing (about half a second), when the cell's editor gains focus, and after a run, for the cell that ran and every cell below it that is showing markers, nearest first. It does not ask while any cell in the notebook is running, and it clears every marker in the notebook when any kernel restarts. Diagnostics are advisory: if the kernel fails to initialize or `GetDiagnosticsAsync` throws, the request goes unanswered and the editor keeps the markers it already shows (none, for a kernel that never started); the failure is reported where it matters, when the cell runs. Return an empty list, not an exception, when there is nothing to report, so fixed problems are cleared. Leading `#!` magic command lines are blanked before the code reaches the kernel, keeping the line count, so a kernel never has to recognize them and its positions still match the cell. At run time the pipeline removes those lines instead, so a kernel that reads a directive from the cell's first line (as SQL reads `--connection`) should skip leading blank lines when it looks for that line during diagnostics. diff --git a/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor b/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor index 1d4f298..c9f418c 100644 --- a/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor +++ b/src/Verso.Blazor.Shared/Components/Editor/MonacoEditor.razor @@ -93,7 +93,8 @@ /// /// The cell has finished running: ask again for this editor's diagnostics and for every - /// editor below it, nearest first, since the run may have changed what their code refers to. + /// editor below it that is showing kernel markers, nearest first, since the run may have + /// changed what their code refers to. /// public async Task RefreshAfterRunAsync() { diff --git a/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js b/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js index b11b014..2c52a6a 100644 --- a/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js +++ b/src/Verso.Blazor.Shared/wwwroot/js/monaco-interop.js @@ -304,12 +304,14 @@ window.versoMonaco = (function () { } // ── Refresh after a run ───────────────────────────────────────────── - // A run changes what the cells after it can refer to, so when a cell's run ends it and every - // cell below it are asked again. They go top to bottom and one at a time: the cells nearest - // the run update first, and a run started in the meantime waits behind at most one request. - // Nothing goes while a cell runs; the walk resumes when the run is over. A cell whose answer - // still holds for its text and kernel state is skipped. Cells above a run are asked on their - // next focus, as before. + // A run changes what the cells after it can refer to, so when a cell's run ends it is asked + // again, and so is every cell below it that is showing kernel markers, since those are the + // ones a run can visibly fix. For C# and F# each request is a full compilation, so the other + // cells below are left to their next focus, as are the cells above. The cells go top to + // bottom and one at a time: the cells nearest the run update first, and a run started in the + // meantime waits behind at most one request. Nothing goes while a cell runs; the walk resumes + // when the run is over. A cell whose answer still holds for its text and kernel state is + // skipped. const refreshAfterRunQueue = new Set(); // elementIds still to ask let refreshAfterRunActive = false; @@ -323,12 +325,21 @@ window.versoMonaco = (function () { }); } + function showsKernelMarkers(elementId) { + const model = editors[elementId] && editors[elementId].getModel(); + return !!model && !model.isDisposed() + && monaco.editor.getModelMarkers({ owner: DIAGNOSTICS_OWNER, resource: model.uri }).length > 0; + } + function queueRefreshAfterRun(elementId) { + refreshAfterRunQueue.add(elementId); const ordered = editorsInDocumentOrder(); const start = ordered.indexOf(elementId); - (start >= 0 ? ordered.slice(start) : [elementId]).forEach(function (id) { - refreshAfterRunQueue.add(id); - }); + if (start >= 0) { + ordered.slice(start + 1).filter(showsKernelMarkers).forEach(function (id) { + refreshAfterRunQueue.add(id); + }); + } runRefreshAfterRunQueue(); } @@ -920,7 +931,8 @@ window.versoMonaco = (function () { refreshDiagnostics(elementId); }, - // This editor's cell has finished running: ask again for it and every cell below it. + // This editor's cell has finished running: ask again for it and every cell below it + // that is showing kernel markers. refreshAfterRun: function (elementId) { queueRefreshAfterRun(elementId); }, diff --git a/tests/Verso.E2E.Tests/Serve/EditorDiagnosticsTests.cs b/tests/Verso.E2E.Tests/Serve/EditorDiagnosticsTests.cs index abde90b..da69f9b 100644 --- a/tests/Verso.E2E.Tests/Serve/EditorDiagnosticsTests.cs +++ b/tests/Verso.E2E.Tests/Serve/EditorDiagnosticsTests.cs @@ -71,6 +71,21 @@ public async Task NameBoundInExecutedCell_IsNotMarked() StringAssert.Contains(markers[0].Message, "notDeclared"); } + [TestMethod] + public async Task MarkerBelowARun_IsClearedWithoutFocus() + { + await OpenAsync(); + + await SetCellSourceAsync(1, "answer + 1"); + await WaitForMarkersAsync(1, "markers.some(m => String(m.code) === 'CS0103')"); + + await SetCellSourceAsync(0, "var answer = 42;"); + await RunCellAsync(0); + + // The cell below is never focused, so only the run can have asked for it again. + await WaitForMarkersAsync(1, "markers.length === 0"); + } + [TestMethod] public async Task KernelRestart_ClearsMarkers() {