@hyperframes/studio-server 0.8.78 → 0.8.79

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
@@ -55,9 +55,25 @@ import { readFile } from "fs/promises";
55
55
  import { join as join3 } from "path";
56
56
 
57
57
  // src/helpers/safePath.ts
58
- import { join } from "path";
59
- import { readdirSync } from "fs";
60
- import { isSafePath, resolveWithinProject } from "@hyperframes/core";
58
+ import { basename, dirname, isAbsolute, join, relative, sep } from "path";
59
+ import { readdirSync, realpathSync } from "fs";
60
+ import { resolveWithinProject } from "@hyperframes/core";
61
+ import { isSafePath, resolveWithinProject as resolveWithinProject2 } from "@hyperframes/core";
62
+ function realFilePath(filePath) {
63
+ try {
64
+ return realpathSync(filePath);
65
+ } catch {
66
+ const dir = dirname(filePath);
67
+ return dir === filePath ? filePath : join(realFilePath(dir), basename(filePath));
68
+ }
69
+ }
70
+ function pinWithinProject(base, relativePath) {
71
+ const checked = resolveWithinProject(base, relativePath);
72
+ if (!checked) return null;
73
+ const inside = relative(realFilePath(base), realFilePath(checked));
74
+ const escapes = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
75
+ return escapes ? null : join(base, inside);
76
+ }
61
77
  var IGNORE_DIRS = /* @__PURE__ */ new Set([
62
78
  ".thumbnails",
63
79
  ".transcode-cache",
@@ -107,7 +123,7 @@ import {
107
123
  readFileSync,
108
124
  writeFileSync
109
125
  } from "fs";
110
- import { dirname, join as join2 } from "path";
126
+ import { dirname as dirname2, join as join2 } from "path";
111
127
 
112
128
  // src/helpers/atomicFile.ts
113
129
  import * as fs from "fs";
@@ -157,7 +173,7 @@ function stampProjectHfIds(projectDir) {
157
173
  next[file] = hash;
158
174
  }
159
175
  try {
160
- mkdirSync(dirname(recordPath), { recursive: true });
176
+ mkdirSync(dirname2(recordPath), { recursive: true });
161
177
  writeFileSync(recordPath, JSON.stringify(next));
162
178
  } catch {
163
179
  }
@@ -211,7 +227,7 @@ function stampFileHfIds(filePath) {
211
227
  // src/helpers/projectSignature.ts
212
228
  import { createHash as createHash2 } from "crypto";
213
229
  import { lstatSync, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
214
- import { extname, isAbsolute, relative, resolve, sep } from "path";
230
+ import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "path";
215
231
  var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
216
232
  ".cjs",
217
233
  ".css",
@@ -246,18 +262,18 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
246
262
  ".hyperframes/studio-motion.json"
247
263
  ];
248
264
  function affectsProjectSignature(projectDir, changedPath) {
249
- const relativePath = relative(resolve(projectDir), resolve(changedPath));
250
- if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
265
+ const relativePath = relative2(resolve(projectDir), resolve(changedPath));
266
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
251
267
  return false;
252
268
  }
253
- const segments = relativePath.split(sep);
269
+ const segments = relativePath.split(sep2);
254
270
  if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
255
271
  return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
256
272
  }
257
273
  var projectSignatureCache = /* @__PURE__ */ new Map();
258
274
  function isPathWithin(parentDir, childPath) {
259
- const childRelativePath = relative(parentDir, childPath);
260
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
275
+ const childRelativePath = relative2(parentDir, childPath);
276
+ return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute2(childRelativePath);
261
277
  }
262
278
  function isTextContentEligible(file, size) {
263
279
  return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
@@ -318,7 +334,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
318
334
  function createProjectFingerprint(projectDir, files) {
319
335
  const hash = createHash2("sha256");
320
336
  for (const entry of files) {
321
- hash.update(relative(projectDir, entry.file));
337
+ hash.update(relative2(projectDir, entry.file));
322
338
  hash.update("\0");
323
339
  hash.update(String(entry.size));
324
340
  hash.update("\0");
@@ -340,7 +356,7 @@ function collectProjectFiles(normalizedProjectDir) {
340
356
  function listProjectFiles(projectDir) {
341
357
  const normalizedProjectDir = resolve(projectDir);
342
358
  return collectProjectFiles(normalizedProjectDir).map((entry) => ({
343
- path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
359
+ path: relative2(normalizedProjectDir, entry.file).split(sep2).join("/"),
344
360
  size: entry.size,
345
361
  mtimeMs: entry.mtimeMs,
346
362
  ctimeMs: entry.ctimeMs
@@ -358,7 +374,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
358
374
  const normalizedProjectDir = resolve(projectDir);
359
375
  const collected = collectProjectFiles(normalizedProjectDir);
360
376
  const files = collected.filter(
361
- (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
377
+ (entry) => !excluding.has(relative2(normalizedProjectDir, entry.file).split(sep2).join("/"))
362
378
  );
363
379
  files.sort((a, b) => a.file.localeCompare(b.file));
364
380
  const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
@@ -367,7 +383,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
367
383
  if (cached?.fingerprint === fingerprint) return cached.signature;
368
384
  const hash = createHash2("sha256");
369
385
  for (const entry of files) {
370
- const relativePath = relative(normalizedProjectDir, entry.file);
386
+ const relativePath = relative2(normalizedProjectDir, entry.file);
371
387
  hash.update(relativePath);
372
388
  hash.update("\0");
373
389
  hash.update(String(entry.size));
@@ -450,7 +466,7 @@ import {
450
466
  renameSync as renameSync2,
451
467
  readdirSync as readdirSync4
452
468
  } from "fs";
453
- import { resolve as resolve3, dirname as dirname4, join as join7 } from "path";
469
+ import { resolve as resolve3, dirname as dirname5, join as join7 } from "path";
454
470
 
455
471
  // src/helpers/mime.ts
456
472
  var MIME_TYPES = {
@@ -510,7 +526,7 @@ import {
510
526
  rmSync,
511
527
  statSync
512
528
  } from "fs";
513
- import { dirname as dirname2, join as join4 } from "path";
529
+ import { dirname as dirname3, join as join4 } from "path";
514
530
  import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
515
531
  var SAMPLE_RATE = 4e3;
516
532
  var PEAK_COUNT = 4e3;
@@ -590,7 +606,7 @@ function isWaveformCacheDirectory(cacheDir) {
590
606
  return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
591
607
  }
592
608
  function writeWaveformCache(cachePath, peaks) {
593
- const cacheDir = dirname2(cachePath);
609
+ const cacheDir = dirname3(cachePath);
594
610
  try {
595
611
  mkdirSync2(cacheDir, { mode: 448 });
596
612
  } catch (error) {
@@ -616,7 +632,7 @@ function writeWaveformCache(cachePath, peaks) {
616
632
  import { spawnSync } from "child_process";
617
633
  import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
618
634
  import { tmpdir } from "os";
619
- import { basename, join as join5 } from "path";
635
+ import { basename as basename2, join as join5 } from "path";
620
636
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
621
637
  var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
622
638
  function validateUploadedMedia(filePath, runner = spawnSync) {
@@ -654,7 +670,7 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
654
670
  }
655
671
  function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
656
672
  const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
657
- const tempPath = join5(tempDir, basename(fileName));
673
+ const tempPath = join5(tempDir, basename2(fileName));
658
674
  try {
659
675
  writeFileSync3(tempPath, buffer);
660
676
  return validateUploadedMedia(tempPath, runner);
@@ -666,7 +682,7 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
666
682
  // src/helpers/backupJournal.ts
667
683
  import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
668
684
  import { Buffer as Buffer2 } from "buffer";
669
- import { join as join6, relative as relative2 } from "path";
685
+ import { join as join6, relative as relative3 } from "path";
670
686
  var DEFAULT_KEEP_PER_FILE = 10;
671
687
  function backupKeyForPath(path) {
672
688
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -676,7 +692,7 @@ function timestampPrefix() {
676
692
  }
677
693
  function backupPathForResponse(projectDir, backupPath) {
678
694
  if (!backupPath) return null;
679
- const rel = relative2(projectDir, backupPath);
695
+ const rel = relative3(projectDir, backupPath);
680
696
  if (!rel || rel.startsWith("..")) return null;
681
697
  return rel.split("\\").join("/");
682
698
  }
@@ -684,7 +700,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
684
700
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
685
701
  try {
686
702
  const content = readFileSync3(absPath);
687
- const relativePath = relative2(projectDir, absPath);
703
+ const relativePath = relative3(projectDir, absPath);
688
704
  const backupDir = join6(projectDir, ".hyperframes", "backup");
689
705
  mkdirSync3(backupDir, { recursive: true });
690
706
  const backupKey = backupKeyForPath(relativePath);
@@ -753,22 +769,38 @@ function createWriteToken(requestToken) {
753
769
  const token = requestToken?.trim();
754
770
  return token && token.length <= 200 ? token : randomUUID2();
755
771
  }
756
- function recordFileWriteReceipt(absPath, receipt) {
772
+ function recordFileWriteReceipt(filePath, receipt) {
773
+ const absPath = realFilePath(filePath);
757
774
  const now = Date.now();
758
- const current = (receipts.get(absPath) ?? []).filter(
759
- (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
760
- );
761
- current.push({ ...receipt, recordedAt: now });
762
- receipts.set(absPath, current);
775
+ for (const [path, list] of receipts) {
776
+ const live = list.filter((entry) => now - entry.recordedAt < RECEIPT_TTL_MS);
777
+ if (live.length > 0) receipts.set(path, live);
778
+ else receipts.delete(path);
779
+ }
780
+ receipts.set(absPath, [...receipts.get(absPath) ?? [], { ...receipt, recordedAt: now }]);
763
781
  }
764
- function clearFileWriteReceipt(absPath, version, writeToken) {
782
+ function clearFileWriteReceipt(filePath, version, writeToken) {
783
+ const absPath = realFilePath(filePath);
765
784
  const current = (receipts.get(absPath) ?? []).filter(
766
785
  (entry) => entry.version !== version || entry.writeToken !== writeToken
767
786
  );
768
787
  if (current.length > 0) receipts.set(absPath, current);
769
788
  else receipts.delete(absPath);
770
789
  }
771
- function identifyFileWrite(absPath, expectedVersion) {
790
+ function identifyFileWrite(filePath, expectedVersion) {
791
+ const receipt = newestReceipt(realFilePath(filePath), expectedVersion);
792
+ if (!receipt) return null;
793
+ const { path, version, writeToken } = receipt;
794
+ return { path, version, writeToken };
795
+ }
796
+ function bytesOverwrittenBy(filePath, version) {
797
+ return newestReceipt(realFilePath(filePath), version)?.overwrote;
798
+ }
799
+ function forgetOverwrittenBytes(filePath, versions) {
800
+ for (const receipt of receipts.get(realFilePath(filePath)) ?? [])
801
+ if (versions.has(receipt.version)) delete receipt.overwrote;
802
+ }
803
+ function newestReceipt(absPath, expectedVersion) {
772
804
  const now = Date.now();
773
805
  const current = (receipts.get(absPath) ?? []).filter(
774
806
  (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
@@ -779,15 +811,13 @@ function identifyFileWrite(absPath, expectedVersion) {
779
811
  for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
780
812
  if (current[i]?.version === expectedVersion) receipt = current[i];
781
813
  }
782
- if (!receipt) return null;
783
- const { path, version, writeToken } = receipt;
784
- return { path, version, writeToken };
814
+ return receipt;
785
815
  }
786
816
  var consumeFileWriteReceipt = identifyFileWrite;
787
817
 
788
818
  // src/helpers/applyFileMutations.ts
789
819
  import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
790
- function applyFileMutations(projectDir, mutations, requestToken, writeFile = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
820
+ function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
791
821
  const prepared = mutations.map((mutation) => ({
792
822
  ...mutation,
793
823
  before: mutation.before ?? readFileSync4(mutation.absPath, "utf-8")
@@ -795,10 +825,10 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
795
825
  const results = [];
796
826
  const attempted = [];
797
827
  for (const mutation of prepared)
798
- assertExpectedVersion(mutation.expectedVersion, readFileSync4(mutation.absPath, "utf-8"));
828
+ assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
799
829
  try {
800
830
  for (const mutation of prepared) {
801
- results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted));
831
+ results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted));
802
832
  }
803
833
  return results;
804
834
  } catch (error) {
@@ -806,7 +836,7 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
806
836
  for (const mutation of attempted.reverse()) {
807
837
  try {
808
838
  if (!mutation.written || readFileSync4(mutation.absPath, "utf-8") === mutation.after)
809
- writeFile(mutation.absPath, mutation.before, "utf-8");
839
+ writeFile2(mutation.absPath, mutation.before, "utf-8");
810
840
  clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
811
841
  } catch (rollbackError) {
812
842
  rollbackErrors.push(rollbackError);
@@ -821,9 +851,9 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
821
851
  throw error;
822
852
  }
823
853
  }
824
- function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted) {
854
+ function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted) {
825
855
  const current = readFileSync4(mutation.absPath, "utf-8");
826
- assertExpectedVersion(mutation.expectedVersion, current);
856
+ assertExpectedVersion(mutation, current);
827
857
  if (mutation.after === mutation.before) {
828
858
  return {
829
859
  ...mutation,
@@ -836,17 +866,19 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
836
866
  }
837
867
  const backup = snapshotBeforeWrite(projectDir, mutation.absPath);
838
868
  if (backup.error) throw new Error(`backup failed: ${backup.error}`);
869
+ assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
839
870
  const before = current;
840
871
  const version = fileContentVersion(mutation.after);
841
872
  const writeToken = createWriteToken(requestToken);
842
873
  const attempt = { ...mutation, before, version, writeToken, written: false };
843
874
  attempted.push(attempt);
844
- writeFile(mutation.absPath, mutation.after, "utf-8");
875
+ writeFile2(mutation.absPath, mutation.after, "utf-8");
845
876
  attempt.written = true;
846
877
  recordFileWriteReceipt(mutation.absPath, {
847
878
  path: mutation.sourceFile,
848
879
  version,
849
- writeToken
880
+ writeToken,
881
+ overwrote: before
850
882
  });
851
883
  return {
852
884
  ...mutation,
@@ -857,10 +889,16 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
857
889
  writeToken
858
890
  };
859
891
  }
860
- function assertExpectedVersion(expectedVersion, current) {
861
- if (expectedVersion !== void 0 && fileContentVersion(current) !== expectedVersion) {
862
- throw new Error("file changed since the timeline was read");
892
+ var FileChangedError = class extends Error {
893
+ constructor(sourceFile) {
894
+ super("file changed since the timeline was read");
895
+ this.sourceFile = sourceFile;
863
896
  }
897
+ sourceFile;
898
+ };
899
+ function assertExpectedVersion(mutation, current) {
900
+ const stale = mutation.expectedVersion === void 0 ? current !== mutation.before : fileContentVersion(current) !== mutation.expectedVersion;
901
+ if (stale) throw new FileChangedError(mutation.sourceFile);
864
902
  }
865
903
 
866
904
  // src/routes/files.ts
@@ -899,9 +937,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
899
937
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
900
938
 
901
939
  // src/helpers/compositionInsertion.ts
902
- import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync } from "fs";
940
+ import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "fs";
903
941
  import { randomUUID as randomUUID3 } from "crypto";
904
- import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
942
+ import { dirname as dirname4, relative as relative4, resolve as resolve2, sep as sep3 } from "path";
905
943
  import { parseHTML } from "linkedom";
906
944
  var CompositionInsertionError = class extends Error {
907
945
  constructor(message, status) {
@@ -937,8 +975,8 @@ function canonicalProjectPath(projectDir, candidate) {
937
975
  if (!existsSync2(candidate)) {
938
976
  throw new CompositionInsertionError("Composition source was not found", 404);
939
977
  }
940
- const canonical = realpathSync(candidate);
941
- if (!isSafePath(realpathSync(projectDir), canonical)) {
978
+ const canonical = realpathSync2(candidate);
979
+ if (!isSafePath(realpathSync2(projectDir), canonical)) {
942
980
  throw new CompositionInsertionError("Composition source escapes the project", 400);
943
981
  }
944
982
  return canonical;
@@ -950,13 +988,13 @@ function validateSourcePath(sourcePath) {
950
988
  }
951
989
  function canonicalProjectFile(projectDir, sourcePath) {
952
990
  validateSourcePath(sourcePath);
953
- return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
991
+ return canonicalProjectPath(projectDir, resolveWithinProject2(projectDir, sourcePath));
954
992
  }
955
993
  function canonicalDependency(projectDir, ownerAbs, sourcePath) {
956
994
  validateSourcePath(sourcePath);
957
995
  return canonicalProjectPath(
958
996
  projectDir,
959
- resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
997
+ resolveWithinProject2(projectDir, relative4(projectDir, resolve2(dirname4(ownerAbs), sourcePath)))
960
998
  );
961
999
  }
962
1000
  function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
@@ -1029,7 +1067,7 @@ function uniqueHostId(root, base) {
1029
1067
  return `${base}_${suffix}`;
1030
1068
  }
1031
1069
  function relativeSourcePath(targetAbs, sourceAbs) {
1032
- return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
1070
+ return relative4(dirname4(targetAbs), sourceAbs).split(sep3).join("/");
1033
1071
  }
1034
1072
  function insertCompositionIntoSource(input) {
1035
1073
  const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
@@ -1161,7 +1199,7 @@ async function loadGsapParser() {
1161
1199
  return import("@hyperframes/parsers/gsap-parser-recast");
1162
1200
  }
1163
1201
  function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1164
- if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
1202
+ if (!isSafePath(projectDir, dirname5(lexicalPath))) return false;
1165
1203
  let stats;
1166
1204
  try {
1167
1205
  stats = lstatSync3(lexicalPath);
@@ -1169,7 +1207,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1169
1207
  return false;
1170
1208
  }
1171
1209
  if (!stats.isSymbolicLink()) return false;
1172
- const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
1210
+ const target = resolve3(dirname5(lexicalPath), readlinkSync(lexicalPath));
1173
1211
  if (!isSafePath(projectDir, target)) return false;
1174
1212
  try {
1175
1213
  statSync3(lexicalPath);
@@ -1193,7 +1231,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
1193
1231
  if (filePath.includes("\0")) {
1194
1232
  return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
1195
1233
  }
1196
- const absPath = resolveWithinProject(project.dir, filePath);
1234
+ const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject2)(project.dir, filePath);
1197
1235
  if (!absPath) {
1198
1236
  if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
1199
1237
  return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
@@ -1209,7 +1247,7 @@ function resolveProjectFile(c, adapter, opts) {
1209
1247
  return resolveProjectPath(c, adapter, "files", opts);
1210
1248
  }
1211
1249
  function resolveFileMutationContext(c, adapter, operation) {
1212
- return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
1250
+ return resolveProjectPath(c, adapter, `file-mutations/${operation}`, { pin: true });
1213
1251
  }
1214
1252
  function isOptionalInteger(value) {
1215
1253
  return value === void 0 || Number.isInteger(value);
@@ -1258,11 +1296,23 @@ function foldElementPatches(originalContent, patches) {
1258
1296
  }
1259
1297
  return { content, matched };
1260
1298
  }
1261
- function commitElementPatchBatches(projectDir, batches, writeFile = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
1299
+ var PATCH_CONFLICT_ATTEMPTS = 3;
1300
+ function commitElementPatchBatches(projectDir, batches, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
1301
+ for (let attempt = 1; ; attempt += 1) {
1302
+ try {
1303
+ return foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken);
1304
+ } catch (error) {
1305
+ if (!(error instanceof FileChangedError)) throw error;
1306
+ if (attempt === PATCH_CONFLICT_ATTEMPTS)
1307
+ return { error: "conflict", sourceFile: error.sourceFile };
1308
+ }
1309
+ }
1310
+ }
1311
+ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken) {
1262
1312
  const resolvedPaths = /* @__PURE__ */ new Set();
1263
1313
  const prepared = [];
1264
1314
  for (const batch of batches) {
1265
- const absPath = resolveWithinProject(projectDir, batch.sourceFile);
1315
+ const absPath = pinWithinProject(projectDir, batch.sourceFile);
1266
1316
  if (!absPath) return { error: "forbidden", sourceFile: batch.sourceFile };
1267
1317
  if (resolvedPaths.has(absPath)) return { error: "duplicate", sourceFile: batch.sourceFile };
1268
1318
  resolvedPaths.add(absPath);
@@ -1303,7 +1353,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = (path, conte
1303
1353
  after
1304
1354
  })),
1305
1355
  requestToken,
1306
- writeFile
1356
+ writeFile2
1307
1357
  );
1308
1358
  const files = applied.map((file, index) => ({
1309
1359
  sourceFile: file.sourceFile,
@@ -1319,23 +1369,24 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
1319
1369
  return commitElementPatchBatches(
1320
1370
  projectDir,
1321
1371
  batches,
1322
- writeFileSync5,
1372
+ void 0,
1323
1373
  c.req.header("X-Hyperframes-Write-Token")
1324
1374
  );
1325
1375
  }
1326
- function recordMutationReceipt(c, filePath, absPath, html) {
1376
+ function writeFileWithReceipt(c, filePath, absPath, html) {
1377
+ const overwrote = readFileSync6(absPath);
1378
+ replaceFileAtomically(absPath, html, statSync3(absPath).mode);
1327
1379
  const version = fileContentVersion(html);
1328
1380
  const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
1329
- recordFileWriteReceipt(absPath, { path: filePath, version, writeToken });
1381
+ recordFileWriteReceipt(absPath, { path: filePath, version, writeToken, overwrote });
1330
1382
  return { version, writeToken };
1331
1383
  }
1332
- function writeFileWithReceipt(c, filePath, absPath, html) {
1333
- replaceFileAtomically(absPath, html, statSync3(absPath).mode);
1334
- return recordMutationReceipt(c, filePath, absPath, html);
1335
- }
1336
- function writeMutationResult(c, projectDir, filePath, absPath, html) {
1384
+ function writeMutationResult(c, projectDir, filePath, absPath, html, original) {
1337
1385
  const backup = snapshotBeforeWrite(projectDir, absPath);
1338
1386
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
1387
+ if (readFileSync6(absPath, "utf-8") !== original) {
1388
+ return c.json({ error: "file changed", conflict: true, path: filePath }, 409);
1389
+ }
1339
1390
  const { version } = writeFileWithReceipt(c, filePath, absPath, html);
1340
1391
  return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version };
1341
1392
  }
@@ -1343,7 +1394,7 @@ function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
1343
1394
  if (next === original) {
1344
1395
  return c.json({ ok: true, changed: false, content: original, path: filePath });
1345
1396
  }
1346
- const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next);
1397
+ const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next, original);
1347
1398
  if (mutationResult instanceof Response) return mutationResult;
1348
1399
  const { backupPath } = mutationResult;
1349
1400
  return c.json({
@@ -1365,6 +1416,8 @@ function rejectUnsafeMutationValues(c, unsafeFields) {
1365
1416
  );
1366
1417
  }
1367
1418
  function elementPatchBatchCommitErrorResponse(c, error, sourceFile) {
1419
+ if (error === "conflict")
1420
+ return c.json({ error: "file changed", conflict: true, sourceFile }, 409);
1368
1421
  if (error === "not-found") return c.json({ error, sourceFile }, 404);
1369
1422
  if (error === "forbidden") return c.json({ error, sourceFile }, 403);
1370
1423
  return c.json({ error: "duplicate source file", sourceFile }, 400);
@@ -1377,7 +1430,7 @@ async function parseMutationBody(c) {
1377
1430
  return { target: body.target, body };
1378
1431
  }
1379
1432
  function ensureDir(filePath) {
1380
- const dir = dirname4(filePath);
1433
+ const dir = dirname5(filePath);
1381
1434
  if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1382
1435
  }
1383
1436
  function generateCopyPath(projectDir, originalPath) {
@@ -1765,7 +1818,8 @@ async function applyGsapMutations(c, res, mutations) {
1765
1818
  res.project.dir,
1766
1819
  res.filePath,
1767
1820
  res.absPath,
1768
- newHtml
1821
+ newHtml,
1822
+ beforeHtml
1769
1823
  );
1770
1824
  if (mutationResult instanceof Response) return mutationResult;
1771
1825
  backupPath = mutationResult.backupPath;
@@ -2598,7 +2652,7 @@ function registerFileRoutes(api, adapter) {
2598
2652
  }
2599
2653
  });
2600
2654
  api.put("/projects/:id/files/*", async (c) => {
2601
- const res = await resolveProjectFile(c, adapter);
2655
+ const res = await resolveProjectFile(c, adapter, { pin: true });
2602
2656
  if ("error" in res) return res.error;
2603
2657
  const body = Buffer.from(await c.req.arrayBuffer());
2604
2658
  const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
@@ -2623,6 +2677,7 @@ function registerFileRoutes(api, adapter) {
2623
2677
  );
2624
2678
  }
2625
2679
  let backup = { backupPath: null };
2680
+ let overwrote;
2626
2681
  if (createOnly) {
2627
2682
  ensureDir(res.absPath);
2628
2683
  let fd;
@@ -2668,6 +2723,7 @@ function registerFileRoutes(api, adapter) {
2668
2723
  }
2669
2724
  try {
2670
2725
  const currentContent = readFileSync6(fd);
2726
+ overwrote = currentContent;
2671
2727
  const currentVersion = fileContentVersion(currentContent);
2672
2728
  if (expectedVersion !== currentVersion) {
2673
2729
  return c.json(
@@ -2692,7 +2748,7 @@ function registerFileRoutes(api, adapter) {
2692
2748
  }
2693
2749
  const version = fileContentVersion(body);
2694
2750
  const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
2695
- recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
2751
+ recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken, overwrote });
2696
2752
  c.header("ETag", version);
2697
2753
  return c.json({
2698
2754
  ok: true,
@@ -2771,6 +2827,11 @@ function registerFileRoutes(api, adapter) {
2771
2827
  }
2772
2828
  const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2773
2829
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2830
+ const current = readFileSync6(ctx.absPath, "utf-8");
2831
+ if (current !== before) {
2832
+ const currentVersion2 = fileContentVersion(current);
2833
+ return c.json({ error: "file conflict", currentVersion: currentVersion2, currentContent: current }, 409);
2834
+ }
2774
2835
  const { version, writeToken } = writeFileWithReceipt(
2775
2836
  c,
2776
2837
  ctx.filePath,
@@ -2847,7 +2908,7 @@ function registerFileRoutes(api, adapter) {
2847
2908
  const seen = /* @__PURE__ */ new Set();
2848
2909
  const prepared = [];
2849
2910
  for (const file of files) {
2850
- const absPath = resolveWithinProject(project.dir, file.path);
2911
+ const absPath = pinWithinProject(project.dir, file.path);
2851
2912
  if (!absPath) return c.json({ error: `forbidden path: ${file.path}` }, 403);
2852
2913
  if (seen.has(absPath)) return c.json({ error: `duplicate path: ${file.path}` }, 400);
2853
2914
  seen.add(absPath);
@@ -2915,7 +2976,8 @@ function registerFileRoutes(api, adapter) {
2915
2976
  recordFileWriteReceipt(file.absPath, {
2916
2977
  path: file.path,
2917
2978
  version: fileContentVersion(file.after),
2918
- writeToken
2979
+ writeToken,
2980
+ overwrote: file.before
2919
2981
  });
2920
2982
  }
2921
2983
  } catch (error) {
@@ -2997,7 +3059,8 @@ function registerFileRoutes(api, adapter) {
2997
3059
  ctx.project.dir,
2998
3060
  ctx.filePath,
2999
3061
  ctx.absPath,
3000
- result.html
3062
+ result.html,
3063
+ originalContent
3001
3064
  );
3002
3065
  if (mutationResult instanceof Response) return mutationResult;
3003
3066
  const { version, backupPath } = mutationResult;
@@ -3024,48 +3087,54 @@ function registerFileRoutes(api, adapter) {
3024
3087
  if (unsafeFields.length > 0) {
3025
3088
  return rejectUnsafeMutationValues(c, unsafeFields);
3026
3089
  }
3027
- let originalContent;
3028
- try {
3029
- originalContent = readFileSync6(ctx.absPath, "utf-8");
3030
- } catch {
3031
- return c.json({ error: "not found" }, 404);
3032
- }
3033
- const { html: patched, matched } = patchElementInHtml(
3034
- originalContent,
3035
- parsed.target,
3036
- parsed.body.operations
3037
- );
3038
- if (patched === originalContent) {
3039
- const version2 = fileContentVersion(originalContent);
3040
- c.header("ETag", version2);
3090
+ for (let attempt = 1; ; attempt += 1) {
3091
+ let originalContent;
3092
+ try {
3093
+ originalContent = readFileSync6(ctx.absPath, "utf-8");
3094
+ } catch {
3095
+ return c.json({ error: "not found" }, 404);
3096
+ }
3097
+ const { html: patched, matched } = patchElementInHtml(
3098
+ originalContent,
3099
+ parsed.target,
3100
+ parsed.body.operations
3101
+ );
3102
+ if (patched === originalContent) {
3103
+ const version2 = fileContentVersion(originalContent);
3104
+ c.header("ETag", version2);
3105
+ return c.json({
3106
+ ok: true,
3107
+ changed: false,
3108
+ matched,
3109
+ content: originalContent,
3110
+ path: ctx.filePath,
3111
+ version: version2
3112
+ });
3113
+ }
3114
+ const mutationResult = writeMutationResult(
3115
+ c,
3116
+ ctx.project.dir,
3117
+ ctx.filePath,
3118
+ ctx.absPath,
3119
+ patched,
3120
+ originalContent
3121
+ );
3122
+ if (mutationResult instanceof Response) {
3123
+ if (mutationResult.status === 409 && attempt < PATCH_CONFLICT_ATTEMPTS) continue;
3124
+ return mutationResult;
3125
+ }
3126
+ const { backupPath, version } = mutationResult;
3127
+ c.header("ETag", version);
3041
3128
  return c.json({
3042
3129
  ok: true,
3043
- changed: false,
3130
+ changed: true,
3044
3131
  matched,
3045
- content: originalContent,
3132
+ content: patched,
3046
3133
  path: ctx.filePath,
3047
- version: version2
3134
+ version,
3135
+ backupPath
3048
3136
  });
3049
3137
  }
3050
- const mutationResult = writeMutationResult(
3051
- c,
3052
- ctx.project.dir,
3053
- ctx.filePath,
3054
- ctx.absPath,
3055
- patched
3056
- );
3057
- if (mutationResult instanceof Response) return mutationResult;
3058
- const { backupPath, version } = mutationResult;
3059
- c.header("ETag", version);
3060
- return c.json({
3061
- ok: true,
3062
- changed: true,
3063
- matched,
3064
- content: patched,
3065
- path: ctx.filePath,
3066
- version,
3067
- backupPath
3068
- });
3069
3138
  });
3070
3139
  api.post("/projects/:id/file-mutations/patch-element-batches", async (c) => {
3071
3140
  const project = await adapter.resolveProject(c.req.param("id"));
@@ -3155,7 +3224,8 @@ function registerFileRoutes(api, adapter) {
3155
3224
  ctx.project.dir,
3156
3225
  ctx.filePath,
3157
3226
  ctx.absPath,
3158
- result.html
3227
+ result.html,
3228
+ originalContent
3159
3229
  );
3160
3230
  if (mutationResult instanceof Response) return mutationResult;
3161
3231
  const { backupPath } = mutationResult;
@@ -3225,7 +3295,7 @@ function registerFileRoutes(api, adapter) {
3225
3295
  if (!body.newPath || body.newPath.includes("\0")) {
3226
3296
  return c.json({ error: "newPath required" }, 400);
3227
3297
  }
3228
- const newAbs = resolveWithinProject(res.project.dir, body.newPath);
3298
+ const newAbs = resolveWithinProject2(res.project.dir, body.newPath);
3229
3299
  if (!newAbs) {
3230
3300
  return c.json({ error: "forbidden" }, 403);
3231
3301
  }
@@ -3244,17 +3314,24 @@ function registerFileRoutes(api, adapter) {
3244
3314
  if (!body.path || body.path.includes("\0")) {
3245
3315
  return c.json({ error: "path required" }, 400);
3246
3316
  }
3247
- const srcAbs = resolveWithinProject(project.dir, body.path);
3317
+ const srcAbs = resolveWithinProject2(project.dir, body.path);
3248
3318
  if (!srcAbs || !existsSync3(srcAbs)) {
3249
3319
  return c.json({ error: "not found" }, 404);
3250
3320
  }
3251
3321
  const copyPath = generateCopyPath(project.dir, body.path);
3252
- const destAbs = resolveWithinProject(project.dir, copyPath);
3322
+ const destAbs = resolveWithinProject2(project.dir, copyPath);
3253
3323
  if (!destAbs) {
3254
3324
  return c.json({ error: "forbidden" }, 403);
3255
3325
  }
3256
3326
  ensureDir(destAbs);
3257
- writeFileSync5(destAbs, readFileSync6(srcAbs));
3327
+ try {
3328
+ writeFileSync5(destAbs, readFileSync6(srcAbs), { flag: "wx" });
3329
+ } catch (error) {
3330
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
3331
+ throw error;
3332
+ }
3333
+ return c.json({ error: "already exists" }, 409);
3334
+ }
3258
3335
  return c.json({ ok: true, path: copyPath }, 201);
3259
3336
  });
3260
3337
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
@@ -3268,7 +3345,7 @@ function registerFileRoutes(api, adapter) {
3268
3345
  const project = await adapter.resolveProject(c.req.param("id"));
3269
3346
  if (!project) return c.json({ error: "not found" }, 404);
3270
3347
  const subDir = c.req.query("dir") ?? "";
3271
- const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3348
+ const targetDir = subDir ? resolveWithinProject2(project.dir, subDir) : project.dir;
3272
3349
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3273
3350
  if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3274
3351
  const formData = await c.req.formData();
@@ -3304,7 +3381,8 @@ function registerFileRoutes(api, adapter) {
3304
3381
  });
3305
3382
  api.post("/projects/:id/gsap-mutations/*", async (c) => {
3306
3383
  const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
3307
- mustExist: true
3384
+ mustExist: true,
3385
+ pin: true
3308
3386
  });
3309
3387
  if ("error" in res) return res.error;
3310
3388
  const body = await c.req.json().catch(() => null);
@@ -3314,7 +3392,10 @@ function registerFileRoutes(api, adapter) {
3314
3392
  return applyGsapMutations(c, res, [body]);
3315
3393
  });
3316
3394
  api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
3317
- const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
3395
+ const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", {
3396
+ mustExist: true,
3397
+ pin: true
3398
+ });
3318
3399
  if ("error" in res) return res.error;
3319
3400
  const body = await c.req.json().catch(() => null);
3320
3401
  if (!body || !Array.isArray(body.mutations) || body.mutations.length === 0) {
@@ -3327,7 +3408,10 @@ function registerFileRoutes(api, adapter) {
3327
3408
  return applyGsapMutations(c, res, body.mutations);
3328
3409
  });
3329
3410
  api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
3330
- const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
3411
+ const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", {
3412
+ mustExist: true,
3413
+ pin: true
3414
+ });
3331
3415
  if ("error" in res) return res.error;
3332
3416
  const body = await c.req.json().catch(() => null);
3333
3417
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
@@ -3348,6 +3432,7 @@ import { Readable } from "stream";
3348
3432
  import { join as join9, resolve as resolve4 } from "path";
3349
3433
  import { createHash as createHash4 } from "crypto";
3350
3434
  import {
3435
+ addScenePartsManifest,
3351
3436
  injectScriptsIntoHtml,
3352
3437
  insertBeforeCloseTag as insertBeforeCloseTag2,
3353
3438
  stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
@@ -3364,9 +3449,7 @@ import {
3364
3449
  rewriteInlineStyleAssetUrls
3365
3450
  } from "@hyperframes/core";
3366
3451
  import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
3367
- function isFullHtmlDocument(html) {
3368
- return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
3369
- }
3452
+ import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
3370
3453
  function rewriteRelativePaths(root, compPath, projectDir) {
3371
3454
  const assetExists = (path) => existsSync4(join8(projectDir, path));
3372
3455
  rewriteAssetPaths(
@@ -3744,7 +3827,8 @@ function resolveProjectMainHtml(projectDir, projectId) {
3744
3827
  var PREVIEW_BUNDLE_OPTIONS = {
3745
3828
  runtime: "placeholder",
3746
3829
  inlineAssets: false,
3747
- staticGuard: false
3830
+ staticGuard: false,
3831
+ sceneParts: true
3748
3832
  };
3749
3833
  function registerPreviewRoutes(api, adapter) {
3750
3834
  const previewCacheHeaders = (etag) => ({
@@ -3794,6 +3878,7 @@ ${runtimeTag}`;
3794
3878
  mainCompositionPath,
3795
3879
  mediaCodecProbeCache
3796
3880
  );
3881
+ bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
3797
3882
  rememberPreview(builtKey, bundled);
3798
3883
  adapter.previewDocuments?.write(builtKey, bundled);
3799
3884
  return bundled;
@@ -3871,7 +3956,7 @@ ${etag}`;
3871
3956
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3872
3957
  const previewVariables = vars.values;
3873
3958
  const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
3874
- const compFile = resolveWithinProject(project.dir, compPath);
3959
+ const compFile = resolveWithinProject2(project.dir, compPath);
3875
3960
  if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
3876
3961
  return c.text("not found", 404);
3877
3962
  }
@@ -4105,7 +4190,7 @@ function registerRenderRoutes(api, adapter) {
4105
4190
  const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
4106
4191
  let composition;
4107
4192
  if (typeof body.composition === "string" && body.composition.length > 0) {
4108
- if (!resolveWithinProject(project.dir, body.composition)) {
4193
+ if (!resolveWithinProject2(project.dir, body.composition)) {
4109
4194
  return c.json({ error: "composition path must be within the project directory" }, 400);
4110
4195
  }
4111
4196
  composition = body.composition;
@@ -4238,7 +4323,7 @@ function registerRenderRoutes(api, adapter) {
4238
4323
  const filename = c.req.path.split("/renders/file/")[1];
4239
4324
  if (!filename) return c.json({ error: "missing filename" }, 400);
4240
4325
  const rendersDir = adapter.rendersDir(project);
4241
- const fp = resolveWithinProject(rendersDir, filename);
4326
+ const fp = resolveWithinProject2(rendersDir, filename);
4242
4327
  if (!fp) return c.json({ error: "forbidden" }, 403);
4243
4328
  if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
4244
4329
  const contentType = renderContentType(fp);
@@ -4322,7 +4407,7 @@ function projectReader(projectDir) {
4322
4407
  const sources = /* @__PURE__ */ new Map();
4323
4408
  return (compPath) => {
4324
4409
  if (!sources.has(compPath)) {
4325
- const file = resolveWithinProject(projectDir, compPath);
4410
+ const file = resolveWithinProject2(projectDir, compPath);
4326
4411
  let source = null;
4327
4412
  try {
4328
4413
  source = file ? readFileSync11(file, "utf-8") : null;
@@ -4547,7 +4632,7 @@ function registerThumbnailRoutes(api, adapter) {
4547
4632
  const { project, signature: projectSignature } = resolved;
4548
4633
  let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
4549
4634
  if (compPath && !compPath.includes(".")) compPath += ".html";
4550
- const htmlFile = resolveWithinProject(project.dir, compPath);
4635
+ const htmlFile = resolveWithinProject2(project.dir, compPath);
4551
4636
  const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
4552
4637
  if (source === "not-a-file") return c.json({ error: "not found" }, 404);
4553
4638
  const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
@@ -4971,7 +5056,7 @@ function registerSelectionRoutes(api, adapter) {
4971
5056
  // src/routes/media.ts
4972
5057
  import { streamSSE as streamSSE2 } from "hono/streaming";
4973
5058
  import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
4974
- import { basename as basename2, dirname as dirname5, extname as extname2, join as join14 } from "path";
5059
+ import { basename as basename3, dirname as dirname6, extname as extname2, join as join14 } from "path";
4975
5060
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
4976
5061
  ".mp4",
4977
5062
  ".mov",
@@ -5001,7 +5086,7 @@ function containsNullByte(path) {
5001
5086
  return path.includes("\0");
5002
5087
  }
5003
5088
  function slugFileBase(path) {
5004
- const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5089
+ const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5005
5090
  return name || "media";
5006
5091
  }
5007
5092
  function uniqueAssetPath(projectDir, assetPath) {
@@ -5057,7 +5142,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5057
5142
  if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
5058
5143
  return c.json({ error: "media metadata requires a project-local asset" }, 400);
5059
5144
  }
5060
- const filePath = resolveWithinProject(project.dir, assetPath);
5145
+ const filePath = resolveWithinProject2(project.dir, assetPath);
5061
5146
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5062
5147
  if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
5063
5148
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
@@ -5079,7 +5164,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5079
5164
  if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
5080
5165
  return c.json({ error: "background removal requires a project-local media asset" }, 400);
5081
5166
  }
5082
- const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5167
+ const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
5083
5168
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5084
5169
  if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
5085
5170
  const inputIsVideo = isVideoPath(inputAssetPath);
@@ -5091,11 +5176,11 @@ function registerMediaRoutes(api, adapter, options = {}) {
5091
5176
  if (requestedOutput && containsNullByte(requestedOutput)) {
5092
5177
  return c.json({ error: "forbidden" }, 403);
5093
5178
  }
5094
- if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
5179
+ if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
5095
5180
  return c.json({ error: "forbidden" }, 403);
5096
5181
  }
5097
5182
  const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
5098
- const outputPath = resolveWithinProject(project.dir, outputAssetPath);
5183
+ const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
5099
5184
  if (!outputPath) return c.json({ error: "forbidden" }, 403);
5100
5185
  if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
5101
5186
  return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
@@ -5110,13 +5195,13 @@ function registerMediaRoutes(api, adapter, options = {}) {
5110
5195
  return c.json({ error: "background plates are only supported for video inputs" }, 400);
5111
5196
  }
5112
5197
  backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
5113
- backgroundOutputPath = resolveWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
5198
+ backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
5114
5199
  if (!backgroundOutputPath) {
5115
5200
  return c.json({ error: "forbidden" }, 403);
5116
5201
  }
5117
5202
  }
5118
- mkdirSync7(dirname5(outputPath), { recursive: true });
5119
- if (backgroundOutputPath) mkdirSync7(dirname5(backgroundOutputPath), { recursive: true });
5203
+ mkdirSync7(dirname6(outputPath), { recursive: true });
5204
+ if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
5120
5205
  const jobId = makeJobId(project.id, mediaJobs);
5121
5206
  const state = adapter.startBackgroundRemoval({
5122
5207
  project,
@@ -5199,28 +5284,38 @@ function registerGlobalAssetRoutes(api) {
5199
5284
 
5200
5285
  // src/history/projectHistory.ts
5201
5286
  import { randomUUID as randomUUID8 } from "crypto";
5202
- import { rm as rm2 } from "fs/promises";
5203
- import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync18 } from "fs";
5287
+ import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
5288
+ import {
5289
+ existsSync as existsSync12,
5290
+ lstatSync as lstatSync4,
5291
+ statSync as statSync9,
5292
+ mkdirSync as mkdirSync11,
5293
+ readdirSync as readdirSync7,
5294
+ readFileSync as readFileSync18,
5295
+ realpathSync as realpathSync3,
5296
+ rmSync as rmSync6
5297
+ } from "fs";
5204
5298
  import { homedir as homedir2 } from "os";
5205
- import { join as join19, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5299
+ import { dirname as dirname9, isAbsolute as isAbsolute3, join as join19, relative as relative5, resolve as resolve7, sep as sep4 } from "path";
5206
5300
 
5207
5301
  // src/history/blobStore.ts
5208
5302
  import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
5209
- import { constants as constants3, createReadStream as createReadStream2 } from "fs";
5303
+ import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
5210
5304
  import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5211
- import { dirname as dirname6, join as join16 } from "path";
5305
+ import { dirname as dirname7, join as join16 } from "path";
5212
5306
  var BLOB_HASH = /^[0-9a-f]{64}$/;
5213
5307
  async function hashFile(path) {
5214
5308
  const hash = createHash6("sha256");
5215
5309
  for await (const chunk of createReadStream2(path)) hash.update(chunk);
5216
5310
  return hash.digest("hex");
5217
5311
  }
5218
- async function cloneOrCopy(from, to) {
5219
- await mkdir(dirname6(to), { recursive: true });
5312
+ async function cloneOrCopy(from, to, beforeReplace) {
5313
+ await mkdir(dirname7(to), { recursive: true });
5220
5314
  const temp = `${to}.${randomUUID5()}.tmp`;
5221
5315
  try {
5222
5316
  await copyFile(from, temp, constants3.COPYFILE_FICLONE);
5223
- await rename(temp, to);
5317
+ beforeReplace?.();
5318
+ renameSync4(temp, to);
5224
5319
  } catch (error) {
5225
5320
  await rm(temp, { force: true });
5226
5321
  throw error;
@@ -5247,7 +5342,7 @@ async function openBlobStore(dir) {
5247
5342
  await rm(temp, { force: true });
5248
5343
  return hash;
5249
5344
  }
5250
- await mkdir(dirname6(pathOf(hash)), { recursive: true });
5345
+ await mkdir(dirname7(pathOf(hash)), { recursive: true });
5251
5346
  const size = (await stat(temp)).size;
5252
5347
  await rename(temp, pathOf(hash));
5253
5348
  sizes.set(hash, size);
@@ -5256,7 +5351,7 @@ async function openBlobStore(dir) {
5256
5351
  },
5257
5352
  has: (hash) => sizes.has(hash),
5258
5353
  read: async (hash) => readFile2(pathOf(hash)),
5259
- writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
5354
+ writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
5260
5355
  bytes: () => total,
5261
5356
  size: (hash) => sizes.get(hash) ?? 0,
5262
5357
  async prune(keep) {
@@ -5272,7 +5367,7 @@ async function openBlobStore(dir) {
5272
5367
 
5273
5368
  // src/history/historyId.ts
5274
5369
  import { randomUUID as randomUUID6 } from "crypto";
5275
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
5370
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5276
5371
  import { join as join17, resolve as resolve6 } from "path";
5277
5372
  var ID_PATH = join17(".hyperframes", "history-id");
5278
5373
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -5284,27 +5379,33 @@ function readId(projectDir) {
5284
5379
  return null;
5285
5380
  }
5286
5381
  }
5287
- function recordedDir(historyDir) {
5382
+ var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
5383
+ function isRecordedFolder(historyDir, folder) {
5288
5384
  try {
5289
- return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8")).dir ?? null;
5385
+ const was = JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
5386
+ return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
5290
5387
  } catch {
5291
- return null;
5388
+ return false;
5292
5389
  }
5293
5390
  }
5294
5391
  function projectHistoryId(projectDir, historyRoot) {
5295
5392
  const dir = resolve6(projectDir);
5393
+ const folder = statSync8(dir);
5296
5394
  let id = readId(dir);
5297
- const was = id && recordedDir(join17(historyRoot, id));
5298
- if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
5395
+ if (!id || existsSync11(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5299
5396
  id = randomUUID6();
5300
5397
  mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5301
5398
  writeFileSync7(join17(dir, ID_PATH), `${id}
5302
5399
  `);
5303
5400
  }
5304
- mkdirSync8(join17(historyRoot, id), { recursive: true });
5305
- writeFileSync7(join17(historyRoot, id, "project.json"), JSON.stringify({ dir }));
5401
+ recordProject(join17(historyRoot, id), dir, folder);
5306
5402
  return id;
5307
5403
  }
5404
+ function recordProject(historyDir, dir, folder) {
5405
+ const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
5406
+ mkdirSync8(historyDir, { recursive: true });
5407
+ replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
5408
+ }
5308
5409
 
5309
5410
  // src/history/ownerLock.ts
5310
5411
  import { randomUUID as randomUUID7 } from "crypto";
@@ -5389,7 +5490,7 @@ async function takeHistoryOwnership(home, waitMs) {
5389
5490
 
5390
5491
  // src/history/historyLog.ts
5391
5492
  import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5392
- import { dirname as dirname7 } from "path";
5493
+ import { dirname as dirname8 } from "path";
5393
5494
  var START = "start";
5394
5495
  function parseRecord(line) {
5395
5496
  try {
@@ -5441,21 +5542,25 @@ function writeLog(file, log) {
5441
5542
  ...log.entries.map((entry) => ({ type: "entry", entry })),
5442
5543
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5443
5544
  ];
5444
- mkdirSync10(dirname7(file), { recursive: true });
5545
+ mkdirSync10(dirname8(file), { recursive: true });
5445
5546
  replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5446
5547
  `).join(""), 420);
5447
5548
  }
5448
- function applyEntry(manifest, entry) {
5449
- for (const file of entry.files)
5450
- if (file.after === null) manifest.delete(file.path);
5451
- else manifest.set(file.path, file.after);
5549
+ function applyEntry(manifest, entry, side) {
5550
+ for (const file of entry.files) {
5551
+ const hash = file[side];
5552
+ if (hash === null) manifest.delete(file.path);
5553
+ else manifest.set(file.path, hash);
5554
+ }
5452
5555
  }
5453
5556
  function manifestAt(log, point) {
5557
+ return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
5558
+ }
5559
+ function manifestAround(log, id, side) {
5454
5560
  const manifest = new Map(log.baseline);
5455
- if (point === START) return manifest;
5456
5561
  for (const entry of log.entries) {
5457
- applyEntry(manifest, entry);
5458
- if (entry.id === point) return manifest;
5562
+ applyEntry(manifest, entry, entry.id === id ? side : "after");
5563
+ if (entry.id === id) return manifest;
5459
5564
  }
5460
5565
  return null;
5461
5566
  }
@@ -5467,24 +5572,35 @@ function undoneIds(entries) {
5467
5572
  }
5468
5573
  return undone;
5469
5574
  }
5470
- function stepTarget(entries, direction) {
5575
+ function stepTarget(entries, direction, mine) {
5471
5576
  const undone = undoneIds(entries);
5472
5577
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
5473
- for (let i = entries.length - 1; i >= 0; i -= 1) {
5474
- const entry = entries[i];
5475
- const isChange = !entry.undoes;
5476
- if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
5477
- if (direction !== "forward") continue;
5478
- if (isChange) return void 0;
5578
+ const owns = ownerOf2(entries, byId, mine);
5579
+ const newestFirst = [...entries].reverse();
5580
+ if (direction === "back")
5581
+ return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
5582
+ for (const entry of newestFirst) {
5583
+ if (!entry.undoes) {
5584
+ if (owns(entry)) return void 0;
5585
+ continue;
5586
+ }
5479
5587
  const target = byId.get(entry.undoes);
5480
- if (!undone.has(entry.id) && target && !target.undoes) return entry;
5588
+ if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
5481
5589
  }
5482
5590
  return void 0;
5483
5591
  }
5592
+ function ownerOf2(entries, byId, mine) {
5593
+ const redoneBy = /* @__PURE__ */ new Map();
5594
+ for (const entry of entries) {
5595
+ const target = entry.undoes ? byId.get(entry.undoes) : void 0;
5596
+ if (target?.undoes) redoneBy.set(target.undoes, entry);
5597
+ }
5598
+ return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
5599
+ }
5484
5600
  function foldOldest(log) {
5485
5601
  const oldest = log.entries[0];
5486
5602
  if (!oldest || log.pins.has(oldest.id)) return false;
5487
- applyEntry(log.baseline, oldest);
5603
+ applyEntry(log.baseline, oldest, "after");
5488
5604
  log.entries.shift();
5489
5605
  return true;
5490
5606
  }
@@ -5501,6 +5617,7 @@ function referencedHashes(log, current) {
5501
5617
  // src/history/projectHistory.ts
5502
5618
  var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
5503
5619
  var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5620
+ var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
5504
5621
  var OUTSIDE = { kind: "outside", name: "Outside" };
5505
5622
  var OUTSIDE_LABEL = "Changed outside the app";
5506
5623
  var RACY_MS = 2e3;
@@ -5514,6 +5631,33 @@ function splitAt(change, at, kept) {
5514
5631
  { ...change, before: at }
5515
5632
  ];
5516
5633
  }
5634
+ var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
5635
+ async function removeEmptyFolders(dir) {
5636
+ let entries;
5637
+ try {
5638
+ entries = await readdir2(dir, { withFileTypes: true });
5639
+ } catch (error) {
5640
+ if (isMissingFolder(error)) return;
5641
+ throw error;
5642
+ }
5643
+ for (const entry of entries)
5644
+ if (entry.isDirectory()) await removeEmptyFolders(join19(dir, entry.name));
5645
+ await rmdir(dir);
5646
+ }
5647
+ function fileLeftIn(dir, at, deleted) {
5648
+ for (const entry of readdirSync7(dir, { withFileTypes: true })) {
5649
+ const path = `${at}/${entry.name}`;
5650
+ const left = entry.isDirectory() ? fileLeftIn(join19(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
5651
+ if (left) return left;
5652
+ }
5653
+ return void 0;
5654
+ }
5655
+ function ignoresCase(dir) {
5656
+ const self = statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false });
5657
+ const other = statSync9(join19(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
5658
+ return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
5659
+ }
5660
+ var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
5517
5661
  var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
5518
5662
  function addChange(group, path, before, after) {
5519
5663
  const earlier = group.changes.get(path);
@@ -5528,17 +5672,24 @@ var Engine = class {
5528
5672
  this.blobs = blobs;
5529
5673
  this.dir = resolve7(options.projectDir);
5530
5674
  this.home = join19(options.historyRoot, projectId);
5675
+ this.foldsCase = ignoresCase(this.dir);
5676
+ const folder = statSync9(this.dir, { throwIfNoEntry: false });
5677
+ if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
5678
+ throw this.replaced();
5679
+ this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
5531
5680
  }
5532
5681
  options;
5533
5682
  projectId;
5534
5683
  blobs;
5535
5684
  dir;
5685
+ foldsCase;
5686
+ folder;
5536
5687
  home;
5537
5688
  log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5538
5689
  tracked = /* @__PURE__ */ new Map();
5539
5690
  windows = [];
5540
5691
  outside = null;
5541
- /** A coalescing claim, open until another key, its idle timer, or an operation commits it. */
5692
+ /** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
5542
5693
  claimed = null;
5543
5694
  writeToken;
5544
5695
  quietTimer;
@@ -5546,6 +5697,12 @@ var Engine = class {
5546
5697
  notedTimer = null;
5547
5698
  listeners = /* @__PURE__ */ new Set();
5548
5699
  tail = Promise.resolve();
5700
+ closed = false;
5701
+ closing;
5702
+ /** A path as the project's disk compares names: folded where `A` and `a` are one name. */
5703
+ nameKey(path) {
5704
+ return this.foldsCase ? path.toLowerCase() : path;
5705
+ }
5549
5706
  get logFile() {
5550
5707
  return join19(this.home, "log.jsonl");
5551
5708
  }
@@ -5554,10 +5711,37 @@ var Engine = class {
5554
5711
  }
5555
5712
  /** One operation at a time, in order: sweeps, windows and undos never interleave. */
5556
5713
  queue(task) {
5714
+ if (this.closed)
5715
+ return Promise.reject(new HistoryClosedError("This project's history is closed."));
5557
5716
  const run = this.tail.then(task);
5558
5717
  this.tail = run.catch(() => void 0);
5559
5718
  return run;
5560
5719
  }
5720
+ /** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
5721
+ whereFolder() {
5722
+ const now = statSync9(this.dir, { throwIfNoEntry: false });
5723
+ if (!now) return "gone";
5724
+ const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
5725
+ return same && readId(this.dir) === this.projectId ? "here" : "replaced";
5726
+ }
5727
+ assertWritable() {
5728
+ const folder = this.whereFolder();
5729
+ if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
5730
+ if (folder === "replaced") throw this.replaced();
5731
+ }
5732
+ persistLog(record) {
5733
+ if (record) saveRecord(this.logFile, this.log, record);
5734
+ else writeLog(this.logFile, this.log);
5735
+ if (!existsSync12(join19(this.home, "project.json")))
5736
+ recordProject(this.home, this.dir, this.folder);
5737
+ }
5738
+ replaced() {
5739
+ return new HistoryClosedError(`${this.dir} is now another project.`);
5740
+ }
5741
+ assertOpen() {
5742
+ if (this.closed) throw new HistoryClosedError("This project's history is closed.");
5743
+ if (this.whereFolder() === "replaced") throw this.replaced();
5744
+ }
5561
5745
  async start() {
5562
5746
  const log = readLog(
5563
5747
  this.logFile,
@@ -5574,9 +5758,7 @@ var Engine = class {
5574
5758
  this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
5575
5759
  }
5576
5760
  this.reopenClosedWindow();
5577
- await this.sweep();
5578
- for (const window of [...this.windows]) await this.endWindow(window);
5579
- await this.commitOutside();
5761
+ await this.settleAll();
5580
5762
  }
5581
5763
  reopenClosedWindow() {
5582
5764
  const closed = this.options.closedWindow;
@@ -5586,13 +5768,13 @@ var Engine = class {
5586
5768
  }
5587
5769
  async firstOpen() {
5588
5770
  const sweptAt = Date.now();
5589
- for (const file of listProjectFiles(this.dir))
5590
- this.tracked.set(file.path, {
5591
- hash: await this.blobs.put(join19(this.dir, file.path)),
5592
- stat: statKey(file, sweptAt)
5593
- });
5771
+ for (const file of listProjectFiles(this.dir)) {
5772
+ const hash = await this.storeIfPresent(file.path);
5773
+ if (this.whereFolder() !== "here") throw this.replaced();
5774
+ if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
5775
+ }
5594
5776
  this.log.baseline = this.manifest();
5595
- writeLog(this.logFile, this.log);
5777
+ this.persistLog();
5596
5778
  this.saveStatCache();
5597
5779
  }
5598
5780
  manifest() {
@@ -5616,19 +5798,32 @@ var Engine = class {
5616
5798
  * project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
5617
5799
  */
5618
5800
  async sweep() {
5619
- if (!existsSync12(this.dir)) return;
5801
+ if (this.whereFolder() !== "here") return;
5620
5802
  const sweptAt = Date.now();
5621
5803
  const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
5622
- const seen = listProjectFiles(this.dir).sort((a, b) => changedAt(a) - changedAt(b));
5623
- let changed = false;
5624
- for (const file of seen)
5625
- changed = await this.observe(file.path, statKey(file, sweptAt), changedAt(file)) || changed;
5804
+ const seen = listProjectFiles(this.dir);
5626
5805
  const present = new Set(seen.map((file) => file.path));
5627
- for (const [path, known] of this.tracked) {
5628
- if (present.has(path)) continue;
5806
+ const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
5807
+ const standing = this.standingAt(path);
5808
+ return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
5809
+ });
5810
+ const appearedAt = (file) => Math.max(
5811
+ changedAt(file),
5812
+ ...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
5813
+ );
5814
+ const events = [
5815
+ ...removed,
5816
+ ...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
5817
+ ].sort((a, b) => a.at - b.at);
5818
+ let changed = false;
5819
+ for (const { at, path, file } of events) {
5820
+ if (file) {
5821
+ changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
5822
+ continue;
5823
+ }
5824
+ const known = this.tracked.get(path);
5629
5825
  this.tracked.delete(path);
5630
- const removedAt = sweptAt;
5631
- await this.record(path, known.hash, null, removedAt);
5826
+ await this.record(path, known.hash, null, at);
5632
5827
  changed = true;
5633
5828
  }
5634
5829
  if (changed) this.saveStatCache();
@@ -5646,7 +5841,7 @@ var Engine = class {
5646
5841
  const known = this.tracked.get(path) ?? { hash: null, stat: null };
5647
5842
  if (stat2 && known.stat === stat2) return false;
5648
5843
  const hash = await this.storeIfPresent(path);
5649
- if (hash === null) return false;
5844
+ if (hash === null || this.whereFolder() !== "here") return false;
5650
5845
  this.tracked.set(path, { hash, stat: stat2 });
5651
5846
  if (known.hash !== hash) await this.record(path, known.hash, hash, at);
5652
5847
  return known.hash !== hash || known.stat !== stat2;
@@ -5662,28 +5857,34 @@ var Engine = class {
5662
5857
  /** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
5663
5858
  async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
5664
5859
  await this.sweep();
5665
- const { taken, cut } = this.takeClaimed(who, paths, overwrote);
5666
- if (!taken.length) {
5667
- if (coalesceKey !== this.claimed?.key) await this.commitClaim();
5668
- return null;
5669
- }
5860
+ const taken = await this.takeClaimed(who, paths, overwrote);
5861
+ if (!taken.length) return this.claimNothing(coalesceKey);
5670
5862
  const held = this.heldFor(coalesceKey, taken);
5671
5863
  if (!held) await this.commitClaim();
5672
- await this.commitOlder(cut);
5864
+ await this.commitPending();
5673
5865
  const group = held ?? this.newGroup(who, label);
5674
5866
  for (const change of taken) addChange(group, change.path, change.before, change.after);
5675
5867
  if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
5676
5868
  const entry = await this.commit(group);
5677
5869
  return entry && { id: entry.id };
5678
5870
  }
5679
- async commitOlder(cut) {
5680
- if (this.outside) cut.delete(this.outside);
5871
+ /** A claim that took nothing still ends a held claim under another key. */
5872
+ async claimNothing(coalesceKey) {
5873
+ if (coalesceKey !== this.claimed?.key) await this.commitClaim();
5874
+ return null;
5875
+ }
5876
+ /** Commits every other writer's pending changes, oldest first; open windows stay open. */
5877
+ async commitPending() {
5681
5878
  await this.commitOutside();
5682
- for (const [window, paths] of cut) await this.commitCut(window, paths);
5879
+ for (const window of [...this.windows])
5880
+ if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
5881
+ }
5882
+ pendingElsewhere() {
5883
+ return Boolean(this.outside?.changes.size) || this.windows.some((open) => open.changes.size > 0);
5683
5884
  }
5684
5885
  /** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
5685
5886
  heldFor(key, taken) {
5686
- const group = key && this.claimed?.key === key ? this.claimed.group : null;
5887
+ const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
5687
5888
  const after = (path, before) => {
5688
5889
  const own = group?.changes.get(path);
5689
5890
  return own ? own.after : before;
@@ -5693,10 +5894,10 @@ var Engine = class {
5693
5894
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
5694
5895
  }
5695
5896
  logPath(path) {
5696
- return relative4(this.dir, resolve7(this.dir, path)).split(sep3).join("/");
5897
+ return relative5(this.dir, resolve7(this.dir, path)).split(sep4).join("/");
5697
5898
  }
5698
- /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote; `cut`: paths each group keeps. */
5699
- takeClaimed(who, paths, overwrote) {
5899
+ /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
5900
+ async takeClaimed(who, paths, overwrote) {
5700
5901
  const wanted = new Set(paths.map((path) => this.logPath(path)));
5701
5902
  const at = new Map(
5702
5903
  Object.entries(overwrote).map(([path, version]) => [
@@ -5706,23 +5907,50 @@ var Engine = class {
5706
5907
  );
5707
5908
  const others = this.windows.filter((open) => !sameWho(open.who, who));
5708
5909
  const groups = this.outside ? [this.outside, ...others] : others;
5709
- const taken = [];
5710
- const cut = /* @__PURE__ */ new Map();
5711
- for (const group of groups) {
5712
- for (const change of [...group.changes.values()]) {
5713
- if (!wanted.has(change.path)) continue;
5714
- const [kept, claimed] = this.cutOut(group, change, at.get(change.path));
5715
- if (claimed) taken.push(claimed);
5716
- if (kept && claimed) cut.set(group, [...cut.get(group) ?? [], change.path]);
5717
- }
5910
+ const hits = groups.flatMap(
5911
+ (group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
5912
+ );
5913
+ const cuts = [];
5914
+ const walked = hits.map(() => /* @__PURE__ */ new Set());
5915
+ for (const [i, { change }] of hits.entries()) {
5916
+ const told = at.get(change.path);
5917
+ cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
5918
+ }
5919
+ hits.forEach(
5920
+ ({ change }, i) => forgetOverwrittenBytes(
5921
+ join19(this.dir, change.path),
5922
+ new Set([...walked[i]].map(hashVersion))
5923
+ )
5924
+ );
5925
+ return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
5926
+ }
5927
+ /** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
5928
+ async overwrittenBy(change, told, seen) {
5929
+ const absPath = join19(this.dir, change.path);
5930
+ let bytes;
5931
+ for (let hash = change.after; hash; ) {
5932
+ if (seen.has(hash)) return void 0;
5933
+ seen.add(hash);
5934
+ const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
5935
+ if (replaced === void 0) break;
5936
+ bytes = replaced;
5937
+ hash = hashOfVersion(fileContentVersion(replaced));
5938
+ if (hash === change.before || hash === told) return hash;
5939
+ }
5940
+ if (bytes === void 0) return void 0;
5941
+ const staged = join19(this.home, `overwrote-${randomUUID8()}`);
5942
+ await writeFile(staged, bytes);
5943
+ try {
5944
+ return await this.blobs.put(staged);
5945
+ } finally {
5946
+ await rm2(staged, { force: true });
5718
5947
  }
5719
- return { taken, cut };
5720
5948
  }
5721
5949
  cutOut(group, change, cut) {
5722
5950
  const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
5723
5951
  group.changes.delete(change.path);
5724
5952
  if (kept) group.changes.set(kept.path, kept);
5725
- return [kept, claimed];
5953
+ return claimed;
5726
5954
  }
5727
5955
  /** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
5728
5956
  * so the writer's next write to a cut file would otherwise span the claimer's edit. */
@@ -5736,8 +5964,8 @@ var Engine = class {
5736
5964
  }
5737
5965
  holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
5738
5966
  clearTimeout(this.claimed?.timer);
5739
- const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
5740
- timer.unref?.();
5967
+ const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
5968
+ timer?.unref?.();
5741
5969
  this.claimed = { group, key, timer };
5742
5970
  return group.changes.size ? { id: group.id } : null;
5743
5971
  }
@@ -5767,6 +5995,7 @@ var Engine = class {
5767
5995
  return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5768
5996
  }
5769
5997
  async commitOutside() {
5998
+ if (this.outside?.changes.size) await this.commitClaim();
5770
5999
  clearTimeout(this.quietTimer);
5771
6000
  clearTimeout(this.maxTimer);
5772
6001
  const group = this.outside;
@@ -5775,14 +6004,12 @@ var Engine = class {
5775
6004
  }
5776
6005
  async commit(group, extra = {}) {
5777
6006
  if (!group.changes.size) return null;
5778
- const { id, who, label, startedAt, lastWriteAt, changes } = group;
5779
- const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
5780
- const previousEnd = this.log.entries.at(-1)?.endedAt ?? 0;
5781
- const endedAt = Math.max(lastWriteAt ?? this.now(), previousEnd);
5782
- const entry = { id, who, label, startedAt, endedAt, files, ...extra };
6007
+ const pending = this.pendingEntry(group);
6008
+ const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
6009
+ const entry = { ...pending, endedAt, ...extra };
5783
6010
  this.log.entries.push(entry);
5784
6011
  try {
5785
- saveRecord(this.logFile, this.log, { type: "entry", entry });
6012
+ this.persistLog({ type: "entry", entry });
5786
6013
  } catch (error) {
5787
6014
  this.log.entries.pop();
5788
6015
  throw error;
@@ -5803,15 +6030,24 @@ var Engine = class {
5803
6030
  let folded = false;
5804
6031
  while (this.historyBytes() > budget && foldOldest(this.log)) {
5805
6032
  folded = true;
5806
- await this.blobs.prune(referencedHashes(this.log, this.manifest()));
6033
+ await this.blobs.prune(
6034
+ /* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
6035
+ );
5807
6036
  }
5808
- if (folded) writeLog(this.logFile, this.log);
6037
+ if (folded) this.persistLog();
6038
+ }
6039
+ /** Hashes only pending changes point at yet, such as a claim's stored cut. */
6040
+ pendingHashes() {
6041
+ const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
6042
+ ...group?.changes.values() ?? []
6043
+ ]);
6044
+ return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
5809
6045
  }
5810
- /** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
6046
+ /** Before an operation or a window: every write is filed and every pending change committed, in write order. */
5811
6047
  async settle() {
5812
6048
  await this.sweep();
5813
6049
  await this.commitClaim();
5814
- await this.commitOutside();
6050
+ await this.commitPending();
5815
6051
  }
5816
6052
  /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
5817
6053
  noteChange(path) {
@@ -5823,11 +6059,13 @@ var Engine = class {
5823
6059
  this.notedTimer.unref?.();
5824
6060
  }
5825
6061
  background(task) {
5826
- this.queue(task).catch((error) => this.options.onError?.(error));
6062
+ this.queue(task).catch((error) => {
6063
+ if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
6064
+ });
5827
6065
  }
5828
6066
  beginWindow(who, label, idleMs) {
5829
6067
  return this.queue(async () => {
5830
- await this.sweep();
6068
+ await this.settle();
5831
6069
  const window = { ...this.newGroup(who, label), idleMs };
5832
6070
  this.windows.push(window);
5833
6071
  this.touch(window, Date.now());
@@ -5854,6 +6092,7 @@ var Engine = class {
5854
6092
  async endWindow(window) {
5855
6093
  if (!this.windows.includes(window)) return window.entry ?? null;
5856
6094
  clearTimeout(window.idleTimer);
6095
+ await this.commitClaim();
5857
6096
  this.windows = this.windows.filter((open) => open !== window);
5858
6097
  window.entry = await this.commit(window) ?? window.entry ?? null;
5859
6098
  return window.entry;
@@ -5865,15 +6104,39 @@ var Engine = class {
5865
6104
  for (const window of [...this.windows]) await this.endWindow(window);
5866
6105
  await this.commitOutside();
5867
6106
  }
6107
+ /** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
6108
+ standingAt(path) {
6109
+ const parts = path.split("/");
6110
+ for (let depth = 1; depth <= parts.length; depth += 1) {
6111
+ const at = parts.slice(0, depth).join("/");
6112
+ const stat2 = lstatSync4(join19(this.dir, at), { throwIfNoEntry: false });
6113
+ if (!stat2) return void 0;
6114
+ if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
6115
+ }
6116
+ return void 0;
6117
+ }
6118
+ /** What a write of `path` would have to delete: a file or link at or above it, or a file in a folder at it. */
6119
+ inTheWay(path, deleted) {
6120
+ const standing = this.standingAt(path);
6121
+ if (!standing) return void 0;
6122
+ if (standing.stat.isSymbolicLink()) return standing.at;
6123
+ if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6124
+ const real = realpathSync3.native(join19(this.dir, standing.at));
6125
+ const at = relative5(realpathSync3.native(this.dir), real).split(sep4).join("/");
6126
+ if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6127
+ return fileLeftIn(real, at, deleted);
6128
+ }
5868
6129
  /** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
5869
6130
  async writeAs(who, label, target, extra) {
6131
+ this.assertWritable();
6132
+ const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
6133
+ const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
6134
+ const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
6135
+ if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
5870
6136
  const group = this.newGroup(who, label);
5871
6137
  this.windows.push(group);
5872
6138
  try {
5873
- for (const [path, hash] of target) {
5874
- if ((this.tracked.get(path)?.hash ?? null) !== hash)
5875
- await this.writeProjectFile(path, hash);
5876
- }
6139
+ for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
5877
6140
  await this.sweep();
5878
6141
  } finally {
5879
6142
  this.windows = this.windows.filter((open) => open !== group);
@@ -5882,14 +6145,20 @@ var Engine = class {
5882
6145
  }
5883
6146
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
5884
6147
  async writeProjectFile(path, hash) {
6148
+ this.assertWritable();
5885
6149
  const absPath = join19(this.dir, path);
5886
6150
  const { writeToken } = this;
5887
6151
  if (writeToken) {
5888
6152
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
5889
6153
  recordFileWriteReceipt(absPath, { path, version, writeToken });
5890
6154
  }
5891
- if (hash === null) await rm2(absPath, { force: true });
5892
- else await this.blobs.writeTo(hash, absPath);
6155
+ if (hash === null) {
6156
+ this.assertWritable();
6157
+ rmSync6(absPath, { force: true });
6158
+ } else {
6159
+ await removeEmptyFolders(absPath);
6160
+ await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
6161
+ }
5893
6162
  }
5894
6163
  entry(id) {
5895
6164
  const entry = this.log.entries.find((candidate) => candidate.id === id);
@@ -5913,7 +6182,9 @@ var Engine = class {
5913
6182
  entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
5914
6183
  };
5915
6184
  }
5916
- const target = new Map(entry.files.map((file) => [file.path, file.before]));
6185
+ const target = new Map(
6186
+ entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
6187
+ );
5917
6188
  return {
5918
6189
  ok: true,
5919
6190
  entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
@@ -5922,15 +6193,19 @@ var Engine = class {
5922
6193
  movedOn(entry) {
5923
6194
  return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
5924
6195
  }
5925
- /** Back reverts the newest change in effect, unless its files moved on under an earlier-ending edit (a held drag
5926
- * committed after an agent's turn): then that edit, if it still applies. */
5927
- next(direction) {
5928
- const top = stepTarget(this.log.entries, direction);
5929
- const moved = new Set(top && direction === "back" ? this.movedOn(top).map((f) => f.path) : []);
5930
- if (!top || !moved.size) return top;
5931
- const undone = undoneIds(this.log.entries);
5932
- const under = this.log.entries.slice(0, this.log.entries.indexOf(top)).reverse().find((e) => !e.undoes && !undone.has(e.id) && e.files.some((f) => moved.has(f.path)));
5933
- return under && !this.movedOn(under).length ? under : top;
6196
+ next(direction, who) {
6197
+ const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
6198
+ const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
6199
+ (group) => group?.changes.size && mine(group.who)
6200
+ );
6201
+ if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
6202
+ return stepTarget(this.log.entries, direction, (entry) => mine(entry.who));
6203
+ }
6204
+ /** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
6205
+ pendingEntry(group) {
6206
+ const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
6207
+ const { id, who, label, startedAt, lastWriteAt } = group;
6208
+ return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
5934
6209
  }
5935
6210
  conflict(entry, changed) {
5936
6211
  const paths = new Set(changed.map((file) => file.path));
@@ -5967,8 +6242,11 @@ var Engine = class {
5967
6242
  projectId: this.projectId,
5968
6243
  beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
5969
6244
  claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
5970
- noteChange: (path) => this.noteChange(path),
6245
+ noteChange: (path) => {
6246
+ if (!this.closed) this.noteChange(path);
6247
+ },
5971
6248
  list: () => {
6249
+ this.assertOpen();
5972
6250
  const undone = undoneIds(this.log.entries);
5973
6251
  return this.log.entries.map((entry) => ({
5974
6252
  ...entry,
@@ -5977,7 +6255,7 @@ var Engine = class {
5977
6255
  }));
5978
6256
  },
5979
6257
  step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
5980
- const target = this.next(direction);
6258
+ const target = this.next(direction, who);
5981
6259
  return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
5982
6260
  }),
5983
6261
  undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
@@ -5986,30 +6264,76 @@ var Engine = class {
5986
6264
  () => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
5987
6265
  ),
5988
6266
  peek: (point) => {
6267
+ this.assertOpen();
5989
6268
  const files = manifestAt(this.log, point);
5990
6269
  return files && Object.fromEntries(files);
5991
6270
  },
5992
- next: (direction) => this.next(direction),
5993
- readBlob: (hash) => this.blobs.read(hash),
6271
+ checkout: (entryId, side, emptyDir) => this.queue(async () => {
6272
+ this.entry(entryId);
6273
+ if (!existsSync12(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6274
+ const dest = resolve7(emptyDir);
6275
+ if (isWithin(this.dir, dest))
6276
+ throw new Error(`Checkout writes outside the project only: ${dest}`);
6277
+ if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
6278
+ throw new Error(`Checkout writes into an empty folder only: ${dest}`);
6279
+ const files = manifestAround(this.log, entryId, side);
6280
+ try {
6281
+ for (const [path, hash] of files) await this.blobs.writeTo(hash, join19(dest, path));
6282
+ } catch (error) {
6283
+ for (const name of await readdir2(dest).catch(() => []))
6284
+ await rm2(join19(dest, name), { recursive: true, force: true });
6285
+ throw error;
6286
+ }
6287
+ }),
6288
+ next: (direction, who) => {
6289
+ this.assertOpen();
6290
+ return this.next(direction, who);
6291
+ },
6292
+ readBlob: async (hash) => {
6293
+ this.assertOpen();
6294
+ return this.blobs.read(hash);
6295
+ },
5994
6296
  pin: (id, pinned) => {
6297
+ this.assertOpen();
5995
6298
  this.entry(id);
5996
6299
  if (pinned) this.log.pins.add(id);
5997
6300
  else this.log.pins.delete(id);
5998
- saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
6301
+ this.persistLog({ type: "pin", id, pinned });
5999
6302
  },
6000
6303
  onEntry: (listener) => {
6001
6304
  this.listeners.add(listener);
6002
6305
  return () => this.listeners.delete(listener);
6003
6306
  },
6004
6307
  flush: () => this.queue(() => this.settleAll()),
6005
- close: async () => {
6308
+ replacedAtPath: () => this.whereFolder() === "replaced",
6309
+ close: () => this.closing ??= (async () => {
6006
6310
  if (this.notedTimer) clearTimeout(this.notedTimer);
6007
- await this.queue(() => this.settleAll());
6008
- }
6311
+ const settled = this.queue(() => this.settleAll());
6312
+ this.closed = true;
6313
+ await settled;
6314
+ })()
6009
6315
  };
6010
6316
  }
6011
6317
  };
6318
+ function isWithin(dir, path) {
6319
+ let probe = resolve7(path);
6320
+ while (!existsSync12(probe) && dirname9(probe) !== probe) probe = dirname9(probe);
6321
+ const rel = relative5(realpathSync3.native(dir), realpathSync3.native(probe));
6322
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
6323
+ }
6324
+ function missingIsEmpty(error) {
6325
+ if (error.code === "ENOENT") return [];
6326
+ throw error;
6327
+ }
6328
+ var HistoryClosedError = class extends Error {
6329
+ constructor(message) {
6330
+ super(message);
6331
+ this.name = "HistoryClosedError";
6332
+ }
6333
+ };
6012
6334
  async function openProjectHistory(options) {
6335
+ if (!existsSync12(options.projectDir))
6336
+ throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
6013
6337
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6014
6338
  const release = await takeHistoryOwnership(
6015
6339
  join19(options.historyRoot, projectId),
@@ -6062,7 +6386,7 @@ function versionsOf(value) {
6062
6386
  return pairs.length ? Object.fromEntries(pairs) : void 0;
6063
6387
  }
6064
6388
  function nextStep(history, direction) {
6065
- const target = history.next(direction);
6389
+ const target = history.next(direction, YOU);
6066
6390
  if (!target) return null;
6067
6391
  const paths = target.files.map((file) => file.path);
6068
6392
  return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
@@ -6097,7 +6421,7 @@ function registerHistoryRoutes(api, adapter) {
6097
6421
  api.post(
6098
6422
  `${base}/undo`,
6099
6423
  (c) => withHistory(adapter, c, (history, body) => {
6100
- const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
6424
+ const mode = UNDO_MODES.find((known) => known === body.mode);
6101
6425
  return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
6102
6426
  })
6103
6427
  );
@@ -6133,11 +6457,9 @@ function registerHistoryRoutes(api, adapter) {
6133
6457
  (c) => withHistory(adapter, c, async (history, body) => {
6134
6458
  const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
6135
6459
  const coalesceKey = text(body.coalesceKey) ?? void 0;
6136
- const idleMs = idleOf(body);
6137
6460
  const overwrote = versionsOf(body.overwrote);
6138
6461
  const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
6139
- ...coalesceKey && { coalesceKey },
6140
- ...idleMs && { idleMs },
6462
+ ...coalesceKey && { coalesceKey, idleMs: idleOf(body) ?? Infinity },
6141
6463
  ...overwrote && { overwrote }
6142
6464
  });
6143
6465
  return { claimed };
@@ -6187,6 +6509,30 @@ function createStudioApi(adapter) {
6187
6509
  return api;
6188
6510
  }
6189
6511
 
6512
+ // src/history/historyCache.ts
6513
+ function historyCache(open) {
6514
+ const histories = /* @__PURE__ */ new Map();
6515
+ const get = async (projectDir) => {
6516
+ let cached = histories.get(projectDir);
6517
+ if (!cached) histories.set(projectDir, cached = open(projectDir));
6518
+ const opened = await cached;
6519
+ if (!opened?.replacedAtPath()) return opened;
6520
+ const reopen = () => open(projectDir);
6521
+ if (histories.get(projectDir) === cached)
6522
+ histories.set(projectDir, opened.close().then(reopen, reopen));
6523
+ return get(projectDir);
6524
+ };
6525
+ return {
6526
+ get,
6527
+ /** The history if one was opened, without opening it. */
6528
+ peek: (projectDir) => histories.get(projectDir),
6529
+ forget: (projectDir) => histories.delete(projectDir),
6530
+ closeAll: () => Promise.all(
6531
+ [...histories.values()].map((opened) => opened.then((history) => history?.close()))
6532
+ )
6533
+ };
6534
+ }
6535
+
6190
6536
  // src/helpers/duplicateElement.ts
6191
6537
  import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
6192
6538
  function duplicateElementInHtml(source, target, newId, at) {
@@ -6240,7 +6586,7 @@ function numericAttribute(element, name) {
6240
6586
 
6241
6587
  // src/helpers/previewDocumentStore.ts
6242
6588
  import { createHash as createHash7 } from "crypto";
6243
- import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync7, rmSync as rmSync6 } from "fs";
6589
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6244
6590
  import { join as join20 } from "path";
6245
6591
  function createPreviewDocumentStore(dir, salt) {
6246
6592
  const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
@@ -6258,8 +6604,8 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
6258
6604
  mkdirSync12(dir, { recursive: true });
6259
6605
  const file = fileFor(key);
6260
6606
  replaceFileAtomically(file, html, 420);
6261
- for (const name of readdirSync7(dir)) {
6262
- if (join20(dir, name) !== file) rmSync6(join20(dir, name), { force: true });
6607
+ for (const name of readdirSync8(dir)) {
6608
+ if (join20(dir, name) !== file) rmSync7(join20(dir, name), { force: true });
6263
6609
  }
6264
6610
  } catch {
6265
6611
  }
@@ -6339,11 +6685,13 @@ export {
6339
6685
  DELETED_VERSION,
6340
6686
  START as HISTORY_START,
6341
6687
  HistoryBusyError,
6688
+ HistoryClosedError,
6342
6689
  MAX_WINDOW_IDLE_MS,
6343
6690
  MIME_TYPES,
6344
6691
  PREVIEW_BUNDLE_OPTIONS,
6345
6692
  STUDIO_MANUAL_EDITS_PATH,
6346
6693
  STUDIO_MOTION_PATH,
6694
+ UNDO_MODES,
6347
6695
  affectsProjectSignature,
6348
6696
  applyFileMutations,
6349
6697
  buildSubCompositionHtml,
@@ -6362,6 +6710,7 @@ export {
6362
6710
  findTargetElement,
6363
6711
  getElementScreenshotClip,
6364
6712
  getMimeType,
6713
+ historyCache,
6365
6714
  identifyFileWrite,
6366
6715
  isSafePath,
6367
6716
  openProjectHistory,