@jxsuite/studio 0.37.1 → 1.1.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 (113) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +103709 -81001
  5. package/dist/studio.js.map +201 -157
  6. package/package.json +47 -45
  7. package/src/account-status.ts +39 -0
  8. package/src/browse/browse.ts +10 -14
  9. package/src/canvas/canvas-live-render.ts +21 -2
  10. package/src/canvas/canvas-render.ts +35 -16
  11. package/src/canvas/canvas-utils.ts +118 -72
  12. package/src/canvas/iframe-entry.ts +20 -0
  13. package/src/canvas/iframe-eval.ts +155 -0
  14. package/src/canvas/iframe-host.ts +132 -6
  15. package/src/canvas/iframe-inline-edit.ts +107 -1
  16. package/src/canvas/iframe-protocol.ts +43 -3
  17. package/src/canvas/iframe-render.ts +18 -0
  18. package/src/collab/collab-session.ts +23 -8
  19. package/src/collab/collab-state.ts +6 -3
  20. package/src/component-props.ts +104 -0
  21. package/src/editor/context-menu.ts +1 -1
  22. package/src/editor/inline-edit-apply.ts +36 -1
  23. package/src/editor/inline-edit.ts +58 -1
  24. package/src/editor/repeater-scope.ts +8 -13
  25. package/src/editor/shortcuts.ts +41 -12
  26. package/src/files/file-ops.ts +14 -0
  27. package/src/files/files.ts +56 -1
  28. package/src/format/format-host.ts +63 -1
  29. package/src/grid/cell-editors.ts +288 -0
  30. package/src/grid/cell-popovers.ts +108 -0
  31. package/src/grid/csv-codec.ts +122 -0
  32. package/src/grid/edit-buffer.ts +490 -0
  33. package/src/grid/grid-controller.ts +417 -0
  34. package/src/grid/grid-layout.ts +83 -0
  35. package/src/grid/grid-open.ts +167 -0
  36. package/src/grid/grid-panel.ts +302 -0
  37. package/src/grid/grid-source.ts +210 -0
  38. package/src/grid/grid-view.ts +367 -0
  39. package/src/grid/schema-columns.ts +261 -0
  40. package/src/grid/sources/connector-source.ts +217 -0
  41. package/src/grid/sources/content-source.ts +542 -0
  42. package/src/grid/sources/csv-file-source.ts +247 -0
  43. package/src/grid/tabulator-tables.d.ts +120 -0
  44. package/src/new-project/add-repo-modal.ts +183 -0
  45. package/src/new-project/new-project-modal.ts +22 -3
  46. package/src/page-params.ts +34 -8
  47. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  48. package/src/panels/ai-panel.ts +61 -4
  49. package/src/panels/block-action-bar.ts +8 -3
  50. package/src/panels/chat-panel.ts +98 -0
  51. package/src/panels/data-grid.ts +241 -0
  52. package/src/panels/editors.ts +43 -17
  53. package/src/panels/events-panel.ts +137 -44
  54. package/src/panels/formula-workspace.ts +379 -0
  55. package/src/panels/frontmatter-fields.ts +231 -0
  56. package/src/panels/frontmatter-panel.ts +132 -0
  57. package/src/panels/git-panel.ts +1 -1
  58. package/src/panels/head-panel.ts +11 -175
  59. package/src/panels/properties-panel.ts +154 -144
  60. package/src/panels/right-panel.ts +9 -47
  61. package/src/panels/signals-panel.ts +217 -460
  62. package/src/panels/statement-editor.ts +710 -0
  63. package/src/panels/statusbar.ts +1 -1
  64. package/src/panels/style-panel.ts +25 -1
  65. package/src/panels/stylebook-panel.ts +0 -2
  66. package/src/panels/tab-bar.ts +175 -6
  67. package/src/panels/tab-strip.ts +62 -6
  68. package/src/panels/toolbar.ts +37 -3
  69. package/src/panels/welcome-screen.ts +50 -0
  70. package/src/platform-errors.ts +30 -0
  71. package/src/platforms/cloud.ts +172 -89
  72. package/src/platforms/devserver.ts +172 -0
  73. package/src/services/ai-project-tools.ts +541 -0
  74. package/src/services/ai-session-store.ts +28 -0
  75. package/src/services/ai-system-prompt.ts +194 -24
  76. package/src/services/ai-tools.ts +88 -21
  77. package/src/services/automation.ts +16 -0
  78. package/src/services/context-resolver.ts +73 -0
  79. package/src/services/data-service.ts +155 -0
  80. package/src/services/document-assistant.ts +81 -11
  81. package/src/services/gated-registry.ts +72 -0
  82. package/src/services/live-preview.ts +0 -0
  83. package/src/services/monaco-setup.ts +75 -26
  84. package/src/services/preview-eval.ts +68 -0
  85. package/src/services/project-adoption.ts +31 -0
  86. package/src/settings/contributed-section.ts +406 -0
  87. package/src/settings/extension-sections.ts +145 -0
  88. package/src/settings/schema-field-ui.ts +4 -2
  89. package/src/settings/settings-modal.ts +101 -42
  90. package/src/site-context.ts +12 -1
  91. package/src/store.ts +4 -0
  92. package/src/studio.ts +107 -52
  93. package/src/tabs/patch-ops.ts +25 -0
  94. package/src/tabs/tab.ts +39 -1
  95. package/src/tabs/transact.ts +61 -14
  96. package/src/types.ts +132 -1
  97. package/src/ui/dynamic-slot.ts +272 -0
  98. package/src/ui/expression-editor.ts +423 -125
  99. package/src/ui/field-row.ts +5 -0
  100. package/src/ui/form-controls.ts +322 -0
  101. package/src/ui/formula-catalog.ts +557 -0
  102. package/src/ui/formula-chips.ts +216 -0
  103. package/src/ui/formula-palette.ts +211 -0
  104. package/src/ui/layers.ts +40 -0
  105. package/src/ui/media-picker.ts +1 -1
  106. package/src/ui/panel-resize.ts +15 -1
  107. package/src/ui/progress-modal.ts +2 -2
  108. package/src/ui/schema-form.ts +524 -0
  109. package/src/utils/preview-format.ts +26 -0
  110. package/src/utils/studio-utils.ts +4 -3
  111. package/src/view.ts +4 -4
  112. package/src/workspace/workspace.ts +14 -0
  113. package/src/settings/content-types-editor.ts +0 -599
