@hyperframes/studio 0.5.7 → 0.6.0-alpha.10

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.
Files changed (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
@@ -1,10 +1,20 @@
1
- import { memo, useState, useCallback, type ReactNode } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
1
+ import {
2
+ memo,
3
+ useState,
4
+ useCallback,
5
+ useImperativeHandle,
6
+ forwardRef,
7
+ type ReactNode,
8
+ } from "react";
3
9
  import { CompositionsTab } from "./CompositionsTab";
4
10
  import { AssetsTab } from "./AssetsTab";
5
11
  import { FileTree } from "../editor/FileTree";
6
12
 
7
- type SidebarTab = "compositions" | "assets" | "code";
13
+ export type SidebarTab = "compositions" | "assets" | "code";
14
+
15
+ export interface LeftSidebarHandle {
16
+ selectTab: (tab: SidebarTab) => void;
17
+ }
8
18
 
9
19
  const STORAGE_KEY = "hf-studio-sidebar-tab";
10
20
 
@@ -36,189 +46,194 @@ interface LeftSidebarProps {
36
46
  onLint?: () => void;
37
47
  linting?: boolean;
38
48
  onToggleCollapse?: () => void;
49
+ takeoverContent?: ReactNode;
39
50
  }
40
51
 
41
- export const LeftSidebar = memo(function LeftSidebar({
42
- width = 240,
43
- projectId,
44
- compositions,
45
- assets,
46
- activeComposition,
47
- onSelectComposition,
48
- onImportFiles,
49
- fileTree: fileProp,
50
- editingFile,
51
- onSelectFile,
52
- onCreateFile,
53
- onCreateFolder,
54
- onDeleteFile,
55
- onRenameFile,
56
- onDuplicateFile,
57
- onMoveFile,
58
- codeChildren,
59
- onLint,
60
- linting,
61
- onToggleCollapse,
62
- }: LeftSidebarProps) {
63
- const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
52
+ export const LeftSidebar = memo(
53
+ forwardRef<LeftSidebarHandle, LeftSidebarProps>(function LeftSidebar(
54
+ {
55
+ width = 240,
56
+ projectId,
57
+ compositions,
58
+ assets,
59
+ activeComposition,
60
+ onSelectComposition,
61
+ onImportFiles,
62
+ fileTree: fileProp,
63
+ editingFile,
64
+ onSelectFile,
65
+ onCreateFile,
66
+ onCreateFolder,
67
+ onDeleteFile,
68
+ onRenameFile,
69
+ onDuplicateFile,
70
+ onMoveFile,
71
+ codeChildren,
72
+ onLint,
73
+ linting,
74
+ onToggleCollapse,
75
+ takeoverContent,
76
+ },
77
+ ref,
78
+ ) {
79
+ const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
64
80
 
65
- const selectTab = useCallback((t: SidebarTab) => {
66
- setTab(t);
67
- localStorage.setItem(STORAGE_KEY, t);
68
- }, []);
81
+ const selectTab = useCallback((t: SidebarTab) => {
82
+ setTab(t);
83
+ localStorage.setItem(STORAGE_KEY, t);
84
+ }, []);
69
85
 
70
- // Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets
71
- useMountEffect(() => {
72
- const handler = (e: KeyboardEvent) => {
73
- if (!e.metaKey && !e.ctrlKey) return;
74
- if (e.key === "1") {
75
- e.preventDefault();
76
- selectTab("compositions");
77
- }
78
- if (e.key === "2") {
79
- e.preventDefault();
80
- selectTab("assets");
81
- }
82
- };
83
- window.addEventListener("keydown", handler);
84
- return () => window.removeEventListener("keydown", handler);
85
- });
86
+ useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
86
87
 
87
- return (
88
- <div
89
- className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
90
- style={{ width }}
91
- >
92
- {/* Tabs — Code first */}
93
- <div className="flex border-b border-neutral-800/50 flex-shrink-0">
94
- <button
95
- type="button"
96
- onClick={() => selectTab("code")}
97
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
98
- tab === "code"
99
- ? "text-neutral-200 border-b-2 border-studio-accent"
100
- : "text-neutral-500 hover:text-neutral-400"
101
- }`}
102
- >
103
- Code
104
- </button>
105
- <button
106
- type="button"
107
- onClick={() => selectTab("compositions")}
108
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
109
- tab === "compositions"
110
- ? "text-neutral-200 border-b-2 border-studio-accent"
111
- : "text-neutral-500 hover:text-neutral-400"
112
- }`}
113
- >
114
- Compositions
115
- </button>
116
- <button
117
- type="button"
118
- onClick={() => selectTab("assets")}
119
- className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
120
- tab === "assets"
121
- ? "text-neutral-200 border-b-2 border-studio-accent"
122
- : "text-neutral-500 hover:text-neutral-400"
123
- }`}
124
- >
125
- Assets
126
- </button>
127
- {onToggleCollapse && (
128
- <button
129
- type="button"
130
- onClick={onToggleCollapse}
131
- className="mx-1 my-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
132
- title="Hide sidebar"
133
- aria-label="Hide sidebar"
134
- >
135
- <svg
136
- width="14"
137
- height="14"
138
- viewBox="0 0 24 24"
139
- fill="none"
140
- stroke="currentColor"
141
- strokeWidth="1.5"
142
- strokeLinecap="round"
143
- strokeLinejoin="round"
144
- aria-hidden="true"
145
- >
146
- <path d="m14 7-5 5 5 5" />
147
- <path d="M19 4v16" />
148
- </svg>
149
- </button>
150
- )}
151
- </div>
88
+ return (
89
+ <div
90
+ className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
91
+ style={{ width }}
92
+ >
93
+ {takeoverContent ? (
94
+ <div className="flex min-h-0 flex-1">{takeoverContent}</div>
95
+ ) : (
96
+ <>
97
+ {/* Tabs — Code first */}
98
+ <div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
99
+ <div className="flex items-center gap-2">
100
+ <div
101
+ className="grid min-w-0 flex-1 gap-0.5 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
102
+ style={{ gridTemplateColumns: "1fr 1fr 1fr" }}
103
+ >
104
+ <button
105
+ type="button"
106
+ onClick={() => selectTab("code")}
107
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
108
+ tab === "code"
109
+ ? "bg-neutral-800 text-white"
110
+ : "text-neutral-500 hover:text-neutral-200"
111
+ }`}
112
+ >
113
+ Code
114
+ </button>
115
+ <button
116
+ type="button"
117
+ onClick={() => selectTab("compositions")}
118
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
119
+ tab === "compositions"
120
+ ? "bg-neutral-800 text-white"
121
+ : "text-neutral-500 hover:text-neutral-200"
122
+ }`}
123
+ >
124
+ Comps
125
+ </button>
126
+ <button
127
+ type="button"
128
+ onClick={() => selectTab("assets")}
129
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
130
+ tab === "assets"
131
+ ? "bg-neutral-800 text-white"
132
+ : "text-neutral-500 hover:text-neutral-200"
133
+ }`}
134
+ >
135
+ Assets
136
+ </button>
137
+ </div>
138
+ {onToggleCollapse && (
139
+ <button
140
+ type="button"
141
+ onClick={onToggleCollapse}
142
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
143
+ title="Hide sidebar"
144
+ aria-label="Hide sidebar"
145
+ >
146
+ <svg
147
+ width="14"
148
+ height="14"
149
+ viewBox="0 0 24 24"
150
+ fill="none"
151
+ stroke="currentColor"
152
+ strokeWidth="1.5"
153
+ strokeLinecap="round"
154
+ strokeLinejoin="round"
155
+ aria-hidden="true"
156
+ >
157
+ <path d="m14 7-5 5 5 5" />
158
+ <path d="M19 4v16" />
159
+ </svg>
160
+ </button>
161
+ )}
162
+ </div>
163
+ </div>
152
164
 
153
- {/* Tab content */}
154
- {tab === "compositions" && (
155
- <CompositionsTab
156
- projectId={projectId}
157
- compositions={compositions}
158
- activeComposition={activeComposition}
159
- onSelect={onSelectComposition}
160
- />
161
- )}
162
- {tab === "assets" && (
163
- <AssetsTab
164
- projectId={projectId}
165
- assets={assets}
166
- onImport={onImportFiles}
167
- onDelete={onDeleteFile}
168
- onRename={onRenameFile}
169
- />
170
- )}
171
- {tab === "code" && (
172
- <div className="flex flex-1 min-h-0">
173
- {(fileProp?.length ?? 0) > 0 && (
174
- <div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
175
- <FileTree
176
- files={fileProp ?? []}
177
- activeFile={editingFile?.path ?? null}
178
- onSelectFile={onSelectFile ?? (() => {})}
179
- onCreateFile={onCreateFile}
180
- onCreateFolder={onCreateFolder}
181
- onDeleteFile={onDeleteFile}
182
- onRenameFile={onRenameFile}
183
- onDuplicateFile={onDuplicateFile}
184
- onMoveFile={onMoveFile}
185
- onImportFiles={onImportFiles}
165
+ {/* Tab content */}
166
+ {tab === "compositions" && (
167
+ <CompositionsTab
168
+ projectId={projectId}
169
+ compositions={compositions}
170
+ activeComposition={activeComposition}
171
+ onSelect={onSelectComposition}
186
172
  />
187
- </div>
188
- )}
189
- <div className="flex-1 overflow-hidden min-w-0">
190
- {codeChildren ?? (
191
- <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
192
- Select a file to edit
173
+ )}
174
+ {tab === "assets" && (
175
+ <AssetsTab
176
+ projectId={projectId}
177
+ assets={assets}
178
+ onImport={onImportFiles}
179
+ onDelete={onDeleteFile}
180
+ onRename={onRenameFile}
181
+ />
182
+ )}
183
+ {tab === "code" && (
184
+ <div className="flex flex-1 min-h-0">
185
+ {(fileProp?.length ?? 0) > 0 && (
186
+ <div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
187
+ <FileTree
188
+ files={fileProp ?? []}
189
+ activeFile={editingFile?.path ?? null}
190
+ onSelectFile={onSelectFile ?? (() => {})}
191
+ onCreateFile={onCreateFile}
192
+ onCreateFolder={onCreateFolder}
193
+ onDeleteFile={onDeleteFile}
194
+ onRenameFile={onRenameFile}
195
+ onDuplicateFile={onDuplicateFile}
196
+ onMoveFile={onMoveFile}
197
+ onImportFiles={onImportFiles}
198
+ />
199
+ </div>
200
+ )}
201
+ <div className="flex-1 overflow-hidden min-w-0">
202
+ {codeChildren ?? (
203
+ <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
204
+ Select a file to edit
205
+ </div>
206
+ )}
207
+ </div>
193
208
  </div>
194
209
  )}
195
- </div>
196
- </div>
197
- )}
198
210
 
199
- {/* Lint button pinned at the bottom */}
200
- {onLint && (
201
- <div className="border-t border-neutral-800 p-2 flex-shrink-0">
202
- <button
203
- onClick={onLint}
204
- disabled={linting}
205
- className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
206
- >
207
- <svg
208
- width="12"
209
- height="12"
210
- viewBox="0 0 24 24"
211
- fill="none"
212
- stroke="currentColor"
213
- strokeWidth="2"
214
- >
215
- <path d="M9 11l3 3L22 4" />
216
- <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
217
- </svg>
218
- {linting ? "Linting…" : "Lint"}
219
- </button>
220
- </div>
221
- )}
222
- </div>
223
- );
224
- });
211
+ {/* Lint button pinned at the bottom */}
212
+ {onLint && (
213
+ <div className="border-t border-neutral-800 p-2 flex-shrink-0">
214
+ <button
215
+ onClick={onLint}
216
+ disabled={linting}
217
+ className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
218
+ >
219
+ <svg
220
+ width="12"
221
+ height="12"
222
+ viewBox="0 0 24 24"
223
+ fill="none"
224
+ stroke="currentColor"
225
+ strokeWidth="2"
226
+ >
227
+ <path d="M9 11l3 3L22 4" />
228
+ <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
229
+ </svg>
230
+ {linting ? "Linting…" : "Lint"}
231
+ </button>
232
+ </div>
233
+ )}
234
+ </>
235
+ )}
236
+ </div>
237
+ );
238
+ }),
239
+ );
@@ -0,0 +1,256 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createEmptyEditHistory } from "../utils/editHistory";
3
+ import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
4
+ import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
5
+ import {
6
+ createPersistentEditHistoryController,
7
+ createPersistentEditHistoryStore,
8
+ } from "./usePersistentEditHistory";
9
+
10
+ describe("createPersistentEditHistoryController", () => {
11
+ it("records history and reloads it for the same project", async () => {
12
+ const storage = createMemoryEditHistoryStorage();
13
+ const first = await createPersistentEditHistoryController({
14
+ projectId: "project-1",
15
+ storage,
16
+ now: () => 100,
17
+ onChange: () => {},
18
+ });
19
+
20
+ await first.recordEdit({
21
+ label: "Move layer",
22
+ kind: "manual",
23
+ files: { "index.html": { before: "a", after: "b" } },
24
+ });
25
+
26
+ const second = await createPersistentEditHistoryController({
27
+ projectId: "project-1",
28
+ storage,
29
+ now: () => 200,
30
+ onChange: () => {},
31
+ });
32
+
33
+ expect(second.snapshot().canUndo).toBe(true);
34
+ expect(second.snapshot().undoLabel).toBe("Move layer");
35
+ expect(second.snapshot().undoPaths).toEqual(["index.html"]);
36
+ });
37
+
38
+ it("undo applies files through the provided callback and persists redo state", async () => {
39
+ const storage = createMemoryEditHistoryStorage();
40
+ const controller = await createPersistentEditHistoryController({
41
+ projectId: "project-1",
42
+ storage,
43
+ now: () => 100,
44
+ onChange: () => {},
45
+ });
46
+ await controller.recordEdit({
47
+ label: "Move layer",
48
+ kind: "manual",
49
+ files: { "index.html": { before: "a", after: "b" } },
50
+ });
51
+
52
+ const result = await controller.undo({
53
+ readFile: async (path) => {
54
+ expect(path).toBe("index.html");
55
+ return "b";
56
+ },
57
+ writeFile: async (path, content) => {
58
+ expect(path).toBe("index.html");
59
+ expect(content).toBe("a");
60
+ },
61
+ });
62
+ expect(result.ok).toBe(true);
63
+ expect(result.paths).toEqual(["index.html"]);
64
+
65
+ expect(controller.snapshot().canUndo).toBe(false);
66
+ expect(controller.snapshot().canRedo).toBe(true);
67
+ expect(controller.snapshot().redoPaths).toEqual(["index.html"]);
68
+ });
69
+
70
+ it("keeps in-memory history when storage saves fail", async () => {
71
+ const storage: EditHistoryStorageAdapter = {
72
+ async get() {
73
+ return null;
74
+ },
75
+ async set() {
76
+ throw new Error("IndexedDB unavailable");
77
+ },
78
+ async delete() {},
79
+ };
80
+ const controller = await createPersistentEditHistoryController({
81
+ projectId: "project-1",
82
+ storage,
83
+ now: () => 100,
84
+ onChange: () => {},
85
+ });
86
+
87
+ await expect(
88
+ controller.recordEdit({
89
+ label: "Move layer",
90
+ kind: "manual",
91
+ files: { "index.html": { before: "a", after: "b" } },
92
+ }),
93
+ ).resolves.toBeUndefined();
94
+
95
+ expect(controller.snapshot().canUndo).toBe(true);
96
+ });
97
+
98
+ it("serializes concurrent record edits against the latest state", async () => {
99
+ const storage = createMemoryEditHistoryStorage();
100
+ let timestamp = 100;
101
+ const store = createPersistentEditHistoryStore({
102
+ projectId: "project-1",
103
+ storage,
104
+ initialState: createEmptyEditHistory(),
105
+ now: () => timestamp++,
106
+ onChange: () => {},
107
+ });
108
+
109
+ await Promise.all([
110
+ store.recordEdit({
111
+ label: "Move layer",
112
+ kind: "manual",
113
+ files: { "index.html": { before: "a", after: "b" } },
114
+ }),
115
+ store.recordEdit({
116
+ label: "Resize layer",
117
+ kind: "manual",
118
+ files: { "index.html": { before: "b", after: "c" } },
119
+ }),
120
+ ]);
121
+
122
+ expect(store.snapshot().state.undo.map((entry) => entry.label)).toEqual([
123
+ "Move layer",
124
+ "Resize layer",
125
+ ]);
126
+ });
127
+
128
+ it("still coalesces concurrent source edits that share a coalesce key", async () => {
129
+ const storage = createMemoryEditHistoryStorage();
130
+ let timestamp = 100;
131
+ const store = createPersistentEditHistoryStore({
132
+ projectId: "project-1",
133
+ storage,
134
+ initialState: createEmptyEditHistory(),
135
+ now: () => timestamp++,
136
+ onChange: () => {},
137
+ });
138
+
139
+ await Promise.all([
140
+ store.recordEdit({
141
+ label: "Edit source",
142
+ kind: "source",
143
+ coalesceKey: "source:index.html",
144
+ files: { "index.html": { before: "a", after: "b" } },
145
+ }),
146
+ store.recordEdit({
147
+ label: "Edit source",
148
+ kind: "source",
149
+ coalesceKey: "source:index.html",
150
+ files: { "index.html": { before: "b", after: "c" } },
151
+ }),
152
+ ]);
153
+
154
+ expect(store.snapshot().state.undo).toHaveLength(1);
155
+ expect(store.snapshot().state.undo[0].files["index.html"].before).toBe("a");
156
+ expect(store.snapshot().state.undo[0].files["index.html"].after).toBe("c");
157
+ });
158
+
159
+ it("reads undo hashes from the live top entry during queued undo calls", async () => {
160
+ const storage = createMemoryEditHistoryStorage();
161
+ let timestamp = 100;
162
+ const store = createPersistentEditHistoryStore({
163
+ projectId: "project-1",
164
+ storage,
165
+ initialState: createEmptyEditHistory(),
166
+ now: () => timestamp++,
167
+ onChange: () => {},
168
+ });
169
+ await store.recordEdit({
170
+ label: "Edit first file",
171
+ kind: "manual",
172
+ files: { "first.html": { before: "first-before", after: "first-after" } },
173
+ });
174
+ await store.recordEdit({
175
+ label: "Edit second file",
176
+ kind: "manual",
177
+ files: { "second.html": { before: "second-before", after: "second-after" } },
178
+ });
179
+
180
+ const files: Record<string, string> = {
181
+ "first.html": "first-after",
182
+ "second.html": "second-after",
183
+ };
184
+ const readPaths: string[] = [];
185
+
186
+ await Promise.all([
187
+ store.undo({
188
+ readFile: async (path) => {
189
+ readPaths.push(path);
190
+ return files[path];
191
+ },
192
+ writeFile: async (path, content) => {
193
+ files[path] = content;
194
+ },
195
+ }),
196
+ store.undo({
197
+ readFile: async (path) => {
198
+ readPaths.push(path);
199
+ return files[path];
200
+ },
201
+ writeFile: async (path, content) => {
202
+ files[path] = content;
203
+ },
204
+ }),
205
+ ]);
206
+
207
+ expect(readPaths).toEqual(["second.html", "first.html"]);
208
+ expect(files).toEqual({
209
+ "first.html": "first-before",
210
+ "second.html": "second-before",
211
+ });
212
+ expect(store.snapshot().canUndo).toBe(false);
213
+ expect(store.snapshot().canRedo).toBe(true);
214
+ });
215
+
216
+ it("rolls back files when an undo write fails partway through", async () => {
217
+ const storage = createMemoryEditHistoryStorage();
218
+ const store = createPersistentEditHistoryStore({
219
+ projectId: "project-1",
220
+ storage,
221
+ initialState: createEmptyEditHistory(),
222
+ now: () => 100,
223
+ onChange: () => {},
224
+ });
225
+ await store.recordEdit({
226
+ label: "Edit files",
227
+ kind: "manual",
228
+ files: {
229
+ "first.html": { before: "first-before", after: "first-after" },
230
+ "second.html": { before: "second-before", after: "second-after" },
231
+ },
232
+ });
233
+
234
+ const files: Record<string, string> = {
235
+ "first.html": "first-after",
236
+ "second.html": "second-after",
237
+ };
238
+ const result = store.undo({
239
+ readFile: async (path) => files[path],
240
+ writeFile: async (path, content) => {
241
+ if (path === "second.html" && content === "second-before") {
242
+ throw new Error("write failed");
243
+ }
244
+ files[path] = content;
245
+ },
246
+ });
247
+
248
+ await expect(result).rejects.toThrow("write failed");
249
+ expect(files).toEqual({
250
+ "first.html": "first-after",
251
+ "second.html": "second-after",
252
+ });
253
+ expect(store.snapshot().undoLabel).toBe("Edit files");
254
+ expect(store.snapshot().canRedo).toBe(false);
255
+ });
256
+ });