@hyperframes/studio-server 0.8.73 → 0.8.75

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/index.js CHANGED
@@ -151,22 +151,22 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
151
151
  if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
152
152
  const file = resolve(dir, entry);
153
153
  if (!isPathWithin(projectDir, file)) continue;
154
- let stat;
154
+ let stat2;
155
155
  try {
156
- stat = lstatSync(file);
156
+ stat2 = lstatSync(file);
157
157
  } catch {
158
158
  continue;
159
159
  }
160
- if (stat.isSymbolicLink()) continue;
161
- if (stat.isDirectory()) {
160
+ if (stat2.isSymbolicLink()) continue;
161
+ if (stat2.isDirectory()) {
162
162
  collectProjectSignatureFiles(projectDir, file, files);
163
- } else if (stat.isFile()) {
163
+ } else if (stat2.isFile()) {
164
164
  files.push({
165
165
  file,
166
- mtimeMs: stat.mtimeMs,
167
- ctimeMs: stat.ctimeMs,
168
- size: stat.size,
169
- textContentEligible: isTextContentEligible(file, stat.size)
166
+ mtimeMs: stat2.mtimeMs,
167
+ ctimeMs: stat2.ctimeMs,
168
+ size: stat2.size,
169
+ textContentEligible: isTextContentEligible(file, stat2.size)
170
170
  });
171
171
  }
172
172
  }
@@ -176,19 +176,19 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
176
176
  for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
177
177
  const file = resolve(projectDir, manifestPath);
178
178
  if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
179
- let stat;
179
+ let stat2;
180
180
  try {
181
- stat = lstatSync(file);
181
+ stat2 = lstatSync(file);
182
182
  } catch {
183
183
  continue;
184
184
  }
185
- if (stat.isSymbolicLink() || !stat.isFile()) continue;
185
+ if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
186
186
  files.push({
187
187
  file,
188
- mtimeMs: stat.mtimeMs,
189
- ctimeMs: stat.ctimeMs,
190
- size: stat.size,
191
- textContentEligible: isTextContentEligible(file, stat.size)
188
+ mtimeMs: stat2.mtimeMs,
189
+ ctimeMs: stat2.ctimeMs,
190
+ size: stat2.size,
191
+ textContentEligible: isTextContentEligible(file, stat2.size)
192
192
  });
193
193
  seen.add(file);
194
194
  }
@@ -209,6 +209,21 @@ function createProjectFingerprint(projectDir, files) {
209
209
  }
210
210
  return hash.digest("hex").slice(0, 24);
211
211
  }
212
+ function collectProjectFiles(normalizedProjectDir) {
213
+ const collected = [];
214
+ collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
215
+ collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
216
+ return collected;
217
+ }
218
+ function listProjectFiles(projectDir) {
219
+ const normalizedProjectDir = resolve(projectDir);
220
+ return collectProjectFiles(normalizedProjectDir).map((entry) => ({
221
+ path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
222
+ size: entry.size,
223
+ mtimeMs: entry.mtimeMs,
224
+ ctimeMs: entry.ctimeMs
225
+ }));
226
+ }
212
227
  function resolveProjectSignature(adapter, projectDir) {
213
228
  return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
214
229
  }
@@ -219,9 +234,7 @@ async function resolveProjectAndSignature(adapter, projectId) {
219
234
  }
