@hyperframes/studio 0.5.0-alpha.10 → 0.5.0-alpha.12
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/assets/index-JhhmFie-.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +371 -149
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineClip.tsx +2 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +139 -0
- package/src/player/hooks/useTimelinePlayer.ts +201 -89
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export type EditHistoryKind = "manual" | "timeline" | "source";
|
|
2
|
+
|
|
3
|
+
export interface EditHistoryFileSnapshot {
|
|
4
|
+
before: string;
|
|
5
|
+
after: string;
|
|
6
|
+
beforeHash: string;
|
|
7
|
+
afterHash: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EditHistoryEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
label: string;
|
|
14
|
+
kind: EditHistoryKind;
|
|
15
|
+
coalesceKey?: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
files: Record<string, EditHistoryFileSnapshot>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EditHistoryState {
|
|
21
|
+
version: 1;
|
|
22
|
+
updatedAt: number;
|
|
23
|
+
undo: EditHistoryEntry[];
|
|
24
|
+
redo: EditHistoryEntry[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EditHistoryOptions {
|
|
28
|
+
maxEntries?: number;
|
|
29
|
+
coalesceMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BuildEditHistoryEntryInput {
|
|
33
|
+
id: string;
|
|
34
|
+
projectId: string;
|
|
35
|
+
label: string;
|
|
36
|
+
kind?: EditHistoryKind;
|
|
37
|
+
coalesceKey?: string;
|
|
38
|
+
now: number;
|
|
39
|
+
files: Record<string, { before: string; after: string }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type EditHistoryDirection = "undo" | "redo";
|
|
43
|
+
|
|
44
|
+
export type EditHistoryApplyCheck =
|
|
45
|
+
| { ok: true }
|
|
46
|
+
| { ok: false; reason: "content-mismatch"; path: string };
|
|
47
|
+
|
|
48
|
+
export type EditHistoryTransitionResult =
|
|
49
|
+
| {
|
|
50
|
+
ok: true;
|
|
51
|
+
state: EditHistoryState;
|
|
52
|
+
entry: EditHistoryEntry;
|
|
53
|
+
filesToWrite: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
ok: false;
|
|
57
|
+
reason: "empty" | "content-mismatch";
|
|
58
|
+
state: EditHistoryState;
|
|
59
|
+
filesToWrite: Record<string, string>;
|
|
60
|
+
path?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const DEFAULT_MAX_ENTRIES = 100;
|
|
64
|
+
const DEFAULT_COALESCE_MS = 1500;
|
|
65
|
+
|
|
66
|
+
export function hashEditHistoryContent(content: string): string {
|
|
67
|
+
let hash = 2166136261;
|
|
68
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
69
|
+
hash ^= content.charCodeAt(index);
|
|
70
|
+
hash = Math.imul(hash, 16777619);
|
|
71
|
+
}
|
|
72
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createEmptyEditHistory(_options?: EditHistoryOptions): EditHistoryState {
|
|
76
|
+
return {
|
|
77
|
+
version: 1,
|
|
78
|
+
updatedAt: 0,
|
|
79
|
+
undo: [],
|
|
80
|
+
redo: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHistoryEntry {
|
|
85
|
+
const files: Record<string, EditHistoryFileSnapshot> = {};
|
|
86
|
+
for (const [path, snapshot] of Object.entries(input.files)) {
|
|
87
|
+
if (snapshot.before === snapshot.after) continue;
|
|
88
|
+
files[path] = {
|
|
89
|
+
before: snapshot.before,
|
|
90
|
+
after: snapshot.after,
|
|
91
|
+
beforeHash: hashEditHistoryContent(snapshot.before),
|
|
92
|
+
afterHash: hashEditHistoryContent(snapshot.after),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
id: input.id,
|
|
98
|
+
projectId: input.projectId,
|
|
99
|
+
label: input.label,
|
|
100
|
+
kind: input.kind ?? "manual",
|
|
101
|
+
coalesceKey: input.coalesceKey,
|
|
102
|
+
createdAt: input.now,
|
|
103
|
+
files,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function pushEditHistoryEntry(
|
|
108
|
+
state: EditHistoryState,
|
|
109
|
+
entry: EditHistoryEntry,
|
|
110
|
+
options?: EditHistoryOptions,
|
|
111
|
+
): EditHistoryState {
|
|
112
|
+
if (Object.keys(entry.files).length === 0) return state;
|
|
113
|
+
|
|
114
|
+
const coalesceMs = options?.coalesceMs ?? DEFAULT_COALESCE_MS;
|
|
115
|
+
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
116
|
+
const previous = state.undo[state.undo.length - 1];
|
|
117
|
+
let undo = state.undo;
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
previous &&
|
|
121
|
+
previous.coalesceKey &&
|
|
122
|
+
previous.coalesceKey === entry.coalesceKey &&
|
|
123
|
+
entry.createdAt - previous.createdAt <= coalesceMs
|
|
124
|
+
) {
|
|
125
|
+
const files: Record<string, EditHistoryFileSnapshot> = {};
|
|
126
|
+
for (const [path, snapshot] of Object.entries(entry.files)) {
|
|
127
|
+
const previousSnapshot = previous.files[path];
|
|
128
|
+
files[path] = previousSnapshot
|
|
129
|
+
? {
|
|
130
|
+
before: previousSnapshot.before,
|
|
131
|
+
after: snapshot.after,
|
|
132
|
+
beforeHash: previousSnapshot.beforeHash,
|
|
133
|
+
afterHash: snapshot.afterHash,
|
|
134
|
+
}
|
|
135
|
+
: snapshot;
|
|
136
|
+
}
|
|
137
|
+
undo = [...state.undo.slice(0, -1), { ...entry, files }];
|
|
138
|
+
} else {
|
|
139
|
+
undo = [...state.undo, entry];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
version: 1,
|
|
144
|
+
updatedAt: entry.createdAt,
|
|
145
|
+
undo: undo.slice(Math.max(0, undo.length - maxEntries)),
|
|
146
|
+
redo: [],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function canApplyEditHistoryEntry(
|
|
151
|
+
entry: EditHistoryEntry,
|
|
152
|
+
direction: EditHistoryDirection,
|
|
153
|
+
currentHashes: Record<string, string>,
|
|
154
|
+
): EditHistoryApplyCheck {
|
|
155
|
+
for (const [path, snapshot] of Object.entries(entry.files)) {
|
|
156
|
+
const expected = direction === "undo" ? snapshot.afterHash : snapshot.beforeHash;
|
|
157
|
+
if (currentHashes[path] !== expected) {
|
|
158
|
+
return { ok: false, reason: "content-mismatch", path };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function undoEditHistory(
|
|
165
|
+
state: EditHistoryState,
|
|
166
|
+
currentHashes: Record<string, string>,
|
|
167
|
+
now: number,
|
|
168
|
+
): EditHistoryTransitionResult {
|
|
169
|
+
const entry = state.undo[state.undo.length - 1];
|
|
170
|
+
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
|
|
171
|
+
|
|
172
|
+
const check = canApplyEditHistoryEntry(entry, "undo", currentHashes);
|
|
173
|
+
if (!check.ok) {
|
|
174
|
+
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
entry,
|
|
180
|
+
filesToWrite: Object.fromEntries(
|
|
181
|
+
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.before]),
|
|
182
|
+
),
|
|
183
|
+
state: {
|
|
184
|
+
version: 1,
|
|
185
|
+
updatedAt: now,
|
|
186
|
+
undo: state.undo.slice(0, -1),
|
|
187
|
+
redo: [...state.redo, entry],
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function redoEditHistory(
|
|
193
|
+
state: EditHistoryState,
|
|
194
|
+
currentHashes: Record<string, string>,
|
|
195
|
+
now: number,
|
|
196
|
+
): EditHistoryTransitionResult {
|
|
197
|
+
const entry = state.redo[state.redo.length - 1];
|
|
198
|
+
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
|
|
199
|
+
|
|
200
|
+
const check = canApplyEditHistoryEntry(entry, "redo", currentHashes);
|
|
201
|
+
if (!check.ok) {
|
|
202
|
+
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
entry,
|
|
208
|
+
filesToWrite: Object.fromEntries(
|
|
209
|
+
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.after]),
|
|
210
|
+
),
|
|
211
|
+
state: {
|
|
212
|
+
version: 1,
|
|
213
|
+
updatedAt: now,
|
|
214
|
+
undo: [...state.undo, entry],
|
|
215
|
+
redo: state.redo.slice(0, -1),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { buildEditHistoryEntry, createEmptyEditHistory, pushEditHistoryEntry } from "./editHistory";
|
|
3
|
+
import {
|
|
4
|
+
createMemoryEditHistoryStorage,
|
|
5
|
+
loadEditHistoryState,
|
|
6
|
+
saveEditHistoryState,
|
|
7
|
+
} from "./editHistoryStorage";
|
|
8
|
+
|
|
9
|
+
describe("edit history storage", () => {
|
|
10
|
+
let storage: ReturnType<typeof createMemoryEditHistoryStorage>;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
storage = createMemoryEditHistoryStorage();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns empty history for projects without persisted state", async () => {
|
|
17
|
+
const state = await loadEditHistoryState(storage, "project-1");
|
|
18
|
+
|
|
19
|
+
expect(state).toEqual(createEmptyEditHistory());
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("saves and loads history per project", async () => {
|
|
23
|
+
const entry = buildEditHistoryEntry({
|
|
24
|
+
id: "entry-1",
|
|
25
|
+
projectId: "project-1",
|
|
26
|
+
label: "Move layer",
|
|
27
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
28
|
+
now: 100,
|
|
29
|
+
});
|
|
30
|
+
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
|
31
|
+
|
|
32
|
+
await saveEditHistoryState(storage, "project-1", state);
|
|
33
|
+
|
|
34
|
+
expect(await loadEditHistoryState(storage, "project-1")).toEqual(state);
|
|
35
|
+
expect(await loadEditHistoryState(storage, "project-2")).toEqual(createEmptyEditHistory());
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createEmptyEditHistory, type EditHistoryState } from "./editHistory";
|
|
2
|
+
|
|
3
|
+
export interface EditHistoryStorageAdapter {
|
|
4
|
+
get(projectId: string): Promise<EditHistoryState | null>;
|
|
5
|
+
set(projectId: string, state: EditHistoryState): Promise<void>;
|
|
6
|
+
delete(projectId: string): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DB_NAME = "hyperframes-studio-edit-history";
|
|
10
|
+
const DB_VERSION = 1;
|
|
11
|
+
const STORE_NAME = "project-history";
|
|
12
|
+
|
|
13
|
+
export function createMemoryEditHistoryStorage(): EditHistoryStorageAdapter {
|
|
14
|
+
const states = new Map<string, EditHistoryState>();
|
|
15
|
+
return {
|
|
16
|
+
async get(projectId) {
|
|
17
|
+
return states.get(projectId) ?? null;
|
|
18
|
+
},
|
|
19
|
+
async set(projectId, state) {
|
|
20
|
+
states.set(projectId, structuredClone(state));
|
|
21
|
+
},
|
|
22
|
+
async delete(projectId) {
|
|
23
|
+
states.delete(projectId);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function openEditHistoryDb(): Promise<IDBDatabase> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
if (!globalThis.indexedDB) {
|
|
31
|
+
reject(new Error("IndexedDB is not available"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
|
|
36
|
+
request.onupgradeneeded = () => {
|
|
37
|
+
const db = request.result;
|
|
38
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
39
|
+
db.createObjectStore(STORE_NAME);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open edit history db"));
|
|
43
|
+
request.onsuccess = () => resolve(request.result);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function withStore<T>(
|
|
48
|
+
mode: IDBTransactionMode,
|
|
49
|
+
callback: (store: IDBObjectStore) => IDBRequest<T>,
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
return openEditHistoryDb().then(
|
|
52
|
+
(db) =>
|
|
53
|
+
new Promise<T>((resolve, reject) => {
|
|
54
|
+
const tx = db.transaction(STORE_NAME, mode);
|
|
55
|
+
const request = callback(tx.objectStore(STORE_NAME));
|
|
56
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
|
57
|
+
request.onsuccess = () => resolve(request.result);
|
|
58
|
+
tx.oncomplete = () => db.close();
|
|
59
|
+
tx.onerror = () => {
|
|
60
|
+
db.close();
|
|
61
|
+
reject(tx.error ?? new Error("IndexedDB transaction failed"));
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createIndexedDbEditHistoryStorage(): EditHistoryStorageAdapter {
|
|
68
|
+
return {
|
|
69
|
+
async get(projectId) {
|
|
70
|
+
return (
|
|
71
|
+
(await withStore<EditHistoryState | undefined>("readonly", (store) =>
|
|
72
|
+
store.get(projectId),
|
|
73
|
+
)) ?? null
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
async set(projectId, state) {
|
|
77
|
+
await withStore<IDBValidKey>("readwrite", (store) => store.put(state, projectId));
|
|
78
|
+
},
|
|
79
|
+
async delete(projectId) {
|
|
80
|
+
await withStore<undefined>("readwrite", (store) => store.delete(projectId));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function loadEditHistoryState(
|
|
86
|
+
storage: EditHistoryStorageAdapter,
|
|
87
|
+
projectId: string,
|
|
88
|
+
): Promise<EditHistoryState> {
|
|
89
|
+
const state = await storage.get(projectId);
|
|
90
|
+
return state ?? createEmptyEditHistory();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function saveEditHistoryState(
|
|
94
|
+
storage: EditHistoryStorageAdapter,
|
|
95
|
+
projectId: string,
|
|
96
|
+
state: EditHistoryState,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
await storage.set(projectId, state);
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
|
|
3
|
+
|
|
4
|
+
describe("frame capture utilities", () => {
|
|
5
|
+
it("builds a PNG capture URL for the master composition", () => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
|
|
8
|
+
|
|
9
|
+
expect(
|
|
10
|
+
buildFrameCaptureUrl({
|
|
11
|
+
projectId: "demo project",
|
|
12
|
+
compositionPath: null,
|
|
13
|
+
currentTime: 1.23456,
|
|
14
|
+
origin: "http://localhost:5194",
|
|
15
|
+
}),
|
|
16
|
+
).toBe(
|
|
17
|
+
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("builds a safe filename from a nested composition path", () => {
|
|
24
|
+
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface FrameCaptureRequest {
|
|
2
|
+
projectId: string;
|
|
3
|
+
compositionPath: string | null;
|
|
4
|
+
currentTime: number;
|
|
5
|
+
origin?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeCompositionPath(compositionPath: string | null): string {
|
|
9
|
+
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildFrameCaptureUrl({
|
|
13
|
+
projectId,
|
|
14
|
+
compositionPath,
|
|
15
|
+
currentTime,
|
|
16
|
+
origin = window.location.origin,
|
|
17
|
+
}: FrameCaptureRequest): string {
|
|
18
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
19
|
+
const url = new URL(
|
|
20
|
+
`/api/projects/${encodeURIComponent(projectId)}/thumbnail/${encodeURIComponent(compPath)}`,
|
|
21
|
+
origin,
|
|
22
|
+
);
|
|
23
|
+
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
|
|
24
|
+
url.searchParams.set("format", "png");
|
|
25
|
+
url.searchParams.set("v", String(Date.now()));
|
|
26
|
+
return url.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
|
|
30
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
31
|
+
const base =
|
|
32
|
+
compPath
|
|
33
|
+
.split("/")
|
|
34
|
+
.pop()
|
|
35
|
+
?.replace(/\.html$/i, "") || "frame";
|
|
36
|
+
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
|
|
37
|
+
return `${base}-${frameTime}s.png`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
3
|
+
|
|
4
|
+
describe("saveProjectFilesWithHistory", () => {
|
|
5
|
+
it("reads before content, writes after content, and records a history entry", async () => {
|
|
6
|
+
const reads: Record<string, string> = { "index.html": "before" };
|
|
7
|
+
const writes: Record<string, string> = {};
|
|
8
|
+
const recordEdit = vi.fn();
|
|
9
|
+
|
|
10
|
+
await saveProjectFilesWithHistory({
|
|
11
|
+
projectId: "project-1",
|
|
12
|
+
label: "Move layer",
|
|
13
|
+
kind: "manual",
|
|
14
|
+
files: { "index.html": "after" },
|
|
15
|
+
readFile: async (path) => reads[path],
|
|
16
|
+
writeFile: async (path, content) => {
|
|
17
|
+
writes[path] = content;
|
|
18
|
+
},
|
|
19
|
+
recordEdit,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(writes).toEqual({ "index.html": "after" });
|
|
23
|
+
expect(recordEdit).toHaveBeenCalledWith({
|
|
24
|
+
label: "Move layer",
|
|
25
|
+
kind: "manual",
|
|
26
|
+
coalesceKey: undefined,
|
|
27
|
+
files: { "index.html": { before: "before", after: "after" } },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("skips writes and history for unchanged content", async () => {
|
|
32
|
+
const writeFile = vi.fn();
|
|
33
|
+
const recordEdit = vi.fn();
|
|
34
|
+
|
|
35
|
+
const changedPaths = await saveProjectFilesWithHistory({
|
|
36
|
+
projectId: "project-1",
|
|
37
|
+
label: "Edit layer",
|
|
38
|
+
kind: "manual",
|
|
39
|
+
files: { "index.html": "same" },
|
|
40
|
+
readFile: async () => "same",
|
|
41
|
+
writeFile,
|
|
42
|
+
recordEdit,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(changedPaths).toEqual([]);
|
|
46
|
+
expect(writeFile).not.toHaveBeenCalled();
|
|
47
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rolls back files already written when a later file write fails", async () => {
|
|
51
|
+
const reads: Record<string, string> = {
|
|
52
|
+
"index.html": "index-before",
|
|
53
|
+
"scene.html": "scene-before",
|
|
54
|
+
};
|
|
55
|
+
const writes: Array<[string, string]> = [];
|
|
56
|
+
const recordEdit = vi.fn();
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
saveProjectFilesWithHistory({
|
|
60
|
+
projectId: "project-1",
|
|
61
|
+
label: "Move layer",
|
|
62
|
+
kind: "manual",
|
|
63
|
+
files: {
|
|
64
|
+
"index.html": "index-after",
|
|
65
|
+
"scene.html": "scene-after",
|
|
66
|
+
},
|
|
67
|
+
readFile: async (path) => reads[path],
|
|
68
|
+
writeFile: async (path, content) => {
|
|
69
|
+
writes.push([path, content]);
|
|
70
|
+
if (path === "scene.html") {
|
|
71
|
+
throw new Error("disk full");
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
recordEdit,
|
|
75
|
+
}),
|
|
76
|
+
).rejects.toThrow("disk full");
|
|
77
|
+
|
|
78
|
+
expect(writes).toEqual([
|
|
79
|
+
["index.html", "index-after"],
|
|
80
|
+
["scene.html", "scene-after"],
|
|
81
|
+
["index.html", "index-before"],
|
|
82
|
+
]);
|
|
83
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rolls back written files when the injected history recorder throws", async () => {
|
|
87
|
+
const reads: Record<string, string> = {
|
|
88
|
+
"index.html": "index-before",
|
|
89
|
+
"scene.html": "scene-before",
|
|
90
|
+
};
|
|
91
|
+
const writes: Array<[string, string]> = [];
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
saveProjectFilesWithHistory({
|
|
95
|
+
projectId: "project-1",
|
|
96
|
+
label: "Move layer",
|
|
97
|
+
kind: "manual",
|
|
98
|
+
files: {
|
|
99
|
+
"index.html": "index-after",
|
|
100
|
+
"scene.html": "scene-after",
|
|
101
|
+
},
|
|
102
|
+
readFile: async (path) => reads[path],
|
|
103
|
+
writeFile: async (path, content) => {
|
|
104
|
+
writes.push([path, content]);
|
|
105
|
+
},
|
|
106
|
+
recordEdit: async () => {
|
|
107
|
+
throw new Error("history unavailable");
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
).rejects.toThrow("history unavailable");
|
|
111
|
+
|
|
112
|
+
expect(writes).toEqual([
|
|
113
|
+
["index.html", "index-after"],
|
|
114
|
+
["scene.html", "scene-after"],
|
|
115
|
+
["scene.html", "scene-before"],
|
|
116
|
+
["index.html", "index-before"],
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reports rollback failure with the original write failure", async () => {
|
|
121
|
+
const reads: Record<string, string> = {
|
|
122
|
+
"index.html": "index-before",
|
|
123
|
+
"scene.html": "scene-before",
|
|
124
|
+
};
|
|
125
|
+
const writes: Array<[string, string]> = [];
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
saveProjectFilesWithHistory({
|
|
129
|
+
projectId: "project-1",
|
|
130
|
+
label: "Move layer",
|
|
131
|
+
kind: "manual",
|
|
132
|
+
files: {
|
|
133
|
+
"index.html": "index-after",
|
|
134
|
+
"scene.html": "scene-after",
|
|
135
|
+
},
|
|
136
|
+
readFile: async (path) => reads[path],
|
|
137
|
+
writeFile: async (path, content) => {
|
|
138
|
+
writes.push([path, content]);
|
|
139
|
+
if (path === "scene.html" && content === "scene-after") {
|
|
140
|
+
throw new Error("write denied");
|
|
141
|
+
}
|
|
142
|
+
if (path === "index.html" && content === "index-before") {
|
|
143
|
+
throw new Error("rollback denied");
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
recordEdit: vi.fn(),
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toThrow("rollback did not complete");
|
|
149
|
+
|
|
150
|
+
expect(writes).toEqual([
|
|
151
|
+
["index.html", "index-after"],
|
|
152
|
+
["scene.html", "scene-after"],
|
|
153
|
+
["index.html", "index-before"],
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { EditHistoryKind } from "./editHistory";
|
|
2
|
+
|
|
3
|
+
interface SaveProjectFilesWithHistoryInput {
|
|
4
|
+
projectId: string;
|
|
5
|
+
label: string;
|
|
6
|
+
kind: EditHistoryKind;
|
|
7
|
+
coalesceKey?: string;
|
|
8
|
+
files: Record<string, string>;
|
|
9
|
+
readFile: (path: string) => Promise<string>;
|
|
10
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
11
|
+
recordEdit: (entry: {
|
|
12
|
+
label: string;
|
|
13
|
+
kind: EditHistoryKind;
|
|
14
|
+
coalesceKey?: string;
|
|
15
|
+
files: Record<string, { before: string; after: string }>;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function saveProjectFilesWithHistory({
|
|
20
|
+
label,
|
|
21
|
+
kind,
|
|
22
|
+
coalesceKey,
|
|
23
|
+
files,
|
|
24
|
+
readFile,
|
|
25
|
+
writeFile,
|
|
26
|
+
recordEdit,
|
|
27
|
+
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
|
|
28
|
+
const snapshots: Record<string, { before: string; after: string }> = {};
|
|
29
|
+
for (const [path, after] of Object.entries(files)) {
|
|
30
|
+
const before = await readFile(path);
|
|
31
|
+
if (before !== after) {
|
|
32
|
+
snapshots[path] = { before, after };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const changedPaths = Object.keys(snapshots);
|
|
37
|
+
if (changedPaths.length === 0) return [];
|
|
38
|
+
|
|
39
|
+
const writtenPaths: string[] = [];
|
|
40
|
+
try {
|
|
41
|
+
for (const path of changedPaths) {
|
|
42
|
+
await writeFile(path, snapshots[path].after);
|
|
43
|
+
writtenPaths.push(path);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await recordEdit({ label, kind, coalesceKey, files: snapshots });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
try {
|
|
49
|
+
for (const path of writtenPaths.reverse()) {
|
|
50
|
+
await writeFile(path, snapshots[path].before);
|
|
51
|
+
}
|
|
52
|
+
} catch (rollbackError) {
|
|
53
|
+
throw new AggregateError(
|
|
54
|
+
[error, rollbackError],
|
|
55
|
+
"Failed to save project files and rollback did not complete",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return changedPaths;
|
|
61
|
+
}
|