@@ -2,41 +2,129 @@
2
2
  /**
3
3
  * Settings modal — site-wide project settings (CSS variables, definitions, content types, head,
4
4
  * general). Modeled after VS Code / Obsidian settings panels: left sidebar nav + right content
5
- * area.
5
+ * area. Sections come from a registry: built-ins register at module init, and extensions add
6
+ * descriptor-contributed sections through `registerSettingsSection`.
6
7
  */
7
8
 
8
9
  import { html } from "lit-html";
9
10
  import { classMap } from "lit-html/directives/class-map.js";
10
11
  import { ref } from "lit-html/directives/ref.js";
11
12
  import { renderDefsEditor } from "./defs-editor";
12
- import { renderContentTypesEditor } from "./content-types-editor";
13
13
  import { renderCssVarsEditor } from "./css-vars-editor";
14
14
  import { renderHeadEditor } from "./head-editor";
15
15
  import { renderGeneralSettings } from "./general-settings";
16
16
  import { renderDependenciesEditor } from "./dependencies-editor";
17
17
  import { openModal } from "../ui/layers";
18
18
 
19
+ // ─── Section registry ─────────────────────────────────────────────────────────
20
+
21
+ /** A settings-modal section: nav entry plus a renderer for the content area. */
22
+ export interface SettingsSection {
23
+ key: string;
24
+ label: string;
25
+ /** Nav icon name (reserved for future nav treatments). */
26
+ icon?: string | undefined;
27
+ /** Sort position — lower orders render higher in the nav. */
28
+ order: number;
29
+ render: (container: HTMLElement) => void;
30
+ }
31
+
32
+ const sectionRegistry = new Map<string, SettingsSection>();
33
+
34
+ /**
35
+ * Register (or replace) a settings section. Extensions use this hook to contribute
36
+ * descriptor-driven sections; built-ins register below at module init.
37
+ *
38
+ * @param {SettingsSection} section
39
+ */
40
+ export function registerSettingsSection(section: SettingsSection): void {
41
+ sectionRegistry.set(section.key, section);
42
+ }
43
+
44
+ /**
45
+ * Remove a registered section — used when a descriptor-contributed section's extension is disabled
46
+ * (see ./extension-sections). Built-ins are never unregistered.
47
+ *
48
+ * @param {string} key
49
+ */
50
+ export function unregisterSettingsSection(key: string): void {
51
+ sectionRegistry.delete(key);
52
+ if (_activeSection === key) {
53
+ _activeSection = "general";
54
+ }
55
+ }
56
+
57
+ /** Registered sections sorted by order (registration order breaks ties). */
58
+ function sortedSections(): SettingsSection[] {
59
+ return [...sectionRegistry.values()].toSorted((a, b) => a.order - b.order);
60
+ }
61
+
62
+ // Built-in sections — orders preserve the historical display order
63
+ registerSettingsSection({
64
+ icon: "sp-icon-properties",
65
+ key: "general",
66
+ label: "General",
67
+ order: 10,
68
+ render: renderGeneralSettings,
69
+ });
70
+ registerSettingsSection({
71
+ icon: "sp-icon-file-single-web-page",
72
+ key: "head",
73
+ label: "Head",
74
+ order: 20,
75
+ render: renderHeadEditor,
76
+ });
77
+ registerSettingsSection({
78
+ icon: "sp-icon-brush",
79
+ key: "cssVars",
80
+ label: "CSS Variables",
81
+ order: 30,
82
+ render: renderCssVarsEditor,
83
+ });
84
+ registerSettingsSection({
85
+ icon: "sp-icon-data",
86
+ key: "definitions",
87
+ label: "Definitions",
88
+ order: 40,
89
+ render: renderDefsEditor,
90
+ });
91
+ // Content Types is no longer a built-in: @jxsuite/parser contributes it (order 50) through its
92
+ // Content class descriptor's $studio.settings block, registered via ./extension-sections.
93
+ registerSettingsSection({
94
+ icon: "sp-icon-box",
95
+ key: "dependencies",
96
+ label: "Dependencies",
97
+ order: 60,
98
+ render: renderDependenciesEditor,
99
+ });
100
+
101
+ // ─── Modal state ──────────────────────────────────────────────────────────────
102
+
19
103
  let _handle: ReturnType<typeof openModal> | null = null;
