@marimo-team/islands 0.19.7-dev27 → 0.19.7-dev30

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.
package/dist/main.js CHANGED
@@ -32291,8 +32291,13 @@ ${c.sqlString}
32291
32291
  transformRange: (e2, r2) => shiftRange(e2, f(r2)),
32292
32292
  reverseRange: (e2, r2) => shiftRange(e2, -f(r2)),
32293
32293
  isInRange: (e2, d2) => {
32294
- let f2 = r[d2].split("\n").length, _2 = c.get(d2) || 0, v = e2.start.line - _2, y = e2.end.line - _2;
32295
- return v >= 0 && y < f2;
32294
+ let f2 = r[d2].split("\n").length, _2 = c.get(d2) || 0, v = e2.start.line - _2, y = e2.end.line - _2, S = v >= 0 && v < f2, w = y >= 0 && y < f2;
32295
+ return S && !w && Logger.warn("[lsp] Cross-cell diagnostic detected: range starts in cell but ends outside", {
32296
+ cellId: d2,
32297
+ range: e2,
32298
+ cellLines: f2,
32299
+ offset: _2
32300
+ }), S && w;
32296
32301
  },
32297
32302
  getEditsForNewText: (e2) => {
32298
32303
  let d2 = e2.split("\n"), f2 = _.split("\n");
@@ -32393,6 +32398,12 @@ ${c.sqlString}
32393
32398
  });
32394
32399
  }), this.snapshotter = new Snapshotter(this.getNotebookCode.bind(this));
32395
32400
  }
32401
+ static pruneSeenCellUris(r) {
32402
+ for (let c of _a.SEEN_CELL_DOCUMENT_URIS) {
32403
+ let d = CellDocumentUri.parse(c);
32404
+ r.has(d) || _a.SEEN_CELL_DOCUMENT_URIS.delete(c);
32405
+ }
32406
+ }
32396
32407
  onNotification(e) {
32397
32408
  return this.client.onNotification(e);
32398
32409
  }
@@ -32466,18 +32477,7 @@ ${c.sqlString}
32466
32477
  } : r;
32467
32478
  }
32468
32479
  async sync() {
32469
- let { lens: e, version: r, didChange: c } = this.snapshotter.snapshot();
32470
- return c ? this.client.textDocumentDidChange({
32471
- textDocument: {
32472
- uri: this.documentUri,
32473
- version: r
32474
- },
32475
- contentChanges: [
32476
- {
32477
- text: e.mergedText
32478
- }
32479
- ]
32480
- }) : {
32480
+ let { lens: e, version: r, didChange: c } = this.snapshotter.snapshot(), d = {
32481
32481
  textDocument: {
32482
32482
  uri: this.documentUri,
32483
32483
  version: r
@@ -32488,15 +32488,22 @@ ${c.sqlString}
32488
32488
  }
32489
32489
  ]
32490
32490
  };
32491
+ return c && await this.client.textDocumentDidChange(d), {
32492
+ params: d,
32493
+ lens: e
32494
+ };
32491
32495
  }
32492
32496
  async textDocumentDidChange(e) {
32493
- return Logger.debug("[lsp] textDocumentDidChange", e), e.contentChanges.length === 1 ? this.sync() : (Logger.warn("[lsp] Unhandled textDocumentDidChange", e), this.client.textDocumentDidChange(e));
32497
+ if (Logger.debug("[lsp] textDocumentDidChange", e), e.contentChanges.length === 1) {
32498
+ let { params: e2 } = await this.sync();
32499
+ return e2;
32500
+ }
32501
+ return Logger.warn("[lsp] Unhandled textDocumentDidChange", e), this.client.textDocumentDidChange(e);
32494
32502
  }