220
235
  function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set()) {
221
236
  const normalizedProjectDir = resolve(projectDir);
222
- const collected = [];
223
- collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
224
- collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
237
+ const collected = collectProjectFiles(normalizedProjectDir);
225
238
  const files = collected.filter(
226
239
  (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
227
240
  );
@@ -622,9 +635,9 @@ var receipts = /* @__PURE__ */ new Map();
622
635
  function fileContentVersion(content) {
623
636
  return `"sha256:${createHash2("sha256").update(content).digest("hex")}"`;
624
637
  }
625
- function settledFileTag(stat, now = Date.now()) {
626
- if (now - stat.ctimeMs < 3e3) return null;
627
- return [stat.ino, stat.ctimeMs, stat.size].map((n) => n.toString(36)).join("-");
638
+ function settledFileTag(stat2, now = Date.now()) {
639
+ if (now - stat2.ctimeMs < 3e3) return null;
640
+ return [stat2.ino, stat2.ctimeMs, stat2.size].map((n) => n.toString(36)).join("-");
628
641
  }
629
642
  function createWriteToken(requestToken) {
630
643
  const token = requestToken?.trim();
@@ -2590,10 +2603,10 @@ function registerFileRoutes(api, adapter) {
2590
2603
  api.delete("/projects/:id/files/*", async (c) => {
2591
2604
  const res = await resolveProjectFile(c, adapter, { mustExist: true });
2592
2605
  if ("error" in res) return res.error;
2593
- const stat = statSync3(res.absPath);
2606
+ const stat2 = statSync3(res.absPath);
2594
2607
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2595
2608
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2596
- if (stat.isDirectory()) {
2609
+ if (stat2.isDirectory()) {
2597
2610
  rmSync3(res.absPath, { recursive: true });
2598
2611
  } else {
2599
2612
  unlinkSync2(res.absPath);
@@ -3373,7 +3386,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
3373
3386
  const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
3374
3387
  headContent = headMatch?.[1] ?? "";
3375
3388
  }
3376
- if (baseHref && !headContent.includes("<base")) {
3389
+ if (baseHref && !hasBaseElement(headContent)) {
3377
3390
  headContent = `<base href="${baseHref}">
3378
3391
  ${headContent}`;
3379
3392
  }
@@ -3401,6 +3414,9 @@ ${rewrittenContent}
3401
3414
  </body>
3402
3415
  </html>`;
3403
3416
  }
3417
+ function hasBaseElement(html) {
3418
+ return parseHTML3(html).document.querySelector("base") !== null;
3419
+ }
3404
3420
 
3405
3421
  // src/routes/preview.ts
3406
3422
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
@@ -3707,7 +3723,7 @@ function registerPreviewRoutes(api, adapter) {
3707
3723
  ${runtimeTag}`;
3708
3724
  }
3709
3725
  const baseHref = `/api/projects/${project.id}/preview/`;
3710
- if (!bundled.includes("<base")) {
3726
+ if (!hasBaseElement(bundled)) {
3711
3727
  bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
3712
3728
  }
3713
3729
  bundled = injectStudioPreviewAugmentations(
@@ -3804,8 +3820,8 @@ ${runtimeTag}`;
3804
3820
  if (!file) {
3805
3821
  return c.text("not found", 404);
3806
3822
  }
3807
- const stat = existsSync5(file) ? statSync5(file) : null;
3808
- if (!stat?.isFile()) {
3823
+ const stat2 = existsSync5(file) ? statSync5(file) : null;
3824
+ if (!stat2?.isFile()) {
3809
3825
  return c.text("not found", 404);
3810
3826
  }
3811
3827
  const contentType = getMimeType(subPath);
@@ -3827,7 +3843,7 @@ ${runtimeTag}`;
3827
3843
  return c.text("media proxy variant does not match asset", 422);
3828
3844
  }
3829
3845
  }
3830
- const tag = settledFileTag(stat);
3846
+ const tag = settledFileTag(stat2);
3831
3847
  const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
3832
3848
  const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
3833
3849
  if (!isText && etag) {
@@ -4153,7 +4169,7 @@ function registerRenderRoutes(api, adapter) {
4153
4169
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4154
4170
  const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4155
4171
  const fp = join10(rendersDir, f);
4156
- const stat = statSync6(fp);
4172
+ const stat2 = statSync6(fp);
4157
4173
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4158
4174
  const metaPath = join10(rendersDir, `${rid}.meta.json`);
4159
4175
  let status = "complete";
@@ -4169,8 +4185,8 @@ function registerRenderRoutes(api, adapter) {
4169
4185
  return {
4170
4186
  id: rid,
4171
4187
  filename: f,
4172
- size: stat.size,
4173
- createdAt: stat.mtimeMs,
4188
+ size: stat2.size,
4189
+ createdAt: stat2.mtimeMs,
4174
4190
  status,
4175
4191
  durationMs
4176
4192
  };
@@ -4282,10 +4298,10 @@ var ThumbnailGenerationCoordinator = class {
4282
4298
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4283
4299
  let entry = this.entries.get(key);
4284
4300
  if (!entry) {
4285
- let resolve6;
4301
+ let resolve8;
4286
4302
  let reject;
4287
4303
  const promise = new Promise((resolvePromise, rejectPromise) => {
4288
- resolve6 = resolvePromise;
4304
+ resolve8 = resolvePromise;
4289
4305
  reject = rejectPromise;
4290
4306
  });
4291
4307
  entry = {
@@ -4295,7 +4311,7 @@ var ThumbnailGenerationCoordinator = class {
4295
4311
  state: "queued",
4296
4312
  work,
4297
4313
  promise,
4298
- resolve: resolve6,
4314
+ resolve: resolve8,
4299
4315
  reject
4300
4316
  };
4301
4317
  this.entries.set(key, entry);
@@ -4309,7 +4325,7 @@ var ThumbnailGenerationCoordinator = class {
4309
4325
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4310
4326
  }
4311
4327
  lease(entry, signal) {
4312
- return new Promise((resolve6, reject) => {
4328
+ return new Promise((resolve8, reject) => {
4313
4329
  let released = false;
4314
4330
  const release = () => {
4315
4331
  if (released) return;
@@ -4333,7 +4349,7 @@ var ThumbnailGenerationCoordinator = class {
4333
4349
  entry.promise.then(
4334
4350
  (value) => {
4335
4351
  release();
4336
- resolve6(value);
4352
+ resolve8(value);
4337
4353
  },
4338
4354
  (reason) => {
4339
4355
  release();
@@ -4424,9 +4440,9 @@ function readCompositionSource(file) {
4424
4440
  throw err;
4425
4441
  }
4426
4442
  try {
4427
- const stat = fstatSync3(fd);
4428
- if (!stat.isFile()) return "not-a-file";
4429
- return { html: readFileSync12(fd, "utf-8"), mtimeMs: stat.mtimeMs };
4443
+ const stat2 = fstatSync3(fd);
4444
+ if (!stat2.isFile()) return "not-a-file";
4445
+ return { html: readFileSync12(fd, "utf-8"), mtimeMs: stat2.mtimeMs };
4430
4446
  } finally {
4431
4447
  closeSync3(fd);
4432
4448
  }
@@ -4712,12 +4728,12 @@ function registerFontRoutes(api) {
4712
4728
  return c.json({ error: "font file not accessible" }, 404);
4713
4729
  }
4714
4730
  try {
4715
- const stat = fstatSync4(fd);
4716
- if (stat.size > SYSTEM_FONT_SIZE_LIMIT) {
4731
+ const stat2 = fstatSync4(fd);
4732
+ if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
4717
4733
  return c.json({ error: "font file too large" }, 413);
4718
4734
  }
4719
- const buffer = Buffer.alloc(stat.size);
4720
- readSync(fd, buffer, 0, stat.size, 0);
4735
+ const buffer = Buffer.alloc(stat2.size);
4736
+ readSync(fd, buffer, 0, stat2.size, 0);
4721
4737
  const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
4722
4738
  const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
4723
4739
  return new Response(buffer, {
@@ -5090,6 +5106,226 @@ function registerGlobalAssetRoutes(api) {
5090
5106
  api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
5091
5107
  }
5092
5108
 
5109
+ // src/history/historyLog.ts
5110
+ import { closeSync as closeSync5, constants as constants3, mkdirSync as mkdirSync7, openSync as openSync5, readFileSync as readFileSync15, writeSync as writeSync2 } from "fs";
5111
+ import { dirname as dirname5 } from "path";
5112
+ var START = "start";
5113
+ function parseRecord(line) {
5114
+ try {
5115
+ return JSON.parse(line);
5116
+ } catch {
5117
+ return null;
5118
+ }
5119
+ }
5120
+ function readLog(file, onUnreadable) {
5121
+ let text2;
5122
+ try {
5123
+ text2 = readFileSync15(file, "utf-8");
5124
+ } catch {
5125
+ return null;
5126
+ }
5127
+ const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5128
+ const lines = text2.split("\n");
5129
+ lines.forEach((line, index) => {
5130
+ const record = line ? parseRecord(line) : null;
5131
+ if (record) applyRecord(log, record);
5132
+ else if (line && index < lines.length - 1) onUnreadable(index + 1);
5133
+ });
5134
+ return log;
5135
+ }
5136
+ function applyRecord(log, record) {
5137
+ if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
5138
+ else if (record.type === "entry") log.entries.push(record.entry);
5139
+ else if (record.pinned) log.pins.add(record.id);
5140
+ else log.pins.delete(record.id);
5141
+ }
5142
+ function saveRecord(file, log, record) {
5143
+ let fd;
5144
+ try {
5145
+ fd = openSync5(file, constants3.O_WRONLY | constants3.O_APPEND);
5146
+ } catch (error) {
5147
+ if (error.code !== "ENOENT") throw error;
5148
+ return writeLog(file, log);
5149
+ }
5150
+ try {
5151
+ writeSync2(fd, `${JSON.stringify(record)}
5152
+ `);
5153
+ } finally {
5154
+ closeSync5(fd);
5155
+ }
5156
+ }
5157
+ function writeLog(file, log) {
5158
+ const records = [
5159
+ { type: "baseline", files: Object.fromEntries(log.baseline) },
5160
+ ...log.entries.map((entry) => ({ type: "entry", entry })),
5161
+ ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5162
+ ];
5163
+ mkdirSync7(dirname5(file), { recursive: true });
5164
+ replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5165
+ `).join(""), 420);
5166
+ }
5167
+ function applyEntry(manifest, entry) {
5168
+ for (const file of entry.files)
5169
+ if (file.after === null) manifest.delete(file.path);
5170
+ else manifest.set(file.path, file.after);
5171
+ }
5172
+ function manifestAt(log, point) {
5173
+ const manifest = new Map(log.baseline);
5174
+ if (point === START) return manifest;
5175
+ for (const entry of log.entries) {
5176
+ applyEntry(manifest, entry);
5177
+ if (entry.id === point) return manifest;
5178
+ }
5179
+ return null;
5180
+ }
5181
+ function undoneIds(entries) {
5182
+ const undone = /* @__PURE__ */ new Set();
5183
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
5184
+ const entry = entries[i];
5185
+ if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
5186
+ }
5187
+ return undone;
5188
+ }
5189
+ function stepTarget(entries, direction) {
5190
+ const undone = undoneIds(entries);
5191
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
5192
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
5193
+ const entry = entries[i];
5194
+ const isChange = !entry.undoes;
5195
+ if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
5196
+ if (direction !== "forward") continue;
5197
+ if (isChange) return void 0;
5198
+ const target = byId.get(entry.undoes);
5199
+ if (!undone.has(entry.id) && target && !target.undoes) return entry;
5200
+ }
5201
+ return void 0;
5202
+ }
5203
+ function foldOldest(log) {
5204
+ const oldest = log.entries[0];
5205
+ if (!oldest || log.pins.has(oldest.id)) return false;
5206
+ applyEntry(log.baseline, oldest);
5207
+ log.entries.shift();
5208
+ return true;
5209
+ }
5210
+ function referencedHashes(log, current) {
5211
+ const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
5212
+ for (const entry of log.entries)
5213
+ for (const file of entry.files) {
5214
+ if (file.before) keep.add(file.before);
5215
+ if (file.after) keep.add(file.after);
5216
+ }
5217
+ return keep;
5218
+ }
5219
+
5220
+ // src/routes/history.ts
5221
+ var YOU = { kind: "person", name: "You" };
5222
+ var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5223
+ async function historyOf(adapter, c) {
5224
+ const project = await adapter.resolveProject(c.req.param("id") ?? "");
5225
+ return project ? await adapter.history?.(project) ?? null : null;
5226
+ }
5227
+ async function bodyOf(c) {
5228
+ const body = await c.req.json().catch(() => null);
5229
+ return body && typeof body === "object" ? body : {};
5230
+ }
5231
+ var text = (value) => typeof value === "string" && value ? value : null;
5232
+ function idleOf(body) {
5233
+ const idleMs = body.idleMs;
5234
+ return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
5235
+ }
5236
+ function nextStep(entries, direction) {
5237
+ const target = stepTarget(entries, direction);
5238
+ if (!target) return null;
5239
+ const paths = target.files.map((file) => file.path);
5240
+ return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
5241
+ }
5242
+ async function withHistory(adapter, c, task) {
5243
+ const history = await historyOf(adapter, c);
5244
+ if (!history) return c.json({ error: "This project has no history here." }, 404);
5245
+ try {
5246
+ return c.json(await task(history, await bodyOf(c)) ?? null);
5247
+ } catch (error) {
5248
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
5249
+ }
5250
+ }
5251
+ function registerHistoryRoutes(api, adapter) {
5252
+ const windows = /* @__PURE__ */ new Map();
5253
+ const base = "/projects/:id/history";
5254
+ api.get(
5255
+ base,
5256
+ (c) => withHistory(adapter, c, (history) => {
5257
+ const entries = history.list();
5258
+ return { entries, back: nextStep(entries, "back"), forward: nextStep(entries, "forward") };
5259
+ })
5260
+ );
5261
+ api.post(
5262
+ `${base}/step`,
5263
+ (c) => withHistory(
5264
+ adapter,
5265
+ c,
5266
+ (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU)
5267
+ )
5268
+ );
5269
+ api.post(
5270
+ `${base}/undo`,
5271
+ (c) => withHistory(adapter, c, (history, body) => {
5272
+ const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
5273
+ return history.undo(text(body.entryId) ?? "", { who: YOU, mode });
5274
+ })
5275
+ );
5276
+ api.post(
5277
+ `${base}/restore`,
5278
+ (c) => withHistory(adapter, c, (history, body) => history.restore(text(body.point) ?? "", YOU))
5279
+ );
5280
+ api.get(
5281
+ `${base}/peek/:point`,
5282
+ (c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
5283
+ );
5284
+ api.post(
5285
+ `${base}/pin`,
5286
+ (c) => withHistory(adapter, c, (history, body) => {
5287
+ history.pin(text(body.entryId) ?? "", body.pinned === true);
5288
+ return { ok: true };
5289
+ })
5290
+ );
5291
+ api.post(
5292
+ `${base}/claim`,
5293
+ (c) => withHistory(adapter, c, async (history, body) => {
5294
+ const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
5295
+ const coalesceKey = text(body.coalesceKey) ?? void 0;
5296
+ const idleMs = idleOf(body);
5297
+ const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
5298
+ ...coalesceKey && { coalesceKey },
5299
+ ...idleMs && { idleMs }
5300
+ });
5301
+ return { claimed };
5302
+ })
5303
+ );
5304
+ api.post(
5305
+ `${base}/window`,
5306
+ (c) => withHistory(adapter, c, async (history, body) => {
5307
+ const idleMs = idleOf(body);
5308
+ const window = await history.beginWindow(
5309
+ YOU,
5310
+ text(body.label) ?? "Edited in Studio",
5311
+ idleMs ? { idleMs } : void 0
5312
+ );
5313
+ windows.set(window.id, { history, window });
5314
+ return { windowId: window.id };
5315
+ })
5316
+ );
5317
+ api.post(
5318
+ `${base}/window/:windowId/close`,
5319
+ (c) => withHistory(adapter, c, async (history) => {
5320
+ const id = c.req.param("windowId") ?? "";
5321
+ const held = windows.get(id);
5322
+ if (held?.history !== history) throw new Error("That window is not open in this project.");
5323
+ windows.delete(id);
5324
+ return { entry: await held.window.close() };
5325
+ })
5326
+ );
5327
+ }
5328
+
5093
5329
  // src/createStudioApi.ts
5094
5330
  function createStudioApi(adapter) {
5095
5331
  const api = new Hono();
@@ -5105,9 +5341,529 @@ function createStudioApi(adapter) {
5105
5341
  registerFontRoutes(api);
5106
5342
  registerRegistryRoutes(api, adapter);
5107
5343
  registerGlobalAssetRoutes(api);
5344
+ registerHistoryRoutes(api, adapter);
5108
5345
  return api;
5109
5346
  }
5110
5347
 
5348
+ // src/history/projectHistory.ts
5349
+ import { randomUUID as randomUUID7 } from "crypto";
5350
+ import { rm as rm2 } from "fs/promises";
5351
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync17 } from "fs";
5352
+ import { join as join17, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5353
+
5354
+ // src/history/blobStore.ts
5355
+ import { createHash as createHash5, randomUUID as randomUUID5 } from "crypto";
5356
+ import { constants as constants4, createReadStream as createReadStream2 } from "fs";
5357
+ import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5358
+ import { dirname as dirname6, join as join15 } from "path";
5359
+ async function hashFile(path) {
5360
+ const hash = createHash5("sha256");
5361
+ for await (const chunk of createReadStream2(path)) hash.update(chunk);
5362
+ return hash.digest("hex");
5363
+ }
5364
+ async function cloneOrCopy(from, to) {
5365
+ await mkdir(dirname6(to), { recursive: true });
5366
+ const temp = `${to}.${randomUUID5()}.tmp`;
5367
+ try {
5368
+ await copyFile(from, temp, constants4.COPYFILE_FICLONE);
5369
+ await rename(temp, to);
5370
+ } catch (error) {
5371
+ await rm(temp, { force: true });
5372
+ throw error;
5373
+ }
5374
+ }
5375
+ async function openBlobStore(dir) {
5376
+ const sizes = /* @__PURE__ */ new Map();
5377
+ await mkdir(dir, { recursive: true });
5378
+ for (const shard of await readdir(dir))
5379
+ for (const hash of await readdir(join15(dir, shard)).catch(() => []))
5380
+ sizes.set(hash, (await stat(join15(dir, shard, hash))).size);
5381
+ const pathOf = (hash) => join15(dir, hash.slice(0, 2), hash);
5382
+ let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
5383
+ return {
5384
+ async put(absPath) {
5385
+ const temp = join15(dir, `incoming-${randomUUID5()}`);
5386
+ await mkdir(dir, { recursive: true });
5387
+ await copyFile(absPath, temp, constants4.COPYFILE_FICLONE);
5388
+ const hash = await hashFile(temp);
5389
+ if (sizes.has(hash)) {
5390
+ await rm(temp, { force: true });
5391
+ return hash;
5392
+ }
5393
+ await mkdir(dirname6(pathOf(hash)), { recursive: true });
5394
+ const size = (await stat(temp)).size;
5395
+ await rename(temp, pathOf(hash));
5396
+ sizes.set(hash, size);
5397
+ total += size;
5398
+ return hash;
5399
+ },
5400
+ read: (hash) => readFile2(pathOf(hash)),
5401
+ writeTo: (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
5402
+ bytes: () => total,
5403
+ size: (hash) => sizes.get(hash) ?? 0,
5404
+ async prune(keep) {
5405
+ for (const [hash, size] of sizes) {
5406
+ if (keep.has(hash)) continue;
5407
+ await rm(pathOf(hash), { force: true });
5408
+ sizes.delete(hash);
5409
+ total -= size;
5410
+ }
5411
+ }
5412
+ };
5413
+ }
5414
+
5415
+ // src/history/historyId.ts
5416
+ import { randomUUID as randomUUID6 } from "crypto";
5417
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync16, writeFileSync as writeFileSync6 } from "fs";
5418
+ import { join as join16, resolve as resolve6 } from "path";
5419
+ var ID_PATH = join16(".hyperframes", "history-id");
5420
+ var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5421
+ function readId(projectDir) {
5422
+ try {
5423
+ const id = readFileSync16(join16(projectDir, ID_PATH), "utf-8").trim();
5424
+ return ID_SHAPE.test(id) ? id : null;
5425
+ } catch {
5426
+ return null;
5427
+ }
5428
+ }
5429
+ function recordedDir(historyDir) {
5430
+ try {
5431
+ return JSON.parse(readFileSync16(join16(historyDir, "project.json"), "utf-8")).dir ?? null;
5432
+ } catch {
5433
+ return null;
5434
+ }
5435
+ }
5436
+ function projectHistoryId(projectDir, historyRoot) {
5437
+ const dir = resolve6(projectDir);
5438
+ let id = readId(dir);
5439
+ const was = id && recordedDir(join16(historyRoot, id));
5440
+ if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
5441
+ id = randomUUID6();
5442
+ mkdirSync8(join16(dir, ".hyperframes"), { recursive: true });
5443
+ writeFileSync6(join16(dir, ID_PATH), `${id}
5444
+ `);
5445
+ }
5446
+ mkdirSync8(join16(historyRoot, id), { recursive: true });
5447
+ writeFileSync6(join16(historyRoot, id, "project.json"), JSON.stringify({ dir }));
5448
+ return id;
5449
+ }
5450
+
5451
+ // src/history/projectHistory.ts
5452
+ var OUTSIDE = { kind: "outside", name: "Outside" };
5453
+ var OUTSIDE_LABEL = "Changed outside the app";
5454
+ var RACY_MS = 2e3;
5455
+ var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
5456
+ function addChange(group, path, before, after) {
5457
+ const earlier = group.changes.get(path);
5458
+ const from = earlier ? earlier.before : before;
5459
+ if (from === after) group.changes.delete(path);
5460
+ else group.changes.set(path, { path, before: from, after });
5461
+ }
5462
+ var Engine = class {
5463
+ constructor(options, projectId, blobs) {
5464
+ this.options = options;
5465
+ this.projectId = projectId;
5466
+ this.blobs = blobs;
5467
+ this.dir = resolve7(options.projectDir);
5468
+ this.home = join17(options.historyRoot, projectId);
5469
+ }
5470
+ options;
5471
+ projectId;
5472
+ blobs;
5473
+ dir;
5474
+ home;
5475
+ log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5476
+ tracked = /* @__PURE__ */ new Map();
5477
+ windows = [];
5478
+ outside = null;
5479
+ /** A coalescing claim, open until another key, its idle timer, or an operation commits it. */
5480
+ claimed = null;
5481
+ quietTimer;
5482
+ maxTimer;
5483
+ notedTimer = null;
5484
+ listeners = /* @__PURE__ */ new Set();
5485
+ tail = Promise.resolve();
5486
+ get logFile() {
5487
+ return join17(this.home, "log.jsonl");
5488
+ }
5489
+ now() {
5490
+ return (this.options.now ?? Date.now)();
5491
+ }
5492
+ /** One operation at a time, in order: sweeps, windows and undos never interleave. */
5493
+ queue(task) {
5494
+ const run = this.tail.then(task);
5495
+ this.tail = run.catch(() => void 0);
5496
+ return run;
5497
+ }
5498
+ async start() {
5499
+ const log = readLog(
5500
+ this.logFile,
5501
+ (line) => this.options.onError?.(
5502
+ new Error(`History log line ${line} could not be read and was skipped.`)
5503
+ )
5504
+ );
5505
+ if (!log) return this.firstOpen();
5506
+ this.log = log;
5507
+ const cache = this.readStatCache();
5508
+ const last = this.log.entries.at(-1)?.id ?? START;
5509
+ for (const [path, hash] of manifestAt(this.log, last) ?? []) {
5510
+ const cached = cache.get(path);
5511
+ this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
5512
+ }
5513
+ await this.sweep();
5514
+ await this.commitOutside();
5515
+ }
5516
+ async firstOpen() {
5517
+ const sweptAt = Date.now();
5518
+ for (const file of listProjectFiles(this.dir))
5519
+ this.tracked.set(file.path, {
5520
+ hash: await this.blobs.put(join17(this.dir, file.path)),
5521
+ stat: statKey(file, sweptAt)
5522
+ });
5523
+ this.log.baseline = this.manifest();
5524
+ writeLog(this.logFile, this.log);
5525
+ this.saveStatCache();
5526
+ }
5527
+ manifest() {
5528
+ return new Map([...this.tracked].map(([path, file]) => [path, file.hash]));
5529
+ }
5530
+ readStatCache() {
5531
+ try {
5532
+ const files = JSON.parse(readFileSync17(join17(this.home, "stat.json"), "utf-8"));
5533
+ return new Map(Object.entries(files));
5534
+ } catch {
5535
+ return /* @__PURE__ */ new Map();
5536
+ }
5537
+ }
5538
+ saveStatCache() {
5539
+ const files = JSON.stringify(Object.fromEntries(this.tracked));
5540
+ mkdirSync9(this.home, { recursive: true });
5541
+ replaceFileAtomically(join17(this.home, "stat.json"), files, 420);
5542
+ }
5543
+ /**
5544
+ * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
5545
+ * project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
5546
+ */
5547
+ async sweep() {
5548
+ if (!existsSync12(this.dir)) return;
5549
+ const sweptAt = Date.now();
5550
+ const seen = listProjectFiles(this.dir);
5551
+ let changed = false;
5552
+ for (const file of seen)
5553
+ changed = await this.observe(file.path, statKey(file, sweptAt)) || changed;
5554
+ const present = new Set(seen.map((file) => file.path));
5555
+ for (const [path, known] of this.tracked) {
5556
+ if (present.has(path)) continue;
5557
+ this.tracked.delete(path);
5558
+ this.record(path, known.hash, null);
5559
+ changed = true;
5560
+ }
5561
+ if (changed) this.saveStatCache();
5562
+ }
5563
+ /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
5564
+ async storeIfPresent(path) {
5565
+ try {
5566
+ return await this.blobs.put(join17(this.dir, path));
5567
+ } catch (error) {
5568
+ if (error.code === "ENOENT") return null;
5569
+ throw error;
5570
+ }
5571
+ }
5572
+ async observe(path, stat2) {
5573
+ const known = this.tracked.get(path) ?? { hash: null, stat: null };
5574
+ if (stat2 && known.stat === stat2) return false;
5575
+ const hash = await this.storeIfPresent(path);
5576
+ if (hash === null) return false;
5577
+ this.tracked.set(path, { hash, stat: stat2 });
5578
+ if (known.hash !== hash) this.record(path, known.hash, hash);
5579
+ return known.hash !== hash || known.stat !== stat2;
5580
+ }
5581
+ record(path, before, after) {
5582
+ const window = this.windows.at(-1);
5583
+ if (window) this.touch(window);
5584
+ addChange(window ?? this.outsideGroup(), path, before, after);
5585
+ }
5586
+ /**
5587
+ * ponytail: a claim takes every uncommitted outside change to its paths, so another writer's write to the same file
5588
+ * between Studio's write and its claim (milliseconds) folds into Studio's entry. Per-write tokens would split them.
5589
+ * A write filed to another writer's open window stays that window's.
5590
+ */
5591
+ async claimNow(who, label, paths, { coalesceKey, idleMs }) {
5592
+ await this.sweep();
5593
+ const taken = this.takeOutside(paths);
5594
+ if (!taken.length) return null;
5595
+ const group = await this.claimGroup(who, label, coalesceKey);
5596
+ for (const change of taken) addChange(group, change.path, change.before, change.after);
5597
+ if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
5598
+ const entry = await this.commit(group);
5599
+ return entry && { id: entry.id };
5600
+ }
5601
+ /** The held claim when the key matches; otherwise it is committed and a new group starts. */
5602
+ async claimGroup(who, label, key) {
5603
+ if (key && this.claimed?.key === key) return this.claimed.group;
5604
+ await this.commitClaim();
5605
+ return this.newGroup(who, label);
5606
+ }
5607
+ /** Removes and returns the uncommitted outside changes to `paths` (project-relative or absolute). */
5608
+ takeOutside(paths) {
5609
+ const outside = this.outside;
5610
+ if (!outside) return [];
5611
+ const wanted = new Set(
5612
+ paths.map((path) => relative4(this.dir, resolve7(this.dir, path)).split(sep3).join("/"))
5613
+ );
5614
+ const taken = [...outside.changes.values()].filter((change) => wanted.has(change.path));
5615
+ for (const change of taken) outside.changes.delete(change.path);
5616
+ return taken;
5617
+ }
5618
+ holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
5619
+ clearTimeout(this.claimed?.timer);
5620
+ const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
5621
+ timer.unref?.();
5622
+ this.claimed = { group, key, timer };
5623
+ return group.changes.size ? { id: group.id } : null;
5624
+ }
5625
+ async commitClaim() {
5626
+ const held = this.claimed;
5627
+ this.claimed = null;
5628
+ if (!held) return;
5629
+ clearTimeout(held.timer);
5630
+ await this.commit(held.group);
5631
+ }
5632
+ outsideGroup() {
5633
+ const { quietMs = 2e3, maxGroupMs = 3e4 } = this.options;
5634
+ const closeIn = (ms) => {
5635
+ const timer = setTimeout(() => this.background(() => this.commitOutside()), ms);
5636
+ timer.unref?.();
5637
+ return timer;
5638
+ };
5639
+ if (!this.outside) {
5640
+ this.outside = this.newGroup(OUTSIDE, OUTSIDE_LABEL);
5641
+ this.maxTimer = closeIn(maxGroupMs);
5642
+ }
5643
+ clearTimeout(this.quietTimer);
5644
+ this.quietTimer = closeIn(quietMs);
5645
+ return this.outside;
5646
+ }
5647
+ newGroup(who, label) {
5648
+ return { id: randomUUID7(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5649
+ }
5650
+ async commitOutside() {
5651
+ clearTimeout(this.quietTimer);
5652
+ clearTimeout(this.maxTimer);
5653
+ const group = this.outside;
5654
+ this.outside = null;
5655
+ if (group) await this.commit(group);
5656
+ }
5657
+ async commit(group, extra = {}) {
5658
+ if (!group.changes.size) return null;
5659
+ const { changes, ...rest } = group;
5660
+ const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
5661
+ const entry = { ...rest, endedAt: this.now(), files, ...extra };
5662
+ this.log.entries.push(entry);
5663
+ try {
5664
+ saveRecord(this.logFile, this.log, { type: "entry", entry });
5665
+ } catch (error) {
5666
+ this.log.entries.pop();
5667
+ throw error;
5668
+ }
5669
+ for (const listener of this.listeners) listener(entry);
5670
+ await this.keepWithinBudget();
5671
+ return entry;
5672
+ }
5673
+ /** Stored bytes beyond what the current files need: a project larger than the budget still keeps its history. */
5674
+ historyBytes() {
5675
+ let current = 0;
5676
+ for (const hash of new Set([...this.tracked.values()].map((file) => file.hash)))
5677
+ current += this.blobs.size(hash);
5678
+ return this.blobs.bytes() - current;
5679
+ }
5680
+ async keepWithinBudget() {
5681
+ const budget = this.options.budgetBytes ?? 2 * 1024 ** 3;
5682
+ let folded = false;
5683
+ while (this.historyBytes() > budget && foldOldest(this.log)) {
5684
+ folded = true;
5685
+ await this.blobs.prune(referencedHashes(this.log, this.manifest()));
5686
+ }
5687
+ if (folded) writeLog(this.logFile, this.log);
5688
+ }
5689
+ /** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
5690
+ async settle() {
5691
+ await this.sweep();
5692
+ await this.commitClaim();
5693
+ await this.commitOutside();
5694
+ }
5695
+ /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
5696
+ noteChange(path) {
5697
+ if (this.notedTimer || !affectsProjectSignature(this.dir, resolve7(this.dir, path))) return;
5698
+ this.notedTimer = setTimeout(() => {
5699
+ this.notedTimer = null;
5700
+ this.background(() => this.sweep());
5701
+ }, 20);
5702
+ this.notedTimer.unref?.();
5703
+ }
5704
+ background(task) {
5705
+ this.queue(task).catch((error) => this.options.onError?.(error));
5706
+ }
5707
+ beginWindow(who, label, idleMs) {
5708
+ return this.queue(async () => {
5709
+ await this.sweep();
5710
+ const window = { ...this.newGroup(who, label), idleMs };
5711
+ this.windows.push(window);
5712
+ this.touch(window);
5713
+ return { id: window.id, close: () => this.queue(() => this.sweepAndEnd(window)) };
5714
+ });
5715
+ }
5716
+ /** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
5717
+ touch(window) {
5718
+ clearTimeout(window.idleTimer);
5719
+ if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
5720
+ window.idleTimer = setTimeout(
5721
+ () => this.background(() => this.sweepAndEnd(window)),
5722
+ window.idleMs
5723
+ );
5724
+ window.idleTimer.unref?.();
5725
+ }
5726
+ async sweepAndEnd(window) {
5727
+ await this.sweep();
5728
+ return this.endWindow(window);
5729
+ }
5730
+ /** Commits an open window once; ending it again returns the entry it became. */
5731
+ async endWindow(window) {
5732
+ if (!this.windows.includes(window)) return window.entry ?? null;
5733
+ clearTimeout(window.idleTimer);
5734
+ this.windows = this.windows.filter((open) => open !== window);
5735
+ window.entry = await this.commit(window);
5736
+ return window.entry;
5737
+ }
5738
+ /** Every pending write, open window and outside group, committed: for flush and close. */
5739
+ async settleAll() {
5740
+ await this.sweep();
5741
+ await this.commitClaim();
5742
+ for (const window of [...this.windows]) await this.endWindow(window);
5743
+ await this.commitOutside();
5744
+ }
5745
+ /** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
5746
+ async writeAs(who, label, target, extra) {
5747
+ const group = this.newGroup(who, label);
5748
+ this.windows.push(group);
5749
+ try {
5750
+ for (const [path, hash] of target) {
5751
+ if ((this.tracked.get(path)?.hash ?? null) === hash) continue;
5752
+ if (hash === null) await rm2(join17(this.dir, path), { force: true });
5753
+ else await this.blobs.writeTo(hash, join17(this.dir, path));
5754
+ }
5755
+ await this.sweep();
5756
+ } finally {
5757
+ this.windows = this.windows.filter((open) => open !== group);
5758
+ }
5759
+ return this.commit(group, extra);
5760
+ }
5761
+ entry(id) {
5762
+ const entry = this.log.entries.find((candidate) => candidate.id === id);
5763
+ if (!entry) throw new Error("That change is no longer kept in this project's history.");
5764
+ return entry;
5765
+ }
5766
+ undoLabel(entry) {
5767
+ if (!entry.undoes) return `Undid: ${entry.label}`;
5768
+ const original = this.log.entries.find((candidate) => candidate.id === entry.undoes);
5769
+ return `Redid: ${original?.label ?? entry.label}`;
5770
+ }
5771
+ async undoNow(id, who, mode) {
5772
+ const entry = this.entry(id);
5773
+ const changed = entry.files.filter(
5774
+ (file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after
5775
+ );
5776
+ if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
5777
+ if (mode === "back-to-before") {
5778
+ const index = this.log.entries.indexOf(entry);
5779
+ const point = index > 0 ? this.log.entries[index - 1].id : START;
5780
+ return {
5781
+ ok: true,
5782
+ entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
5783
+ };
5784
+ }
5785
+ const target = new Map(entry.files.map((file) => [file.path, file.before]));
5786
+ return {
5787
+ ok: true,
5788
+ entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
5789
+ };
5790
+ }
5791
+ conflict(entry, changed) {
5792
+ const paths = new Set(changed.map((file) => file.path));
5793
+ const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
5794
+ const newer = after.filter((later) => later.files.some((file) => paths.has(file.path)));
5795
+ return { files: [...paths], newer: newer.map((later) => later.id) };
5796
+ }
5797
+ async restoreNow(point, who, label) {
5798
+ const files = manifestAt(this.log, point);
5799
+ if (!files) throw new Error("That point is no longer kept in this project's history.");
5800
+ const target = new Map(
5801
+ [...this.tracked.keys()].map((path) => [path, null])
5802
+ );
5803
+ for (const [path, hash] of files) target.set(path, hash);
5804
+ return this.writeAs(who, label, target, { restoredTo: point });
5805
+ }
5806
+ pointLabel(point) {
5807
+ return point === START ? "the start" : this.entry(point).label;
5808
+ }
5809
+ api() {
5810
+ return {
5811
+ projectId: this.projectId,
5812
+ beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
5813
+ claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
5814
+ noteChange: (path) => this.noteChange(path),
5815
+ list: () => {
5816
+ const undone = undoneIds(this.log.entries);
5817
+ return this.log.entries.map((entry) => ({
5818
+ ...entry,
5819
+ pinned: this.log.pins.has(entry.id),
5820
+ undone: undone.has(entry.id)
5821
+ }));
5822
+ },
5823
+ step: (direction, who) => this.queue(async () => {
5824
+ await this.settle();
5825
+ const target = stepTarget(this.log.entries, direction);
5826
+ return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
5827
+ }),
5828
+ undo: (id, { who, mode }) => this.queue(async () => {
5829
+ await this.settle();
5830
+ return this.undoNow(id, who, mode);
5831
+ }),
5832
+ restore: (point, who) => this.queue(async () => {
5833
+ await this.settle();
5834
+ return this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`);
5835
+ }),
5836
+ peek: (point) => {
5837
+ const files = manifestAt(this.log, point);
5838
+ return files && Object.fromEntries(files);
5839
+ },
5840
+ readBlob: (hash) => this.blobs.read(hash),
5841
+ pin: (id, pinned) => {
5842
+ this.entry(id);
5843
+ if (pinned) this.log.pins.add(id);
5844
+ else this.log.pins.delete(id);
5845
+ saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
5846
+ },
5847
+ onEntry: (listener) => {
5848
+ this.listeners.add(listener);
5849
+ return () => this.listeners.delete(listener);
5850
+ },
5851
+ flush: () => this.queue(() => this.settleAll()),
5852
+ close: async () => {
5853
+ if (this.notedTimer) clearTimeout(this.notedTimer);
5854
+ await this.queue(() => this.settleAll());
5855
+ }
5856
+ };
5857
+ }
5858
+ };
5859
+ async function openProjectHistory(options) {
5860
+ const projectId = projectHistoryId(options.projectDir, options.historyRoot);
5861
+ const blobs = await openBlobStore(join17(options.historyRoot, projectId, "blobs"));
5862
+ const engine = new Engine(options, projectId, blobs);
5863
+ await engine.queue(() => engine.start());
5864
+ return engine.api();
5865
+ }
5866
+
5111
5867
  // src/helpers/duplicateElement.ts
5112
5868
  import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
5113
5869
  function duplicateElementInHtml(source, target, newId, at) {
@@ -5227,6 +5983,7 @@ function updateBackgroundRemovalProgress(state, event) {
5227
5983
  state.avgMsPerFrame = event.avgMsPerFrame;
5228
5984
  }
5229
5985
  export {
5986
+ START as HISTORY_START,
5230
5987
  MIME_TYPES,
5231
5988
  PREVIEW_BUNDLE_OPTIONS,
5232
5989
  STUDIO_MANUAL_EDITS_PATH,
@@ -5250,6 +6007,7 @@ export {
5250
6007
  getMimeType,
5251
6008
  identifyFileWrite,
5252
6009
  isSafePath,
6010
+ openProjectHistory,
5253
6011
  parseSourceDocument,
5254
6012
  patchElementInHtml,
5255
6013
  removeElementFromHtml,