@jxsuite/studio 1.0.0 → 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.
- package/dist/iframe-entry.js +622 -49
- package/dist/iframe-entry.js.map +15 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +53069 -31746
- package/dist/studio.js.map +109 -74
- package/package.json +11 -9
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +20 -0
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +131 -5
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +43 -3
- package/src/canvas/iframe-render.ts +18 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +53 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +9 -387
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +115 -23
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/style-panel.ts +25 -1
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +175 -6
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +83 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -1
- package/src/tabs/transact.ts +60 -13
- package/src/types.ts +12 -0
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/utils/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
|
@@ -12,16 +12,22 @@
|
|
|
12
12
|
import { createChatState, createProxyStreamingClient, createToolRegistry } from "@jxsuite/ai";
|
|
13
13
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
14
14
|
import { getPlatform } from "../platform";
|
|
15
|
-
import { activeTab, workspace } from "../workspace/workspace";
|
|
15
|
+
import { activeTab, setWorkspaceProject, workspace } from "../workspace/workspace";
|
|
16
16
|
import { toRaw } from "../reactivity";
|
|
17
|
+
import { projectState, setProjectState } from "../store";
|
|
18
|
+
import type { Tab } from "../tabs/tab";
|
|
17
19
|
import { componentRegistry } from "../files/components";
|
|
18
20
|
import { registerAiTools } from "./ai-tools";
|
|
21
|
+
import { registerProjectTools } from "./ai-project-tools";
|
|
22
|
+
import { createGatedToolRegistry } from "./gated-registry";
|
|
23
|
+
import type { ToolAvailability } from "./gated-registry";
|
|
24
|
+
import { adoptProject } from "./project-adoption";
|
|
19
25
|
import { runAgentLoop } from "./tool-executor";
|
|
20
|
-
import { buildSystemPrompt } from "./ai-system-prompt";
|
|
26
|
+
import { AI_TOOL_TIERS, buildSystemPrompt, tierActive } from "./ai-system-prompt";
|
|
21
27
|
import { getBaseUrl, getModel, getOpenAiKey } from "./ai-settings";
|
|
22
28
|
import { trimContext } from "./context-manager";
|
|
23
29
|
import { renderCheck } from "./render-critic";
|
|
24
|
-
import { openFileInTab } from "../files/files";
|
|
30
|
+
import { openFileInTab, reloadFileInTab } from "../files/files";
|
|
25
31
|
import * as sessionStore from "./ai-session-store";
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -51,8 +57,23 @@ function projectRoot() {
|
|
|
51
57
|
export function createDocumentAssistant() {
|
|
52
58
|
const chatState = createChatState({ model: getModel() });
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
/** The persisted session backing the live chat; null = fresh unsaved chat. */
|
|
61
|
+
let sessionId: string | null = null;
|
|
62
|
+
|
|
63
|
+
const getProjectStyle = () =>
|
|
64
|
+
(workspace.projectConfig as ProjectConfig | null)?.style as Record<string, string> | undefined;
|
|
65
|
+
|
|
66
|
+
const findOpenTab = (path: string): Tab | null => {
|
|
67
|
+
for (const tab of workspace.tabs.values()) {
|
|
68
|
+
if (tab.documentPath === path) {
|
|
69
|
+
return tab;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const innerRegistry = createToolRegistry();
|
|
76
|
+
registerAiTools(innerRegistry, {
|
|
56
77
|
getTab: () => activeTab.value,
|
|
57
78
|
saveFile: async (relPath: string, content: string) => {
|
|
58
79
|
const plat = getPlatform();
|
|
@@ -62,23 +83,72 @@ export function createDocumentAssistant() {
|
|
|
62
83
|
doc: unknown,
|
|
63
84
|
) => Promise<{ ok: true } | { ok: false; error: string }>,
|
|
64
85
|
openDocument: openFileInTab,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
getProjectStyle,
|
|
87
|
+
findOpenTab,
|
|
88
|
+
reloadTab: reloadFileInTab,
|
|
89
|
+
});
|
|
90
|
+
registerProjectTools(innerRegistry, {
|
|
91
|
+
getTab: () => activeTab.value,
|
|
92
|
+
renderCheck: renderCheck as (
|
|
93
|
+
doc: unknown,
|
|
94
|
+
) => Promise<{ ok: true } | { ok: false; error: string }>,
|
|
95
|
+
getProjectStyle,
|
|
96
|
+
findOpenTab,
|
|
97
|
+
reloadTab: reloadFileInTab,
|
|
98
|
+
adoptProject,
|
|
99
|
+
// Re-key the live chat from the unscoped store to the adopted project so the bootstrap
|
|
100
|
+
// Conversation keeps persisting (saveSession drops writes for ids missing from the index).
|
|
101
|
+
onProjectAdopted: (root: string) => {
|
|
102
|
+
if (sessionId) {
|
|
103
|
+
sessionStore.moveSession("", root, sessionId);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
onProjectConfigWritten: (config: object) => {
|
|
107
|
+
setWorkspaceProject(workspace.projectRoot, config);
|
|
108
|
+
if (projectState) {
|
|
109
|
+
setProjectState({ ...projectState, projectConfig: config as ProjectConfig });
|
|
110
|
+
}
|
|
111
|
+
},
|
|
68
112
|
});
|
|
69
113
|
|
|
70
|
-
|
|
114
|
+
/*
|
|
115
|
+
* Gate tool advertisement/execution on live studio state, derived from the same tier table the
|
|
116
|
+
* system prompt renders — the model is never shown a tool it cannot execute. listForLLM() runs
|
|
117
|
+
* every agent-loop round, so a mid-loop create_project unlocks the higher tiers immediately.
|
|
118
|
+
*/
|
|
119
|
+
const TIER_REQUIREMENTS = {
|
|
120
|
+
"no-project": "no project to be open (it bootstraps one)",
|
|
121
|
+
project: "an open project",
|
|
122
|
+
document: "an open document (use open_document first)",
|
|
123
|
+
} as const;
|
|
124
|
+
const availability = new Map<string, ToolAvailability>(
|
|
125
|
+
AI_TOOL_TIERS.map((t) => [
|
|
126
|
+
t.name,
|
|
127
|
+
{
|
|
128
|
+
when: () => tierActive(t.tier, Boolean(workspace.projectRoot), Boolean(activeTab.value)),
|
|
129
|
+
requires: TIER_REQUIREMENTS[t.tier],
|
|
130
|
+
},
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
const toolRegistry = createGatedToolRegistry(innerRegistry, availability);
|
|
71
134
|
|
|
72
|
-
|
|
73
|
-
let sessionId: string | null = null;
|
|
135
|
+
let controller: AbortController | null = null;
|
|
74
136
|
|
|
75
137
|
function buildPrompt() {
|
|
76
138
|
const tab = activeTab.value;
|
|
139
|
+
const inventory = projectState
|
|
140
|
+
? [...projectState.dirs.values()]
|
|
141
|
+
.flat()
|
|
142
|
+
.filter((e) => e.type === "file")
|
|
143
|
+
.map((e) => e.path)
|
|
144
|
+
: undefined;
|
|
77
145
|
return buildSystemPrompt({
|
|
78
146
|
document: tab ? toRaw(tab.doc.document) : undefined,
|
|
79
147
|
projectConfig: (workspace.projectConfig as ProjectConfig | null) || undefined,
|
|
80
148
|
components: componentRegistry.length > 0 ? componentRegistry : undefined,
|
|
81
149
|
projectRoot: workspace.projectRoot || undefined,
|
|
150
|
+
hasProject: Boolean(workspace.projectRoot),
|
|
151
|
+
...(inventory && inventory.length > 0 ? { fileInventory: inventory } : {}),
|
|
82
152
|
});
|
|
83
153
|
}
|
|
84
154
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gated-registry.ts — state-aware tool advertisement for the AI assistant.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a `@jxsuite/ai` ToolRegistry so each tool is only advertised to the LLM while its
|
|
5
|
+
* availability predicate holds (no project open → bootstrap tools only; project open → file tools;
|
|
6
|
+
* document open → document tools). `runAgentLoop` calls `listForLLM()` every round, so availability
|
|
7
|
+
* flips mid-loop: after `create_project` succeeds, the very next round advertises the file and
|
|
8
|
+
* document tools without any extra plumbing.
|
|
9
|
+
*
|
|
10
|
+
* Executing a tool whose predicate currently fails returns a tool error naming the missing state
|
|
11
|
+
* instead of throwing, so the agent loop can self-correct.
|
|
12
|
+
*
|
|
13
|
+
* @license MIT
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ToolDefinition, ToolRegistry } from "@jxsuite/ai/tools";
|
|
17
|
+
|
|
18
|
+
export interface ToolAvailability {
|
|
19
|
+
/** Whether the tool is currently advertised/executable. */
|
|
20
|
+
when: () => boolean;
|
|
21
|
+
/** Human-readable requirement, surfaced when execution is refused (e.g. "a project is open"). */
|
|
22
|
+
requires: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap `inner` so tools listed in `availability` are only advertised/executable while their
|
|
27
|
+
* predicate holds. Tools without an entry are always available.
|
|
28
|
+
*
|
|
29
|
+
* @param {ToolRegistry} inner
|
|
30
|
+
* @param {Map<string, ToolAvailability>} availability
|
|
31
|
+
* @returns {ToolRegistry}
|
|
32
|
+
*/
|
|
33
|
+
export function createGatedToolRegistry(
|
|
34
|
+
inner: ToolRegistry,
|
|
35
|
+
availability: Map<string, ToolAvailability>,
|
|
36
|
+
): ToolRegistry {
|
|
37
|
+
function isAvailable(name: string): boolean {
|
|
38
|
+
const gate = availability.get(name);
|
|
39
|
+
return gate ? gate.when() : true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
register(tool: ToolDefinition) {
|
|
44
|
+
inner.register(tool);
|
|
45
|
+
},
|
|
46
|
+
list() {
|
|
47
|
+
return inner.list().filter((t) => isAvailable(t.name));
|
|
48
|
+
},
|
|
49
|
+
listForLLM() {
|
|
50
|
+
const available = new Set(this.list().map((t) => t.name));
|
|
51
|
+
return inner
|
|
52
|
+
.listForLLM()
|
|
53
|
+
.filter((t) => available.has((t as { function?: { name?: string } }).function?.name ?? ""));
|
|
54
|
+
},
|
|
55
|
+
getDefinition(toolName: string) {
|
|
56
|
+
return inner.getDefinition(toolName);
|
|
57
|
+
},
|
|
58
|
+
validate(toolName: string, args: object) {
|
|
59
|
+
return inner.validate(toolName, args);
|
|
60
|
+
},
|
|
61
|
+
async execute(toolName: string, args: object) {
|
|
62
|
+
if (inner.getDefinition(toolName) && !isAvailable(toolName)) {
|
|
63
|
+
const gate = availability.get(toolName);
|
|
64
|
+
return {
|
|
65
|
+
success: false,
|
|
66
|
+
error: `Tool "${toolName}" is not available right now — it requires ${gate?.requires ?? "a different studio state"}.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return inner.execute(toolName, args);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Parent-side live expression preview (spec §19.9).
|
|
4
|
+
*
|
|
5
|
+
* Evaluates an expression node against the canvas iframe's last dataScope snapshot
|
|
6
|
+
* (tab.session.canvas.scope) on a structuredClone, so mutating operators are safe — they mutate the
|
|
7
|
+
* clone, never the live canvas. The engine's trace hook reports every sub-node's value keyed by its
|
|
8
|
+
* path within the tree; values are formatted to display strings at report time so later mutations
|
|
9
|
+
* of the clone cannot retroactively change a badge.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
|
|
13
|
+
import { toRaw } from "../reactivity";
|
|
14
|
+
// Imported for local use AND re-exported below so existing consumers keep their import site.
|
|
15
|
+
// oxlint-disable-next-line unicorn/prefer-export-from
|
|
16
|
+
import { formatPreviewValue } from "../utils/preview-format";
|
|
17
|
+
|
|
18
|
+
import type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
19
|
+
|
|
20
|
+
export interface ExpressionPreview {
|
|
21
|
+
/** Path-keyed display values: "" is the root node, "value/target" a nested operand, etc. */
|
|
22
|
+
values: Map<string, string>;
|
|
23
|
+
/** Evaluation error message, if the expression threw. */
|
|
24
|
+
error: string | null;
|
|
25
|
+
/** Whether the root operator mutates its target (badge renders as an effect, not a value). */
|
|
26
|
+
mutating: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The formatter is shared with the in-iframe live evaluator: it lives in utils/preview-format.ts
|
|
30
|
+
// (dependency-light, safe for the iframe bundle) and is re-exported here so existing consumers
|
|
31
|
+
// Keep their import site.
|
|
32
|
+
export { formatPreviewValue };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate `node` against a scope snapshot for display. Returns null when no snapshot is available
|
|
36
|
+
* (canvas not yet rendered) — the editor renders no badges rather than wrong ones.
|
|
37
|
+
*/
|
|
38
|
+
export function previewExpression(
|
|
39
|
+
node: unknown,
|
|
40
|
+
scope?: Record<string, unknown> | null,
|
|
41
|
+
): ExpressionPreview | null {
|
|
42
|
+
if (!scope || !node || typeof node !== "object" || !("operator" in node)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let state: Record<string, unknown>;
|
|
47
|
+
try {
|
|
48
|
+
// Callers hand over tab.session.canvas.scope, which the reactive session tree wraps in a
|
|
49
|
+
// Proxy — structuredClone rejects proxies, so unwrap to the raw snapshot first.
|
|
50
|
+
state = structuredClone(toRaw(scope));
|
|
51
|
+
} catch {
|
|
52
|
+
// Snapshot values are JSON-safe by construction (serialize-scope), but stay defensive.
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const values = new Map<string, string>();
|
|
57
|
+
const expr = node as ExpressionNode;
|
|
58
|
+
let errorMessage: string | null = null;
|
|
59
|
+
try {
|
|
60
|
+
evaluateExpression(expr, state, null, undefined, {
|
|
61
|
+
report: (path, value) => values.set(path.join("/"), formatPreviewValue(value)),
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
errorMessage = error instanceof Error ? error.message : String(error);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { error: errorMessage, mutating: isMutating(expr.operator), values };
|
|
68
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-adoption.ts — a late-bound slot for opening a freshly created project in this window.
|
|
3
|
+
*
|
|
4
|
+
* The AI `create_project` tool (services/ai-project-tools.ts) needs to run the full project-open
|
|
5
|
+
* flow (`openRecentProject` in studio.ts), but services must not import studio.ts — the entry
|
|
6
|
+
* module imports the services. studio.ts registers the adopter at boot; the tool calls
|
|
7
|
+
* `adoptProject(root)`.
|
|
8
|
+
*
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
let _adopter: ((root: string) => Promise<void>) | null = null;
|
|
13
|
+
|
|
14
|
+
/** Register the project-open flow (studio.ts boot). */
|
|
15
|
+
export function setProjectAdopter(fn: (root: string) => Promise<void>): void {
|
|
16
|
+
_adopter = fn;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Open the project at `root` in this window via the registered flow.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} root - Absolute project root.
|
|
23
|
+
*/
|
|
24
|
+
export async function adoptProject(root: string): Promise<void> {
|
|
25
|
+
if (!_adopter) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"Project adoption is not available in this environment — open the project manually from the welcome screen.",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
await _adopter(root);
|
|
31
|
+
}
|
package/src/site-context.ts
CHANGED
|
@@ -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,
|
|
@@ -272,6 +273,7 @@ export async function updateSiteConfig(patch: Partial<ProjectConfig>) {
|
|
|
272
273
|
} as ProjectConfig;
|
|
273
274
|
await platform.writeFile("project.json", JSON.stringify(config, null, 2));
|
|
274
275
|
setProjectState({ ...requireProjectState(), projectConfig: config });
|
|
276
|
+
setWorkspaceProject(workspace.projectRoot, config);
|
|
275
277
|
if ("extensions" in patch) {
|
|
276
278
|
const { loadFormats, refreshExtensionUi, refreshFormats } =
|
|
277
279
|
await import("./format/format-host");
|
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 {
|
|
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
|
|
40
|
+
import { applyTransform, initCanvasUtils } from "./canvas/canvas-utils";
|
|
34
41
|
import {
|
|
35
42
|
initCanvasRender,
|
|
36
43
|
renderCanvas,
|
|
@@ -113,12 +120,15 @@ import { components as _swc } from "./ui/spectrum";
|
|
|
113
120
|
import "./ui/panel-resize.js";
|
|
114
121
|
// Built-in schema-form controls (schema-builder, secret) register on import
|
|
115
122
|
import "./ui/form-controls.js";
|
|
116
|
-
import { initLayers } from "./ui/layers";
|
|
123
|
+
import { initLayers, showSaveDiscardDialog } from "./ui/layers";
|
|
117
124
|
import { initShortcuts } from "./editor/shortcuts";
|
|
118
125
|
import { renderActivityBar, mount as mountActivityBar } from "./panels/activity-bar";
|
|
119
126
|
import * as toolbarPanel from "./panels/toolbar";
|
|
120
127
|
import * as overlaysPanel from "./panels/overlays";
|
|
128
|
+
import * as frontmatterPanelMod from "./panels/frontmatter-panel";
|
|
121
129
|
import * as rightPanelMod from "./panels/right-panel";
|
|
130
|
+
import * as chatPanelMod from "./panels/chat-panel";
|
|
131
|
+
import { setProjectAdopter } from "./services/project-adoption";
|
|
122
132
|
import * as leftPanelMod from "./panels/left-panel";
|
|
123
133
|
import * as tabStrip from "./panels/tab-strip";
|
|
124
134
|
import * as tabBar from "./panels/tab-bar";
|
|
@@ -127,6 +137,7 @@ import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from ".
|
|
|
127
137
|
import { registerCanvasDndBridge } from "./panels/canvas-dnd-bridge";
|
|
128
138
|
import { defaultDef } from "./panels/shared";
|
|
129
139
|
import { registerFunctionCompletions } from "./panels/editors";
|
|
140
|
+
import { closeFormulaWorkspace } from "./panels/formula-workspace";
|
|
130
141
|
import {
|
|
131
142
|
initBlockActionBar,
|
|
132
143
|
isEditChromeTarget,
|
|
@@ -142,6 +153,7 @@ import { initWelcome } from "./panels/welcome-screen";
|
|
|
142
153
|
import { openAddRepoModal } from "./new-project/add-repo-modal";
|
|
143
154
|
import { openNewProjectModal } from "./new-project/new-project-modal";
|
|
144
155
|
import type { DocumentStackEntry, GitDiffState } from "./types";
|
|
156
|
+
import type { Tab } from "./tabs/tab";
|
|
145
157
|
import type { JxPath } from "./state";
|
|
146
158
|
import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
|
|
147
159
|
|
|
@@ -223,19 +235,41 @@ async function navigateToComponent(componentPath: string) {
|
|
|
223
235
|
}
|
|
224
236
|
}
|
|
225
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
|
+
|
|
226
266
|
async function navigateBack() {
|
|
227
267
|
const tab = activeTab.value;
|
|
228
268
|
if (!tab?.session.documentStack || tab.session.documentStack.length === 0) {
|
|
229
269
|
return;
|
|
230
270
|
}
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
const platform = getPlatform();
|
|
234
|
-
await platform.writeFile(tab.documentPath, await serializeDocument(tab));
|
|
235
|
-
} catch (error) {
|
|
236
|
-
const err = error as Error;
|
|
237
|
-
statusMessage(`Save error: ${err.message}`);
|
|
238
|
-
}
|
|
271
|
+
if (!(await confirmLeaveDirtyChild(tab))) {
|
|
272
|
+
return;
|
|
239
273
|
}
|
|
240
274
|
|
|
241
275
|
// Pop the stack
|
|
@@ -262,14 +296,8 @@ async function navigateToLevel(targetIndex: number) {
|
|
|
262
296
|
if (!stack || targetIndex < 0 || targetIndex >= stack.length) {
|
|
263
297
|
return;
|
|
264
298
|
}
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
const platform = getPlatform();
|
|
268
|
-
await platform.writeFile(tab.documentPath, await serializeDocument(tab));
|
|
269
|
-
} catch (error) {
|
|
270
|
-
const err = error as Error;
|
|
271
|
-
statusMessage(`Save error: ${err.message}`);
|
|
272
|
-
}
|
|
299
|
+
if (!(await confirmLeaveDirtyChild(tab))) {
|
|
300
|
+
return;
|
|
273
301
|
}
|
|
274
302
|
|
|
275
303
|
const frame = stack[targetIndex] as DocumentStackEntry;
|
|
@@ -393,6 +421,7 @@ initQuickSearch({ openRecentProject: (root: string) => openRecentProject(root) }
|
|
|
393
421
|
tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
|
|
394
422
|
|
|
395
423
|
tabBar.mount(document.querySelector("#tab-bar") as HTMLElement, {
|
|
424
|
+
closeFormulaWorkspace: () => closeFormulaWorkspace(),
|
|
396
425
|
closeFunctionEditor: () => closeFunctionEditor(),
|
|
397
426
|
exportFile,
|
|
398
427
|
getCanvasMode,
|
|
@@ -442,6 +471,26 @@ document.addEventListener(
|
|
|
442
471
|
true,
|
|
443
472
|
);
|
|
444
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
|
+
|
|
445
494
|
initCanvasUtils({
|
|
446
495
|
getCanvasMode,
|
|
447
496
|
getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
|
|
@@ -518,10 +567,12 @@ effect(() => {
|
|
|
518
567
|
if (tab) {
|
|
519
568
|
void tab.doc.mode;
|
|
520
569
|
void tab.session.ui.canvasMode;
|
|
570
|
+
void tab.session.ui.editingFormula;
|
|
521
571
|
void tab.session.ui.editingFunction;
|
|
522
572
|
void tab.session.ui.featureToggles;
|
|
523
573
|
void tab.session.ui.preview;
|
|
524
574
|
void tab.session.ui.previewParams;
|
|
575
|
+
void tab.session.ui.previewProps;
|
|
525
576
|
void tab.session.ui.settingsTab;
|
|
526
577
|
void tab.session.ui.showLayout;
|
|
527
578
|
void tab.session.ui.stylebookTab;
|
|
@@ -537,6 +588,15 @@ rightPanelMod.mount({
|
|
|
537
588
|
renderCanvas: () => renderCanvas(),
|
|
538
589
|
});
|
|
539
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
|
+
|
|
540
600
|
leftPanelMod.mount({
|
|
541
601
|
cloneRepository: () => cloneRepository({ openRecentProject }),
|
|
542
602
|
defBadgeLabel,
|
|
@@ -568,6 +628,8 @@ leftPanelMod.mount({
|
|
|
568
628
|
registerRenderer("leftPanel", () => leftPanelMod.render());
|
|
569
629
|
registerRenderer("canvas", () => renderCanvas());
|
|
570
630
|
registerRenderer("rightPanel", () => rightPanelMod.render());
|
|
631
|
+
registerRenderer("frontmatterPanel", () => frontmatterPanelMod.render());
|
|
632
|
+
registerRenderer("chatPanel", () => chatPanelMod.render());
|
|
571
633
|
registerRenderer("overlays", () => overlaysPanel.render());
|
|
572
634
|
setStatusbarRenderer(() => renderStatusbar());
|
|
573
635
|
mountStatusbar();
|
|
@@ -667,6 +729,7 @@ if (_projectParam) {
|
|
|
667
729
|
searchQuery: "",
|
|
668
730
|
selectedPath: siteCtx.fileRelPath || null,
|
|
669
731
|
});
|
|
732
|
+
setWorkspaceProject(siteCtx.sitePath, siteCtx.projectConfig || null);
|
|
670
733
|
|
|
671
734
|
refreshExtensionUi(platform);
|
|
672
735
|
await autoSyncProjectOnOpen();
|
|
@@ -859,6 +922,7 @@ async function openRecentProject(root: string) {
|
|
|
859
922
|
searchQuery: "",
|
|
860
923
|
selectedPath: null,
|
|
861
924
|
});
|
|
925
|
+
setWorkspaceProject(root, config);
|
|
862
926
|
|
|
863
927
|
await autoSyncProjectOnOpen();
|
|
864
928
|
await ensureDependenciesInstalled();
|
|
@@ -918,7 +982,6 @@ initShortcuts(() => ({
|
|
|
918
982
|
openProject,
|
|
919
983
|
panX: view.panX,
|
|
920
984
|
panY: view.panY,
|
|
921
|
-
positionZoomIndicator,
|
|
922
985
|
saveFile,
|
|
923
986
|
setPan: (x, y) => {
|
|
924
987
|
view.panX = x;
|
|
@@ -926,35 +989,3 @@ initShortcuts(() => ({
|
|
|
926
989
|
view.needsCenter = false;
|
|
927
990
|
},
|
|
928
991
|
}));
|
|
929
|
-
|
|
930
|
-
// ─── Autosave (registered as update middleware) ──────────────────────────────
|
|
931
|
-
|
|
932
|
-
const AUTO_SAVE_DELAY = 2000;
|
|
933
|
-
|
|
934
|
-
function scheduleAutosave() {
|
|
935
|
-
const tab = activeTab.value;
|
|
936
|
-
if (!tab?.fileHandle || !tab.doc.dirty) {
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
if (view.autosaveTimer) {
|
|
940
|
-
clearTimeout(view.autosaveTimer);
|
|
941
|
-
}
|
|
942
|
-
view.autosaveTimer = setTimeout(async () => {
|
|
943
|
-
const t = activeTab.value;
|
|
944
|
-
if (t?.fileHandle && t.doc.dirty && "createWritable" in t.fileHandle) {
|
|
945
|
-
try {
|
|
946
|
-
const writable = await t.fileHandle.createWritable();
|
|
947
|
-
await writable.write(await serializeDocument(t));
|
|
948
|
-
await writable.close();
|
|
949
|
-
t.doc.dirty = false;
|
|
950
|
-
statusMessage("Auto-saved");
|
|
951
|
-
} catch {}
|
|
952
|
-
}
|
|
953
|
-
}, AUTO_SAVE_DELAY);
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
effect(() => {
|
|
957
|
-
if (activeTab.value?.doc.dirty) {
|
|
958
|
-
scheduleAutosave();
|
|
959
|
-
}
|
|
960
|
-
});
|
package/src/tabs/patch-ops.ts
CHANGED
|
@@ -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
|
}
|