32495
32503
  async textDocumentDefinition(e) {
32496
32504
  let r = e.textDocument.uri;
32497
32505
  if (!CellDocumentUri.is(r)) return Logger.warn("Invalid cell document URI", r), null;
32498
- await this.sync();
32499
- let { lens: c } = this.snapshotter.getLatestSnapshot(), d = CellDocumentUri.parse(r);
32506
+ let { lens: c } = await this.sync(), d = CellDocumentUri.parse(r);
32500
32507
  return this.client.textDocumentDefinition({
32501
32508
  ...e,
32502
32509
  textDocument: {
@@ -32508,8 +32515,7 @@ ${c.sqlString}
32508
32515
  async textDocumentSignatureHelp(e) {
32509
32516
  let r = e.textDocument.uri;
32510
32517
  if (!CellDocumentUri.is(r)) return Logger.warn("Invalid cell document URI", r), null;
32511
- await this.sync();
32512
- let { lens: c } = this.snapshotter.getLatestSnapshot(), d = CellDocumentUri.parse(r);
32518
+ let { lens: c } = await this.sync(), d = CellDocumentUri.parse(r);
32513
32519
  return await this.client.textDocumentSignatureHelp({
32514
32520
  ...e,
32515
32521
  textDocument: {
@@ -32525,9 +32531,7 @@ ${c.sqlString}
32525
32531
  var _a2;
32526
32532
  let r = e.textDocument.uri;
32527
32533
  if (!CellDocumentUri.is(r)) return Logger.warn("Invalid cell document URI", r), null;
32528
- let c = CellDocumentUri.parse(r);
32529
- await this.sync();
32530
- let { lens: d } = this.snapshotter.getLatestSnapshot(), f = d.transformPosition(e.position, c), _ = await this.client.textDocumentRename({
32534
+ let c = CellDocumentUri.parse(r), { lens: d } = await this.sync(), f = d.transformPosition(e.position, c), _ = await this.client.textDocumentRename({
32531
32535
  ...e,
32532
32536
  textDocument: {
32533
32537
  uri: this.documentUri
@@ -32547,20 +32551,21 @@ ${c.sqlString}
32547
32551
  let r2 = O.state.doc.line(e.position.line + 1).from + e.position.character, { startToken: c2, endToken: d2 } = getPositionAtWordBounds(O.state.doc, r2), f2 = O.state.doc.sliceString(c2, d2);
32548
32552
  M = isPrivateVariable(f2), M && Logger.debug("[lsp] Private variable rename detected, limiting to current cell", f2);
32549
32553
  }
32554
+ let I = [], z = 0;
32550
32555
  for (let [e2, r2] of Objects.entries(E)) {
32551
32556
  if (M && e2 !== c) continue;
32552
32557
  let d2 = w.get(e2);
32553
32558
  if (d2 == null) {
32554
- Logger.warn("No new code for cell", e2);
32559
+ Logger.warn("[lsp] No new code for cell during rename", e2), I.push(e2);
32555
32560
  continue;
32556
32561
  }
32557
32562
  if (!r2) {
32558
- Logger.warn("No view for plugin", e2);
32563
+ Logger.warn("[lsp] No editor view for cell during rename", e2), I.push(e2);
32559
32564
  continue;
32560
32565
  }
32561
- getEditorCodeAsPython(r2) !== d2 && updateEditorCodeFromPython(r2, d2);
32566
+ getEditorCodeAsPython(r2) !== d2 && (updateEditorCodeFromPython(r2, d2), z++);
32562
32567
  }
32563
- return {
32568
+ return I.length > 0 && Logger.error(`[lsp] Rename partially failed: could not update ${I.length} cell(s)`, I), Logger.debug(`[lsp] Rename completed: updated ${z} cell(s)`), {
32564
32569
  ..._,
32565
32570
  documentChanges: [
32566
32571
  {
@@ -32582,8 +32587,7 @@ ${c.sqlString}
32582
32587
  async textDocumentPrepareRename(e) {
32583
32588
  let r = e.textDocument.uri;
32584
32589
  if (!CellDocumentUri.is(r)) return Logger.warn("Invalid cell document URI", r), null;
32585
- await this.sync();
32586
- let { lens: c } = this.snapshotter.getLatestSnapshot(), d = CellDocumentUri.parse(r);
32590
+ let { lens: c } = await this.sync(), d = CellDocumentUri.parse(r);
32587
32591
  return this.client.textDocumentPrepareRename({
32588
32592
  ...e,
32589
32593
  textDocument: {
@@ -32593,8 +32597,8 @@ ${c.sqlString}
32593
32597
  });
32594
32598
  }
32595
32599
  async textDocumentHover(e) {
32596
- Logger.debug("[lsp] textDocumentHover", e), await this.sync();
32597
- let { lens: r } = this.snapshotter.getLatestSnapshot(), c = CellDocumentUri.parse(e.textDocument.uri), d = await this.client.textDocumentHover({
32600
+ Logger.debug("[lsp] textDocumentHover", e);
32601
+ let { lens: r } = await this.sync(), c = CellDocumentUri.parse(e.textDocument.uri), d = await this.client.textDocumentHover({
32598
32602
  ...e,
32599
32603
  textDocument: {
32600
32604
  uri: this.documentUri
@@ -32631,11 +32635,13 @@ ${c.sqlString}
32631
32635
  y.get(r2).push(c3);
32632
32636
  break;
32633
32637
  }
32634
- let S = new Set(_a.SEEN_CELL_DOCUMENT_URIS);
32638
+ let S = new Set(_.cellIds);
32639
+ _a.pruneSeenCellUris(S);
32640
+ let w = new Set(_a.SEEN_CELL_DOCUMENT_URIS);
32635
32641
  for (let [e, d2] of y.entries()) {
32636
32642
  Logger.debug("[lsp] diagnostics for cell", e, d2);
32637
32643
  let f2 = CellDocumentUri.of(e);
32638
- S.delete(f2), r({
32644
+ w.delete(f2), r({
32639
32645
  ...c2,
32640
32646
  params: {
32641
32647
  ...c2.params,
@@ -32645,9 +32651,9 @@ ${c.sqlString}
32645
32651
  }
32646
32652
  });
32647
32653
  }
32648
- if (S.size > 0) {
32649
- Logger.debug("[lsp] clearing diagnostics", S);
32650
- for (let e of S) r({
32654
+ if (w.size > 0) {
32655
+ Logger.debug("[lsp] clearing diagnostics", w);
32656
+ for (let e of w) r({
32651
32657
  method: "textDocument/publishDiagnostics",
32652
32658
  params: {
32653
32659
  uri: e,
@@ -61387,7 +61393,7 @@ ${r}
61387
61393
  const defaultHtmlToImageOptions = {
61388
61394
  filter: (e) => {
61389
61395
  try {
61390
- return "classList" in e ? !e.classList.contains("mpl-toolbar") : true;
61396
+ return !("classList" in e && (e.classList.contains("mpl-toolbar") || e.classList.contains("no-print")));
61391
61397
  } catch (e2) {
61392
61398
  return Logger.error("Error filtering node:", e2), true;
61393
61399
  }
@@ -61396,7 +61402,7 @@ ${r}
61396
61402
  Logger.error("Error loading image:", e);
61397
61403
  }
61398
61404
  };
61399
- function toPng(e, r) {
61405
+ async function toPng(e, r) {
61400
61406
  return toPng$1(e, {
61401
61407
  ...defaultHtmlToImageOptions,
61402
61408
  ...r
@@ -62019,7 +62025,7 @@ Defaulting to \`null\`.`;
62019
62025
  e === "too_many" ? "Unknown" : prettifyRowCount(e, c),
62020
62026
  `${prettyNumber(r, c)} ${new PluralWord("column").pluralize(r)}`
62021
62027
  ].join(", "), TableActions = ({ enableSearch: e, onSearchQueryChange: r, isSearchEnabled: c, setIsSearchEnabled: d, pagination: f, totalColumns: _, selection: v, onRowSelectionChange: y, table: S, downloadAs: E, getRowIds: O, toggleDisplayHeader: M, showChartBuilder: I, showColumnExplorer: z, showRowExplorer: G, showPageSizeSelector: q, togglePanel: IY, isPanelOpen: LY, tableLoading: RY }) => (0, import_jsx_runtime.jsxs)("div", {
62022
- className: "flex items-center shrink-0 pt-1",
62028
+ className: "flex items-center shrink-0 pt-1 no-print",
62023
62029
  children: [
62024
62030
  r && e && (0, import_jsx_runtime.jsx)(Tooltip, {
62025
62031
  content: "Search",
@@ -73076,7 +73082,7 @@ Image URL: ${r.imageUrl}`)), contextToXml({
73076
73082
  return Logger.warn("Failed to get version from mount config"), null;
73077
73083
  }
73078
73084
  }
73079
- const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-dev27"), showCodeInRunModeAtom = atom(true);
73085
+ const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.7-dev30"), showCodeInRunModeAtom = atom(true);
73080
73086
  atom(null);
73081
73087
  var import_compiler_runtime$88 = require_compiler_runtime();
73082
73088
  function useKeydownOnElement(e, r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.19.7-dev27",
3
+ "version": "0.19.7-dev30",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -107,7 +107,7 @@ export const TableActions = <TData,>({
107
107
  };
108
108
 
109
109
  return (
110
- <div className="flex items-center shrink-0 pt-1">
110
+ <div className="flex items-center shrink-0 pt-1 no-print">
111
111
  {onSearchQueryChange && enableSearch && (
112
112
  <Tooltip content="Search">
113
113
  <Button
@@ -0,0 +1,124 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import { fireEvent, render, waitFor } from "@testing-library/react";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { FilenameForm } from "../filename-form";
7
+
8
+ // Mock the hooks
9
+ vi.mock("@/core/saving/filename", () => ({
10
+ useUpdateFilename: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("@/core/saving/save-component", () => ({
14
+ useSaveNotebook: vi.fn(),
15
+ }));
16
+
17
+ // Mock FilenameInput to make testing easier
18
+ vi.mock("@/components/editor/header/filename-input", () => ({
19
+ FilenameInput: ({
20
+ onNameChange,
21
+ initialValue,
22
+ }: {
23
+ onNameChange: (value: string) => void;
24
+ initialValue?: string | null;
25
+ }) => (
26
+ <input
27
+ data-testid="filename-input"
28
+ data-initial-value={initialValue || ""}
29
+ onChange={(e) => onNameChange(e.target.value)}
30
+ />
31
+ ),
32
+ }));
33
+
34
+ const mockUseUpdateFilename = vi.mocked(
35
+ await import("@/core/saving/filename"),
36
+ ).useUpdateFilename;
37
+
38
+ const mockUseSaveNotebook = vi.mocked(
39
+ await import("@/core/saving/save-component"),
40
+ ).useSaveNotebook;
41
+
42
+ describe("FilenameForm", () => {
43
+ const mockUpdateFilename = vi.fn();
44
+ const mockSaveNotebook = vi.fn();
45
+ const mockSaveOrNameNotebook = vi.fn();
46
+ const mockSaveIfNotebookIsPersistent = vi.fn();
47
+
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+
51
+ mockUseUpdateFilename.mockReturnValue(mockUpdateFilename);
52
+ mockUseSaveNotebook.mockReturnValue({
53
+ saveNotebook: mockSaveNotebook,
54
+ saveOrNameNotebook: mockSaveOrNameNotebook,
55
+ saveIfNotebookIsPersistent: mockSaveIfNotebookIsPersistent,
56
+ });
57
+ });
58
+
59
+ it("should call saveNotebook when creating a new file from an unnamed notebook", async () => {
60
+ // Setup: updateFilename resolves with the new filename
61
+ mockUpdateFilename.mockResolvedValue("/path/to/new-file.py");
62
+
63
+ const { getByTestId } = render(<FilenameForm filename={null} />);
64
+
65
+ const input = getByTestId("filename-input");
66
+
67
+ // Simulate name change (user entering a filename)
68
+ fireEvent.change(input, { target: { value: "new-file.py" } });
69
+
70
+ // Wait for the promise to resolve
71
+ await waitFor(() => {
72
+ expect(mockUpdateFilename).toHaveBeenCalledWith("new-file.py");
73
+ });
74
+
75
+ await waitFor(() => {
76
+ expect(mockSaveNotebook).toHaveBeenCalledWith(
77
+ "/path/to/new-file.py",
78
+ true,
79
+ );
80
+ });
81
+ });
82
+
83
+ it("should not call saveNotebook when renaming an existing file", async () => {
84
+ // Setup: updateFilename resolves with the new filename
85
+ mockUpdateFilename.mockResolvedValue("/path/to/renamed-file.py");
86
+
87
+ const { getByTestId } = render(
88
+ <FilenameForm filename="/path/to/existing-file.py" />,
89
+ );
90
+
91
+ const input = getByTestId("filename-input");
92
+
93
+ // Simulate name change
94
+ fireEvent.change(input, { target: { value: "renamed-file.py" } });
95
+
96
+ // Wait for the promise to resolve
97
+ await waitFor(() => {
98
+ expect(mockUpdateFilename).toHaveBeenCalledWith("renamed-file.py");
99
+ });
100
+
101
+ // saveNotebook should NOT be called because the file was already named
102
+ expect(mockSaveNotebook).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("should not call saveNotebook when updateFilename returns null", async () => {
106
+ // Setup: updateFilename resolves with null (e.g., user cancelled or error)
107
+ mockUpdateFilename.mockResolvedValue(null);
108
+
109
+ const { getByTestId } = render(<FilenameForm filename={null} />);
110
+
111
+ const input = getByTestId("filename-input");
112
+
113
+ // Simulate name change
114
+ fireEvent.change(input, { target: { value: "new-file.py" } });
115
+
116
+ // Wait for the promise to resolve
117
+ await waitFor(() => {
118
+ expect(mockUpdateFilename).toHaveBeenCalled();
119
+ });
120
+
121
+ // saveNotebook should NOT be called because updateFilename returned null
122
+ expect(mockSaveNotebook).not.toHaveBeenCalled();
123
+ });
124
+ });
@@ -25,6 +25,30 @@ const Cells = {
25
25
  };
26
26
 
27
27
  describe("createNotebookLens", () => {
28
+ it("should produce correct lens for same inputs", () => {
29
+ // Use unique content for this test
30
+ const cellIds: CellId[] = [Cells.cell1, Cells.cell2];
31
+ const codes: Record<CellId, string> = {
32
+ [Cells.cell1]: "unique_memo_test_line1",
33
+ [Cells.cell2]: "unique_memo_test_line2",
34
+ };
35
+
36
+ const lens1 = createNotebookLens(cellIds, codes);
37
+ const lens2 = createNotebookLens(cellIds, codes);
38
+
39
+ // Same inputs should produce equivalent lens (same merged text)
40
+ expect(lens1.mergedText).toBe(lens2.mergedText);
41
+ expect(lens1.cellIds).toEqual(lens2.cellIds);
42
+
43
+ // Different content should produce a different lens
44
+ const differentCodes: Record<CellId, string> = {
45
+ [Cells.cell1]: "unique_memo_different",
46
+ [Cells.cell2]: "unique_memo_content",
47
+ };
48
+ const lens3 = createNotebookLens(cellIds, differentCodes);
49
+ expect(lens3.mergedText).not.toBe(lens1.mergedText);
50
+ });
51
+
28
52
  it("should calculate correct line offsets", () => {
29
53
  const cellIds: CellId[] = [Cells.cell1, Cells.cell2, Cells.cell3];
30
54
  const codes: Record<CellId, string> = {
@@ -111,6 +135,7 @@ describe("createNotebookLens", () => {
111
135
  ),
112
136
  ).toBe(true);
113
137
 
138
+ // This triggers the cross-cell diagnostic warning (start in range, end outside)
114
139
  expect(
115
140
  lens.isInRange(
116
141
  {
@@ -122,6 +147,24 @@ describe("createNotebookLens", () => {
122
147
  ).toBe(false);
123
148
  });
124
149
 
150
+ it("should detect cross-cell diagnostics where start is in range but end is outside", () => {
151
+ const cellIds: CellId[] = [Cells.cell1, Cells.cell2];
152
+ const codes: Record<CellId, string> = {
153
+ [Cells.cell1]: "line1\nline2",
154
+ [Cells.cell2]: "line3",
155
+ };
156
+ const lens = createNotebookLens(cellIds, codes);
157
+
158
+ // Range that starts in cell1 (line 1) but ends in cell2 (line 2 = first line of cell2)
159
+ // This should return false and log a warning
160
+ const crossCellRange = {
161
+ start: { line: 1, character: 0 }, // line 1 is in cell1
162
+ end: { line: 2, character: 0 }, // line 2 is in cell2
163
+ };
164
+
165
+ expect(lens.isInRange(crossCellRange, Cells.cell1)).toBe(false);
166
+ });
167
+
125
168
  it("should join all code into merged text", () => {
126
169
  const cellIds: CellId[] = [Cells.cell1, Cells.cell2, Cells.cell3];
127
170
  const codes: Record<CellId, string> = {
@@ -1120,6 +1163,83 @@ describe("NotebookLanguageServerClient", () => {
1120
1163
  });
1121
1164
  });
1122
1165
 
1166
+ describe("sync returns lens to prevent race conditions", () => {
1167
+ it("should return the lens used for synchronization", async () => {
1168
+ mockClient.textDocumentDidChange = vi
1169
+ .fn()
1170
+ .mockImplementation((params) => params);
1171
+
1172
+ const result = await notebookClient.sync();
1173
+
1174
+ // sync() should return both params and the lens
1175
+ expect(result).toHaveProperty("params");
1176
+ expect(result).toHaveProperty("lens");
1177
+ expect(result.lens.cellIds).toEqual([
1178
+ Cells.cell1,
1179
+ Cells.cell2,
1180
+ Cells.cell3,
1181
+ ]);
1182
+ expect(result.lens.mergedText).toContain("# this is a comment");
1183
+ });
1184
+
1185
+ it("should return consistent lens even when called multiple times rapidly", async () => {
1186
+ mockClient.textDocumentDidChange = vi
1187
+ .fn()
1188
+ .mockImplementation((params) => params);
1189
+
1190
+ // Call sync multiple times rapidly
1191
+ const [result1, result2, result3] = await Promise.all([
1192
+ notebookClient.sync(),
1193
+ notebookClient.sync(),
1194
+ notebookClient.sync(),
1195
+ ]);
1196
+
1197
+ // All should return the same lens content (memoized)
1198
+ expect(result1.lens.mergedText).toBe(result2.lens.mergedText);
1199
+ expect(result2.lens.mergedText).toBe(result3.lens.mergedText);
1200
+ });
1201
+ });
1202
+
1203
+ describe("SEEN_CELL_DOCUMENT_URIS memory management", () => {
1204
+ it("should track opened cells in SEEN_CELL_DOCUMENT_URIS", async () => {
1205
+ // Clear any existing state
1206
+ const seenUris = (NotebookLanguageServerClient as any)
1207
+ .SEEN_CELL_DOCUMENT_URIS;
1208
+ seenUris.clear();
1209
+
1210
+ // Open some cells to add them to SEEN_CELL_DOCUMENT_URIS
1211
+ await notebookClient.textDocumentDidOpen({
1212
+ textDocument: {
1213
+ uri: CellDocumentUri.of(Cells.cell1),
1214
+ languageId: "python",
1215
+ version: 1,
1216
+ text: "code1",
1217
+ },
1218
+ });
1219
+
1220
+ await notebookClient.textDocumentDidOpen({
1221
+ textDocument: {
1222
+ uri: CellDocumentUri.of(Cells.cell2),
1223
+ languageId: "python",
1224
+ version: 1,
1225
+ text: "code2",
1226
+ },
1227
+ });
1228
+
1229
+ // Verify cells were added
1230
+ expect(seenUris.has(CellDocumentUri.of(Cells.cell1))).toBe(true);
1231
+ expect(seenUris.has(CellDocumentUri.of(Cells.cell2))).toBe(true);
1232
+ expect(seenUris.size).toBe(2);
1233
+ });
1234
+
1235
+ it("should have pruneSeenCellUris static method", () => {
1236
+ // Verify the method exists and is callable
1237
+ expect(
1238
+ typeof (NotebookLanguageServerClient as any).pruneSeenCellUris,
1239
+ ).toBe("function");
1240
+ });
1241
+ });
1242
+
1123
1243
  describe("initialization and configuration", () => {
1124
1244
  it("should send configuration after initialization", async () => {
1125
1245
  const configNotifications: any[] = [];
@@ -68,12 +68,28 @@ export function createNotebookLens(
68
68
  reverseRange: (range: LSP.Range, cellId: CellId) =>
69
69
  shiftRange(range, -getCurrentLineOffset(cellId)),
70
70
 
71
+ /**
72
+ * Check if a range falls entirely within the given cell.
73
+ * Returns false for ranges that span multiple cells (cross-cell diagnostics).
74
+ */
71
75
  isInRange: (range: LSP.Range, cellId: CellId) => {
72
76
  const cellLines = codes[cellId].split("\n").length;
73
77
  const offset = cellLineOffsets.get(cellId) || 0;
74
78
  const startLine = range.start.line - offset;
75
79
  const endLine = range.end.line - offset;
76
- return startLine >= 0 && endLine < cellLines;
80
+
81
+ const startInRange = startLine >= 0 && startLine < cellLines;
82
+ const endInRange = endLine >= 0 && endLine < cellLines;
83
+
84
+ // Warn about cross-cell diagnostics that start in this cell but end outside
85
+ if (startInRange && !endInRange) {
86
+ Logger.warn(
87
+ "[lsp] Cross-cell diagnostic detected: range starts in cell but ends outside",
88
+ { cellId, range, cellLines, offset },
89
+ );
90
+ }
91
+
92
+ return startInRange && endInRange;
77
93
  },
78
94
 
79
95
  getEditsForNewText: (newText: string) => {
@@ -113,8 +113,28 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
113
113
  EditorView | null | undefined
114
114
  >;
115
115
  private readonly initialSettings: Record<string, unknown>;
116
+ /**
117
+ * Tracks which cell document URIs have been opened with the LSP server.
118
+ * Used to clear diagnostics for cells that no longer have any.
119
+ *
120
+ * This set is pruned when diagnostics are processed to only include
121
+ * cells that exist in the current notebook snapshot.
122
+ */
116
123
  private static readonly SEEN_CELL_DOCUMENT_URIS = new Set<CellDocumentUri>();
117
124
 
125
+ /**
126
+ * Remove cell URIs that are no longer in the notebook.
127
+ * Called during diagnostic processing to prevent memory leaks.
128
+ */
129
+ private static pruneSeenCellUris(currentCellIds: Set<CellId>): void {
130
+ for (const uri of NotebookLanguageServerClient.SEEN_CELL_DOCUMENT_URIS) {
131
+ const cellId = CellDocumentUri.parse(uri);
132
+ if (!currentCellIds.has(cellId)) {
133
+ NotebookLanguageServerClient.SEEN_CELL_DOCUMENT_URIS.delete(uri);
134
+ }
135
+ }
136
+ }
137
+
118
138
  /**
119
139
  * Cache of completion items to avoid jitter while typing in the same completion item
120
140
  */
@@ -274,26 +294,30 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
274
294
  };
275
295
  }
276
296
 
277
- async sync(): Promise<LSP.DidChangeTextDocumentParams> {
297
+ /**
298
+ * Synchronize the document with the LSP server and return the lens used.
299
+ * This ensures the caller uses the same lens that was sent to the server,
300
+ * avoiding race conditions if cells change between sync and subsequent operations.
301
+ */
302
+ async sync(): Promise<{
303
+ params: LSP.DidChangeTextDocumentParams;
304
+ lens: NotebookLens;
305
+ }> {
278
306
  const { lens, version, didChange } = this.snapshotter.snapshot();
279
- if (!didChange) {
280
- return {
281
- textDocument: {
282
- uri: this.documentUri,
283
- version: version,
284
- },
285
- contentChanges: [{ text: lens.mergedText }],
286
- };
287
- }
288
-
289
- // Update changes for merged doc, etc.
290
- return this.client.textDocumentDidChange({
307
+ const params: LSP.DidChangeTextDocumentParams = {
291
308
  textDocument: {
292
309
  uri: this.documentUri,
293
310
  version: version,
294
311
  },
295
312
  contentChanges: [{ text: lens.mergedText }],
296
- });
313
+ };
314
+
315
+ if (didChange) {
316
+ // Update changes for merged doc
317
+ await this.client.textDocumentDidChange(params);
318
+ }
319
+
320
+ return { params, lens };
297
321
  }
298
322
 
299
323
  public async textDocumentDidChange(params: LSP.DidChangeTextDocumentParams) {
@@ -301,7 +325,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
301
325
  // We know how to only handle single content changes
302
326
  // But that is all we expect to receive
303
327
  if (params.contentChanges.length === 1) {
304
- return this.sync();
328
+ const { params: syncParams } = await this.sync();
329
+ return syncParams;
305
330
  }
306
331
 
307
332
  Logger.warn("[lsp] Unhandled textDocumentDidChange", params);
@@ -319,9 +344,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
319
344
  return null;
320
345
  }
321
346
 
322
- // This LSP method has no version, so lets sync and then get the latest snapshot
323
- await this.sync();
324
- const { lens } = this.snapshotter.getLatestSnapshot();
347
+ // Sync and use the same lens that was sent to the server
348
+ const { lens } = await this.sync();
325
349
 
326
350
  // Find the lens for this cell
327
351
  const cellId = CellDocumentUri.parse(cellDocumentUri);
@@ -345,9 +369,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
345
369
  return null;
346
370
  }
347
371
 
348
- // This LSP method has no version, so lets sync and then get the latest snapshot
349
- await this.sync();
350
- const { lens } = this.snapshotter.getLatestSnapshot();
372
+ // Sync and use the same lens that was sent to the server
373
+ const { lens } = await this.sync();
351
374
 
352
375
  const cellId = CellDocumentUri.parse(cellDocumentUri);
353
376
 
@@ -367,6 +390,23 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
367
390
  return response;
368
391
  }
369
392
 
393
+ /**
394
+ * Code actions are currently disabled because mapping code action edits
395
+ * back to individual cells is complex and error-prone.
396
+ *
397
+ * The LSP server returns edits in merged document coordinates, but we need
398
+ * to apply them to individual cell editors. Unlike simple position transforms
399
+ * (hover, completion), code actions can include arbitrary text edits that
400
+ * may span multiple cells or change line counts, making the reverse mapping
401
+ * unreliable.
402
+ *
403
+ * To enable this, we would need to:
404
+ * 1. Transform edit ranges from merged doc coordinates to cell coordinates
405
+ * 2. Handle edits that span cell boundaries (split or reject them)
406
+ * 3. Apply edits atomically across multiple cell editors
407
+ *
408
+ * See textDocumentRename for a similar workaround that manually applies edits.
409
+ */
370
410
  textDocumentCodeAction(
371
411
  params: LSP.CodeActionParams,
372
412
  ): Promise<(LSP.Command | LSP.CodeAction)[] | null> {
@@ -378,12 +418,23 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
378
418
  }
379
419
 
380
420
  /**
381
- * HACK
382
- * This whole function is a hack to work around the fact that we don't have
383
- * a great way to map the edits back to the appropriate LSP view plugin.
421
+ * Rename implementation with manual edit application.
422
+ *
423
+ * This is a workaround because the standard LSP rename flow doesn't work well
424
+ * with our notebook architecture. The LSP server returns a WorkspaceEdit with
425
+ * edits in merged document coordinates, but CodeMirror's LSP plugin expects
426
+ * edits for individual cell documents.
427
+ *
428
+ * Instead of trying to transform the WorkspaceEdit (which is complex because
429
+ * edits can span cells, change line counts, etc.), we:
430
+ * 1. Request the rename from the LSP server
431
+ * 2. Extract the new merged text from the response
432
+ * 3. Split it back into per-cell text using the lens
433
+ * 4. Manually update each cell's editor
384
434
  *
385
- * Instead, we parse out the new text from the response and then update the
386
- * code in the plugins manually, instead of using the LSP.
435
+ * This approach is simpler and more reliable, though it bypasses the normal
436
+ * LSP edit application flow. The trade-off is that we lose undo grouping
437
+ * across cells.
387
438
  */
388
439
  async textDocumentRename(
389
440
  params: LSP.RenameParams,
@@ -397,9 +448,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
397
448
 
398
449
  const cellId = CellDocumentUri.parse(cellDocumentUri);
399
450
 
400
- // This LSP method has no version, so lets sync and then get the latest snapshot
401
- await this.sync();
402
- const { lens } = this.snapshotter.getLatestSnapshot();
451
+ // Sync and use the same lens that was sent to the server
452
+ const { lens } = await this.sync();
403
453
 
404
454
  const transformedPosition = lens.transformPosition(params.position, cellId);
405
455
 
@@ -468,6 +518,9 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
468
518
  }
469
519
  }
470
520
 
521
+ const failedCells: CellId[] = [];
522
+ let updatedCount = 0;
523
+
471
524
  for (const [currentCellId, ev] of Objects.entries(editors)) {
472
525
  // For private variable renames, only update the originating cell
473
526
  if (isPrivateRename && currentCellId !== cellId) {
@@ -476,12 +529,17 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
476
529
 
477
530
  const newCode = editsToNewCode.get(currentCellId);
478
531
  if (newCode == null) {
479
- Logger.warn("No new code for cell", currentCellId);
532
+ Logger.warn("[lsp] No new code for cell during rename", currentCellId);
533
+ failedCells.push(currentCellId);
480
534
  continue;
481
535
  }
482
536
 
483
537
  if (!ev) {
484
- Logger.warn("No view for plugin", currentCellId);
538
+ Logger.warn(
539
+ "[lsp] No editor view for cell during rename",
540
+ currentCellId,
541
+ );
542
+ failedCells.push(currentCellId);
485
543
  continue;
486
544
  }
487
545
 
@@ -489,9 +547,19 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
489
547
  const code = getEditorCodeAsPython(ev);
490
548
  if (code !== newCode) {
491
549
  updateEditorCodeFromPython(ev, newCode);
550
+ updatedCount++;
492
551
  }
493
552
  }
494
553
 
554
+ if (failedCells.length > 0) {
555
+ Logger.error(
556
+ `[lsp] Rename partially failed: could not update ${failedCells.length} cell(s)`,
557
+ failedCells,
558
+ );
559
+ }
560
+
561
+ Logger.debug(`[lsp] Rename completed: updated ${updatedCount} cell(s)`);
562
+
495
563
  return {
496
564
  ...response,
497
565
  documentChanges: [
@@ -531,9 +599,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
531
599
  return null;
532
600
  }
533
601
 
534
- // This LSP method has no version, so lets sync and then get the latest snapshot
535
- await this.sync();
536
- const { lens } = this.snapshotter.getLatestSnapshot();
602
+ // Sync and use the same lens that was sent to the server
603
+ const { lens } = await this.sync();
537
604
 
538
605
  const cellId = CellDocumentUri.parse(cellDocumentUri);
539
606
 
@@ -549,9 +616,8 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
549
616
  public async textDocumentHover(params: LSP.HoverParams) {
550
617
  Logger.debug("[lsp] textDocumentHover", params);
551
618
 
552
- // This LSP method has no version, so lets sync and then get the latest snapshot
553
- await this.sync();
554
- const { lens } = this.snapshotter.getLatestSnapshot();
619
+ // Sync and use the same lens that was sent to the server
620
+ const { lens } = await this.sync();
555
621
 
556
622
  const cellId = CellDocumentUri.parse(params.textDocument.uri);
557
623
 
@@ -655,6 +721,10 @@ export class NotebookLanguageServerClient implements ILanguageServerClient {
655
721
  }
656
722
  }
657
723
 
724
+ // Prune any cell URIs for cells that no longer exist
725
+ const currentCellIds = new Set(lens.cellIds);
726
+ NotebookLanguageServerClient.pruneSeenCellUris(currentCellIds);
727
+
658
728
  const cellsToClear = new Set(
659
729
  NotebookLanguageServerClient.SEEN_CELL_DOCUMENT_URIS,
660
730
  );
@@ -94,6 +94,8 @@ function prepareCellElementForScreenshot(
94
94
  };
95
95
  }
96
96
 
97
+ const THRESHOLD_TIME_MS = 500;
98
+
97
99
  /**
98
100
  * Capture a cell output as a PNG data URL.
99
101
  *
@@ -119,7 +121,17 @@ export async function getImageDataUrlForCell(
119
121
  const cleanup = prepareCellElementForScreenshot(element, enablePrintMode);
120
122
 
121
123
  try {
122
- return await toPng(element);
124
+ const startTime = Date.now();
125
+ const dataUrl = await toPng(element);
126
+ const timeTaken = Date.now() - startTime;
127
+ if (timeTaken > THRESHOLD_TIME_MS) {
128
+ Logger.debug(
129
+ "toPng operation for element",
130
+ element,
131
+ `took ${timeTaken} ms (exceeds threshold)`,
132
+ );
133
+ }
134
+ return dataUrl;
123
135
  } finally {
124
136
  cleanup();
125
137
  }
@@ -13,7 +13,13 @@ export const defaultHtmlToImageOptions: HtmlToImageOptions = {
13
13
  try {
14
14
  if ("classList" in node) {
15
15
  // Filter out matplotlib toolbars
16
- return !node.classList.contains("mpl-toolbar");
16
+ if (node.classList.contains("mpl-toolbar")) {
17
+ return false;
18
+ }
19
+
20
+ if (node.classList.contains("no-print")) {
21
+ return false;
22
+ }
17
23
  }
18
24
  return true;
19
25
  } catch (error) {
@@ -30,7 +36,7 @@ export const defaultHtmlToImageOptions: HtmlToImageOptions = {
30
36
  * Convert an HTML element to a PNG data URL.
31
37
  * This is a wrapper around html-to-image's toPng with default options applied.
32
38
  */
33
- export function toPng(
39
+ export async function toPng(
34
40
  element: HTMLElement,
35
41
  options?: HtmlToImageOptions,
36
42
  ): Promise<string> {