aethel 0.4.0 → 1.2.0
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/CHANGELOG.md +23 -0
- package/README.md +95 -45
- package/docs/ARCHITECTURE.md +24 -0
- package/package.json +16 -3
- package/scripts/demo.js +416 -0
- package/scripts/render-demo-gif.py +90 -0
- package/scripts/render-demo-screenshot.js +65 -0
- package/src/cli.js +47 -13
- package/src/core/compress.js +285 -0
- package/src/core/config.js +119 -0
- package/src/core/diff.js +146 -7
- package/src/core/pack-manifest.js +163 -0
- package/src/core/pack.js +355 -0
- package/src/core/snapshot.js +55 -9
package/scripts/demo.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { initWorkspace } from "../src/core/config.js";
|
|
10
|
+
import { ensureFolder, resetFolderLookupCache } from "../src/core/drive-api.js";
|
|
11
|
+
import { Repository } from "../src/core/repository.js";
|
|
12
|
+
|
|
13
|
+
const FOLDER_MIME = "application/vnd.google-apps.folder";
|
|
14
|
+
|
|
15
|
+
function md5(buffer) {
|
|
16
|
+
return createHash("md5").update(buffer).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clone(value) {
|
|
20
|
+
return JSON.parse(JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function folder(id, name, parentId, createdTime) {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
mimeType: FOLDER_MIME,
|
|
28
|
+
parents: parentId ? [parentId] : [],
|
|
29
|
+
createdTime,
|
|
30
|
+
modifiedTime: createdTime,
|
|
31
|
+
md5Checksum: null,
|
|
32
|
+
size: null,
|
|
33
|
+
trashed: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function file(id, name, parentId, createdTime, content) {
|
|
38
|
+
const body = Buffer.from(content);
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
name,
|
|
42
|
+
mimeType: "application/octet-stream",
|
|
43
|
+
parents: [parentId],
|
|
44
|
+
createdTime,
|
|
45
|
+
modifiedTime: createdTime,
|
|
46
|
+
md5Checksum: md5(body),
|
|
47
|
+
size: body.length,
|
|
48
|
+
trashed: false,
|
|
49
|
+
body,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createFakeDrive(initialItems = []) {
|
|
54
|
+
const items = new Map();
|
|
55
|
+
const contentById = new Map();
|
|
56
|
+
let sequence = 0;
|
|
57
|
+
let idCounter = 1000;
|
|
58
|
+
|
|
59
|
+
for (const item of initialItems) {
|
|
60
|
+
const next = clone(item);
|
|
61
|
+
delete next.body;
|
|
62
|
+
items.set(next.id, next);
|
|
63
|
+
if (item.body) {
|
|
64
|
+
contentById.set(next.id, Buffer.from(item.body));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodeQueryValue(value) {
|
|
69
|
+
return value.replace(/\\\\/g, "\\").replace(/\\'/g, "'");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function matches(item, query) {
|
|
73
|
+
if (!query) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return query.split(" and ").every((part) => {
|
|
78
|
+
if (part === "trashed = false") {
|
|
79
|
+
return !item.trashed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nameMatch = part.match(/^name = '(.+)'$/);
|
|
83
|
+
if (nameMatch) {
|
|
84
|
+
return item.name === decodeQueryValue(nameMatch[1]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const mimeMatch = part.match(/^mimeType = '(.+)'$/);
|
|
88
|
+
if (mimeMatch) {
|
|
89
|
+
return item.mimeType === decodeQueryValue(mimeMatch[1]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parentMatch = part.match(/^'(.+)' in parents$/);
|
|
93
|
+
if (parentMatch) {
|
|
94
|
+
return (item.parents || []).includes(parentMatch[1]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function drain(stream) {
|
|
102
|
+
if (!stream) {
|
|
103
|
+
return Buffer.alloc(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const chunks = [];
|
|
107
|
+
for await (const chunk of stream) {
|
|
108
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
109
|
+
}
|
|
110
|
+
return Buffer.concat(chunks);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function touch(item) {
|
|
114
|
+
item.modifiedTime = new Date(1710000000000 + sequence++).toISOString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
files: {
|
|
119
|
+
async list({ q, pageSize = 1000, pageToken, orderBy }) {
|
|
120
|
+
const matchesQuery = [...items.values()].filter((item) => matches(item, q));
|
|
121
|
+
matchesQuery.sort((left, right) => {
|
|
122
|
+
if (orderBy === "createdTime desc") {
|
|
123
|
+
return Date.parse(right.createdTime) - Date.parse(left.createdTime);
|
|
124
|
+
}
|
|
125
|
+
return String(left.id).localeCompare(String(right.id));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const start = Number(pageToken || 0);
|
|
129
|
+
const slice = matchesQuery.slice(start, start + pageSize).map(clone);
|
|
130
|
+
const nextPageToken =
|
|
131
|
+
start + pageSize < matchesQuery.length ? String(start + pageSize) : undefined;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
data: {
|
|
135
|
+
files: slice,
|
|
136
|
+
nextPageToken,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
async create({ requestBody, media }) {
|
|
141
|
+
const body = await drain(media?.body);
|
|
142
|
+
const id = `id-${++idCounter}`;
|
|
143
|
+
const createdTime = new Date(1710000000000 + sequence++).toISOString();
|
|
144
|
+
const item = {
|
|
145
|
+
id,
|
|
146
|
+
name: requestBody.name,
|
|
147
|
+
mimeType: requestBody.mimeType || "application/octet-stream",
|
|
148
|
+
parents: requestBody.parents || [],
|
|
149
|
+
createdTime,
|
|
150
|
+
modifiedTime: createdTime,
|
|
151
|
+
md5Checksum: requestBody.mimeType === FOLDER_MIME ? null : md5(body),
|
|
152
|
+
size: requestBody.mimeType === FOLDER_MIME ? null : body.length,
|
|
153
|
+
trashed: false,
|
|
154
|
+
};
|
|
155
|
+
items.set(id, item);
|
|
156
|
+
if (item.mimeType !== FOLDER_MIME) {
|
|
157
|
+
contentById.set(id, body);
|
|
158
|
+
}
|
|
159
|
+
return { data: clone(item) };
|
|
160
|
+
},
|
|
161
|
+
async update({ fileId, requestBody = {}, addParents, removeParents, media }) {
|
|
162
|
+
const body = await drain(media?.body);
|
|
163
|
+
const item = items.get(fileId);
|
|
164
|
+
|
|
165
|
+
if (!item) {
|
|
166
|
+
throw new Error(`Missing file: ${fileId}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (requestBody.name) {
|
|
170
|
+
item.name = requestBody.name;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (Object.hasOwn(requestBody, "trashed")) {
|
|
174
|
+
item.trashed = Boolean(requestBody.trashed);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (addParents || removeParents) {
|
|
178
|
+
const nextParents = new Set(item.parents || []);
|
|
179
|
+
for (const parentId of String(removeParents || "").split(",").filter(Boolean)) {
|
|
180
|
+
nextParents.delete(parentId);
|
|
181
|
+
}
|
|
182
|
+
if (addParents) {
|
|
183
|
+
nextParents.add(addParents);
|
|
184
|
+
}
|
|
185
|
+
item.parents = [...nextParents];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (body.length && item.mimeType !== FOLDER_MIME) {
|
|
189
|
+
item.md5Checksum = md5(body);
|
|
190
|
+
item.size = body.length;
|
|
191
|
+
contentById.set(fileId, body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
touch(item);
|
|
195
|
+
return { data: clone(item) };
|
|
196
|
+
},
|
|
197
|
+
async delete({ fileId }) {
|
|
198
|
+
items.delete(fileId);
|
|
199
|
+
contentById.delete(fileId);
|
|
200
|
+
return { data: {} };
|
|
201
|
+
},
|
|
202
|
+
async get({ fileId, alt }) {
|
|
203
|
+
if (fileId === "root") {
|
|
204
|
+
return {
|
|
205
|
+
data: {
|
|
206
|
+
id: "root",
|
|
207
|
+
name: "My Drive",
|
|
208
|
+
mimeType: FOLDER_MIME,
|
|
209
|
+
parents: [],
|
|
210
|
+
createdTime: "2026-01-01T00:00:00.000Z",
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const item = items.get(fileId);
|
|
216
|
+
if (!item) {
|
|
217
|
+
throw new Error(`Missing file: ${fileId}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (alt === "media") {
|
|
221
|
+
return {
|
|
222
|
+
data: Readable.from([contentById.get(fileId) || Buffer.alloc(0)]),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { data: clone(item) };
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function writeLocal(root, relativePath, content) {
|
|
233
|
+
const absolutePath = path.join(root, relativePath);
|
|
234
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
235
|
+
fs.writeFileSync(absolutePath, content);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatStatus(diff, staged) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
|
|
241
|
+
if (diff.isClean && staged.length === 0) {
|
|
242
|
+
lines.push("Everything up to date.");
|
|
243
|
+
return lines;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (staged.length) {
|
|
247
|
+
lines.push(`Staged changes (${staged.length}):`);
|
|
248
|
+
for (const entry of staged) {
|
|
249
|
+
lines.push(` ${entry.action.padStart(15, " ")} ${entry.path}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const stagedPaths = new Set(staged.map((e) => e.path));
|
|
254
|
+
const unstagedRemote = diff.remoteChanges.filter((c) => !stagedPaths.has(c.path));
|
|
255
|
+
const unstagedLocal = diff.localChanges.filter((c) => !stagedPaths.has(c.path));
|
|
256
|
+
const unstagedConflicts = diff.conflicts.filter((c) => !stagedPaths.has(c.path));
|
|
257
|
+
|
|
258
|
+
if (unstagedRemote.length) {
|
|
259
|
+
lines.push(`Remote changes (${unstagedRemote.length}):`);
|
|
260
|
+
for (const change of unstagedRemote) {
|
|
261
|
+
lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (unstagedLocal.length) {
|
|
266
|
+
lines.push(`Local changes (${unstagedLocal.length}):`);
|
|
267
|
+
for (const change of unstagedLocal) {
|
|
268
|
+
lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (unstagedConflicts.length) {
|
|
273
|
+
lines.push(`Conflicts (${unstagedConflicts.length}):`);
|
|
274
|
+
for (const change of unstagedConflicts) {
|
|
275
|
+
lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return lines;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function formatDiff(diff) {
|
|
283
|
+
if (diff.isClean) {
|
|
284
|
+
return ["No changes detected."];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lines = [];
|
|
288
|
+
const sections = [
|
|
289
|
+
["Remote changes", diff.remoteChanges],
|
|
290
|
+
["Local changes", diff.localChanges],
|
|
291
|
+
["Conflicts", diff.conflicts],
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
for (const [title, changes] of sections) {
|
|
295
|
+
if (!changes.length) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
lines.push(`${title}:`);
|
|
300
|
+
for (const change of changes) {
|
|
301
|
+
lines.push(` ${change.shortStatus} ${change.path}`);
|
|
302
|
+
lines.push(` ${change.description}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function commit(repo, message, snapshotHint) {
|
|
310
|
+
const result = await repo.executeStaged();
|
|
311
|
+
await repo.saveSnapshot(message, snapshotHint);
|
|
312
|
+
return result.summary;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function renderCommand(lines, command, output) {
|
|
316
|
+
if (lines.length) {
|
|
317
|
+
lines.push("");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
lines.push(`$ ${command}`);
|
|
321
|
+
lines.push(...output);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function generateDemoTranscript({ redactWorkspace = false } = {}) {
|
|
325
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "aethel-demo-"));
|
|
326
|
+
const visibleWorkspace = redactWorkspace ? "/tmp/aethel-demo-XXXXXX" : workspace;
|
|
327
|
+
resetFolderLookupCache();
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
initWorkspace(workspace, "root", "My Drive");
|
|
331
|
+
|
|
332
|
+
const drive = createFakeDrive([
|
|
333
|
+
folder("fld-docs", "docs", "root", "2026-04-01T10:00:00.000Z"),
|
|
334
|
+
folder("fld-notes", "notes", "root", "2026-04-01T10:01:00.000Z"),
|
|
335
|
+
file("file-spec", "spec.txt", "fld-docs", "2026-04-01T10:02:00.000Z", "Spec v1\n"),
|
|
336
|
+
file("file-ideas", "ideas.txt", "fld-notes", "2026-04-01T10:03:00.000Z", "Idea v1\n"),
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
const repo = new Repository(workspace, { drive });
|
|
340
|
+
|
|
341
|
+
writeLocal(workspace, "docs/spec.txt", "Spec v1\n");
|
|
342
|
+
writeLocal(workspace, "notes/ideas.txt", "Idea v1\n");
|
|
343
|
+
await repo.saveSnapshot("initial sync");
|
|
344
|
+
|
|
345
|
+
await drive.files.update({
|
|
346
|
+
fileId: "file-spec",
|
|
347
|
+
media: { body: Readable.from(["Spec v2 from Drive\n"]) },
|
|
348
|
+
});
|
|
349
|
+
const designFolderId = await ensureFolder(drive, "design");
|
|
350
|
+
await drive.files.create({
|
|
351
|
+
requestBody: {
|
|
352
|
+
name: "roadmap.txt",
|
|
353
|
+
parents: [designFolderId],
|
|
354
|
+
},
|
|
355
|
+
media: { body: Readable.from(["Roadmap from Drive\n"]) },
|
|
356
|
+
});
|
|
357
|
+
writeLocal(workspace, "notes/ideas.txt", "Idea v2 from local\n");
|
|
358
|
+
writeLocal(workspace, "drafts/todo.txt", "Local draft\n");
|
|
359
|
+
repo.invalidateRemoteCache();
|
|
360
|
+
|
|
361
|
+
const lines = [
|
|
362
|
+
"Aethel demo",
|
|
363
|
+
`Workspace: ${visibleWorkspace}`,
|
|
364
|
+
"Backend: fake Google Drive",
|
|
365
|
+
"",
|
|
366
|
+
"Scenario:",
|
|
367
|
+
" Drive changed docs/spec.txt and added design/roadmap.txt",
|
|
368
|
+
" Local changed notes/ideas.txt and added drafts/todo.txt",
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
let state = await repo.loadState({ useCache: false });
|
|
372
|
+
|
|
373
|
+
renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
|
|
374
|
+
renderCommand(lines, "aethel diff --side all", formatDiff(state.diff));
|
|
375
|
+
|
|
376
|
+
const stagedCount = repo.stageChanges(
|
|
377
|
+
state.diff.changes.filter((change) => change.suggestedAction !== "conflict")
|
|
378
|
+
);
|
|
379
|
+
renderCommand(lines, "aethel add --all", [`Staged ${stagedCount} change(s).`]);
|
|
380
|
+
renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
|
|
381
|
+
|
|
382
|
+
const summary = await commit(repo, "demo sync");
|
|
383
|
+
renderCommand(lines, 'aethel commit -m "demo sync"', [`Commit complete: ${summary}`]);
|
|
384
|
+
|
|
385
|
+
state = await repo.loadState({ useCache: false });
|
|
386
|
+
renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
|
|
387
|
+
|
|
388
|
+
lines.push("");
|
|
389
|
+
lines.push(`Inspect the demo workspace at: ${visibleWorkspace}`);
|
|
390
|
+
|
|
391
|
+
return { workspace, lines };
|
|
392
|
+
} finally {
|
|
393
|
+
resetFolderLookupCache();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function runDemo({ cleanup = false, redactWorkspace = false } = {}) {
|
|
398
|
+
const { workspace, lines } = await generateDemoTranscript({ redactWorkspace });
|
|
399
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
400
|
+
|
|
401
|
+
if (cleanup) {
|
|
402
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const entryPath = fileURLToPath(import.meta.url);
|
|
407
|
+
|
|
408
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === entryPath) {
|
|
409
|
+
const cleanup = process.argv.includes("--cleanup");
|
|
410
|
+
const redactWorkspace = process.argv.includes("--redact-workspace");
|
|
411
|
+
|
|
412
|
+
runDemo({ cleanup, redactWorkspace }).catch((error) => {
|
|
413
|
+
console.error(error?.stack || String(error));
|
|
414
|
+
process.exitCode = 1;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from subprocess import run
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
8
|
+
OUTPUT = ROOT / "docs" / "demo.gif"
|
|
9
|
+
DISPLAY_WIDTH = 1320
|
|
10
|
+
DISPLAY_MIN_HEIGHT = 760
|
|
11
|
+
LINE_HEIGHT = 30
|
|
12
|
+
SCALE = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_lines():
|
|
16
|
+
result = run(
|
|
17
|
+
["node", "scripts/demo.js", "--cleanup", "--redact-workspace"],
|
|
18
|
+
cwd=ROOT,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
check=True,
|
|
22
|
+
)
|
|
23
|
+
return result.stdout.rstrip("\n").splitlines()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_font(size):
|
|
27
|
+
for candidate in [
|
|
28
|
+
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
|
|
29
|
+
"/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
|
|
30
|
+
]:
|
|
31
|
+
path = Path(candidate)
|
|
32
|
+
if path.exists():
|
|
33
|
+
return ImageFont.truetype(str(path), size)
|
|
34
|
+
return ImageFont.load_default()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_title_font(size):
|
|
38
|
+
for candidate in [
|
|
39
|
+
"/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
|
|
40
|
+
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
|
|
41
|
+
]:
|
|
42
|
+
path = Path(candidate)
|
|
43
|
+
if path.exists():
|
|
44
|
+
return ImageFont.truetype(str(path), size)
|
|
45
|
+
return ImageFont.load_default()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_frame(lines, font, title_font, visible):
|
|
49
|
+
shown = lines[:visible]
|
|
50
|
+
width = DISPLAY_WIDTH * SCALE
|
|
51
|
+
height = max(DISPLAY_MIN_HEIGHT, 180 + len(lines) * LINE_HEIGHT) * SCALE
|
|
52
|
+
image = Image.new("RGB", (width, height), "#0b1324")
|
|
53
|
+
draw = ImageDraw.Draw(image)
|
|
54
|
+
draw.rounded_rectangle((40, 40, width - 40, height - 40), 44, fill="#0a0f1a", outline="#243044", width=4)
|
|
55
|
+
draw.rounded_rectangle((40, 40, width - 40, 108), 44, fill="#10192a")
|
|
56
|
+
draw.ellipse((84, 62, 108, 86), fill="#fb7185")
|
|
57
|
+
draw.ellipse((124, 62, 148, 86), fill="#f59e0b")
|
|
58
|
+
draw.ellipse((164, 62, 188, 86), fill="#22c55e")
|
|
59
|
+
draw.text((236, 52), "Aethel demo", font=title_font, fill="#dbe4f0")
|
|
60
|
+
y = 176
|
|
61
|
+
for line in shown:
|
|
62
|
+
draw.text((104, y), line, font=font, fill="#dbe4f0")
|
|
63
|
+
y += LINE_HEIGHT * SCALE
|
|
64
|
+
if visible < len(lines):
|
|
65
|
+
draw.rounded_rectangle((104, y + 8, 134, y + 48), 6, fill="#38bdf8")
|
|
66
|
+
return image.resize((DISPLAY_WIDTH, height // SCALE), Image.Resampling.LANCZOS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
lines = load_lines()
|
|
71
|
+
font = load_font(24 * SCALE)
|
|
72
|
+
title_font = load_title_font(22 * SCALE)
|
|
73
|
+
checkpoints = [3, 7, 13, 21, 27, len(lines)]
|
|
74
|
+
frames = [build_frame(lines, font, title_font, count) for count in checkpoints]
|
|
75
|
+
durations = [500, 600, 700, 800, 900, 1800]
|
|
76
|
+
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
frames[0].save(
|
|
78
|
+
OUTPUT,
|
|
79
|
+
save_all=True,
|
|
80
|
+
append_images=frames[1:],
|
|
81
|
+
duration=durations,
|
|
82
|
+
loop=0,
|
|
83
|
+
optimize=False,
|
|
84
|
+
disposal=2,
|
|
85
|
+
)
|
|
86
|
+
print(OUTPUT)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { generateDemoTranscript } from "./demo.js";
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const outputPath = path.join(root, "docs", "demo-screenshot.svg");
|
|
10
|
+
|
|
11
|
+
function escapeXml(value) {
|
|
12
|
+
return value
|
|
13
|
+
.replaceAll("&", "&")
|
|
14
|
+
.replaceAll("<", "<")
|
|
15
|
+
.replaceAll(">", ">")
|
|
16
|
+
.replaceAll('"', """);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { workspace, lines } = await generateDemoTranscript({ redactWorkspace: true });
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
23
|
+
const charWidth = 12.4;
|
|
24
|
+
const lineHeight = 32;
|
|
25
|
+
const width = Math.max(1120, Math.ceil(longest * charWidth) + 160);
|
|
26
|
+
const height = lines.length * lineHeight + 168;
|
|
27
|
+
const text = lines
|
|
28
|
+
.map(
|
|
29
|
+
(line, index) =>
|
|
30
|
+
`<text x="64" y="${100 + index * lineHeight}" xml:space="preserve">${escapeXml(line)}</text>`
|
|
31
|
+
)
|
|
32
|
+
.join("\n");
|
|
33
|
+
|
|
34
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Aethel demo screenshot">
|
|
35
|
+
<defs>
|
|
36
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
37
|
+
<stop offset="0%" stop-color="#0b1324"/>
|
|
38
|
+
<stop offset="55%" stop-color="#111827"/>
|
|
39
|
+
<stop offset="100%" stop-color="#1f2937"/>
|
|
40
|
+
</linearGradient>
|
|
41
|
+
<linearGradient id="glow" x1="0" y1="0" x2="1" y2="0">
|
|
42
|
+
<stop offset="0%" stop-color="#22c55e"/>
|
|
43
|
+
<stop offset="50%" stop-color="#38bdf8"/>
|
|
44
|
+
<stop offset="100%" stop-color="#f59e0b"/>
|
|
45
|
+
</linearGradient>
|
|
46
|
+
</defs>
|
|
47
|
+
<rect width="${width}" height="${height}" rx="28" fill="url(#bg)"/>
|
|
48
|
+
<rect x="24" y="24" width="${width - 48}" height="${height - 48}" rx="20" fill="#0a0f1a" stroke="#243044"/>
|
|
49
|
+
<rect x="24" y="24" width="${width - 48}" height="36" rx="20" fill="#10192a"/>
|
|
50
|
+
<circle cx="52" cy="42" r="7" fill="#fb7185"/>
|
|
51
|
+
<circle cx="74" cy="42" r="7" fill="#f59e0b"/>
|
|
52
|
+
<circle cx="96" cy="42" r="7" fill="#22c55e"/>
|
|
53
|
+
<rect x="132" y="35" width="${Math.min(320, width - 220)}" height="12" rx="6" fill="url(#glow)" opacity="0.8"/>
|
|
54
|
+
<g fill="#dbe4f0" font-family="'Times New Roman', Times, serif" font-size="22">
|
|
55
|
+
${text}
|
|
56
|
+
</g>
|
|
57
|
+
</svg>
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(outputPath, svg);
|
|
62
|
+
process.stdout.write(`${outputPath}\n`);
|
|
63
|
+
} finally {
|
|
64
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
65
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -289,7 +289,9 @@ async function handleStatus(options) {
|
|
|
289
289
|
const { diff } = await loadStateWithProgress(repo);
|
|
290
290
|
const staged = repo.getStagedEntries();
|
|
291
291
|
|
|
292
|
-
|
|
292
|
+
const hasPackChanges = diff.hasPackChanges || (options.verbose && diff.syncedPacks?.length > 0);
|
|
293
|
+
|
|
294
|
+
if (diff.isClean && staged.length === 0 && !hasPackChanges) {
|
|
293
295
|
console.log("Everything up to date.");
|
|
294
296
|
return;
|
|
295
297
|
}
|
|
@@ -301,23 +303,54 @@ async function handleStatus(options) {
|
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
305
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
const stagedPaths = new Set(staged.map((e) => e.path));
|
|
307
|
+
const unstagedRemote = diff.remoteChanges.filter((c) => !stagedPaths.has(c.path));
|
|
308
|
+
const unstagedLocal = diff.localChanges.filter((c) => !stagedPaths.has(c.path));
|
|
309
|
+
const unstagedConflicts = diff.conflicts.filter((c) => !stagedPaths.has(c.path));
|
|
310
|
+
|
|
311
|
+
if (unstagedRemote.length) {
|
|
312
|
+
console.log(`\nRemote changes (${unstagedRemote.length}):`);
|
|
313
|
+
for (const change of unstagedRemote) {
|
|
307
314
|
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
308
315
|
}
|
|
309
316
|
}
|
|
310
317
|
|
|
311
|
-
if (
|
|
312
|
-
console.log(`\nLocal changes (${
|
|
313
|
-
for (const change of
|
|
318
|
+
if (unstagedLocal.length) {
|
|
319
|
+
console.log(`\nLocal changes (${unstagedLocal.length}):`);
|
|
320
|
+
for (const change of unstagedLocal) {
|
|
314
321
|
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
315
322
|
}
|
|
316
323
|
}
|
|
317
324
|
|
|
318
|
-
if (
|
|
319
|
-
console.log(`\nConflicts (${
|
|
320
|
-
for (const change of
|
|
325
|
+
if (unstagedConflicts.length) {
|
|
326
|
+
console.log(`\nConflicts (${unstagedConflicts.length}):`);
|
|
327
|
+
for (const change of unstagedConflicts) {
|
|
328
|
+
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Display pack changes
|
|
333
|
+
const pendingPacks = diff.pendingPackChanges || [];
|
|
334
|
+
const packConflicts = diff.packConflicts || [];
|
|
335
|
+
const syncedPacks = diff.syncedPacks || [];
|
|
336
|
+
|
|
337
|
+
if (pendingPacks.length) {
|
|
338
|
+
console.log(`\nPack changes (${pendingPacks.length}):`);
|
|
339
|
+
for (const change of pendingPacks) {
|
|
340
|
+
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (packConflicts.length) {
|
|
345
|
+
console.log(`\nPack conflicts (${packConflicts.length}):`);
|
|
346
|
+
for (const change of packConflicts) {
|
|
347
|
+
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (options.verbose && syncedPacks.length) {
|
|
352
|
+
console.log(`\nSynced packs (${syncedPacks.length}):`);
|
|
353
|
+
for (const change of syncedPacks) {
|
|
321
354
|
console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
|
|
322
355
|
}
|
|
323
356
|
}
|
|
@@ -1076,9 +1109,10 @@ async function main() {
|
|
|
1076
1109
|
.option("--drive-folder-name <name>", "Display name for the Drive folder")
|
|
1077
1110
|
).action(handleInit);
|
|
1078
1111
|
|
|
1079
|
-
addAuthOptions(
|
|
1080
|
-
|
|
1081
|
-
|
|
1112
|
+
addAuthOptions(
|
|
1113
|
+
program.command("status").description("Show sync status")
|
|
1114
|
+
.option("-v, --verbose", "Show all pack states including synced")
|
|
1115
|
+
).action(handleStatus);
|
|
1082
1116
|
|
|
1083
1117
|
addAuthOptions(
|
|
1084
1118
|
program
|