20
104
 
21
105
  let _activeSection = "general";
22
106
 
23
107
  let _contentEl: HTMLElement | null = null;
24
108
 
25
- const sections = [
26
- { icon: "sp-icon-properties", key: "general", label: "General" },
27
- { icon: "sp-icon-file-single-web-page", key: "head", label: "Head" },
28
- { icon: "sp-icon-brush", key: "cssVars", label: "CSS Variables" },
29
- { icon: "sp-icon-data", key: "definitions", label: "Definitions" },
30
- { icon: "sp-icon-view-grid", key: "contentTypes", label: "Content Types" },
31
- { icon: "sp-icon-box", key: "dependencies", label: "Dependencies" },
32
- ];
33
-
34
109
  export function openSettingsModal() {
35
110
  if (_handle) {
36
111
  return;
37
112
  }
38
113
  _activeSection = "general";
39
114
  renderModal();
115
+ // Refresh descriptor-contributed sections (cached payloads make this cheap) and rerender the
116
+ // Nav once they land. Lazy import breaks the settings-modal ↔ extension-sections module cycle.
117
+ void import("./extension-sections")
118
+ .then(async ({ syncExtensionSettingsSections }) => {
119
+ await syncExtensionSettingsSections();
120
+ if (_handle) {
121
+ renderModal();
122
+ renderActiveSection();
123
+ }
124
+ })
125
+ .catch(() => {
126
+ // Contributed sections are optional — the built-ins render regardless.
127
+ });
40
128
  }
41
129
 
