@elabs-ai/components-editor 4.1.0 → 4.2.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.
Files changed (45) hide show
  1. package/dist/{chunk-C62O7IOQ.js → chunk-FO5S3YZM.js} +241 -158
  2. package/dist/chunk-FO5S3YZM.js.map +1 -0
  3. package/dist/index.d.ts +5 -4
  4. package/dist/index.js +57 -39
  5. package/dist/index.js.map +1 -1
  6. package/dist/markdown/index.d.ts +2 -2
  7. package/dist/markdown/index.js +264 -187
  8. package/dist/markdown/index.js.map +1 -1
  9. package/dist/{markdown-editor-CBp_4eDv.d.ts → markdown-editor-Dn-0L_MM.d.ts} +8 -1
  10. package/dist/monaco.d.ts +2 -0
  11. package/dist/monaco.js +9 -0
  12. package/dist/monaco.js.map +1 -0
  13. package/package.json +9 -5
  14. package/src/ai-objects/decision-card.tsx +15 -9
  15. package/src/ai-objects/knowledge-card.tsx +18 -9
  16. package/src/barrel-monaco-lazy.test.ts +50 -0
  17. package/src/calc-block/calc-block.tsx +10 -4
  18. package/src/code-editor/code-editor.test.tsx +141 -14
  19. package/src/code-editor/code-editor.tsx +166 -35
  20. package/src/code-workspace/code-workspace.test.tsx +31 -12
  21. package/src/copy-button/copy-button.tsx +6 -3
  22. package/src/diff-editor/diff-editor.test.tsx +9 -3
  23. package/src/diff-editor/diff-editor.tsx +47 -23
  24. package/src/editor-toolbar/editor-toolbar.tsx +8 -2
  25. package/src/index.ts +4 -3
  26. package/src/markdown-academic/citations.tsx +14 -7
  27. package/src/markdown-academic/footnotes.tsx +1 -1
  28. package/src/markdown-academic/math.tsx +7 -4
  29. package/src/markdown-editor/completions/completions-menu.tsx +5 -2
  30. package/src/markdown-editor/directive-views.tsx +33 -19
  31. package/src/markdown-editor/slash/slash-menu.tsx +5 -2
  32. package/src/markdown-editor/table-view.tsx +28 -15
  33. package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
  34. package/src/markdown-iteration/template-dialog.tsx +25 -7
  35. package/src/markdown-outline/document-outline.tsx +6 -2
  36. package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
  37. package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
  38. package/src/markdown-workspace/markdown-workspace.test.tsx +22 -3
  39. package/src/markdown-workspace/markdown-workspace.tsx +6 -3
  40. package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
  41. package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
  42. package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
  43. package/src/mermaid-diagram/mermaid-viewer.tsx +21 -20
  44. package/src/monaco.ts +21 -0
  45. package/dist/chunk-C62O7IOQ.js.map +0 -1
@@ -9,6 +9,13 @@ import { Command } from '@milkdown/kit/prose/state';
9
9
  type MonacoCodeEditor = monaco.editor.IStandaloneCodeEditor;
10
10
  /** A Monaco editor action (system command + optional hotkey + palette/menu entry). */
11
11
  type EditorAction = monaco.editor.IActionDescriptor;
12
+ /**
13
+ * The runtime value type handed back by `import("monaco-editor")` — same
14
+ * namespace as the type-only `monaco` import above (`typeof` a type-only
15
+ * namespace import resolves to the module's own type), used for `monacoRef`
16
+ * and `onMount`'s second argument.
17
+ */
18
+ type MonacoNamespace = typeof monaco;
12
19
  interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
13
20
  /** Controlled content. Pair with `onChange`. */
14
21
  value?: string;
@@ -44,7 +51,7 @@ interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange
44
51
  */
45
52
  contextMenu?: "brand" | "monaco" | "none";
46
53
  /** Called once the editor instance + monaco namespace are ready. */
