@hachej/boring-diagram 0.1.67
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/README.md +38 -0
- package/dist/front/index.d.ts +5 -0
- package/dist/front/index.js +1042 -0
- package/dist/server/index.d.ts +10 -0
- package/dist/server/index.js +255 -0
- package/dist/shared/index.d.ts +8 -0
- package/dist/shared/index.js +28 -0
- package/package.json +81 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/shared/index.ts
|
|
12
|
+
function isDiagramPath(path) {
|
|
13
|
+
const lower = path.toLowerCase();
|
|
14
|
+
return lower.endsWith(".excalidraw") || lower.endsWith(".excalidraw.png");
|
|
15
|
+
}
|
|
16
|
+
function titleForPath(path) {
|
|
17
|
+
if (!path) return "Diagram";
|
|
18
|
+
return path.split("/").pop() || path;
|
|
19
|
+
}
|
|
20
|
+
function saveTargetFor(path) {
|
|
21
|
+
if (path.toLowerCase().endsWith(".excalidraw.png")) return path.replace(/\.png$/i, "");
|
|
22
|
+
return path || "untitled.excalidraw";
|
|
23
|
+
}
|
|
24
|
+
function renderTargetFor(path) {
|
|
25
|
+
const source = saveTargetFor(path);
|
|
26
|
+
if (source.toLowerCase().endsWith(".excalidraw")) return source.replace(/\.excalidraw$/i, ".render.png");
|
|
27
|
+
return `${source}.render.png`;
|
|
28
|
+
}
|
|
29
|
+
var DIAGRAM_PLUGIN_ID, DIAGRAM_PANEL_ID;
|
|
30
|
+
var init_shared = __esm({
|
|
31
|
+
"src/shared/index.ts"() {
|
|
32
|
+
"use strict";
|
|
33
|
+
DIAGRAM_PLUGIN_ID = "diagram";
|
|
34
|
+
DIAGRAM_PANEL_ID = "diagram.panel";
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// src/front/DiagramPane.tsx
|
|
39
|
+
var DiagramPane_exports = {};
|
|
40
|
+
__export(DiagramPane_exports, {
|
|
41
|
+
DiagramPane: () => DiagramPane
|
|
42
|
+
});
|
|
43
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
44
|
+
import { Excalidraw, exportToBlob, loadFromBlob, serializeAsJSON } from "@excalidraw/excalidraw";
|
|
45
|
+
import "@excalidraw/excalidraw/index.css";
|
|
46
|
+
import { useApiBaseUrl, useWorkspaceRequestId } from "@hachej/boring-workspace";
|
|
47
|
+
import { ModelSelect } from "@hachej/boring-agent/front";
|
|
48
|
+
import { Toolbar, ToolbarGroup } from "@hachej/boring-ui-kit";
|
|
49
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
50
|
+
function bytesToArrayBuffer(bytes) {
|
|
51
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
52
|
+
copy.set(bytes);
|
|
53
|
+
return copy.buffer;
|
|
54
|
+
}
|
|
55
|
+
function normalizeWorkspacePath(path) {
|
|
56
|
+
return path.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
57
|
+
}
|
|
58
|
+
function workspacePathsEqual(left, right) {
|
|
59
|
+
if (!left || !right) return false;
|
|
60
|
+
return normalizeWorkspacePath(left) === normalizeWorkspacePath(right);
|
|
61
|
+
}
|
|
62
|
+
function SaveIcon() {
|
|
63
|
+
return /* @__PURE__ */ jsxs("svg", { "aria-hidden": "true", className: "h-4 w-4", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
|
|
64
|
+
/* @__PURE__ */ jsx("path", { d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" }),
|
|
65
|
+
/* @__PURE__ */ jsx("path", { d: "M17 21v-8H7v8" }),
|
|
66
|
+
/* @__PURE__ */ jsx("path", { d: "M7 3v5h8" })
|
|
67
|
+
] });
|
|
68
|
+
}
|
|
69
|
+
function ReloadIcon() {
|
|
70
|
+
return /* @__PURE__ */ jsxs("svg", { "aria-hidden": "true", className: "h-4 w-4", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
|
|
71
|
+
/* @__PURE__ */ jsx("path", { d: "M21 12a9 9 0 1 1-2.64-6.36" }),
|
|
72
|
+
/* @__PURE__ */ jsx("path", { d: "M21 3v6h-6" })
|
|
73
|
+
] });
|
|
74
|
+
}
|
|
75
|
+
function ChevronIcon({ open }) {
|
|
76
|
+
return /* @__PURE__ */ jsx("svg", { "aria-hidden": "true", className: "h-4 w-4", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("path", { d: open ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6" }) });
|
|
77
|
+
}
|
|
78
|
+
function readPromptCatalog() {
|
|
79
|
+
if (typeof window === "undefined") return [];
|
|
80
|
+
try {
|
|
81
|
+
const raw = window.localStorage.getItem(PROMPT_CATALOG_STORAGE_KEY);
|
|
82
|
+
const parsed = raw ? JSON.parse(raw) : [];
|
|
83
|
+
if (!Array.isArray(parsed)) return [];
|
|
84
|
+
return parsed.flatMap((item) => {
|
|
85
|
+
if (!item || typeof item.prompt !== "string" || !item.prompt.trim()) return [];
|
|
86
|
+
return [{
|
|
87
|
+
id: typeof item.id === "string" && item.id ? item.id : newPromptCatalogId(),
|
|
88
|
+
label: typeof item.label === "string" && item.label ? item.label : promptCatalogLabel(item.prompt),
|
|
89
|
+
prompt: item.prompt,
|
|
90
|
+
updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : (/* @__PURE__ */ new Date()).toISOString()
|
|
91
|
+
}];
|
|
92
|
+
}).slice(0, MAX_PROMPT_CATALOG_ITEMS);
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function writePromptCatalog(items) {
|
|
98
|
+
if (typeof window === "undefined") return;
|
|
99
|
+
try {
|
|
100
|
+
window.localStorage.setItem(PROMPT_CATALOG_STORAGE_KEY, JSON.stringify(items.slice(0, MAX_PROMPT_CATALOG_ITEMS)));
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function promptCatalogLabel(prompt) {
|
|
105
|
+
const oneLine = prompt.trim().replace(/\s+/g, " ");
|
|
106
|
+
return oneLine.length > 48 ? `${oneLine.slice(0, 45)}\u2026` : oneLine || "Untitled prompt";
|
|
107
|
+
}
|
|
108
|
+
function upsertPromptCatalogItem(items, prompt) {
|
|
109
|
+
const trimmed = prompt.trim();
|
|
110
|
+
if (!trimmed) return items;
|
|
111
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
112
|
+
const existing = items.find((item) => item.prompt.trim() === trimmed);
|
|
113
|
+
const nextItem = existing ? { ...existing, label: promptCatalogLabel(trimmed), updatedAt: now } : { id: newPromptCatalogId(), label: promptCatalogLabel(trimmed), prompt: trimmed, updatedAt: now };
|
|
114
|
+
return [nextItem, ...items.filter((item) => item.id !== nextItem.id && item.prompt.trim() !== trimmed)].slice(0, MAX_PROMPT_CATALOG_ITEMS);
|
|
115
|
+
}
|
|
116
|
+
function newPromptCatalogId() {
|
|
117
|
+
return globalThis.crypto?.randomUUID?.() ?? `prompt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
118
|
+
}
|
|
119
|
+
async function blobToBase64(blob) {
|
|
120
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
121
|
+
const reader = new FileReader();
|
|
122
|
+
reader.onload = () => resolve(String(reader.result ?? ""));
|
|
123
|
+
reader.onerror = () => reject(reader.error ?? new Error("Failed to read image blob"));
|
|
124
|
+
reader.readAsDataURL(blob);
|
|
125
|
+
});
|
|
126
|
+
return dataUrl.replace(/^data:[^;]+;base64,/, "");
|
|
127
|
+
}
|
|
128
|
+
function renderModelKey(model) {
|
|
129
|
+
return `${model.provider}:${model.id}`;
|
|
130
|
+
}
|
|
131
|
+
function sceneToJson(path, elements, appState, files) {
|
|
132
|
+
return serializeAsJSON(
|
|
133
|
+
elements,
|
|
134
|
+
{ ...appState, name: titleForPath(path) },
|
|
135
|
+
files,
|
|
136
|
+
"local"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
async function loadSceneFromText(text) {
|
|
140
|
+
if (!text.trim()) return { elements: [], appState: {}, files: {} };
|
|
141
|
+
const parsed = JSON.parse(text);
|
|
142
|
+
return {
|
|
143
|
+
elements: Array.isArray(parsed.elements) ? parsed.elements : [],
|
|
144
|
+
appState: parsed.appState && typeof parsed.appState === "object" ? parsed.appState : {},
|
|
145
|
+
files: parsed.files && typeof parsed.files === "object" ? parsed.files : {}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function metadataPathForRender(outputPath) {
|
|
149
|
+
return outputPath.replace(/\.png$/i, ".json");
|
|
150
|
+
}
|
|
151
|
+
async function loadSceneFromBytes(bytes, mimeType) {
|
|
152
|
+
if (bytes.byteLength === 0) return { elements: [], appState: {}, files: {} };
|
|
153
|
+
const scene = await loadFromBlob(new Blob([bytesToArrayBuffer(bytes)], { type: mimeType }), null, null);
|
|
154
|
+
return {
|
|
155
|
+
elements: scene.elements ?? [],
|
|
156
|
+
appState: scene.appState ?? {},
|
|
157
|
+
files: scene.files ?? {}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function DiagramPane({ params }) {
|
|
161
|
+
const path = params?.path ?? "";
|
|
162
|
+
const rawFilesystem = typeof params?.filesystem === "string" && params.filesystem ? params.filesystem : void 0;
|
|
163
|
+
const filesystem = rawFilesystem && rawFilesystem !== "user" ? rawFilesystem : void 0;
|
|
164
|
+
const viewOnly = params?.mode === "view";
|
|
165
|
+
const title = useMemo(() => titleForPath(path), [path]);
|
|
166
|
+
const saveTarget = useMemo(() => saveTargetFor(path), [path]);
|
|
167
|
+
const renderTarget = useMemo(() => renderTargetFor(path), [path]);
|
|
168
|
+
const apiBaseUrl = useApiBaseUrl();
|
|
169
|
+
const workspaceId = useWorkspaceRequestId();
|
|
170
|
+
const excalidrawApiRef = useRef(null);
|
|
171
|
+
const tokenRef = useRef(0);
|
|
172
|
+
const loadedRef = useRef(false);
|
|
173
|
+
const savingRef = useRef(false);
|
|
174
|
+
const readOnlyRef = useRef(false);
|
|
175
|
+
const queuedSaveRef = useRef(null);
|
|
176
|
+
const lastJsonRef = useRef("");
|
|
177
|
+
const latestSceneRef = useRef(null);
|
|
178
|
+
const pendingAutosaveRef = useRef(false);
|
|
179
|
+
const saveTimerRef = useRef(null);
|
|
180
|
+
const suppressAutosaveUntilRef = useRef(0);
|
|
181
|
+
const targetMtimeRef = useRef({});
|
|
182
|
+
const observedMissingTargetsRef = useRef(/* @__PURE__ */ new Set());
|
|
183
|
+
const saveWorkspaceFileRef = useRef(async () => {
|
|
184
|
+
});
|
|
185
|
+
const flushPendingSaveRef = useRef(async () => {
|
|
186
|
+
});
|
|
187
|
+
const saveIdleResolversRef = useRef([]);
|
|
188
|
+
const [refreshNonce, setRefreshNonce] = useState(0);
|
|
189
|
+
const [editorEpoch, setEditorEpoch] = useState(0);
|
|
190
|
+
const [editorReady, setEditorReady] = useState(false);
|
|
191
|
+
const [status, setStatus] = useState("Loading editor\u2026");
|
|
192
|
+
const [readOnly, setReadOnly] = useState(false);
|
|
193
|
+
const [error, setError] = useState(null);
|
|
194
|
+
const [viewMode, setViewMode] = useState("diagram");
|
|
195
|
+
const [renderDrawerOpen, setRenderDrawerOpen] = useState(false);
|
|
196
|
+
const [renderPrompt, setRenderPrompt] = useState(DEFAULT_RENDER_PROMPT);
|
|
197
|
+
const [promptCatalog, setPromptCatalog] = useState(() => readPromptCatalog());
|
|
198
|
+
const [renderModels, setRenderModels] = useState([]);
|
|
199
|
+
const [selectedRenderModel, setSelectedRenderModel] = useState(null);
|
|
200
|
+
const [renderAuthHint, setRenderAuthHint] = useState(null);
|
|
201
|
+
const [rendering, setRendering] = useState(false);
|
|
202
|
+
const [renderOutputPath, setRenderOutputPath] = useState(null);
|
|
203
|
+
const [renderVersion, setRenderVersion] = useState(0);
|
|
204
|
+
const [renderStale, setRenderStale] = useState(false);
|
|
205
|
+
const [renderMetadata, setRenderMetadata] = useState(null);
|
|
206
|
+
const [renderMetadataError, setRenderMetadataError] = useState(null);
|
|
207
|
+
const [renderMetadataOpen, setRenderMetadataOpen] = useState(false);
|
|
208
|
+
const loadKey = useMemo(() => [path, filesystem ?? "user", viewOnly ? "view" : "edit", String(refreshNonce)].join("\0"), [filesystem, path, refreshNonce, viewOnly]);
|
|
209
|
+
const freshEditorForLoadKeyRef = useRef(null);
|
|
210
|
+
const requestHeaders = useMemo(() => {
|
|
211
|
+
const headers = {};
|
|
212
|
+
if (workspaceId) headers["x-boring-workspace-id"] = String(workspaceId);
|
|
213
|
+
return headers;
|
|
214
|
+
}, [workspaceId]);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
setViewMode("diagram");
|
|
217
|
+
setRenderOutputPath(null);
|
|
218
|
+
setRenderVersion(0);
|
|
219
|
+
setRenderStale(false);
|
|
220
|
+
setRenderMetadata(null);
|
|
221
|
+
setRenderMetadataError(null);
|
|
222
|
+
setRenderMetadataOpen(false);
|
|
223
|
+
}, [loadKey]);
|
|
224
|
+
const fileQuery = useCallback((target) => {
|
|
225
|
+
const query = new URLSearchParams({ path: target });
|
|
226
|
+
if (filesystem) query.set("filesystem", filesystem);
|
|
227
|
+
return query.toString();
|
|
228
|
+
}, [filesystem]);
|
|
229
|
+
const readTextFile = useCallback(async (target) => {
|
|
230
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/files?${fileQuery(target)}`, {
|
|
231
|
+
credentials: "include",
|
|
232
|
+
headers: requestHeaders
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) throw new WorkspaceFileReadError(`Failed to load ${target}: ${response.status}`, response.status);
|
|
235
|
+
const result = await response.json();
|
|
236
|
+
return {
|
|
237
|
+
content: typeof result.content === "string" ? result.content : "",
|
|
238
|
+
mtimeMs: typeof result.mtimeMs === "number" ? result.mtimeMs : void 0,
|
|
239
|
+
access: result.access === "readonly" || result.access === "readwrite" ? result.access : void 0
|
|
240
|
+
};
|
|
241
|
+
}, [apiBaseUrl, fileQuery, requestHeaders]);
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!renderTarget) return;
|
|
244
|
+
let cancelled = false;
|
|
245
|
+
void fetch(`${apiBaseUrl}/api/v1/files/raw?${fileQuery(renderTarget)}`, {
|
|
246
|
+
credentials: "include",
|
|
247
|
+
headers: requestHeaders
|
|
248
|
+
}).then((response) => {
|
|
249
|
+
if (cancelled || !response.ok) return;
|
|
250
|
+
setRenderOutputPath(renderTarget);
|
|
251
|
+
setRenderVersion(Date.now());
|
|
252
|
+
setRenderStale(false);
|
|
253
|
+
}).catch(() => {
|
|
254
|
+
});
|
|
255
|
+
return () => {
|
|
256
|
+
cancelled = true;
|
|
257
|
+
};
|
|
258
|
+
}, [apiBaseUrl, fileQuery, renderTarget, requestHeaders]);
|
|
259
|
+
const rememberTargetMtimeIfPresent = useCallback(async (target) => {
|
|
260
|
+
try {
|
|
261
|
+
const result = await readTextFile(target);
|
|
262
|
+
targetMtimeRef.current[target] = result.mtimeMs;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
targetMtimeRef.current[target] = void 0;
|
|
265
|
+
if (err instanceof WorkspaceFileReadError && err.status === 404) observedMissingTargetsRef.current.add(target);
|
|
266
|
+
}
|
|
267
|
+
}, [readTextFile]);
|
|
268
|
+
const applyScene = useCallback((scene, target) => {
|
|
269
|
+
const api = excalidrawApiRef.current;
|
|
270
|
+
if (!api) return;
|
|
271
|
+
suppressAutosaveUntilRef.current = Date.now() + 1e3;
|
|
272
|
+
api.resetScene();
|
|
273
|
+
api.updateScene({ elements: scene.elements, appState: scene.appState });
|
|
274
|
+
const files = Object.values(scene.files);
|
|
275
|
+
if (files.length > 0) api.addFiles(files);
|
|
276
|
+
api.history.clear();
|
|
277
|
+
latestSceneRef.current = scene;
|
|
278
|
+
lastJsonRef.current = sceneToJson(target, scene.elements, scene.appState, scene.files);
|
|
279
|
+
}, []);
|
|
280
|
+
const loadWorkspaceFile = useCallback(async () => {
|
|
281
|
+
if (!editorReady) return;
|
|
282
|
+
const api = excalidrawApiRef.current;
|
|
283
|
+
if (!api) return;
|
|
284
|
+
await flushPendingSaveRef.current();
|
|
285
|
+
if (freshEditorForLoadKeyRef.current === null) {
|
|
286
|
+
freshEditorForLoadKeyRef.current = loadKey;
|
|
287
|
+
} else if (freshEditorForLoadKeyRef.current !== loadKey) {
|
|
288
|
+
freshEditorForLoadKeyRef.current = loadKey;
|
|
289
|
+
excalidrawApiRef.current = null;
|
|
290
|
+
setEditorReady(false);
|
|
291
|
+
setEditorEpoch((value) => value + 1);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const token = ++tokenRef.current;
|
|
295
|
+
loadedRef.current = false;
|
|
296
|
+
readOnlyRef.current = false;
|
|
297
|
+
queuedSaveRef.current = null;
|
|
298
|
+
lastJsonRef.current = "";
|
|
299
|
+
latestSceneRef.current = null;
|
|
300
|
+
pendingAutosaveRef.current = false;
|
|
301
|
+
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
302
|
+
targetMtimeRef.current = {};
|
|
303
|
+
observedMissingTargetsRef.current = /* @__PURE__ */ new Set();
|
|
304
|
+
setReadOnly(false);
|
|
305
|
+
setError(null);
|
|
306
|
+
try {
|
|
307
|
+
if (!path) {
|
|
308
|
+
const existing = await readTextFile(saveTarget).catch((err) => {
|
|
309
|
+
if (err instanceof WorkspaceFileReadError && err.status === 404) {
|
|
310
|
+
observedMissingTargetsRef.current.add(saveTarget);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
throw err;
|
|
314
|
+
});
|
|
315
|
+
if (token !== tokenRef.current) return;
|
|
316
|
+
if (existing) {
|
|
317
|
+
const scene2 = await loadSceneFromText(existing.content);
|
|
318
|
+
targetMtimeRef.current[saveTarget] = existing.mtimeMs;
|
|
319
|
+
applyScene(scene2, saveTarget);
|
|
320
|
+
const isReadonly2 = viewOnly || existing.access === "readonly";
|
|
321
|
+
readOnlyRef.current = isReadonly2;
|
|
322
|
+
setReadOnly(isReadonly2);
|
|
323
|
+
loadedRef.current = true;
|
|
324
|
+
setStatus(isReadonly2 ? `Loaded ${saveTarget} read-only` : `Loaded ${saveTarget}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
api.resetScene();
|
|
328
|
+
api.history.clear();
|
|
329
|
+
latestSceneRef.current = { elements: [], appState: {}, files: {} };
|
|
330
|
+
lastJsonRef.current = sceneToJson(saveTarget, [], {}, {});
|
|
331
|
+
readOnlyRef.current = viewOnly;
|
|
332
|
+
setReadOnly(viewOnly);
|
|
333
|
+
loadedRef.current = true;
|
|
334
|
+
setStatus(viewOnly ? "New drawing \u2014 read-only" : `New drawing \u2014 saves to ${saveTarget}`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
setStatus(`Loading ${title}\u2026`);
|
|
338
|
+
let scene;
|
|
339
|
+
let createInitialSidecar = false;
|
|
340
|
+
let loadedAccess;
|
|
341
|
+
if (path.toLowerCase().endsWith(".excalidraw")) {
|
|
342
|
+
const result = await readTextFile(path);
|
|
343
|
+
if (token !== tokenRef.current) return;
|
|
344
|
+
targetMtimeRef.current[saveTarget] = result.mtimeMs;
|
|
345
|
+
loadedAccess = result.access;
|
|
346
|
+
scene = await loadSceneFromText(result.content);
|
|
347
|
+
} else {
|
|
348
|
+
const sidecar = await readTextFile(saveTarget).catch((err) => {
|
|
349
|
+
if (err instanceof WorkspaceFileReadError && err.status === 404) {
|
|
350
|
+
observedMissingTargetsRef.current.add(saveTarget);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
throw err;
|
|
354
|
+
});
|
|
355
|
+
if (token !== tokenRef.current) return;
|
|
356
|
+
if (sidecar) {
|
|
357
|
+
targetMtimeRef.current[saveTarget] = sidecar.mtimeMs;
|
|
358
|
+
loadedAccess = sidecar.access;
|
|
359
|
+
try {
|
|
360
|
+
scene = await loadSceneFromText(sidecar.content);
|
|
361
|
+
setStatus(`Loaded ${saveTarget} sidecar for ${path}`);
|
|
362
|
+
} catch (sidecarErr) {
|
|
363
|
+
if (filesystem) throw sidecarErr;
|
|
364
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/files/raw?${fileQuery(path)}`, {
|
|
365
|
+
credentials: "include",
|
|
366
|
+
headers: requestHeaders
|
|
367
|
+
});
|
|
368
|
+
if (!response.ok) throw new Error(`Failed to load ${path}: ${response.status}`);
|
|
369
|
+
const buffer = await response.arrayBuffer();
|
|
370
|
+
if (token !== tokenRef.current) return;
|
|
371
|
+
loadedAccess = "readonly";
|
|
372
|
+
scene = await loadSceneFromBytes(new Uint8Array(buffer), response.headers.get("content-type") ?? "image/png");
|
|
373
|
+
setStatus(`Loaded embedded PNG from ${path}; ${saveTarget} sidecar is invalid and was not overwritten`);
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
if (filesystem) {
|
|
377
|
+
const source = await readTextFile(path).catch(() => null);
|
|
378
|
+
loadedAccess = source?.access;
|
|
379
|
+
throw new Error(`Opening ${path} from filesystem ${filesystem} requires an existing ${saveTarget} sidecar; binary PNG import is only supported for workspace files.`);
|
|
380
|
+
}
|
|
381
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/files/raw?${fileQuery(path)}`, {
|
|
382
|
+
credentials: "include",
|
|
383
|
+
headers: requestHeaders
|
|
384
|
+
});
|
|
385
|
+
if (!response.ok) throw new Error(`Failed to load ${path}: ${response.status}`);
|
|
386
|
+
const buffer = await response.arrayBuffer();
|
|
387
|
+
if (token !== tokenRef.current) return;
|
|
388
|
+
await rememberTargetMtimeIfPresent(saveTarget);
|
|
389
|
+
createInitialSidecar = true;
|
|
390
|
+
scene = await loadSceneFromBytes(new Uint8Array(buffer), response.headers.get("content-type") ?? "image/png");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (token !== tokenRef.current) return;
|
|
394
|
+
applyScene(scene, saveTarget);
|
|
395
|
+
const isReadonly = viewOnly || loadedAccess === "readonly";
|
|
396
|
+
readOnlyRef.current = isReadonly;
|
|
397
|
+
setReadOnly(isReadonly);
|
|
398
|
+
loadedRef.current = true;
|
|
399
|
+
if (createInitialSidecar && !isReadonly) {
|
|
400
|
+
await saveWorkspaceFileRef.current({ token, target: saveTarget, json: lastJsonRef.current });
|
|
401
|
+
}
|
|
402
|
+
setStatus(isReadonly ? `Loaded ${path} read-only` : path === saveTarget ? `Loaded ${path}` : `Loaded ${path} \u2014 edits save to ${saveTarget}`);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (token !== tokenRef.current) return;
|
|
405
|
+
loadedRef.current = false;
|
|
406
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
407
|
+
setError(message);
|
|
408
|
+
setStatus("Load failed \u2014 saving disabled");
|
|
409
|
+
}
|
|
410
|
+
}, [apiBaseUrl, applyScene, editorReady, fileQuery, loadKey, path, readTextFile, rememberTargetMtimeIfPresent, requestHeaders, saveTarget, title, viewOnly]);
|
|
411
|
+
const saveWorkspaceFile = useCallback(async (request) => {
|
|
412
|
+
if (request.token !== tokenRef.current || !loadedRef.current || readOnlyRef.current) return;
|
|
413
|
+
if (savingRef.current) {
|
|
414
|
+
queuedSaveRef.current = request;
|
|
415
|
+
setStatus(`Save queued for ${request.target}\u2026`);
|
|
416
|
+
await new Promise((resolve) => saveIdleResolversRef.current.push(resolve));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
savingRef.current = true;
|
|
420
|
+
setStatus(`Saving ${request.target}\u2026`);
|
|
421
|
+
setError(null);
|
|
422
|
+
try {
|
|
423
|
+
const body = {
|
|
424
|
+
path: request.target,
|
|
425
|
+
content: request.json,
|
|
426
|
+
createDirs: true
|
|
427
|
+
};
|
|
428
|
+
if (filesystem) body.filesystem = filesystem;
|
|
429
|
+
const expectedMtimeMs = targetMtimeRef.current[request.target];
|
|
430
|
+
if (typeof expectedMtimeMs === "number") body.expectedMtimeMs = expectedMtimeMs;
|
|
431
|
+
if (typeof expectedMtimeMs !== "number" && observedMissingTargetsRef.current.has(request.target)) {
|
|
432
|
+
await readTextFile(request.target).then(
|
|
433
|
+
() => {
|
|
434
|
+
throw new Error(`${request.target} now exists. Reload before saving again.`);
|
|
435
|
+
},
|
|
436
|
+
(err) => {
|
|
437
|
+
if (err instanceof WorkspaceFileReadError && err.status === 404) return;
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/files`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
credentials: "include",
|
|
445
|
+
headers: { "content-type": "application/json", ...requestHeaders },
|
|
446
|
+
body: JSON.stringify(body)
|
|
447
|
+
});
|
|
448
|
+
if (response.status === 409) throw new Error("Save conflict: file changed on disk. Reload before saving again.");
|
|
449
|
+
if (!response.ok) {
|
|
450
|
+
const text = await response.text().catch(() => "");
|
|
451
|
+
throw new Error(`Failed to save ${request.target}: ${response.status}${text ? ` ${text}` : ""}`);
|
|
452
|
+
}
|
|
453
|
+
const result = await response.json().catch(() => null);
|
|
454
|
+
if (request.token !== tokenRef.current) return;
|
|
455
|
+
targetMtimeRef.current[request.target] = typeof result?.mtimeMs === "number" ? result.mtimeMs : void 0;
|
|
456
|
+
observedMissingTargetsRef.current.delete(request.target);
|
|
457
|
+
setStatus(`Saved ${request.target}`);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (request.token !== tokenRef.current) return;
|
|
460
|
+
loadedRef.current = false;
|
|
461
|
+
queuedSaveRef.current = null;
|
|
462
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
463
|
+
setError(message);
|
|
464
|
+
setStatus("Save disabled \u2014 reload required");
|
|
465
|
+
} finally {
|
|
466
|
+
savingRef.current = false;
|
|
467
|
+
const queued = queuedSaveRef.current;
|
|
468
|
+
queuedSaveRef.current = null;
|
|
469
|
+
if (queued && queued.token === tokenRef.current && loadedRef.current) {
|
|
470
|
+
await saveWorkspaceFile(queued);
|
|
471
|
+
}
|
|
472
|
+
if (!savingRef.current && !queuedSaveRef.current) {
|
|
473
|
+
const resolvers = saveIdleResolversRef.current.splice(0);
|
|
474
|
+
for (const resolve of resolvers) resolve();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}, [apiBaseUrl, filesystem, requestHeaders]);
|
|
478
|
+
saveWorkspaceFileRef.current = saveWorkspaceFile;
|
|
479
|
+
const requestSave = useCallback(() => {
|
|
480
|
+
const api = excalidrawApiRef.current;
|
|
481
|
+
if (readOnlyRef.current) {
|
|
482
|
+
setStatus("Read-only \u2014 saving disabled");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (!api || !loadedRef.current) {
|
|
486
|
+
setError("Refusing to save before the workspace file has loaded successfully");
|
|
487
|
+
setStatus("Save unavailable");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const scene = { elements: api.getSceneElements(), appState: api.getAppState(), files: api.getFiles() };
|
|
491
|
+
latestSceneRef.current = scene;
|
|
492
|
+
const json = sceneToJson(saveTarget, scene.elements, scene.appState, scene.files);
|
|
493
|
+
lastJsonRef.current = json;
|
|
494
|
+
pendingAutosaveRef.current = false;
|
|
495
|
+
void saveWorkspaceFile({ token: tokenRef.current, target: saveTarget, json });
|
|
496
|
+
}, [saveTarget, saveWorkspaceFile]);
|
|
497
|
+
const loadRenderModels = useCallback(async () => {
|
|
498
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/plugins/diagram/render/models`, {
|
|
499
|
+
credentials: "include",
|
|
500
|
+
headers: requestHeaders
|
|
501
|
+
});
|
|
502
|
+
if (!response.ok) throw new Error(`Failed to load render models: ${response.status}`);
|
|
503
|
+
const result = await response.json();
|
|
504
|
+
const models = Array.isArray(result.models) ? result.models : [];
|
|
505
|
+
setRenderModels(models);
|
|
506
|
+
setRenderAuthHint(result.authHint ?? null);
|
|
507
|
+
const firstAvailable = models.find((model) => model.available);
|
|
508
|
+
const defaultModel = result.defaultModel;
|
|
509
|
+
const preferred = defaultModel && models.some((model) => renderModelKey(model) === renderModelKey(defaultModel)) ? defaultModel : firstAvailable ? { provider: firstAvailable.provider, id: firstAvailable.id } : null;
|
|
510
|
+
setSelectedRenderModel((current) => current && models.some((model) => renderModelKey(model) === renderModelKey(current)) ? current : preferred);
|
|
511
|
+
}, [apiBaseUrl, requestHeaders]);
|
|
512
|
+
const saveCurrentPromptToCatalog = useCallback(() => {
|
|
513
|
+
const nextCatalog = upsertPromptCatalogItem(promptCatalog, renderPrompt);
|
|
514
|
+
setPromptCatalog(nextCatalog);
|
|
515
|
+
writePromptCatalog(nextCatalog);
|
|
516
|
+
}, [promptCatalog, renderPrompt]);
|
|
517
|
+
const requestRender = useCallback(async () => {
|
|
518
|
+
const api = excalidrawApiRef.current;
|
|
519
|
+
if (!api || !loadedRef.current) {
|
|
520
|
+
setError("Refusing to render before the workspace file has loaded successfully");
|
|
521
|
+
setStatus("Render unavailable");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (filesystem) {
|
|
525
|
+
setError("Render is only supported for default workspace files right now");
|
|
526
|
+
setStatus("Render unavailable");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const prompt = renderPrompt.trim();
|
|
530
|
+
if (!prompt) {
|
|
531
|
+
setError("Enter a render prompt");
|
|
532
|
+
setStatus("Render prompt required");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
setRendering(true);
|
|
536
|
+
setError(null);
|
|
537
|
+
setStatus("Rendering image\u2026");
|
|
538
|
+
try {
|
|
539
|
+
await flushPendingSaveRef.current();
|
|
540
|
+
if (!loadedRef.current) throw new Error("Save failed before render. Reload or resolve the save conflict before rendering.");
|
|
541
|
+
const scene = { elements: api.getSceneElements(), appState: api.getAppState(), files: api.getFiles() };
|
|
542
|
+
latestSceneRef.current = scene;
|
|
543
|
+
const exportAppState = { ...scene.appState, exportWithDarkMode: false, viewBackgroundColor: scene.appState.viewBackgroundColor ?? "#ffffff" };
|
|
544
|
+
const blob = await exportToBlob({
|
|
545
|
+
elements: scene.elements.filter((element) => !element.isDeleted),
|
|
546
|
+
appState: exportAppState,
|
|
547
|
+
files: scene.files,
|
|
548
|
+
mimeType: "image/png",
|
|
549
|
+
exportPadding: 32
|
|
550
|
+
});
|
|
551
|
+
const sketchPngBase64 = await blobToBase64(blob);
|
|
552
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/plugins/diagram/render`, {
|
|
553
|
+
method: "POST",
|
|
554
|
+
credentials: "include",
|
|
555
|
+
headers: { "content-type": "application/json", ...requestHeaders },
|
|
556
|
+
body: JSON.stringify({
|
|
557
|
+
path,
|
|
558
|
+
prompt,
|
|
559
|
+
model: selectedRenderModel ? renderModelKey(selectedRenderModel) : void 0,
|
|
560
|
+
sketchPngBase64,
|
|
561
|
+
mimeType: "image/png"
|
|
562
|
+
})
|
|
563
|
+
});
|
|
564
|
+
const result = await response.json().catch(() => ({}));
|
|
565
|
+
const responseError = typeof result.error === "string" ? result.error : result.error?.message;
|
|
566
|
+
if (!response.ok || !result.path) throw new Error(responseError || `Render failed: ${response.status}`);
|
|
567
|
+
setRenderOutputPath(result.path);
|
|
568
|
+
setRenderVersion(Date.now());
|
|
569
|
+
setRenderStale(false);
|
|
570
|
+
setViewMode("image");
|
|
571
|
+
const nextCatalog = upsertPromptCatalogItem(promptCatalog, prompt);
|
|
572
|
+
setPromptCatalog(nextCatalog);
|
|
573
|
+
writePromptCatalog(nextCatalog);
|
|
574
|
+
setStatus(`Rendered ${result.path}`);
|
|
575
|
+
} catch (err) {
|
|
576
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
577
|
+
setError(message);
|
|
578
|
+
setStatus("Render failed");
|
|
579
|
+
} finally {
|
|
580
|
+
setRendering(false);
|
|
581
|
+
}
|
|
582
|
+
}, [apiBaseUrl, filesystem, path, promptCatalog, renderPrompt, requestHeaders, selectedRenderModel]);
|
|
583
|
+
const scheduleSave = useCallback((elements, appState, files) => {
|
|
584
|
+
if (Date.now() < suppressAutosaveUntilRef.current) return;
|
|
585
|
+
latestSceneRef.current = { elements, appState, files };
|
|
586
|
+
if (readOnlyRef.current || !loadedRef.current) return;
|
|
587
|
+
let json;
|
|
588
|
+
try {
|
|
589
|
+
json = sceneToJson(saveTarget, elements, appState, files);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
loadedRef.current = false;
|
|
592
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
593
|
+
setStatus("Editor error \u2014 saving disabled");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (json === lastJsonRef.current) return;
|
|
597
|
+
if (renderOutputPath) setRenderStale(true);
|
|
598
|
+
pendingAutosaveRef.current = true;
|
|
599
|
+
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
600
|
+
saveTimerRef.current = window.setTimeout(() => {
|
|
601
|
+
if (!loadedRef.current) return;
|
|
602
|
+
pendingAutosaveRef.current = false;
|
|
603
|
+
lastJsonRef.current = json;
|
|
604
|
+
void saveWorkspaceFile({ token: tokenRef.current, target: saveTarget, json });
|
|
605
|
+
}, 900);
|
|
606
|
+
}, [renderOutputPath, saveTarget, saveWorkspaceFile]);
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
void loadWorkspaceFile();
|
|
609
|
+
}, [loadWorkspaceFile, refreshNonce]);
|
|
610
|
+
useEffect(() => {
|
|
611
|
+
if (!renderDrawerOpen) return;
|
|
612
|
+
void loadRenderModels().catch((err) => {
|
|
613
|
+
setRenderModels([]);
|
|
614
|
+
setRenderAuthHint(err instanceof Error ? err.message : String(err));
|
|
615
|
+
});
|
|
616
|
+
}, [loadRenderModels, renderDrawerOpen]);
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
if (!renderOutputPath) {
|
|
619
|
+
setRenderMetadata(null);
|
|
620
|
+
setRenderMetadataError(null);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
let cancelled = false;
|
|
624
|
+
const metadataPath = metadataPathForRender(renderOutputPath);
|
|
625
|
+
void readTextFile(metadataPath).then((result) => {
|
|
626
|
+
if (cancelled) return;
|
|
627
|
+
setRenderMetadata(JSON.parse(result.content));
|
|
628
|
+
setRenderMetadataError(null);
|
|
629
|
+
}).catch((err) => {
|
|
630
|
+
if (cancelled) return;
|
|
631
|
+
setRenderMetadata(null);
|
|
632
|
+
setRenderMetadataError(err instanceof Error ? err.message : String(err));
|
|
633
|
+
});
|
|
634
|
+
return () => {
|
|
635
|
+
cancelled = true;
|
|
636
|
+
};
|
|
637
|
+
}, [readTextFile, renderOutputPath, renderVersion]);
|
|
638
|
+
useEffect(() => {
|
|
639
|
+
if (typeof EventSource === "undefined") return;
|
|
640
|
+
const query = new URLSearchParams();
|
|
641
|
+
if (workspaceId) query.set("workspaceId", String(workspaceId));
|
|
642
|
+
const eventsUrl = `${apiBaseUrl}/api/v1/fs/events${query.toString() ? `?${query.toString()}` : ""}`;
|
|
643
|
+
const source = new EventSource(eventsUrl, { withCredentials: true });
|
|
644
|
+
const shouldReloadForChange = (payload) => {
|
|
645
|
+
const change = payload.change;
|
|
646
|
+
if (!change || change.op === "mkdir") return false;
|
|
647
|
+
const changedPath = change.path;
|
|
648
|
+
const oldPath = change.oldPath;
|
|
649
|
+
const matchesOpenPath = workspacePathsEqual(changedPath, path) || workspacePathsEqual(oldPath, path);
|
|
650
|
+
const matchesSaveTarget = workspacePathsEqual(changedPath, saveTarget) || workspacePathsEqual(oldPath, saveTarget);
|
|
651
|
+
if (!matchesOpenPath && !matchesSaveTarget) return false;
|
|
652
|
+
if (savingRef.current || pendingAutosaveRef.current) return false;
|
|
653
|
+
const knownMtime = targetMtimeRef.current[saveTarget];
|
|
654
|
+
if (change.op === "write" && typeof knownMtime === "number" && change.mtimeMs === knownMtime) return false;
|
|
655
|
+
return true;
|
|
656
|
+
};
|
|
657
|
+
const reloadFromExternalChange = (label) => {
|
|
658
|
+
setStatus(`${label} \u2014 reloading\u2026`);
|
|
659
|
+
setRefreshNonce((value) => value + 1);
|
|
660
|
+
};
|
|
661
|
+
source.addEventListener("change", (event) => {
|
|
662
|
+
try {
|
|
663
|
+
const payload = JSON.parse(event.data);
|
|
664
|
+
if (shouldReloadForChange(payload)) reloadFromExternalChange("External file change detected");
|
|
665
|
+
} catch {
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
source.addEventListener("resync-required", () => reloadFromExternalChange("File watcher resync required"));
|
|
669
|
+
source.addEventListener("unsupported", () => source.close());
|
|
670
|
+
return () => source.close();
|
|
671
|
+
}, [apiBaseUrl, path, saveTarget, workspaceId]);
|
|
672
|
+
flushPendingSaveRef.current = async () => {
|
|
673
|
+
if (saveTimerRef.current !== null) {
|
|
674
|
+
window.clearTimeout(saveTimerRef.current);
|
|
675
|
+
saveTimerRef.current = null;
|
|
676
|
+
}
|
|
677
|
+
if (!readOnlyRef.current && pendingAutosaveRef.current && loadedRef.current) {
|
|
678
|
+
const api = excalidrawApiRef.current;
|
|
679
|
+
const scene = api ? { elements: api.getSceneElements(), appState: api.getAppState(), files: api.getFiles() } : latestSceneRef.current;
|
|
680
|
+
if (scene) {
|
|
681
|
+
const json = sceneToJson(saveTarget, scene.elements, scene.appState, scene.files);
|
|
682
|
+
pendingAutosaveRef.current = false;
|
|
683
|
+
if (json !== lastJsonRef.current) {
|
|
684
|
+
lastJsonRef.current = json;
|
|
685
|
+
await saveWorkspaceFile({ token: tokenRef.current, target: saveTarget, json });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (savingRef.current || queuedSaveRef.current) {
|
|
690
|
+
await new Promise((resolve) => saveIdleResolversRef.current.push(resolve));
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
useEffect(() => {
|
|
694
|
+
return () => {
|
|
695
|
+
void flushPendingSaveRef.current();
|
|
696
|
+
};
|
|
697
|
+
}, []);
|
|
698
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground", children: [
|
|
699
|
+
/* @__PURE__ */ jsxs(Toolbar, { className: "shrink-0 border-b border-border px-3 py-2", children: [
|
|
700
|
+
/* @__PURE__ */ jsx(ToolbarGroup, { children: /* @__PURE__ */ jsxs("div", { className: "inline-flex rounded-md border border-border bg-muted/50 p-0.5 text-xs", children: [
|
|
701
|
+
/* @__PURE__ */ jsx(
|
|
702
|
+
"button",
|
|
703
|
+
{
|
|
704
|
+
type: "button",
|
|
705
|
+
className: `rounded px-2 py-1 ${viewMode === "diagram" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`,
|
|
706
|
+
onClick: () => {
|
|
707
|
+
setViewMode("diagram");
|
|
708
|
+
setRenderDrawerOpen(false);
|
|
709
|
+
},
|
|
710
|
+
children: "Diagram"
|
|
711
|
+
}
|
|
712
|
+
),
|
|
713
|
+
/* @__PURE__ */ jsxs(
|
|
714
|
+
"button",
|
|
715
|
+
{
|
|
716
|
+
type: "button",
|
|
717
|
+
className: `rounded px-2 py-1 ${viewMode === "image" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`,
|
|
718
|
+
onClick: () => {
|
|
719
|
+
setViewMode("image");
|
|
720
|
+
setRenderDrawerOpen(true);
|
|
721
|
+
},
|
|
722
|
+
children: [
|
|
723
|
+
"Image",
|
|
724
|
+
renderStale ? " *" : ""
|
|
725
|
+
]
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
] }) }),
|
|
729
|
+
/* @__PURE__ */ jsx(ToolbarGroup, { children: /* @__PURE__ */ jsxs("span", { className: "max-w-[42vw] truncate text-xs text-muted-foreground", title: error ? `${status} \u2014 ${error}` : status, children: [
|
|
730
|
+
status,
|
|
731
|
+
readOnly ? " \u2014 read-only" : "",
|
|
732
|
+
renderStale ? " \u2014 render stale" : "",
|
|
733
|
+
error ? ` \u2014 ${error}` : ""
|
|
734
|
+
] }) }),
|
|
735
|
+
/* @__PURE__ */ jsxs(ToolbarGroup, { className: "ml-auto gap-1", children: [
|
|
736
|
+
/* @__PURE__ */ jsx(
|
|
737
|
+
"button",
|
|
738
|
+
{
|
|
739
|
+
className: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
740
|
+
type: "button",
|
|
741
|
+
disabled: readOnly,
|
|
742
|
+
onClick: requestSave,
|
|
743
|
+
"aria-label": readOnly ? "Read-only" : "Save now",
|
|
744
|
+
title: readOnly ? "Read-only" : "Save now",
|
|
745
|
+
children: /* @__PURE__ */ jsx(SaveIcon, {})
|
|
746
|
+
}
|
|
747
|
+
),
|
|
748
|
+
/* @__PURE__ */ jsx(
|
|
749
|
+
"button",
|
|
750
|
+
{
|
|
751
|
+
className: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
752
|
+
type: "button",
|
|
753
|
+
"aria-label": "Reload",
|
|
754
|
+
title: "Reload",
|
|
755
|
+
onClick: () => {
|
|
756
|
+
void (async () => {
|
|
757
|
+
await flushPendingSaveRef.current();
|
|
758
|
+
setRefreshNonce((value) => value + 1);
|
|
759
|
+
})();
|
|
760
|
+
},
|
|
761
|
+
children: /* @__PURE__ */ jsx(ReloadIcon, {})
|
|
762
|
+
}
|
|
763
|
+
)
|
|
764
|
+
] })
|
|
765
|
+
] }),
|
|
766
|
+
viewMode === "image" ? /* @__PURE__ */ jsxs("div", { className: "shrink-0 border-b border-border bg-background/95 text-sm shadow-sm", children: [
|
|
767
|
+
/* @__PURE__ */ jsxs(
|
|
768
|
+
"button",
|
|
769
|
+
{
|
|
770
|
+
type: "button",
|
|
771
|
+
className: "flex h-9 w-full items-center justify-between px-3 text-left text-xs font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
|
772
|
+
"aria-expanded": renderDrawerOpen,
|
|
773
|
+
onClick: () => setRenderDrawerOpen((value) => !value),
|
|
774
|
+
children: [
|
|
775
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
776
|
+
"Image render controls",
|
|
777
|
+
renderStale ? " \u2014 stale" : ""
|
|
778
|
+
] }),
|
|
779
|
+
/* @__PURE__ */ jsx(ChevronIcon, { open: renderDrawerOpen })
|
|
780
|
+
]
|
|
781
|
+
}
|
|
782
|
+
),
|
|
783
|
+
renderDrawerOpen ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
784
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 px-3 pb-3 md:flex-row md:items-end", children: [
|
|
785
|
+
/* @__PURE__ */ jsxs("label", { className: "min-w-0 flex-1 text-xs font-medium text-muted-foreground", children: [
|
|
786
|
+
"Prompt",
|
|
787
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex flex-col gap-2 sm:flex-row", children: [
|
|
788
|
+
/* @__PURE__ */ jsxs(
|
|
789
|
+
"select",
|
|
790
|
+
{
|
|
791
|
+
className: "h-9 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm text-foreground outline-none focus:border-ring disabled:opacity-60",
|
|
792
|
+
value: "",
|
|
793
|
+
disabled: promptCatalog.length === 0,
|
|
794
|
+
"aria-label": "Prompt catalog",
|
|
795
|
+
onChange: (event) => {
|
|
796
|
+
const item = promptCatalog.find((candidate) => candidate.id === event.target.value);
|
|
797
|
+
if (item) setRenderPrompt(item.prompt);
|
|
798
|
+
},
|
|
799
|
+
children: [
|
|
800
|
+
/* @__PURE__ */ jsx("option", { value: "", children: promptCatalog.length === 0 ? "No saved prompts" : "Saved prompts\u2026" }),
|
|
801
|
+
promptCatalog.map((item) => /* @__PURE__ */ jsx("option", { value: item.id, children: item.label }, item.id))
|
|
802
|
+
]
|
|
803
|
+
}
|
|
804
|
+
),
|
|
805
|
+
/* @__PURE__ */ jsx(
|
|
806
|
+
"button",
|
|
807
|
+
{
|
|
808
|
+
type: "button",
|
|
809
|
+
className: "inline-flex h-9 shrink-0 items-center justify-center rounded-md border border-border px-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
810
|
+
disabled: !renderPrompt.trim(),
|
|
811
|
+
onClick: saveCurrentPromptToCatalog,
|
|
812
|
+
children: "Save prompt"
|
|
813
|
+
}
|
|
814
|
+
)
|
|
815
|
+
] }),
|
|
816
|
+
/* @__PURE__ */ jsx(
|
|
817
|
+
"textarea",
|
|
818
|
+
{
|
|
819
|
+
className: "mt-2 h-20 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-ring",
|
|
820
|
+
placeholder: "Make this sketch into a polished product architecture illustration\u2026",
|
|
821
|
+
value: renderPrompt,
|
|
822
|
+
onChange: (event) => setRenderPrompt(event.target.value),
|
|
823
|
+
onKeyDown: (event) => {
|
|
824
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
825
|
+
event.preventDefault();
|
|
826
|
+
void requestRender();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
)
|
|
831
|
+
] }),
|
|
832
|
+
/* @__PURE__ */ jsxs("div", { className: "w-full text-xs font-medium text-muted-foreground md:w-72", children: [
|
|
833
|
+
"Model",
|
|
834
|
+
/* @__PURE__ */ jsx("div", { className: "mt-1", children: /* @__PURE__ */ jsx(
|
|
835
|
+
ModelSelect,
|
|
836
|
+
{
|
|
837
|
+
value: selectedRenderModel,
|
|
838
|
+
onChange: setSelectedRenderModel,
|
|
839
|
+
options: renderModels,
|
|
840
|
+
disabled: rendering || renderModels.length === 0
|
|
841
|
+
}
|
|
842
|
+
) })
|
|
843
|
+
] }),
|
|
844
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
845
|
+
/* @__PURE__ */ jsx(
|
|
846
|
+
"button",
|
|
847
|
+
{
|
|
848
|
+
type: "button",
|
|
849
|
+
className: "inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
850
|
+
disabled: rendering || !renderPrompt.trim() || !selectedRenderModel,
|
|
851
|
+
onClick: () => void requestRender(),
|
|
852
|
+
children: rendering ? "Rendering\u2026" : "Render"
|
|
853
|
+
}
|
|
854
|
+
),
|
|
855
|
+
/* @__PURE__ */ jsx(
|
|
856
|
+
"button",
|
|
857
|
+
{
|
|
858
|
+
type: "button",
|
|
859
|
+
className: "inline-flex h-9 items-center rounded-md px-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
860
|
+
onClick: () => setRenderDrawerOpen(false),
|
|
861
|
+
children: "Cancel"
|
|
862
|
+
}
|
|
863
|
+
)
|
|
864
|
+
] })
|
|
865
|
+
] }),
|
|
866
|
+
renderAuthHint ? /* @__PURE__ */ jsx("div", { className: "px-3 pb-3 text-xs text-amber-600", children: renderAuthHint }) : null
|
|
867
|
+
] }) : null
|
|
868
|
+
] }) : null,
|
|
869
|
+
/* @__PURE__ */ jsxs("div", { className: "relative min-h-0 min-w-0 flex-1 bg-white text-black", children: [
|
|
870
|
+
/* @__PURE__ */ jsx("div", { className: viewMode === "diagram" ? "h-full" : "hidden h-full", children: /* @__PURE__ */ jsx(
|
|
871
|
+
Excalidraw,
|
|
872
|
+
{
|
|
873
|
+
excalidrawAPI: (api) => {
|
|
874
|
+
excalidrawApiRef.current = api;
|
|
875
|
+
setEditorReady(true);
|
|
876
|
+
},
|
|
877
|
+
onChange: scheduleSave,
|
|
878
|
+
viewModeEnabled: readOnly,
|
|
879
|
+
UIOptions: {
|
|
880
|
+
canvasActions: {
|
|
881
|
+
loadScene: true,
|
|
882
|
+
saveToActiveFile: false,
|
|
883
|
+
export: { saveFileToDisk: true }
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
editorEpoch
|
|
888
|
+
) }),
|
|
889
|
+
viewMode === "image" ? /* @__PURE__ */ jsx("div", { className: "flex h-full min-h-0 flex-col bg-muted/20 text-foreground", children: renderOutputPath ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
890
|
+
/* @__PURE__ */ jsxs("div", { className: "shrink-0 border-b border-border bg-background/95 text-sm shadow-sm", children: [
|
|
891
|
+
/* @__PURE__ */ jsxs(
|
|
892
|
+
"button",
|
|
893
|
+
{
|
|
894
|
+
type: "button",
|
|
895
|
+
className: "flex h-9 w-full items-center justify-between gap-3 px-3 text-left text-xs font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
|
896
|
+
"aria-expanded": renderMetadataOpen,
|
|
897
|
+
onClick: () => setRenderMetadataOpen((value) => !value),
|
|
898
|
+
children: [
|
|
899
|
+
/* @__PURE__ */ jsxs("span", { className: "min-w-0 truncate", children: [
|
|
900
|
+
"Render metadata",
|
|
901
|
+
renderMetadata?.model?.provider ? ` \u2014 ${renderMetadata.model.provider}:${renderMetadata.model.id ?? ""}` : "",
|
|
902
|
+
renderMetadata?.generatedAt ? ` \u2014 ${new Date(renderMetadata.generatedAt).toLocaleString()}` : "",
|
|
903
|
+
!renderMetadata && renderMetadataError ? " \u2014 unavailable" : ""
|
|
904
|
+
] }),
|
|
905
|
+
/* @__PURE__ */ jsx(ChevronIcon, { open: renderMetadataOpen })
|
|
906
|
+
]
|
|
907
|
+
}
|
|
908
|
+
),
|
|
909
|
+
renderMetadataOpen ? /* @__PURE__ */ jsxs("div", { className: "space-y-3 px-3 pb-3 text-sm text-foreground", children: [
|
|
910
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground", children: [
|
|
911
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
912
|
+
"Metadata file: ",
|
|
913
|
+
metadataPathForRender(renderOutputPath)
|
|
914
|
+
] }),
|
|
915
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
916
|
+
"Output: ",
|
|
917
|
+
renderMetadata?.outputPath || renderOutputPath
|
|
918
|
+
] })
|
|
919
|
+
] }),
|
|
920
|
+
renderMetadata ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
921
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
922
|
+
/* @__PURE__ */ jsx("div", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: "Prompt" }),
|
|
923
|
+
/* @__PURE__ */ jsx("div", { className: "mt-1 whitespace-pre-wrap rounded-md bg-muted/50 p-2 text-xs leading-relaxed", children: renderMetadata.prompt?.text || "\u2014" })
|
|
924
|
+
] }),
|
|
925
|
+
/* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-2 text-xs md:grid-cols-4", children: [
|
|
926
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
927
|
+
/* @__PURE__ */ jsx("div", { className: "font-medium text-muted-foreground", children: "Model" }),
|
|
928
|
+
/* @__PURE__ */ jsx("div", { className: "mt-0.5 break-words", children: renderMetadata.model?.provider ? `${renderMetadata.model.provider}:${renderMetadata.model.id ?? ""}` : "\u2014" })
|
|
929
|
+
] }),
|
|
930
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
931
|
+
/* @__PURE__ */ jsx("div", { className: "font-medium text-muted-foreground", children: "Generated" }),
|
|
932
|
+
/* @__PURE__ */ jsx("div", { className: "mt-0.5 break-words", children: renderMetadata.generatedAt ? new Date(renderMetadata.generatedAt).toLocaleString() : "\u2014" })
|
|
933
|
+
] }),
|
|
934
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
935
|
+
/* @__PURE__ */ jsx("div", { className: "font-medium text-muted-foreground", children: "Source" }),
|
|
936
|
+
/* @__PURE__ */ jsx("div", { className: "mt-0.5 break-words", children: renderMetadata.sourcePath || "\u2014" })
|
|
937
|
+
] }),
|
|
938
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
939
|
+
/* @__PURE__ */ jsx("div", { className: "font-medium text-muted-foreground", children: "Output" }),
|
|
940
|
+
/* @__PURE__ */ jsx("div", { className: "mt-0.5 break-words", children: renderMetadata.outputPath || renderOutputPath })
|
|
941
|
+
] })
|
|
942
|
+
] }),
|
|
943
|
+
/* @__PURE__ */ jsxs("details", { className: "text-xs", children: [
|
|
944
|
+
/* @__PURE__ */ jsx("summary", { className: "cursor-pointer text-muted-foreground hover:text-foreground", children: "Raw JSON" }),
|
|
945
|
+
/* @__PURE__ */ jsx("pre", { className: "mt-2 max-h-56 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] leading-relaxed", children: JSON.stringify(renderMetadata, null, 2) })
|
|
946
|
+
] })
|
|
947
|
+
] }) : /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: renderMetadataError ? `Metadata unavailable: ${renderMetadataError}` : "Loading metadata\u2026" })
|
|
948
|
+
] }) : null
|
|
949
|
+
] }),
|
|
950
|
+
/* @__PURE__ */ jsx("div", { className: "flex min-h-0 flex-1 items-center justify-center overflow-auto p-6", children: /* @__PURE__ */ jsx(
|
|
951
|
+
"img",
|
|
952
|
+
{
|
|
953
|
+
className: "max-h-full max-w-full rounded-lg border border-border bg-white object-contain shadow-sm",
|
|
954
|
+
src: `${apiBaseUrl}/api/v1/files/raw?${fileQuery(renderOutputPath)}&renderVersion=${renderVersion}`,
|
|
955
|
+
alt: `Rendered ${renderOutputPath}`
|
|
956
|
+
}
|
|
957
|
+
) })
|
|
958
|
+
] }) : /* @__PURE__ */ jsxs("div", { className: "flex h-full flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground", children: [
|
|
959
|
+
/* @__PURE__ */ jsx("div", { children: "No rendered image yet." }),
|
|
960
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
961
|
+
"Use Render to create ",
|
|
962
|
+
renderTarget,
|
|
963
|
+
" from the current diagram."
|
|
964
|
+
] }),
|
|
965
|
+
/* @__PURE__ */ jsx(
|
|
966
|
+
"button",
|
|
967
|
+
{
|
|
968
|
+
type: "button",
|
|
969
|
+
className: "inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground",
|
|
970
|
+
onClick: () => {
|
|
971
|
+
setViewMode("image");
|
|
972
|
+
setRenderDrawerOpen(true);
|
|
973
|
+
},
|
|
974
|
+
children: "Open render prompt"
|
|
975
|
+
}
|
|
976
|
+
)
|
|
977
|
+
] }) }) : null
|
|
978
|
+
] })
|
|
979
|
+
] });
|
|
980
|
+
}
|
|
981
|
+
var WorkspaceFileReadError, DEFAULT_RENDER_PROMPT, PROMPT_CATALOG_STORAGE_KEY, MAX_PROMPT_CATALOG_ITEMS;
|
|
982
|
+
var init_DiagramPane = __esm({
|
|
983
|
+
"src/front/DiagramPane.tsx"() {
|
|
984
|
+
"use strict";
|
|
985
|
+
init_shared();
|
|
986
|
+
WorkspaceFileReadError = class extends Error {
|
|
987
|
+
constructor(message, status) {
|
|
988
|
+
super(message);
|
|
989
|
+
this.status = status;
|
|
990
|
+
}
|
|
991
|
+
status;
|
|
992
|
+
};
|
|
993
|
+
DEFAULT_RENDER_PROMPT = "Make this sketch into a polished diagram illustration.";
|
|
994
|
+
PROMPT_CATALOG_STORAGE_KEY = "boring-diagram:render-prompts:v1";
|
|
995
|
+
MAX_PROMPT_CATALOG_ITEMS = 20;
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// src/front/index.tsx
|
|
1000
|
+
init_shared();
|
|
1001
|
+
import { lazy, Suspense } from "react";
|
|
1002
|
+
import {
|
|
1003
|
+
definePlugin,
|
|
1004
|
+
WORKSPACE_OPEN_PATH_SURFACE_KIND
|
|
1005
|
+
} from "@hachej/boring-workspace/plugin";
|
|
1006
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
1007
|
+
var LazyDiagramPane = lazy(async () => {
|
|
1008
|
+
const module = await Promise.resolve().then(() => (init_DiagramPane(), DiagramPane_exports));
|
|
1009
|
+
return { default: module.DiagramPane };
|
|
1010
|
+
});
|
|
1011
|
+
function DiagramPanel(props) {
|
|
1012
|
+
return /* @__PURE__ */ jsx2(Suspense, { fallback: /* @__PURE__ */ jsx2("div", { className: "flex h-full items-center justify-center text-sm text-muted-foreground", children: "Loading Diagram\u2026" }), children: /* @__PURE__ */ jsx2(LazyDiagramPane, { ...props }) });
|
|
1013
|
+
}
|
|
1014
|
+
var diagramPlugin = definePlugin({
|
|
1015
|
+
id: DIAGRAM_PLUGIN_ID,
|
|
1016
|
+
label: "Diagram",
|
|
1017
|
+
panels: [{ id: DIAGRAM_PANEL_ID, label: "Diagram", component: DiagramPanel }],
|
|
1018
|
+
commands: [{ id: "diagram.open", title: "Open Diagram", panelId: DIAGRAM_PANEL_ID }],
|
|
1019
|
+
surfaceResolvers: [
|
|
1020
|
+
{
|
|
1021
|
+
id: "diagram.open-file",
|
|
1022
|
+
kind: WORKSPACE_OPEN_PATH_SURFACE_KIND,
|
|
1023
|
+
resolve: (request) => {
|
|
1024
|
+
if (request.kind !== WORKSPACE_OPEN_PATH_SURFACE_KIND) return null;
|
|
1025
|
+
if (!isDiagramPath(request.target)) return null;
|
|
1026
|
+
const filesystem = typeof request.filesystem === "string" && request.filesystem ? request.filesystem : void 0;
|
|
1027
|
+
return {
|
|
1028
|
+
id: `diagram:${encodeURIComponent(filesystem ?? "user")}:${encodeURIComponent(request.target)}`,
|
|
1029
|
+
component: DIAGRAM_PANEL_ID,
|
|
1030
|
+
title: titleForPath(request.target),
|
|
1031
|
+
params: { path: request.target, ...filesystem ? { filesystem } : {} },
|
|
1032
|
+
score: 1e3
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
]
|
|
1037
|
+
});
|
|
1038
|
+
var front_default = diagramPlugin;
|
|
1039
|
+
export {
|
|
1040
|
+
front_default as default,
|
|
1041
|
+
diagramPlugin
|
|
1042
|
+
};
|