@hyperframes/studio-server 0.8.73 → 0.8.74
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
|
|
154
|
+
let stat2;
|
|
155
155
|
try {
|
|
156
|
-
|
|
156
|
+
stat2 = lstatSync(file);
|
|
157
157
|
} catch {
|
|
158
158
|
continue;
|
|
159
159
|
}
|
|
160
|
-
if (
|
|
161
|
-
if (
|
|
160
|
+
if (stat2.isSymbolicLink()) continue;
|
|
161
|
+
if (stat2.isDirectory()) {
|
|
162
162
|
collectProjectSignatureFiles(projectDir, file, files);
|
|
163
|
-
} else if (
|
|
163
|
+
} else if (stat2.isFile()) {
|
|
164
164
|
files.push({
|
|
165
165
|
file,
|
|
166
|
-
mtimeMs:
|
|
167
|
-
ctimeMs:
|
|
168
|
-
size:
|
|
169
|
-
textContentEligible: isTextContentEligible(file,
|
|
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
|
|
179
|
+
let stat2;
|
|
180
180
|
try {
|
|
181
|
-
|
|
181
|
+
stat2 = lstatSync(file);
|
|
182
182
|
} catch {
|
|
183
183
|
continue;
|
|
184
184
|
}
|
|
185
|
-
if (
|
|
185
|
+
if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
|
|
186
186
|
files.push({
|
|
187
187
|
file,
|
|
188
|
-
mtimeMs:
|
|
189
|
-
ctimeMs:
|
|
190
|
-
size:
|
|
191
|
-
textContentEligible: isTextContentEligible(file,
|
|
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(
|
|
626
|
-
if (now -
|
|
627
|
-
return [
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
3808
|
-
if (!
|
|
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(
|
|
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
|
|
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:
|
|
4173
|
-
createdAt:
|
|
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
|
|
4301
|
+
let resolve8;
|
|
4286
4302
|
let reject;
|
|
4287
4303
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
4288
|
-
|
|
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:
|
|
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((
|
|
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
|
-
|
|
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
|
|
4428
|
-
if (!
|
|
4429
|
-
return { html: readFileSync12(fd, "utf-8"), 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
|
|
4716
|
-
if (
|
|
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(
|
|
4720
|
-
readSync(fd, buffer, 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,80 @@ function registerGlobalAssetRoutes(api) {
|
|
|
5090
5106
|
api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
|
|
5091
5107
|
}
|
|
5092
5108
|
|
|
5109
|
+
// src/routes/history.ts
|
|
5110
|
+
var YOU = { kind: "person", name: "You" };
|
|
5111
|
+
async function historyOf(adapter, c) {
|
|
5112
|
+
const project = await adapter.resolveProject(c.req.param("id") ?? "");
|
|
5113
|
+
return project ? await adapter.history?.(project) ?? null : null;
|
|
5114
|
+
}
|
|
5115
|
+
async function bodyOf(c) {
|
|
5116
|
+
const body = await c.req.json().catch(() => null);
|
|
5117
|
+
return body && typeof body === "object" ? body : {};
|
|
5118
|
+
}
|
|
5119
|
+
var text = (value) => typeof value === "string" && value ? value : null;
|
|
5120
|
+
async function withHistory(adapter, c, task) {
|
|
5121
|
+
const history = await historyOf(adapter, c);
|
|
5122
|
+
if (!history) return c.json({ error: "This project has no history here." }, 404);
|
|
5123
|
+
try {
|
|
5124
|
+
return c.json(await task(history, await bodyOf(c)) ?? null);
|
|
5125
|
+
} catch (error) {
|
|
5126
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
5129
|
+
function registerHistoryRoutes(api, adapter) {
|
|
5130
|
+
const windows = /* @__PURE__ */ new Map();
|
|
5131
|
+
const base = "/projects/:id/history";
|
|
5132
|
+
api.get(base, (c) => withHistory(adapter, c, (history) => ({ entries: history.list() })));
|
|
5133
|
+
api.post(
|
|
5134
|
+
`${base}/step`,
|
|
5135
|
+
(c) => withHistory(
|
|
5136
|
+
adapter,
|
|
5137
|
+
c,
|
|
5138
|
+
(history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU)
|
|
5139
|
+
)
|
|
5140
|
+
);
|
|
5141
|
+
api.post(
|
|
5142
|
+
`${base}/undo`,
|
|
5143
|
+
(c) => withHistory(adapter, c, (history, body) => {
|
|
5144
|
+
const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
|
|
5145
|
+
return history.undo(text(body.entryId) ?? "", { who: YOU, mode });
|
|
5146
|
+
})
|
|
5147
|
+
);
|
|
5148
|
+
api.post(
|
|
5149
|
+
`${base}/restore`,
|
|
5150
|
+
(c) => withHistory(adapter, c, (history, body) => history.restore(text(body.point) ?? "", YOU))
|
|
5151
|
+
);
|
|
5152
|
+
api.get(
|
|
5153
|
+
`${base}/peek/:point`,
|
|
5154
|
+
(c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
|
|
5155
|
+
);
|
|
5156
|
+
api.post(
|
|
5157
|
+
`${base}/pin`,
|
|
5158
|
+
(c) => withHistory(adapter, c, (history, body) => {
|
|
5159
|
+
history.pin(text(body.entryId) ?? "", body.pinned === true);
|
|
5160
|
+
return { ok: true };
|
|
5161
|
+
})
|
|
5162
|
+
);
|
|
5163
|
+
api.post(
|
|
5164
|
+
`${base}/window`,
|
|
5165
|
+
(c) => withHistory(adapter, c, async (history, body) => {
|
|
5166
|
+
const window = await history.beginWindow(YOU, text(body.label) ?? "Edited in Studio");
|
|
5167
|
+
windows.set(window.id, { history, window });
|
|
5168
|
+
return { windowId: window.id };
|
|
5169
|
+
})
|
|
5170
|
+
);
|
|
5171
|
+
api.post(
|
|
5172
|
+
`${base}/window/:windowId/close`,
|
|
5173
|
+
(c) => withHistory(adapter, c, async (history) => {
|
|
5174
|
+
const id = c.req.param("windowId") ?? "";
|
|
5175
|
+
const held = windows.get(id);
|
|
5176
|
+
if (held?.history !== history) throw new Error("That window is not open in this project.");
|
|
5177
|
+
windows.delete(id);
|
|
5178
|
+
return { entry: await held.window.close() };
|
|
5179
|
+
})
|
|
5180
|
+
);
|
|
5181
|
+
}
|
|
5182
|
+
|
|
5093
5183
|
// src/createStudioApi.ts
|
|
5094
5184
|
function createStudioApi(adapter) {
|
|
5095
5185
|
const api = new Hono();
|
|
@@ -5105,9 +5195,587 @@ function createStudioApi(adapter) {
|
|
|
5105
5195
|
registerFontRoutes(api);
|
|
5106
5196
|
registerRegistryRoutes(api, adapter);
|
|
5107
5197
|
registerGlobalAssetRoutes(api);
|
|
5198
|
+
registerHistoryRoutes(api, adapter);
|
|
5108
5199
|
return api;
|
|
5109
5200
|
}
|
|
5110
5201
|
|
|
5202
|
+
// src/history/projectHistory.ts
|
|
5203
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
5204
|
+
import { rm as rm2 } from "fs/promises";
|
|
5205
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync17 } from "fs";
|
|
5206
|
+
import { join as join17, resolve as resolve7 } from "path";
|
|
5207
|
+
|
|
5208
|
+
// src/history/blobStore.ts
|
|
5209
|
+
import { createHash as createHash5, randomUUID as randomUUID5 } from "crypto";
|
|
5210
|
+
import { constants as constants3, createReadStream as createReadStream2 } from "fs";
|
|
5211
|
+
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
|
|
5212
|
+
import { dirname as dirname5, join as join15 } from "path";
|
|
5213
|
+
async function hashFile(path) {
|
|
5214
|
+
const hash = createHash5("sha256");
|
|
5215
|
+
for await (const chunk of createReadStream2(path)) hash.update(chunk);
|
|
5216
|
+
return hash.digest("hex");
|
|
5217
|
+
}
|
|
5218
|
+
async function cloneOrCopy(from, to) {
|
|
5219
|
+
await mkdir(dirname5(to), { recursive: true });
|
|
5220
|
+
const temp = `${to}.${randomUUID5()}.tmp`;
|
|
5221
|
+
try {
|
|
5222
|
+
await copyFile(from, temp, constants3.COPYFILE_FICLONE);
|
|
5223
|
+
await rename(temp, to);
|
|
5224
|
+
} catch (error) {
|
|
5225
|
+
await rm(temp, { force: true });
|
|
5226
|
+
throw error;
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
async function openBlobStore(dir) {
|
|
5230
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
5231
|
+
await mkdir(dir, { recursive: true });
|
|
5232
|
+
for (const shard of await readdir(dir))
|
|
5233
|
+
for (const hash of await readdir(join15(dir, shard)).catch(() => []))
|
|
5234
|
+
sizes.set(hash, (await stat(join15(dir, shard, hash))).size);
|
|
5235
|
+
const pathOf = (hash) => join15(dir, hash.slice(0, 2), hash);
|
|
5236
|
+
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
|
|
5237
|
+
return {
|
|
5238
|
+
async put(absPath) {
|
|
5239
|
+
const temp = join15(dir, `incoming-${randomUUID5()}`);
|
|
5240
|
+
await mkdir(dir, { recursive: true });
|
|
5241
|
+
await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
|
|
5242
|
+
const hash = await hashFile(temp);
|
|
5243
|
+
if (sizes.has(hash)) {
|
|
5244
|
+
await rm(temp, { force: true });
|
|
5245
|
+
return hash;
|
|
5246
|
+
}
|
|
5247
|
+
await mkdir(dirname5(pathOf(hash)), { recursive: true });
|
|
5248
|
+
const size = (await stat(temp)).size;
|
|
5249
|
+
await rename(temp, pathOf(hash));
|
|
5250
|
+
sizes.set(hash, size);
|
|
5251
|
+
total += size;
|
|
5252
|
+
return hash;
|
|
5253
|
+
},
|
|
5254
|
+
read: (hash) => readFile2(pathOf(hash)),
|
|
5255
|
+
writeTo: (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
|
|
5256
|
+
bytes: () => total,
|
|
5257
|
+
size: (hash) => sizes.get(hash) ?? 0,
|
|
5258
|
+
async prune(keep) {
|
|
5259
|
+
for (const [hash, size] of sizes) {
|
|
5260
|
+
if (keep.has(hash)) continue;
|
|
5261
|
+
await rm(pathOf(hash), { force: true });
|
|
5262
|
+
sizes.delete(hash);
|
|
5263
|
+
total -= size;
|
|
5264
|
+
}
|
|
5265
|
+
}
|
|
5266
|
+
};
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
// src/history/historyId.ts
|
|
5270
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
5271
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync6 } from "fs";
|
|
5272
|
+
import { join as join16, resolve as resolve6 } from "path";
|
|
5273
|
+
var ID_PATH = join16(".hyperframes", "history-id");
|
|
5274
|
+
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
5275
|
+
function readId(projectDir) {
|
|
5276
|
+
try {
|
|
5277
|
+
const id = readFileSync15(join16(projectDir, ID_PATH), "utf-8").trim();
|
|
5278
|
+
return ID_SHAPE.test(id) ? id : null;
|
|
5279
|
+
} catch {
|
|
5280
|
+
return null;
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
5283
|
+
function recordedDir(historyDir) {
|
|
5284
|
+
try {
|
|
5285
|
+
return JSON.parse(readFileSync15(join16(historyDir, "project.json"), "utf-8")).dir ?? null;
|
|
5286
|
+
} catch {
|
|
5287
|
+
return null;
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
function projectHistoryId(projectDir, historyRoot) {
|
|
5291
|
+
const dir = resolve6(projectDir);
|
|
5292
|
+
let id = readId(dir);
|
|
5293
|
+
const was = id && recordedDir(join16(historyRoot, id));
|
|
5294
|
+
if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
|
|
5295
|
+
id = randomUUID6();
|
|
5296
|
+
mkdirSync7(join16(dir, ".hyperframes"), { recursive: true });
|
|
5297
|
+
writeFileSync6(join16(dir, ID_PATH), `${id}
|
|
5298
|
+
`);
|
|
5299
|
+
}
|
|
5300
|
+
mkdirSync7(join16(historyRoot, id), { recursive: true });
|
|
5301
|
+
writeFileSync6(join16(historyRoot, id, "project.json"), JSON.stringify({ dir }));
|
|
5302
|
+
return id;
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
// src/history/historyLog.ts
|
|
5306
|
+
import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync8, openSync as openSync5, readFileSync as readFileSync16, writeSync as writeSync2 } from "fs";
|
|
5307
|
+
import { dirname as dirname6 } from "path";
|
|
5308
|
+
var START = "start";
|
|
5309
|
+
function parseRecord(line) {
|
|
5310
|
+
try {
|
|
5311
|
+
return JSON.parse(line);
|
|
5312
|
+
} catch {
|
|
5313
|
+
return null;
|
|
5314
|
+
}
|
|
5315
|
+
}
|
|
5316
|
+
function readLog(file, onUnreadable) {
|
|
5317
|
+
let text2;
|
|
5318
|
+
try {
|
|
5319
|
+
text2 = readFileSync16(file, "utf-8");
|
|
5320
|
+
} catch {
|
|
5321
|
+
return null;
|
|
5322
|
+
}
|
|
5323
|
+
const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5324
|
+
const lines = text2.split("\n");
|
|
5325
|
+
lines.forEach((line, index) => {
|
|
5326
|
+
const record = line ? parseRecord(line) : null;
|
|
5327
|
+
if (record) applyRecord(log, record);
|
|
5328
|
+
else if (line && index < lines.length - 1) onUnreadable(index + 1);
|
|
5329
|
+
});
|
|
5330
|
+
return log;
|
|
5331
|
+
}
|
|
5332
|
+
function applyRecord(log, record) {
|
|
5333
|
+
if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
|
|
5334
|
+
else if (record.type === "entry") log.entries.push(record.entry);
|
|
5335
|
+
else if (record.pinned) log.pins.add(record.id);
|
|
5336
|
+
else log.pins.delete(record.id);
|
|
5337
|
+
}
|
|
5338
|
+
function saveRecord(file, log, record) {
|
|
5339
|
+
let fd;
|
|
5340
|
+
try {
|
|
5341
|
+
fd = openSync5(file, constants4.O_WRONLY | constants4.O_APPEND);
|
|
5342
|
+
} catch (error) {
|
|
5343
|
+
if (error.code !== "ENOENT") throw error;
|
|
5344
|
+
return writeLog(file, log);
|
|
5345
|
+
}
|
|
5346
|
+
try {
|
|
5347
|
+
writeSync2(fd, `${JSON.stringify(record)}
|
|
5348
|
+
`);
|
|
5349
|
+
} finally {
|
|
5350
|
+
closeSync5(fd);
|
|
5351
|
+
}
|
|
5352
|
+
}
|
|
5353
|
+
function writeLog(file, log) {
|
|
5354
|
+
const records = [
|
|
5355
|
+
{ type: "baseline", files: Object.fromEntries(log.baseline) },
|
|
5356
|
+
...log.entries.map((entry) => ({ type: "entry", entry })),
|
|
5357
|
+
...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
|
|
5358
|
+
];
|
|
5359
|
+
mkdirSync8(dirname6(file), { recursive: true });
|
|
5360
|
+
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
|
|
5361
|
+
`).join(""), 420);
|
|
5362
|
+
}
|
|
5363
|
+
function applyEntry(manifest, entry) {
|
|
5364
|
+
for (const file of entry.files)
|
|
5365
|
+
if (file.after === null) manifest.delete(file.path);
|
|
5366
|
+
else manifest.set(file.path, file.after);
|
|
5367
|
+
}
|
|
5368
|
+
function manifestAt(log, point) {
|
|
5369
|
+
const manifest = new Map(log.baseline);
|
|
5370
|
+
if (point === START) return manifest;
|
|
5371
|
+
for (const entry of log.entries) {
|
|
5372
|
+
applyEntry(manifest, entry);
|
|
5373
|
+
if (entry.id === point) return manifest;
|
|
5374
|
+
}
|
|
5375
|
+
return null;
|
|
5376
|
+
}
|
|
5377
|
+
function undoneIds(entries) {
|
|
5378
|
+
const undone = /* @__PURE__ */ new Set();
|
|
5379
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
5380
|
+
const entry = entries[i];
|
|
5381
|
+
if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
|
|
5382
|
+
}
|
|
5383
|
+
return undone;
|
|
5384
|
+
}
|
|
5385
|
+
function stepTarget(entries, direction) {
|
|
5386
|
+
const undone = undoneIds(entries);
|
|
5387
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
5388
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
5389
|
+
const entry = entries[i];
|
|
5390
|
+
const isChange = !entry.undoes;
|
|
5391
|
+
if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
|
|
5392
|
+
if (direction !== "forward") continue;
|
|
5393
|
+
if (isChange) return void 0;
|
|
5394
|
+
const target = byId.get(entry.undoes);
|
|
5395
|
+
if (!undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5396
|
+
}
|
|
5397
|
+
return void 0;
|
|
5398
|
+
}
|
|
5399
|
+
function foldOldest(log) {
|
|
5400
|
+
const oldest = log.entries[0];
|
|
5401
|
+
if (!oldest || log.pins.has(oldest.id)) return false;
|
|
5402
|
+
applyEntry(log.baseline, oldest);
|
|
5403
|
+
log.entries.shift();
|
|
5404
|
+
return true;
|
|
5405
|
+
}
|
|
5406
|
+
function referencedHashes(log, current) {
|
|
5407
|
+
const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
|
|
5408
|
+
for (const entry of log.entries)
|
|
5409
|
+
for (const file of entry.files) {
|
|
5410
|
+
if (file.before) keep.add(file.before);
|
|
5411
|
+
if (file.after) keep.add(file.after);
|
|
5412
|
+
}
|
|
5413
|
+
return keep;
|
|
5414
|
+
}
|
|
5415
|
+
|
|
5416
|
+
// src/history/projectHistory.ts
|
|
5417
|
+
var OUTSIDE = { kind: "outside", name: "Outside" };
|
|
5418
|
+
var OUTSIDE_LABEL = "Changed outside the app";
|
|
5419
|
+
var RACY_MS = 2e3;
|
|
5420
|
+
var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
|
|
5421
|
+
var Engine = class {
|
|
5422
|
+
constructor(options, projectId, blobs) {
|
|
5423
|
+
this.options = options;
|
|
5424
|
+
this.projectId = projectId;
|
|
5425
|
+
this.blobs = blobs;
|
|
5426
|
+
this.dir = resolve7(options.projectDir);
|
|
5427
|
+
this.home = join17(options.historyRoot, projectId);
|
|
5428
|
+
}
|
|
5429
|
+
options;
|
|
5430
|
+
projectId;
|
|
5431
|
+
blobs;
|
|
5432
|
+
dir;
|
|
5433
|
+
home;
|
|
5434
|
+
log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5435
|
+
tracked = /* @__PURE__ */ new Map();
|
|
5436
|
+
windows = [];
|
|
5437
|
+
outside = null;
|
|
5438
|
+
quietTimer;
|
|
5439
|
+
maxTimer;
|
|
5440
|
+
notedTimer = null;
|
|
5441
|
+
listeners = /* @__PURE__ */ new Set();
|
|
5442
|
+
tail = Promise.resolve();
|
|
5443
|
+
get logFile() {
|
|
5444
|
+
return join17(this.home, "log.jsonl");
|
|
5445
|
+
}
|
|
5446
|
+
now() {
|
|
5447
|
+
return (this.options.now ?? Date.now)();
|
|
5448
|
+
}
|
|
5449
|
+
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
|
5450
|
+
queue(task) {
|
|
5451
|
+
const run = this.tail.then(task);
|
|
5452
|
+
this.tail = run.catch(() => void 0);
|
|
5453
|
+
return run;
|
|
5454
|
+
}
|
|
5455
|
+
async start() {
|
|
5456
|
+
const log = readLog(
|
|
5457
|
+
this.logFile,
|
|
5458
|
+
(line) => this.options.onError?.(
|
|
5459
|
+
new Error(`History log line ${line} could not be read and was skipped.`)
|
|
5460
|
+
)
|
|
5461
|
+
);
|
|
5462
|
+
if (!log) return this.firstOpen();
|
|
5463
|
+
this.log = log;
|
|
5464
|
+
const cache = this.readStatCache();
|
|
5465
|
+
const last = this.log.entries.at(-1)?.id ?? START;
|
|
5466
|
+
for (const [path, hash] of manifestAt(this.log, last) ?? []) {
|
|
5467
|
+
const cached = cache.get(path);
|
|
5468
|
+
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
|
5469
|
+
}
|
|
5470
|
+
await this.sweep();
|
|
5471
|
+
await this.commitOutside();
|
|
5472
|
+
}
|
|
5473
|
+
async firstOpen() {
|
|
5474
|
+
const sweptAt = Date.now();
|
|
5475
|
+
for (const file of listProjectFiles(this.dir))
|
|
5476
|
+
this.tracked.set(file.path, {
|
|
5477
|
+
hash: await this.blobs.put(join17(this.dir, file.path)),
|
|
5478
|
+
stat: statKey(file, sweptAt)
|
|
5479
|
+
});
|
|
5480
|
+
this.log.baseline = this.manifest();
|
|
5481
|
+
writeLog(this.logFile, this.log);
|
|
5482
|
+
this.saveStatCache();
|
|
5483
|
+
}
|
|
5484
|
+
manifest() {
|
|
5485
|
+
return new Map([...this.tracked].map(([path, file]) => [path, file.hash]));
|
|
5486
|
+
}
|
|
5487
|
+
readStatCache() {
|
|
5488
|
+
try {
|
|
5489
|
+
const files = JSON.parse(readFileSync17(join17(this.home, "stat.json"), "utf-8"));
|
|
5490
|
+
return new Map(Object.entries(files));
|
|
5491
|
+
} catch {
|
|
5492
|
+
return /* @__PURE__ */ new Map();
|
|
5493
|
+
}
|
|
5494
|
+
}
|
|
5495
|
+
saveStatCache() {
|
|
5496
|
+
const files = JSON.stringify(Object.fromEntries(this.tracked));
|
|
5497
|
+
mkdirSync9(this.home, { recursive: true });
|
|
5498
|
+
replaceFileAtomically(join17(this.home, "stat.json"), files, 420);
|
|
5499
|
+
}
|
|
5500
|
+
/**
|
|
5501
|
+
* Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
|
|
5502
|
+
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
|
5503
|
+
*/
|
|
5504
|
+
async sweep() {
|
|
5505
|
+
if (!existsSync12(this.dir)) return;
|
|
5506
|
+
const sweptAt = Date.now();
|
|
5507
|
+
const seen = listProjectFiles(this.dir);
|
|
5508
|
+
let changed = false;
|
|
5509
|
+
for (const file of seen)
|
|
5510
|
+
changed = await this.observe(file.path, statKey(file, sweptAt)) || changed;
|
|
5511
|
+
const present = new Set(seen.map((file) => file.path));
|
|
5512
|
+
for (const [path, known] of this.tracked) {
|
|
5513
|
+
if (present.has(path)) continue;
|
|
5514
|
+
this.tracked.delete(path);
|
|
5515
|
+
this.record(path, known.hash, null);
|
|
5516
|
+
changed = true;
|
|
5517
|
+
}
|
|
5518
|
+
if (changed) this.saveStatCache();
|
|
5519
|
+
}
|
|
5520
|
+
/** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
|
|
5521
|
+
async storeIfPresent(path) {
|
|
5522
|
+
try {
|
|
5523
|
+
return await this.blobs.put(join17(this.dir, path));
|
|
5524
|
+
} catch (error) {
|
|
5525
|
+
if (error.code === "ENOENT") return null;
|
|
5526
|
+
throw error;
|
|
5527
|
+
}
|
|
5528
|
+
}
|
|
5529
|
+
async observe(path, stat2) {
|
|
5530
|
+
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
|
5531
|
+
if (stat2 && known.stat === stat2) return false;
|
|
5532
|
+
const hash = await this.storeIfPresent(path);
|
|
5533
|
+
if (hash === null) return false;
|
|
5534
|
+
this.tracked.set(path, { hash, stat: stat2 });
|
|
5535
|
+
if (known.hash !== hash) this.record(path, known.hash, hash);
|
|
5536
|
+
return known.hash !== hash || known.stat !== stat2;
|
|
5537
|
+
}
|
|
5538
|
+
record(path, before, after) {
|
|
5539
|
+
const window = this.windows.at(-1);
|
|
5540
|
+
if (window) this.touch(window);
|
|
5541
|
+
const group = window ?? this.outsideGroup();
|
|
5542
|
+
const earlier = group.changes.get(path);
|
|
5543
|
+
const from = earlier ? earlier.before : before;
|
|
5544
|
+
if (from === after) group.changes.delete(path);
|
|
5545
|
+
else group.changes.set(path, { path, before: from, after });
|
|
5546
|
+
}
|
|
5547
|
+
outsideGroup() {
|
|
5548
|
+
const { quietMs = 2e3, maxGroupMs = 3e4 } = this.options;
|
|
5549
|
+
const closeIn = (ms) => {
|
|
5550
|
+
const timer = setTimeout(() => this.background(() => this.commitOutside()), ms);
|
|
5551
|
+
timer.unref?.();
|
|
5552
|
+
return timer;
|
|
5553
|
+
};
|
|
5554
|
+
if (!this.outside) {
|
|
5555
|
+
this.outside = this.newGroup(OUTSIDE, OUTSIDE_LABEL);
|
|
5556
|
+
this.maxTimer = closeIn(maxGroupMs);
|
|
5557
|
+
}
|
|
5558
|
+
clearTimeout(this.quietTimer);
|
|
5559
|
+
this.quietTimer = closeIn(quietMs);
|
|
5560
|
+
return this.outside;
|
|
5561
|
+
}
|
|
5562
|
+
newGroup(who, label) {
|
|
5563
|
+
return { id: randomUUID7(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
5564
|
+
}
|
|
5565
|
+
async commitOutside() {
|
|
5566
|
+
clearTimeout(this.quietTimer);
|
|
5567
|
+
clearTimeout(this.maxTimer);
|
|
5568
|
+
const group = this.outside;
|
|
5569
|
+
this.outside = null;
|
|
5570
|
+
if (group) await this.commit(group);
|
|
5571
|
+
}
|
|
5572
|
+
async commit(group, extra = {}) {
|
|
5573
|
+
if (!group.changes.size) return null;
|
|
5574
|
+
const { changes, ...rest } = group;
|
|
5575
|
+
const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
5576
|
+
const entry = { ...rest, endedAt: this.now(), files, ...extra };
|
|
5577
|
+
this.log.entries.push(entry);
|
|
5578
|
+
try {
|
|
5579
|
+
saveRecord(this.logFile, this.log, { type: "entry", entry });
|
|
5580
|
+
} catch (error) {
|
|
5581
|
+
this.log.entries.pop();
|
|
5582
|
+
throw error;
|
|
5583
|
+
}
|
|
5584
|
+
for (const listener of this.listeners) listener(entry);
|
|
5585
|
+
await this.keepWithinBudget();
|
|
5586
|
+
return entry;
|
|
5587
|
+
}
|
|
5588
|
+
/** Stored bytes beyond what the current files need: a project larger than the budget still keeps its history. */
|
|
5589
|
+
historyBytes() {
|
|
5590
|
+
let current = 0;
|
|
5591
|
+
for (const hash of new Set([...this.tracked.values()].map((file) => file.hash)))
|
|
5592
|
+
current += this.blobs.size(hash);
|
|
5593
|
+
return this.blobs.bytes() - current;
|
|
5594
|
+
}
|
|
5595
|
+
async keepWithinBudget() {
|
|
5596
|
+
const budget = this.options.budgetBytes ?? 2 * 1024 ** 3;
|
|
5597
|
+
let folded = false;
|
|
5598
|
+
while (this.historyBytes() > budget && foldOldest(this.log)) {
|
|
5599
|
+
folded = true;
|
|
5600
|
+
await this.blobs.prune(referencedHashes(this.log, this.manifest()));
|
|
5601
|
+
}
|
|
5602
|
+
if (folded) writeLog(this.logFile, this.log);
|
|
5603
|
+
}
|
|
5604
|
+
/** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
|
|
5605
|
+
async settle() {
|
|
5606
|
+
await this.sweep();
|
|
5607
|
+
await this.commitOutside();
|
|
5608
|
+
}
|
|
5609
|
+
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
|
|
5610
|
+
noteChange(path) {
|
|
5611
|
+
if (this.notedTimer || !affectsProjectSignature(this.dir, resolve7(this.dir, path))) return;
|
|
5612
|
+
this.notedTimer = setTimeout(() => {
|
|
5613
|
+
this.notedTimer = null;
|
|
5614
|
+
this.background(() => this.sweep());
|
|
5615
|
+
}, 20);
|
|
5616
|
+
this.notedTimer.unref?.();
|
|
5617
|
+
}
|
|
5618
|
+
background(task) {
|
|
5619
|
+
this.queue(task).catch((error) => this.options.onError?.(error));
|
|
5620
|
+
}
|
|
5621
|
+
beginWindow(who, label, idleMs) {
|
|
5622
|
+
return this.queue(async () => {
|
|
5623
|
+
await this.sweep();
|
|
5624
|
+
const window = { ...this.newGroup(who, label), idleMs };
|
|
5625
|
+
this.windows.push(window);
|
|
5626
|
+
this.touch(window);
|
|
5627
|
+
return { id: window.id, close: () => this.queue(() => this.sweepAndEnd(window)) };
|
|
5628
|
+
});
|
|
5629
|
+
}
|
|
5630
|
+
/** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
|
|
5631
|
+
touch(window) {
|
|
5632
|
+
clearTimeout(window.idleTimer);
|
|
5633
|
+
if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
|
|
5634
|
+
window.idleTimer = setTimeout(
|
|
5635
|
+
() => this.background(() => this.sweepAndEnd(window)),
|
|
5636
|
+
window.idleMs
|
|
5637
|
+
);
|
|
5638
|
+
window.idleTimer.unref?.();
|
|
5639
|
+
}
|
|
5640
|
+
async sweepAndEnd(window) {
|
|
5641
|
+
await this.sweep();
|
|
5642
|
+
return this.endWindow(window);
|
|
5643
|
+
}
|
|
5644
|
+
/** Commits an open window once; ending it again returns the entry it became. */
|
|
5645
|
+
async endWindow(window) {
|
|
5646
|
+
if (!this.windows.includes(window)) return window.entry ?? null;
|
|
5647
|
+
clearTimeout(window.idleTimer);
|
|
5648
|
+
this.windows = this.windows.filter((open) => open !== window);
|
|
5649
|
+
window.entry = await this.commit(window);
|
|
5650
|
+
return window.entry;
|
|
5651
|
+
}
|
|
5652
|
+
/** Every pending write, open window and outside group, committed: for flush and close. */
|
|
5653
|
+
async settleAll() {
|
|
5654
|
+
await this.sweep();
|
|
5655
|
+
for (const window of [...this.windows]) await this.endWindow(window);
|
|
5656
|
+
await this.commitOutside();
|
|
5657
|
+
}
|
|
5658
|
+
/** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
|
|
5659
|
+
async writeAs(who, label, target, extra) {
|
|
5660
|
+
const group = this.newGroup(who, label);
|
|
5661
|
+
this.windows.push(group);
|
|
5662
|
+
try {
|
|
5663
|
+
for (const [path, hash] of target) {
|
|
5664
|
+
if ((this.tracked.get(path)?.hash ?? null) === hash) continue;
|
|
5665
|
+
if (hash === null) await rm2(join17(this.dir, path), { force: true });
|
|
5666
|
+
else await this.blobs.writeTo(hash, join17(this.dir, path));
|
|
5667
|
+
}
|
|
5668
|
+
await this.sweep();
|
|
5669
|
+
} finally {
|
|
5670
|
+
this.windows = this.windows.filter((open) => open !== group);
|
|
5671
|
+
}
|
|
5672
|
+
return this.commit(group, extra);
|
|
5673
|
+
}
|
|
5674
|
+
entry(id) {
|
|
5675
|
+
const entry = this.log.entries.find((candidate) => candidate.id === id);
|
|
5676
|
+
if (!entry) throw new Error("That change is no longer kept in this project's history.");
|
|
5677
|
+
return entry;
|
|
5678
|
+
}
|
|
5679
|
+
undoLabel(entry) {
|
|
5680
|
+
if (!entry.undoes) return `Undid: ${entry.label}`;
|
|
5681
|
+
const original = this.log.entries.find((candidate) => candidate.id === entry.undoes);
|
|
5682
|
+
return `Redid: ${original?.label ?? entry.label}`;
|
|
5683
|
+
}
|
|
5684
|
+
async undoNow(id, who, mode) {
|
|
5685
|
+
const entry = this.entry(id);
|
|
5686
|
+
const changed = entry.files.filter(
|
|
5687
|
+
(file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after
|
|
5688
|
+
);
|
|
5689
|
+
if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
|
|
5690
|
+
if (mode === "back-to-before") {
|
|
5691
|
+
const index = this.log.entries.indexOf(entry);
|
|
5692
|
+
const point = index > 0 ? this.log.entries[index - 1].id : START;
|
|
5693
|
+
return {
|
|
5694
|
+
ok: true,
|
|
5695
|
+
entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
|
|
5696
|
+
};
|
|
5697
|
+
}
|
|
5698
|
+
const target = new Map(entry.files.map((file) => [file.path, file.before]));
|
|
5699
|
+
return {
|
|
5700
|
+
ok: true,
|
|
5701
|
+
entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
|
|
5702
|
+
};
|
|
5703
|
+
}
|
|
5704
|
+
conflict(entry, changed) {
|
|
5705
|
+
const paths = new Set(changed.map((file) => file.path));
|
|
5706
|
+
const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
|
|
5707
|
+
const newer = after.filter((later) => later.files.some((file) => paths.has(file.path)));
|
|
5708
|
+
return { files: [...paths], newer: newer.map((later) => later.id) };
|
|
5709
|
+
}
|
|
5710
|
+
async restoreNow(point, who, label) {
|
|
5711
|
+
const files = manifestAt(this.log, point);
|
|
5712
|
+
if (!files) throw new Error("That point is no longer kept in this project's history.");
|
|
5713
|
+
const target = new Map(
|
|
5714
|
+
[...this.tracked.keys()].map((path) => [path, null])
|
|
5715
|
+
);
|
|
5716
|
+
for (const [path, hash] of files) target.set(path, hash);
|
|
5717
|
+
return this.writeAs(who, label, target, { restoredTo: point });
|
|
5718
|
+
}
|
|
5719
|
+
pointLabel(point) {
|
|
5720
|
+
return point === START ? "the start" : this.entry(point).label;
|
|
5721
|
+
}
|
|
5722
|
+
api() {
|
|
5723
|
+
return {
|
|
5724
|
+
projectId: this.projectId,
|
|
5725
|
+
beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
|
|
5726
|
+
noteChange: (path) => this.noteChange(path),
|
|
5727
|
+
list: () => {
|
|
5728
|
+
const undone = undoneIds(this.log.entries);
|
|
5729
|
+
return this.log.entries.map((entry) => ({
|
|
5730
|
+
...entry,
|
|
5731
|
+
pinned: this.log.pins.has(entry.id),
|
|
5732
|
+
undone: undone.has(entry.id)
|
|
5733
|
+
}));
|
|
5734
|
+
},
|
|
5735
|
+
step: (direction, who) => this.queue(async () => {
|
|
5736
|
+
await this.settle();
|
|
5737
|
+
const target = stepTarget(this.log.entries, direction);
|
|
5738
|
+
return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
|
|
5739
|
+
}),
|
|
5740
|
+
undo: (id, { who, mode }) => this.queue(async () => {
|
|
5741
|
+
await this.settle();
|
|
5742
|
+
return this.undoNow(id, who, mode);
|
|
5743
|
+
}),
|
|
5744
|
+
restore: (point, who) => this.queue(async () => {
|
|
5745
|
+
await this.settle();
|
|
5746
|
+
return this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`);
|
|
5747
|
+
}),
|
|
5748
|
+
peek: (point) => {
|
|
5749
|
+
const files = manifestAt(this.log, point);
|
|
5750
|
+
return files && Object.fromEntries(files);
|
|
5751
|
+
},
|
|
5752
|
+
readBlob: (hash) => this.blobs.read(hash),
|
|
5753
|
+
pin: (id, pinned) => {
|
|
5754
|
+
this.entry(id);
|
|
5755
|
+
if (pinned) this.log.pins.add(id);
|
|
5756
|
+
else this.log.pins.delete(id);
|
|
5757
|
+
saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
|
|
5758
|
+
},
|
|
5759
|
+
onEntry: (listener) => {
|
|
5760
|
+
this.listeners.add(listener);
|
|
5761
|
+
return () => this.listeners.delete(listener);
|
|
5762
|
+
},
|
|
5763
|
+
flush: () => this.queue(() => this.settleAll()),
|
|
5764
|
+
close: async () => {
|
|
5765
|
+
if (this.notedTimer) clearTimeout(this.notedTimer);
|
|
5766
|
+
await this.queue(() => this.settleAll());
|
|
5767
|
+
}
|
|
5768
|
+
};
|
|
5769
|
+
}
|
|
5770
|
+
};
|
|
5771
|
+
async function openProjectHistory(options) {
|
|
5772
|
+
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
|
5773
|
+
const blobs = await openBlobStore(join17(options.historyRoot, projectId, "blobs"));
|
|
5774
|
+
const engine = new Engine(options, projectId, blobs);
|
|
5775
|
+
await engine.queue(() => engine.start());
|
|
5776
|
+
return engine.api();
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5111
5779
|
// src/helpers/duplicateElement.ts
|
|
5112
5780
|
import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
|
|
5113
5781
|
function duplicateElementInHtml(source, target, newId, at) {
|
|
@@ -5227,6 +5895,7 @@ function updateBackgroundRemovalProgress(state, event) {
|
|
|
5227
5895
|
state.avgMsPerFrame = event.avgMsPerFrame;
|
|
5228
5896
|
}
|
|
5229
5897
|
export {
|
|
5898
|
+
START as HISTORY_START,
|
|
5230
5899
|
MIME_TYPES,
|
|
5231
5900
|
PREVIEW_BUNDLE_OPTIONS,
|
|
5232
5901
|
STUDIO_MANUAL_EDITS_PATH,
|
|
@@ -5250,6 +5919,7 @@ export {
|
|
|
5250
5919
|
getMimeType,
|
|
5251
5920
|
identifyFileWrite,
|
|
5252
5921
|
isSafePath,
|
|
5922
|
+
openProjectHistory,
|
|
5253
5923
|
parseSourceDocument,
|
|
5254
5924
|
patchElementInHtml,
|
|
5255
5925
|
removeElementFromHtml,
|