42
130
  export function closeSettingsModal() {
@@ -73,7 +161,7 @@ function renderModal() {
73
161
  </div>
74
162
  <div class="settings-modal-body">
75
163
  <nav class="settings-modal-nav">
76
- ${sections.map(
164
+ ${sortedSections().map(
77
165
  (s) => html`
78
166
  <button
79
167
  class=${classMap({
@@ -111,34 +199,5 @@ function renderActiveSection() {
111
199
  if (!_handle || !_contentEl) {
112
200
  return;
113
201
  }
114
-
115
- switch (_activeSection) {
116
- case "general": {
117
- renderGeneralSettings(_contentEl);
118
- break;
119
- }
120
- case "head": {
121
- renderHeadEditor(_contentEl);
122
- break;
123
- }
124
- case "cssVars": {
125
- renderCssVarsEditor(_contentEl);
126
- break;
127
- }
128
- case "definitions": {
129
- renderDefsEditor(_contentEl);
130
- break;
131
- }
132
- case "contentTypes": {
133
- renderContentTypesEditor(_contentEl);
134
- break;
135
- }
136
- case "dependencies": {
137
- renderDependenciesEditor(_contentEl);
138
- break;
139
- }
140
- default: {
141
- break;
142
- }
143
- }
202
+ sectionRegistry.get(_activeSection)?.render(_contentEl);
144
203
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { projectState, requireProjectState, setProjectState } from "./store";
10
10
  import { getPlatform } from "./platform";
11
+ import { setWorkspaceProject, workspace } from "./workspace/workspace";
11
12
 
12
13
  import type {
13
14
  JxElement,
@@ -258,7 +259,9 @@ function fillSlots(
258
259
  }
259
260
 
260
261
  /**
261
- * Update the project's project.json with a partial patch and persist to disk.
262
+ * Update the project's project.json with a partial patch and persist to disk. A patch touching
263
+ * `extensions` invalidates the format/extensions caches and refreshes the editor's per-project
264
+ * schemas plus the contributed settings sections — the enabled-extension surface just changed.
262
265
  *
263
266
  * @param {Partial<ProjectConfig>} patch - Fields to merge into the current projectConfig
264
267
  */
@@ -270,4 +273,12 @@ export async function updateSiteConfig(patch: Partial<ProjectConfig>) {
270
273
  } as ProjectConfig;
271
274
  await platform.writeFile("project.json", JSON.stringify(config, null, 2));
272
275
  setProjectState({ ...requireProjectState(), projectConfig: config });
276
+ setWorkspaceProject(workspace.projectRoot, config);
277
+ if ("extensions" in patch) {
278
+ const { loadFormats, refreshExtensionUi, refreshFormats } =
279
+ await import("./format/format-host");
280
+ refreshFormats();
281
+ void loadFormats();
282
+ refreshExtensionUi(platform);
283
+ }
273
284
  }
package/src/store.ts CHANGED
@@ -37,16 +37,20 @@ export let canvasWrap = null as unknown as HTMLElement;
37
37
  export let activityBar = null as unknown as HTMLElement;
38
38
  export let leftPanel = null as unknown as HTMLElement;
39
39
  export let rightPanel = null as unknown as HTMLElement;
40
+ export let chatPanelEl = null as unknown as HTMLElement;
40
41
  export let toolbarEl = null as unknown as HTMLElement;
41
42
  export let statusbarEl = null as unknown as HTMLElement;
43
+ export let frontmatterPanelEl = null as unknown as HTMLElement;
42
44
 
43
45
  export function initShellRefs() {
44
46
  canvasWrap = document.querySelector("#canvas-wrap") as HTMLElement;
45
47
  activityBar = document.querySelector("#activity-bar") as HTMLElement;
46
48
  leftPanel = document.querySelector("#left-panel") as HTMLElement;
47
49
  rightPanel = document.querySelector("#right-panel") as HTMLElement;
50
+ chatPanelEl = document.querySelector("#chat-panel") as HTMLElement;
48
51
  toolbarEl = document.querySelector("#toolbar") as HTMLElement;
49
52
  statusbarEl = document.querySelector("#statusbar") as HTMLElement;
53
+ frontmatterPanelEl = document.querySelector("#frontmatter-panel") as HTMLElement;
50
54
  }
51
55
 
52
56
  // ─── Shared containers (mutated in place by owner modules) ───────────────────
package/src/studio.ts CHANGED
@@ -11,6 +11,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
11
11
 
12
12
  import {
13
13
  canvasWrap,
14
+ chatPanelEl,
14
15
  getNodeAtPath,
15
16
  initShellRefs,
16
17
  projectState,
@@ -23,14 +24,20 @@ import {
23
24
  updateUi,
24
25
  } from "./store";
25
26
 
26
- import { activeTab, closeAllTabs, openTab } from "./workspace/workspace";
27
+ import {
28
+ activeTab,
29
+ closeAllTabs,
30
+ openTab,
31
+ setWorkspaceProject,
32
+ workspace,
33
+ } from "./workspace/workspace";
27
34
  import { mutateUpdateDef, mutateUpdateProperty, transactDoc } from "./tabs/transact";
28
35
  import { effect } from "./reactivity";
29
36
 
30
37
  import { view } from "./view";
31
38
 
32
39
  import { isEditing } from "./editor/inline-edit";
33
- import { applyTransform, initCanvasUtils, positionZoomIndicator } from "./canvas/canvas-utils";
40
+ import { applyTransform, initCanvasUtils } from "./canvas/canvas-utils";
34
41
  import {
35
42
  initCanvasRender,
36
43
  renderCanvas,
@@ -63,6 +70,7 @@ import {
63
70
  documentExtensions,
64
71
  formatForPath,
65
72
  loadFormats,
73
+ refreshExtensionUi,
66
74
  refreshFormats,
67
75
  } from "./format/format-host";
68
76
  import {
@@ -110,12 +118,17 @@ import { cloneRepository, renderGitPanel } from "./panels/git-panel";
110
118
  // By Bun's bundler despite sideEffects declarations in Spectrum's package.json.
111
119
  import { components as _swc } from "./ui/spectrum";
112
120
  import "./ui/panel-resize.js";
113
- import { initLayers } from "./ui/layers";
121
+ // Built-in schema-form controls (schema-builder, secret) register on import
122
+ import "./ui/form-controls.js";
123
+ import { initLayers, showSaveDiscardDialog } from "./ui/layers";
114
124
  import { initShortcuts } from "./editor/shortcuts";
115
125
  import { renderActivityBar, mount as mountActivityBar } from "./panels/activity-bar";
116
126
  import * as toolbarPanel from "./panels/toolbar";
117
127
  import * as overlaysPanel from "./panels/overlays";
128
+ import * as frontmatterPanelMod from "./panels/frontmatter-panel";
118
129
  import * as rightPanelMod from "./panels/right-panel";
130
+ import * as chatPanelMod from "./panels/chat-panel";
131
+ import { setProjectAdopter } from "./services/project-adoption";
119
132
  import * as leftPanelMod from "./panels/left-panel";
120
133
  import * as tabStrip from "./panels/tab-strip";
121
134
  import * as tabBar from "./panels/tab-bar";
@@ -124,6 +137,7 @@ import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from ".
124
137
  import { registerCanvasDndBridge } from "./panels/canvas-dnd-bridge";
125
138
  import { defaultDef } from "./panels/shared";
126
139
  import { registerFunctionCompletions } from "./panels/editors";
140
+ import { closeFormulaWorkspace } from "./panels/formula-workspace";
127
141
  import {
128
142
  initBlockActionBar,
129
143
  isEditChromeTarget,
@@ -131,12 +145,15 @@ import {
131
145
  } from "./panels/block-action-bar";
132
146
  import { initCssData } from "./panels/style-utils";
133
147
  import { initQuickSearch } from "./panels/quick-search";
148
+ import { hydrateAccountStatus } from "./account-status";
134
149
  import { hydrateProjectList } from "./project-list";
135
150
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
136
151
  import { hydrateSettings } from "./services/settings-store";
137
152
  import { initWelcome } from "./panels/welcome-screen";
153
+ import { openAddRepoModal } from "./new-project/add-repo-modal";
138
154
  import { openNewProjectModal } from "./new-project/new-project-modal";
139
155
  import type { DocumentStackEntry, GitDiffState } from "./types";
156
+ import type { Tab } from "./tabs/tab";
140
157
  import type { JxPath } from "./state";
141
158
  import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
142
159
 
@@ -218,19 +235,41 @@ async function navigateToComponent(componentPath: string) {
218
235
  }
219
236
  }
220
237
 
238
+ /**
239
+ * Leaving a drilled-in component discards its edits (the pop restores the parent). Because saving
240
+ * is explicit, prompt when the child is dirty: Save writes it, Discard drops it, Cancel stays.
241
+ * Returns false to abort navigation.
242
+ *
243
+ * @param {Tab} tab
244
+ */
245
+ async function confirmLeaveDirtyChild(tab: Tab): Promise<boolean> {
246
+ if (!tab.doc.dirty || !tab.documentPath) {
247
+ return true;
248
+ }
249
+ const name = tab.documentPath.split("/").pop() || "component";
250
+ const choice = await showSaveDiscardDialog("Unsaved Changes", `"${name}" has unsaved changes.`);
251
+ if (choice === "cancel") {
252
+ return false;
253
+ }
254
+ if (choice === "save") {
255
+ try {
256
+ await getPlatform().writeFile(tab.documentPath, await serializeDocument(tab));
257
+ } catch (error) {
258
+ statusMessage(`Save error: ${(error as Error).message}`);
259
+ return false;
260
+ }
261
+ }
262
+ // "discard": leave without writing — the child's edits are dropped with the popped frame.
263
+ return true;
264
+ }
265
+
221
266
  async function navigateBack() {
222
267
  const tab = activeTab.value;
223
268
  if (!tab?.session.documentStack || tab.session.documentStack.length === 0) {
224
269
  return;
225
270
  }
226
- if (tab.doc.dirty && tab.documentPath) {
227
- try {
228
- const platform = getPlatform();
229
- await platform.writeFile(tab.documentPath, await serializeDocument(tab));
230
- } catch (error) {
231
- const err = error as Error;
232
- statusMessage(`Save error: ${err.message}`);
233
- }
271
+ if (!(await confirmLeaveDirtyChild(tab))) {
272
+ return;
234
273
  }
235
274
 
236
275
  // Pop the stack
@@ -257,14 +296,8 @@ async function navigateToLevel(targetIndex: number) {
257
296
  if (!stack || targetIndex < 0 || targetIndex >= stack.length) {
258
297
  return;
259
298
  }
260
- if (tab.doc.dirty && tab.documentPath) {
261
- try {
262
- const platform = getPlatform();
263
- await platform.writeFile(tab.documentPath, await serializeDocument(tab));
264
- } catch (error) {
265
- const err = error as Error;
266
- statusMessage(`Save error: ${err.message}`);
267
- }
299
+ if (!(await confirmLeaveDirtyChild(tab))) {
300
+ return;
268
301
  }
269
302
 
270
303
  const frame = stack[targetIndex] as DocumentStackEntry;
@@ -388,6 +421,7 @@ initQuickSearch({ openRecentProject: (root: string) => openRecentProject(root) }
388
421
  tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
389
422
 
390
423
  tabBar.mount(document.querySelector("#tab-bar") as HTMLElement, {
424
+ closeFormulaWorkspace: () => closeFormulaWorkspace(),
391
425
  closeFunctionEditor: () => closeFunctionEditor(),
392
426
  exportFile,
393
427
  getCanvasMode,
@@ -437,6 +471,26 @@ document.addEventListener(
437
471
  true,
438
472
  );
439
473
 
474
+ // Unsaved-changes guard: saving is explicit (no idle autosave), so warn before the window unloads
475
+ // While any open tab has unsaved edits. For collab tabs, `dirty` reflects the room-level unsaved
476
+ // State; closing loses in-memory edits that were never flushed to disk.
477
+ export function hasUnsavedTabs(): boolean {
478
+ for (const tab of workspace.tabs.values()) {
479
+ if (tab.doc.dirty) {
480
+ return true;
481
+ }
482
+ }
483
+ return false;
484
+ }
485
+
486
+ window.addEventListener("beforeunload", (e: BeforeUnloadEvent) => {
487
+ if (hasUnsavedTabs()) {
488
+ e.preventDefault();
489
+ // Legacy browsers require a truthy returnValue to trigger the native confirm prompt.
490
+ e.returnValue = "";
491
+ }
492
+ });
493
+
440
494
  initCanvasUtils({
441
495
  getCanvasMode,
442
496
  getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
@@ -471,6 +525,16 @@ initCanvasRender({
471
525
  });
472
526
 
473
527
  initWelcome({
528
+ addExistingRepo: async () => {
529
+ const result = await openAddRepoModal();
530
+ if (result) {
531
+ // The catalogue gained an entry; refresh it before navigating into the project.
532
+ void hydrateProjectList().then(() => {
533
+ render();
534
+ });
535
+ void openRecentProject(result.root);
536
+ }
537
+ },
474
538
  cloneRepository: () => cloneRepository({ openRecentProject }),
475
539
  openNewProject: async () => {
476
540
  const result = await openNewProjectModal();
@@ -503,10 +567,12 @@ effect(() => {
503
567
  if (tab) {
504
568
  void tab.doc.mode;
505
569
  void tab.session.ui.canvasMode;
570
+ void tab.session.ui.editingFormula;
506
571
  void tab.session.ui.editingFunction;
507
572
  void tab.session.ui.featureToggles;
508
573
  void tab.session.ui.preview;
509
574
  void tab.session.ui.previewParams;
575
+ void tab.session.ui.previewProps;
510
576
  void tab.session.ui.settingsTab;
511
577
  void tab.session.ui.showLayout;
512
578
  void tab.session.ui.stylebookTab;
@@ -522,6 +588,15 @@ rightPanelMod.mount({
522
588
  renderCanvas: () => renderCanvas(),
523
589
  });
524
590
 
591
+ // Above-canvas frontmatter Properties panel (content-collection docs, edit mode).
592
+ frontmatterPanelMod.mount({ getCanvasMode });
593
+
594
+ // The persistent AI chat sidebar — mounts once, available with or without a project/document.
595
+ chatPanelMod.mount(chatPanelEl);
596
+ // The assistant's create_project tool adopts freshly scaffolded projects through the same
597
+ // Flow as the recent-projects list.
598
+ setProjectAdopter(openRecentProject);
599
+
525
600
  leftPanelMod.mount({
526
601
  cloneRepository: () => cloneRepository({ openRecentProject }),
527
602
  defBadgeLabel,
@@ -553,6 +628,8 @@ leftPanelMod.mount({
553
628
  registerRenderer("leftPanel", () => leftPanelMod.render());
554
629
  registerRenderer("canvas", () => renderCanvas());
555
630
  registerRenderer("rightPanel", () => rightPanelMod.render());
631
+ registerRenderer("frontmatterPanel", () => frontmatterPanelMod.render());
632
+ registerRenderer("chatPanel", () => chatPanelMod.render());
556
633
  registerRenderer("overlays", () => overlaysPanel.render());
557
634
  setStatusbarRenderer(() => renderStatusbar());
558
635
  mountStatusbar();
@@ -652,7 +729,9 @@ if (_projectParam) {
652
729
  searchQuery: "",
653
730
  selectedPath: siteCtx.fileRelPath || null,
654
731
  });
732
+ setWorkspaceProject(siteCtx.sitePath, siteCtx.projectConfig || null);
655
733
 
734
+ refreshExtensionUi(platform);
656
735
  await autoSyncProjectOnOpen();
657
736
  await ensureDependenciesInstalled();
658
737
  await loadComponentRegistry();
@@ -769,6 +848,13 @@ void hydrateProjectList().then(() => {
769
848
  render();
770
849
  });
771
850
 
851
+ // Hydrate the account onboarding status (GitHub-App installation coverage on cloud), then refresh
852
+ // The welcome screen's install prompt. No-op on platforms without getAccountStatus.
853
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
854
+ void hydrateAccountStatus().then(() => {
855
+ render();
856
+ });
857
+
772
858
  // Hydrate user settings (AI connection parameters) from the backend store, then re-render so
773
859
  // Key-gated surfaces (assistant gate, New Project Import/Agent tabs) see the stored key.
774
860
  // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
@@ -819,6 +905,7 @@ async function openRecentProject(root: string) {
819
905
  // "No format class imported" until a reload. Mirrors openProject in files.ts.
820
906
  refreshFormats();
821
907
  void loadFormats();
908
+ refreshExtensionUi(platform);
822
909
  const content = await platform.readFile("project.json");
823
910
  const config = JSON.parse(content) as ProjectConfig;
824
911
 
@@ -835,6 +922,7 @@ async function openRecentProject(root: string) {
835
922
  searchQuery: "",
836
923
  selectedPath: null,
837
924
  });
925
+ setWorkspaceProject(root, config);
838
926
 
839
927
  await autoSyncProjectOnOpen();
840
928
  await ensureDependenciesInstalled();
@@ -894,7 +982,6 @@ initShortcuts(() => ({
894
982
  openProject,
895
983
  panX: view.panX,
896
984
  panY: view.panY,
897
- positionZoomIndicator,
898
985
  saveFile,
899
986
  setPan: (x, y) => {
900
987
  view.panX = x;
@@ -902,35 +989,3 @@ initShortcuts(() => ({
902
989
  view.needsCenter = false;
903
990
  },
904
991
  }));
905
-
906
- // ─── Autosave (registered as update middleware) ──────────────────────────────
907
-
908
- const AUTO_SAVE_DELAY = 2000;
909
-
910
- function scheduleAutosave() {
911
- const tab = activeTab.value;
912
- if (!tab?.fileHandle || !tab.doc.dirty) {
913
- return;
914
- }
915
- if (view.autosaveTimer) {
916
- clearTimeout(view.autosaveTimer);
917
- }
918
- view.autosaveTimer = setTimeout(async () => {
919
- const t = activeTab.value;
920
- if (t?.fileHandle && t.doc.dirty && "createWritable" in t.fileHandle) {
921
- try {
922
- const writable = await t.fileHandle.createWritable();
923
- await writable.write(await serializeDocument(t));
924
- await writable.close();
925
- t.doc.dirty = false;
926
- statusMessage("Auto-saved");
927
- } catch {}
928
- }
929
- }, AUTO_SAVE_DELAY);
930
- }
931
-
932
- effect(() => {
933
- if (activeTab.value?.doc.dirty) {
934
- scheduleAutosave();
935
- }
936
- });
@@ -40,12 +40,26 @@ export type JxPatchOp =
40
40
  export type { JxDocOp } from "@jxsuite/collab/ops";
41
41
  export type { JxDocOpPair };
42
42
 
43
+ /**
44
+ * A frontmatter key change, replayable in either direction. Frontmatter lives outside the document
45
+ * tree (`tab.doc.content.frontmatter`, split off by the format host), so these are studio-local and
46
+ * never enter the shared `JxDocOp` vocabulary the collab bridge and canvas iframe mirror.
47
+ * `undefined` means the key was/becomes absent.
48
+ */
49
+ export interface JxFmOp {
50
+ field: string;
51
+ before?: unknown;
52
+ after?: unknown;
53
+ }
54
+
43
55
  /** Everything recorded during one transaction. */
44
56
  export interface TransactionRecord {
45
57
  /** Canvas patch ops (path-only) for surgical DOM updates. */
46
58
  ops: JxPatchOp[];
47
59
  /** Replayable forward/inverse document ops for patch-based history. */
48
60
  docOps: JxDocOpPair[];
61
+ /** Replayable frontmatter key changes (content-mode docs) for history undo/redo. */
62
+ fmOps: JxFmOp[];
49
63
  /**
50
64
  * False when some mutation in the transaction could not produce a guaranteed-correct inverse
51
65
  * (e.g. a move whose parents' paths interact) — history falls back to a checkpoint snapshot.
@@ -86,12 +100,14 @@ export function getPatchConsumer(): PatchConsumer | null {
86
100
 
87
101
  let _recording: JxPatchOp[] | null = null;
88
102
  let _docOps: JxDocOpPair[] | null = null;
103
+ let _fmOps: JxFmOp[] | null = null;
89
104
  let _invertible = true;
90
105
 
91
106
  /** Begin recording patch ops for a transaction. */
92
107
  export function beginRecording() {
93
108
  _recording = [];
94
109
  _docOps = [];
110
+ _fmOps = [];
95
111
  _invertible = true;
96
112
  }
97
113
 
@@ -109,6 +125,13 @@ export function recordDocOp(pair: JxDocOpPair) {
109
125
  }
110
126
  }
111
127
 
128
+ /** Record a replayable frontmatter key change. No-op outside a transaction. */
129
+ export function recordFmOp(op: JxFmOp) {
130
+ if (_fmOps) {
131
+ _fmOps.push(op);
132
+ }
133
+ }
134
+
112
135
  /** Mark the current transaction as non-invertible — history stores a checkpoint instead. */
113
136
  export function markNonInvertible() {
114
137
  _invertible = false;
@@ -121,11 +144,13 @@ export function markNonInvertible() {
121
144
  export function endRecording(): TransactionRecord {
122
145
  const record: TransactionRecord = {
123
146
  docOps: _docOps ?? [],
147
+ fmOps: _fmOps ?? [],
124
148
  invertible: _invertible,
125
149
  ops: _recording ?? [],
126
150
  };
127
151
  _recording = null;
128
152
  _docOps = null;
153
+ _fmOps = null;
129
154
  _invertible = true;
130
155
  return record;
131
156
  }