47
- onMount?: (editor: MonacoCodeEditor, monacoApi: typeof monaco) => void;
54
+ onMount?: (editor: MonacoCodeEditor, monacoApi: MonacoNamespace) => void;
48
55
  /**
49
56
  * Declarative Monaco editor actions — each registers a command (run on its
50
57
  * `keybindings`, in the command palette, and optionally the context menu via
@@ -0,0 +1,2 @@
1
+ import * as monaco from 'monaco-editor';
2
+ export { monaco };
package/dist/monaco.js ADDED
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/monaco.ts
5
+ import * as monaco from "monaco-editor";
6
+ export {
7
+ monaco
8
+ };
9
+ //# sourceMappingURL=monaco.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/monaco.ts"],"sourcesContent":["\"use client\";\n\n/**\n * The `monaco-editor` namespace, as its own subpath (ADR 0006 subpath exports).\n *\n * The root barrel (`.`) exports lightweight chrome — `CopyButton`,\n * `EDITOR_LANGUAGES`, `languageLabel` — that a consumer should be able to\n * import WITHOUT pulling Monaco in at all: the editing surfaces\n * (`CodeEditor`, `DiffEditor`, `MarkdownEditor`) already own lazy-loading the\n * engine themselves. Re-exporting the whole `monaco-editor` namespace from\n * that SAME barrel defeated that split — `import { CopyButton } from\n * \"@elabs-ai/components-editor\"` pulled megabytes of Monaco and touched\n * browser globals at import time, breaking SSR/RSC for a component that never\n * renders the editor.\n *\n * A consumer that needs to build a custom editor, or wire Monaco commands\n * directly, imports this subpath instead:\n *\n * import { monaco } from \"@elabs-ai/components-editor/monaco\";\n */\nexport * as monaco from \"monaco-editor\";\n"],"mappings":";;;;AAoBA,YAAY,YAAY;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elabs-ai/components-editor",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,6 +36,10 @@
36
36
  "./monaco-environment": {
37
37
  "types": "./dist/lib/monaco-environment.d.ts",
38
38
  "default": "./dist/lib/monaco-environment.js"
39
+ },
40
+ "./monaco": {
41
+ "types": "./dist/monaco.d.ts",
42
+ "default": "./dist/monaco.js"
39
43
  }
40
44
  },
41
45
  "publishConfig": {
@@ -65,8 +69,8 @@
65
69
  "monaco-editor": "^0.55.1",
66
70
  "react": "^18.2.0 || ^19.0.0",
67
71
  "react-dom": "^18.2.0 || ^19.0.0",
68
- "@elabs-ai/components-tokens": "4.1.0",
69
- "@elabs-ai/components-ui": "4.1.0"
72
+ "@elabs-ai/components-tokens": "4.2.0",
73
+ "@elabs-ai/components-ui": "4.2.0"
70
74
  },
71
75
  "devDependencies": {
72
76
  "@testing-library/jest-dom": "^6.6.3",
@@ -86,9 +90,9 @@
86
90
  "typescript": "^5.7.3",
87
91
  "vitest": "^3.0.2",
88
92
  "@elabs-ai/components-eslint-config": "0.1.0",
89
- "@elabs-ai/components-tokens": "4.1.0",
93
+ "@elabs-ai/components-tokens": "4.2.0",
90
94
  "@elabs-ai/components-typescript-config": "0.1.0",
91
- "@elabs-ai/components-ui": "4.1.0"
95
+ "@elabs-ai/components-ui": "4.2.0"
92
96
  },
93
97
  "scripts": {
94
98
  "build": "rm -rf dist && tsup && node ../../scripts/link-dist-css.mjs",
@@ -24,6 +24,7 @@ import {
24
24
  CardContent,
25
25
  CardHeader,
26
26
  Separator,
27
+ useLocale,
27
28
  type BadgeProps,
28
29
  } from "@elabs-ai/components-ui";
29
30
  import { cn } from "@elabs-ai/components-ui/lib/cn";
@@ -51,11 +52,12 @@ const STATUS_BADGE_VARIANT: Record<DecisionStatus, BadgeProps["variant"]> = {
51
52
  superseded: "secondary",
52
53
  };
53
54
 
54
- const STATUS_LABELS: Record<DecisionStatus, string> = {
55
- accepted: "Accepted",
56
- rejected: "Rejected",
57
- proposed: "Proposed",
58
- superseded: "Superseded",
55
+ /** Status translation key (see `editor.decisionCard.status.*` in messages.ts). */
56
+ const STATUS_LABEL_KEYS: Record<DecisionStatus, string> = {
57
+ accepted: "editor.decisionCard.statusAccepted",
58
+ rejected: "editor.decisionCard.statusRejected",
59
+ proposed: "editor.decisionCard.statusProposed",
60
+ superseded: "editor.decisionCard.statusSuperseded",
59
61
  };
60
62
 
61
63
  const STATUS_ICONS: Record<DecisionStatus, ComponentType<SVGProps<SVGSVGElement>>> = {
@@ -113,11 +115,12 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
113
115
  { status: rawStatus, date, alternatives, children, className, ...props },
114
116
  ref,
115
117
  ) {
118
+ const { t } = useLocale();
116
119
  const status: DecisionStatus = isDecisionStatus(rawStatus ?? "")
117
120
  ? (rawStatus as DecisionStatus)
118
121
  : "proposed";
119
122
  const badgeVariant = STATUS_BADGE_VARIANT[status];
120
- const label = STATUS_LABELS[status];
123
+ const label = t(STATUS_LABEL_KEYS[status]);
121
124
  const Icon = STATUS_ICONS[status];
122
125
 
123
126
  const altItems = alternatives
@@ -130,7 +133,7 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
130
133
  return (
131
134
  <section
132
135
  ref={ref}
133
- aria-label={`Decision: ${label}`}
136
+ aria-label={t("editor.decisionCard.label", { label })}
134
137
  className={cn("not-prose", className)}
135
138
  {...props}
136
139
  >
@@ -158,9 +161,12 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
158
161
  <Separator />
159
162
  <div className="px-6 py-4">
160
163
  <p className="mb-2 text-meta font-medium text-muted-foreground">
161
- Alternatives considered
164
+ {t("editor.decisionCard.alternativesConsidered")}
162
165
  </p>
163
- <ul className="flex flex-wrap gap-1.5" aria-label="Alternatives considered">
166
+ <ul
167
+ className="flex flex-wrap gap-1.5"
168
+ aria-label={t("editor.decisionCard.alternativesConsidered")}
169
+ >
164
170
  {altItems.map((alt) => (
165
171
  <li key={alt}>
166
172
  <Badge variant="outline" className="text-meta">
@@ -18,7 +18,7 @@
18
18
  * `KnowledgeCard` is standalone (no `resolve` → sources rendered as plain labels).
19
19
  * The `knowledgeDirective({ resolve })` factory wires the hook for directive use.
20
20
  */
21
- import { Card, CardContent, CardFooter } from "@elabs-ai/components-ui";
21
+ import { Card, CardContent, CardFooter, useLocale } from "@elabs-ai/components-ui";
22
22
  import { cn } from "@elabs-ai/components-ui/lib/cn";
23
23
  import { BookOpen, FileText } from "lucide-react";
24
24
  import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
@@ -49,6 +49,7 @@ interface SourceRowProps {
49
49
  }
50
50
 
51
51
  function SourceRow({ path, resolve }: SourceRowProps) {
52
+ const { t } = useLocale();
52
53
  const resolved = resolve ? resolve(path) : null;
53
54
  const display = resolved?.title ?? path;
54
55
 
@@ -58,9 +59,9 @@ function SourceRow({ path, resolve }: SourceRowProps) {
58
59
  href={resolved.href}
59
60
  rel="noopener noreferrer"
60
61
  target="_blank"
61
- // #399 — a source link is TEXT: on-surface `-text` rung, not the fill.
62
- className="flex min-w-0 items-center gap-1.5 text-meta text-primary-text underline-offset-2 hover:underline focus-ring"
63
- aria-label={`Source: ${display}`}
62
+ // #399 — a source link is TEXT: the `--link` ink, not the fill.
63
+ className="flex min-w-0 items-center gap-1.5 text-meta text-link underline-offset-2 hover:underline focus-ring"
64
+ aria-label={t("editor.knowledgeCard.source", { name: display })}
64
65
  >
65
66
  <FileText className="size-3 shrink-0" aria-hidden="true" />
66
67
  <span className="truncate">{display}</span>
@@ -71,7 +72,7 @@ function SourceRow({ path, resolve }: SourceRowProps) {
71
72
  return (
72
73
  <span
73
74
  className="flex min-w-0 items-center gap-1.5 text-meta text-muted-foreground"
74
- aria-label={`Source (unresolved): ${display}`}
75
+ aria-label={t("editor.knowledgeCard.sourceUnresolved", { name: display })}
75
76
  >
76
77
  <FileText className="size-3 shrink-0" aria-hidden="true" />
77
78
  <span className="truncate">{display}</span>
@@ -102,6 +103,7 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
102
103
  { sources, resolve, children, className, ...props },
103
104
  ref,
104
105
  ) {
106
+ const { t } = useLocale();
105
107
  const sourcePaths = sources
106
108
  ? sources
107
109
  .split(",")
@@ -112,7 +114,7 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
112
114
  return (
113
115
  <section
114
116
  ref={ref}
115
- aria-label="Knowledge fact"
117
+ aria-label={t("editor.knowledgeCard.label")}
116
118
  className={cn("not-prose", className)}
117
119
  {...props}
118
120
  >
@@ -120,15 +122,22 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
120
122
  <CardContent className="pt-4">
121
123
  <div className="mb-2 flex items-center gap-1.5">
122
124
  <BookOpen className="size-3.5 shrink-0 text-info-text" aria-hidden="true" />
123
- <span className="text-meta font-medium text-info-text">Knowledge</span>
125
+ <span className="text-meta font-medium text-info-text">
126
+ {t("editor.knowledgeCard.heading")}
127
+ </span>
124
128
  </div>
125
129
  <div className="text-body text-foreground">{children}</div>
126
130
  </CardContent>
127
131
 
128
132
  {sourcePaths.length > 0 ? (
129
133
  <CardFooter className="flex-col items-start gap-1 border-t border-border pt-3">
130
- <p className="text-meta font-medium text-muted-foreground">Sources</p>
131
- <ul className="flex w-full flex-col gap-1" aria-label="Sources">
134
+ <p className="text-meta font-medium text-muted-foreground">
135
+ {t("editor.knowledgeCard.sources")}
136
+ </p>
137
+ <ul
138
+ className="flex w-full flex-col gap-1"
139
+ aria-label={t("editor.knowledgeCard.sources")}
140
+ >
132
141
  {sourcePaths.map((path) => (
133
142
  <li key={path} className="min-w-0">
134
143
  <SourceRow path={path} resolve={resolve} />
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ /**
4
+ * The root barrel (`.`) exports lightweight chrome (`CopyButton`,
5
+ * `EDITOR_LANGUAGES`, `languageLabel`) alongside the Monaco-backed editing
6
+ * surfaces (`CodeEditor`, `DiffEditor`, `CodeWorkspace`). A consumer that only
7
+ * wants the chrome must never pay for Monaco: `monaco-editor` touches browser
8
+ * globals at import time and is megabytes on the wire, breaking SSR/RSC for a
9
+ * component that never renders an editor.
10
+ *
11
+ * `code-editor.tsx`/`diff-editor.tsx` used to `import * as monaco from
12
+ * "monaco-editor"` at the top of the module — a VALUE import, evaluated the
13
+ * moment anything imports the barrel (native ESM instantiates every module in
14
+ * the graph, regardless of which named export is actually read). This test
15
+ * locks the fix: mock `monaco-editor` to THROW on evaluation, then import the
16
+ * barrel and prove the lightweight exports still work. If either editing
17
+ * surface regresses to a top-level value import, this fails at the `import`
18
+ * statement itself.
19
+ */
20
+ vi.mock("monaco-editor", () => {
21
+ throw new Error(
22
+ "monaco-editor must not be evaluated merely by importing the @elabs-ai/components-editor barrel",
23
+ );
24
+ });
25
+
26
+ describe("@elabs-ai/components-editor barrel", () => {
27
+ it("importing the barrel never evaluates monaco-editor", async () => {
28
+ await expect(import("./index")).resolves.toBeDefined();
29
+ });
30
+
31
+ it("CopyButton is usable without evaluating monaco-editor", async () => {
32
+ const { CopyButton } = await import("./index");
33
+ expect(CopyButton).toBeTypeOf("function");
34
+ });
35
+
36
+ it("EDITOR_LANGUAGES / languageLabel are usable without evaluating monaco-editor", async () => {
37
+ const { EDITOR_LANGUAGES, languageLabel } = await import("./index");
38
+ expect(Array.isArray(EDITOR_LANGUAGES)).toBe(true);
39
+ expect(EDITOR_LANGUAGES.length).toBeGreaterThan(0);
40
+ expect(languageLabel(EDITOR_LANGUAGES[0]!.id)).toBeTypeOf("string");
41
+ });
42
+
43
+ it("EditorToolbar + EditorContextMenu are usable (rendered) without evaluating monaco-editor", async () => {
44
+ const { EditorToolbar, EditorContextMenu } = await import("./index");
45
+ // Both are `forwardRef`/context components — objects, not plain functions;
46
+ // assert they're valid React element types instead of a bare `typeof`.
47
+ expect(EditorToolbar).toBeTruthy();
48
+ expect(EditorContextMenu).toBeTruthy();
49
+ });
50
+ });
@@ -32,6 +32,7 @@
32
32
  * color is never the only signal (var-def = weight, unresolved = dotted underline),
33
33
  * so roles stay distinct in the high-contrast theme.
34
34
  */
35
+ import { useLocale } from "@elabs-ai/components-ui";
35
36
  import { cn } from "@elabs-ai/components-ui/lib/cn";
36
37
  import { TriangleAlert } from "lucide-react";
37
38
  import { forwardRef, useId, useMemo, type HTMLAttributes, type ReactNode } from "react";
@@ -239,6 +240,7 @@ function renderSource(text: string, tokens: CalcToken[]): ReactNode {
239
240
 
240
241
  /** The right-hand cell: a value, a calm error marker, or nothing. */
241
242
  function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
243
+ const { t } = useLocale();
242
244
  if (result.error) {
243
245
  // Native `title` (not a Radix Tooltip) so CalcBlock is self-contained — it
244
246
  // renders inside MarkdownPreview / a story with no TooltipProvider ancestor.
@@ -246,7 +248,7 @@ function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
246
248
  <span
247
249
  className="inline-flex shrink-0 text-calc-warning"
248
250
  title={result.error.message}
249
- aria-label={`Error: ${result.error.message}`}
251
+ aria-label={t("editor.calcBlock.error", { message: result.error.message })}
250
252
  >
251
253
  <TriangleAlert className="size-3.5" aria-hidden="true" />
252
254
  </span>
@@ -255,7 +257,7 @@ function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
255
257
  if (result.value) {
256
258
  return (
257
259
  <div className="brand-calc-tok--result shrink-0 tabular-nums text-calc-result">
258
- <span className="sr-only">equals </span>
260
+ <span className="sr-only">{t("editor.calcBlock.equals")}</span>
259
261
  <span>{result.value.display}</span>
260
262
  </div>
261
263
  );
@@ -270,7 +272,7 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
270
272
  title,
271
273
  showTotal = true,
272
274
  total,
273
- totalLabel = "Total",
275
+ totalLabel: totalLabelProp,
274
276
  markers = true,
275
277
  readOnly: _readOnly,
276
278
  className,
@@ -278,6 +280,8 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
278
280
  },
279
281
  ref,
280
282
  ) {
283
+ const { t } = useLocale();
284
+ const totalLabel = totalLabelProp ?? t("editor.calcBlock.total");
281
285
  // Author markers are stripped BEFORE evaluation: the math engine sees clean
282
286
  // lines, and because markers are trailing, token columns stay aligned with the
283
287
  // rendered (cleaned) text. `lines`/`evalSource` are the marker-free versions.
@@ -376,7 +380,9 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
376
380
  </div>
377
381
 
378
382
  {empty ? (
379
- <p className="px-4 py-6 text-body text-muted-foreground">Empty calc block.</p>
383
+ <p className="px-4 py-6 text-body text-muted-foreground">
384
+ {t("editor.calcBlock.emptyBlock")}
385
+ </p>
380
386
  ) : (
381
387
  <div className="flex flex-col gap-y-1 px-4 py-3 font-mono text-code leading-relaxed">
382
388
  {rows}
@@ -1,13 +1,23 @@
1
1
  import { act, cleanup, render } from "@testing-library/react";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
3
3
 
4
4
  // Monaco needs real layout/canvas, so it cannot mount in jsdom — mock the engine
5
5
  // and assert the wrapper's lifecycle contract. Real rendering + a11y are covered
6
6
  // by the Storybook interaction tests.
7
7
  const h = vi.hoisted(() => {
8
8
  const state = { changeHandler: undefined as undefined | (() => void), value: "" };
9
- const model = { dispose: vi.fn() };
9
+ const models: {
10
+ dispose: Mock;
11
+ uri: { toString(): string } | undefined;
12
+ getValue: () => string;
13
+ getPositionAt: (offset: number) => { lineNumber: number; column: number };
14
+ _value: string;
15
+ }[] = [];
10
16
  const actionDisposable = { dispose: vi.fn() };
17
+ // The most recently created/set model — a simple single-document stand-in
18
+ // (columns are 1-based character OFFSETS, not real Monaco line/column) good
19
+ // enough to exercise `executeEdits`'s edit shape without a real Monaco model.
20
+ let currentModel: (typeof models)[number] | undefined;
11
21
  const editor = {
12
22
  onDidChangeModelContent: vi.fn((cb: () => void) => {
13
23
  state.changeHandler = cb;
@@ -17,7 +27,19 @@ const h = vi.hoisted(() => {
17
27
  setValue: vi.fn((v: string) => {
18
28
  state.value = v;
19
29
  }),
20
- getModel: vi.fn(() => model),
30
+ getModel: vi.fn(() => currentModel),
31
+ setModel: vi.fn((m: (typeof models)[number]) => {
32
+ currentModel = m;
33
+ }),
34
+ executeEdits: vi.fn(
35
+ (_source: string, edits: { range: { start: number; end: number }; text: string }[]) => {
36
+ const edit = edits[0]!;
37
+ const next =
38
+ state.value.slice(0, edit.range.start) + edit.text + state.value.slice(edit.range.end);
39
+ state.value = next;
40
+ if (currentModel) currentModel._value = next;
41
+ },
42
+ ),
21
43
  updateOptions: vi.fn(),
22
44
  addAction: vi.fn(() => actionDisposable),
23
45
  // Minimal DOM node so the aria-forwarding effect can find (or not) a textarea.
@@ -26,13 +48,29 @@ const h = vi.hoisted(() => {
26
48
  };
27
49
  return {
28
50
  state,
29
- model,
51
+ models,
52
+ get model() {
53
+ return models[0];
54
+ },
30
55
  actionDisposable,
31
56
  editor,
32
57
  create: vi.fn(() => editor),
33
- createModel: vi.fn((value: string) => {
58
+ createModel: vi.fn((value: string, _language: string, uri?: { toString(): string }) => {
34
59
  state.value = value;
35
- return model;
60
+ const m = {
61
+ dispose: vi.fn(),
62
+ uri,
63
+ _value: value,
64
+ getValue(this: { _value: string }) {
65
+ return this._value;
66
+ },
67
+ getPositionAt(offset: number) {
68
+ return { lineNumber: 1, column: offset };
69
+ },
70
+ };
71
+ models.push(m);
72
+ currentModel = m;
73
+ return m;
36
74
  }),
37
75
  };
38
76
  });
@@ -46,38 +84,56 @@ vi.mock("monaco-editor", () => ({
46
84
  setTheme: vi.fn(),
47
85
  },
48
86
  Uri: { parse: (s: string) => ({ toString: () => s }) },
87
+ Range: {
88
+ fromPositions: (start: { column: number }, end: { column: number }) => ({
89
+ start: start.column,
90
+ end: end.column,
91
+ }),
92
+ },
49
93
  }));
50
94
 
51
95
  import { CodeEditor } from "./code-editor";
52
96
 
97
+ // The engine now loads via a dynamic `import("monaco-editor")` inside the
98
+ // mount effect (kept lazy so the barrel never evaluates Monaco just for
99
+ // `CopyButton`/`EDITOR_LANGUAGES` — see barrel-monaco-lazy.test.ts). The mock
100
+ // module still resolves through that dynamic import, but only after a
101
+ // microtask: flush it with `await act(async () => {})` after every
102
+ // render/rerender that expects the editor to already exist.
103
+ const flush = () => act(async () => {});
104
+
53
105
  beforeEach(() => {
54
106
  h.state.changeHandler = undefined;
55
107
  h.state.value = "";
108
+ h.models.length = 0;
56
109
  vi.clearAllMocks();
57
110
  });
58
111
  afterEach(cleanup);
59
112
 
60
113
  describe("CodeEditor", () => {
61
- it("creates a Monaco editor with the initial value + language", () => {
114
+ it("creates a Monaco editor with the initial value + language", async () => {
62
115
  const { getByTestId } = render(
63
116
  <CodeEditor defaultValue="const a = 1;" language="typescript" />,
64
117
  );
65
118
  expect(getByTestId("code-editor")).toBeInTheDocument();
119
+ await flush();
66
120
  expect(h.create).toHaveBeenCalledTimes(1);
67
121
  // No `path` → no model URI.
68
122
  expect(h.createModel).toHaveBeenCalledWith("const a = 1;", "typescript", undefined);
69
123
  });
70
124
 
71
- it("creates the model with a URI derived from `path`", () => {
125
+ it("creates the model with a URI derived from `path`", async () => {
72
126
  render(<CodeEditor defaultValue="x" language="json" path="config/app.json" />);
127
+ await flush();
73
128
  expect(h.createModel).toHaveBeenCalledWith("x", "json", {
74
129
  toString: expect.any(Function),
75
130
  });
76
131
  });
77
132
 
78
- it("forwards content edits to onChange", () => {
133
+ it("forwards content edits to onChange", async () => {
79
134
  const onChange = vi.fn();
80
135
  render(<CodeEditor defaultValue="" onChange={onChange} />);
136
+ await flush();
81
137
  expect(h.state.changeHandler).toBeTypeOf("function");
82
138
  act(() => {
83
139
  h.state.value = "typed";
@@ -86,32 +142,35 @@ describe("CodeEditor", () => {
86
142
  expect(onChange).toHaveBeenCalledWith("typed");
87
143
  });
88
144
 
89
- it("disposes the editor + model on unmount", () => {
145
+ it("disposes the editor + model on unmount", async () => {
90
146
  const { unmount } = render(<CodeEditor defaultValue="x" />);
147
+ await flush();
91
148
  unmount();
92
149
  expect(h.editor.dispose).toHaveBeenCalledTimes(1);
93
- expect(h.model.dispose).toHaveBeenCalledTimes(1);
150
+ expect(h.model!.dispose).toHaveBeenCalledTimes(1);
94
151
  });
95
152
 
96
153
  describe("actions prop", () => {
97
- it("calls addAction once per descriptor on mount", () => {
154
+ it("calls addAction once per descriptor on mount", async () => {
98
155
  const actions = [
99
156
  { id: "test.one", label: "One", run: vi.fn() },
100
157
  { id: "test.two", label: "Two", run: vi.fn() },
101
158
  ];
102
159
  render(<CodeEditor defaultValue="" actions={actions} />);
160
+ await flush();
103
161
  expect(h.editor.addAction).toHaveBeenCalledTimes(2);
104
162
  expect(h.editor.addAction).toHaveBeenCalledWith(actions[0]);
105
163
  expect(h.editor.addAction).toHaveBeenCalledWith(actions[1]);
106
164
  });
107
165
 
108
- it("disposes old disposables and re-adds when the actions array identity changes", () => {
166
+ it("disposes old disposables and re-adds when the actions array identity changes", async () => {
109
167
  const actionsV1 = [{ id: "test.a", label: "A", run: vi.fn() }];
110
168
  const actionsV2 = [
111
169
  { id: "test.a", label: "A", run: vi.fn() },
112
170
  { id: "test.b", label: "B", run: vi.fn() },
113
171
  ];
114
172
  const { rerender } = render(<CodeEditor defaultValue="" actions={actionsV1} />);
173
+ await flush();
115
174
  expect(h.editor.addAction).toHaveBeenCalledTimes(1);
116
175
 
117
176
  rerender(<CodeEditor defaultValue="" actions={actionsV2} />);
@@ -120,15 +179,83 @@ describe("CodeEditor", () => {
120
179
  expect(h.editor.addAction).toHaveBeenCalledTimes(3); // 1 + 2
121
180
  });
122
181
 
123
- it("disposes all action disposables on unmount", () => {
182
+ it("disposes all action disposables on unmount", async () => {
124
183
  const actions = [
125
184
  { id: "test.x", label: "X", run: vi.fn() },
126
185
  { id: "test.y", label: "Y", run: vi.fn() },
127
186
  ];
128
187
  const { unmount } = render(<CodeEditor defaultValue="" actions={actions} />);
188
+ await flush();
129
189
  expect(h.editor.addAction).toHaveBeenCalledTimes(2);
130
190
  unmount();
131
191
  expect(h.actionDisposable.dispose).toHaveBeenCalledTimes(2);
132
192
  });
133
193
  });
194
+
195
+ describe("path changes after mount", () => {
196
+ it("swaps the model instead of ignoring the new path (Monaco has no URI setter)", async () => {
197
+ const { rerender } = render(
198
+ <CodeEditor defaultValue="x" path="a.ts" language="typescript" />,
199
+ );
200
+ await flush();
201
+ expect(h.createModel).toHaveBeenCalledTimes(1);
202
+ const firstModel = h.models[0]!;
203
+
204
+ rerender(<CodeEditor defaultValue="x" path="b.ts" language="typescript" />);
205
+
206
+ expect(h.createModel).toHaveBeenCalledTimes(2);
207
+ expect(h.editor.setModel).toHaveBeenCalledWith(h.models[1]);
208
+ expect(firstModel.dispose).toHaveBeenCalledTimes(1);
209
+ });
210
+
211
+ it("does not rebuild the model when path is unchanged across a re-render", async () => {
212
+ const { rerender } = render(<CodeEditor defaultValue="x" path="a.ts" />);
213
+ await flush();
214
+ expect(h.createModel).toHaveBeenCalledTimes(1);
215
+
216
+ rerender(<CodeEditor defaultValue="x" path="a.ts" ariaLabel="renamed" />);
217
+
218
+ expect(h.createModel).toHaveBeenCalledTimes(1);
219
+ expect(h.editor.setModel).not.toHaveBeenCalled();
220
+ });
221
+ });
222
+
223
+ describe("controlled value sync", () => {
224
+ it("applies a single-character change via executeEdits, never setValue", async () => {
225
+ const { rerender } = render(<CodeEditor value="abc" onChange={vi.fn()} />);
226
+ await flush();
227
+ expect(h.state.value).toBe("abc");
228
+
229
+ rerender(<CodeEditor value="abd" onChange={vi.fn()} />);
230
+
231
+ expect(h.editor.executeEdits).toHaveBeenCalledTimes(1);
232
+ expect(h.editor.setValue).not.toHaveBeenCalled();
233
+ expect(h.state.value).toBe("abd");
234
+ });
235
+
236
+ it("does nothing when the controlled value already matches the model", async () => {
237
+ render(<CodeEditor value="same" onChange={vi.fn()} />);
238
+ await flush();
239
+ h.editor.executeEdits.mockClear();
240
+
241
+ render(<CodeEditor value="same" onChange={vi.fn()} />);
242
+ await flush();
243
+
244
+ expect(h.editor.executeEdits).not.toHaveBeenCalled();
245
+ });
246
+ });
247
+
248
+ describe("options changes after mount", () => {
249
+ it("re-applies a changed `options` prop via updateOptions", async () => {
250
+ const { rerender } = render(
251
+ <CodeEditor defaultValue="" options={{ minimap: { enabled: false } }} />,
252
+ );
253
+ await flush();
254
+ h.editor.updateOptions.mockClear();
255
+
256
+ rerender(<CodeEditor defaultValue="" options={{ minimap: { enabled: true } }} />);
257
+
258
+ expect(h.editor.updateOptions).toHaveBeenCalledWith({ minimap: { enabled: true } });
259
+ });
260
+ });
134
261
  });