@coderook/cli 0.29.5 → 0.30.1

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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.29.5",
5
+ "version": "0.30.1",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -35,6 +35,7 @@ const skill_command_js_1 = require("./skill_command.js");
35
35
  const licence_commands_js_1 = require("./licence_commands.js");
36
36
  const service_commands_js_1 = require("./service_commands.js");
37
37
  const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
38
+ const rules_js_1 = require("../../desktop-app/src/main/rules.js");
38
39
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
39
40
  const download_js_1 = require("../../desktop-app/src/main/download.js");
40
41
  const faults_js_1 = require("../../desktop-app/src/main/faults.js");
@@ -349,7 +350,22 @@ async function commandStatus(parsed) {
349
350
  console.log(dim("Offline — comparing with the last Version cached for this folder."));
350
351
  }
351
352
  const rules = await (0, worktree_js_1.readRules)(folder);
352
- const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
353
+ /*
354
+ Scanned the way `cbx diff` scans, and reported the way `cbx submit` acts.
355
+
356
+ Status used to ask only for additions and updates, which is exactly what a
357
+ plain save sends — so it was not wrong. But a deleted file then appeared
358
+ nowhere: `cbx diff` showed it as removed, `cbx status` said nothing, and
359
+ the two commands disagreed about the same folder. Somebody who had deleted
360
+ a file read that as CodeRook not having noticed it.
361
+
362
+ So deletions are found and named here, kept apart from what a save will
363
+ send, with the flag that would send them spelled out. The safe default
364
+ does not change: a save adds and updates.
365
+ */
366
+ const found = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, "synchronize");
367
+ const gone = found.filter((file) => file.deleted);
368
+ const files = found.filter((file) => !file.deleted);
353
369
  console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
354
370
  console.log(link
355
371
  ? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
@@ -370,18 +386,85 @@ async function commandStatus(parsed) {
370
386
  for (const [path] of stale.slice(0, 10))
371
387
  console.log(dim(` behind ${path}`));
372
388
  }
389
+ /** The deletions, and what a plain save will do about them. */
390
+ /*
391
+ What this folder is, and what its own rules never heard about.
392
+
393
+ A real .gitignore always wins — that is not negotiable — but it also meant
394
+ that a Unity project whose file was written before the engine rules
395
+ existed went on sending its entire import cache, and nothing anywhere
396
+ said so. One real upload of forty-seven thousand files was forty-five
397
+ thousand of rebuildable cache, and it broke the project on the machine it
398
+ was downloaded to.
399
+ */
400
+ const engine = async () => {
401
+ const { kind, missing } = await (0, worktree_js_1.missingEngineRules)(folder);
402
+ if (!kind.length || !missing.length)
403
+ return;
404
+ /*
405
+ Only the rules that would actually leave something out.
406
+
407
+ Naming every line the template has and this folder does not produced
408
+ sixty-odd of them, most matching nothing at all — a Visual Studio
409
+ template mentions `[Dd]ebug/x64/` whether or not you have one. What a
410
+ person needs is the two that are costing them forty thousand files, so
411
+ each candidate is measured against what this folder is currently
412
+ sending and the ones worth nothing are not mentioned.
413
+ */
414
+ const sending = found.map((one) => one.path);
415
+ const worth = missing
416
+ .map((rule) => {
417
+ const layers = [{ base: "", rules: (0, rules_js_1.parseRules)(rule) }];
418
+ return {
419
+ rule,
420
+ files: sending.filter((one) => (0, rules_js_1.excludes)(one, false, layers)).length,
421
+ };
422
+ })
423
+ .filter((one) => one.files > 0)
424
+ .sort((left, right) => right.files - left.files);
425
+ if (!worth.length)
426
+ return;
427
+ const total = worth.reduce((sum, one) => sum + one.files, 0);
428
+ console.log(dim(`\nThis looks like a ${kind.join(" + ")} project, and its own rules are ` +
429
+ `missing ${worth.length} that would leave out ` +
430
+ `${total.toLocaleString()} file${total === 1 ? "" : "s"} you are sending — ` +
431
+ `${accent("cbx ignore --template " + kind[0])} adds them.`));
432
+ for (const one of worth.slice(0, 6)) {
433
+ console.log(dim(` ${String(one.files).padStart(7)} ${one.rule}`));
434
+ }
435
+ };
436
+ const deletions = () => {
437
+ if (!gone.length)
438
+ return;
439
+ const many = gone.length === 1 ? "" : "s";
440
+ console.log(dim(`\n${gone.length} file${many} deleted here ${gone.length === 1 ? "is" : "are"} ` +
441
+ `still in the saved version. A save adds and updates, so ` +
442
+ `${gone.length === 1 ? "it stays" : "they stay"} until you pass ` +
443
+ `${accent("--sync")}.`));
444
+ for (const file of gone.slice(0, 10)) {
445
+ console.log(` ${red("deleted")} ${file.path}`);
446
+ }
447
+ if (gone.length > 10)
448
+ console.log(dim(` …and ${gone.length - 10} more`));
449
+ };
373
450
  if (!files.length) {
374
451
  console.log(stale.length
375
452
  ? "Nothing to submit; your own work is all saved."
376
- : "Nothing to submit; this folder matches the saved version.");
453
+ : gone.length
454
+ ? "Nothing here to add or update."
455
+ : "Nothing to submit; this folder matches the saved version.");
456
+ deletions();
457
+ await engine();
377
458
  return 0;
378
459
  }
379
460
  console.log(`\n${files.length} file${files.length === 1 ? "" : "s"} to submit:`);
380
461
  for (const file of files.slice(0, 50)) {
381
- console.log(` ${file.deleted ? red("deleted") : accent("changed")} ${file.path}`);
462
+ console.log(` ${accent("changed")} ${file.path}`);
382
463
  }
383
464
  if (files.length > 50)
384
465
  console.log(dim(` …and ${files.length - 50} more`));
466
+ deletions();
467
+ await engine();
385
468
  return 0;
386
469
  }
387
470
  /**
@@ -1311,11 +1394,14 @@ async function commandRules(parsed) {
1311
1394
  return suggestRules(folder, hasFlag(parsed, "apply"));
1312
1395
  }
1313
1396
  if (hasFlag(parsed, "init")) {
1397
+ /*
1398
+ Whatever `readRules` decided, which is now the template that matches the
1399
+ folder rather than one generic list. Writing STARTER_IGNORE here would
1400
+ have handed a Unity project the JavaScript rules, which is how an upload
1401
+ came back missing things inside Assets.
1402
+ */
1314
1403
  const existing = await (0, worktree_js_1.readRules)(folder);
1315
- await (0, worktree_js_1.writeRules)(folder, {
1316
- shared: existing.shared.trim() ? existing.shared : worktree_js_1.STARTER_IGNORE,
1317
- local: existing.local,
1318
- });
1404
+ await (0, worktree_js_1.writeRules)(folder, existing);
1319
1405
  console.log(`Wrote ${node_path_1.default.join(folder, ".gitignore")}`);
1320
1406
  return 0;
1321
1407
  }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.STARTER_TOPUP_HEADING = exports.SUGGESTED_HEADING = void 0;
7
+ exports.templatesFor = templatesFor;
7
8
  exports.suggestExclusions = suggestExclusions;
8
9
  exports.suggestBulkDirectories = suggestBulkDirectories;
9
10
  exports.rulesFromSuggestions = rulesFromSuggestions;
@@ -59,24 +60,24 @@ const RULES = [
59
60
  project where the question "what should I leave out?" has the largest
60
61
  possible answer was the one the detector had nothing to say about.
61
62
  */
62
- { directory: "Library", reason: "Unity's import cache — rebuilt when the project next opens", recommended: true },
63
- { directory: "Temp", reason: "Unity's scratch folder for the running editor", recommended: true },
64
- { directory: "MemoryCaptures", reason: "Unity memory snapshots — large, and not the project", recommended: true },
65
- { directory: "Recordings", reason: "Unity recorder output", recommended: true },
66
- { directory: "UserSettings", reason: "Your own editor layout — not part of the project", recommended: true },
67
- { directory: "DerivedDataCache", reason: "Unreal's derived data cache — rebuilt on demand", recommended: true },
68
- { directory: "Intermediate", reason: "Unreal build intermediates — rebuilt when you build", recommended: true },
69
- { directory: "Binaries", reason: "Unreal compiled output — rebuilt when you build", recommended: true },
63
+ { directory: "Library", atRoot: true, reason: "Unity's import cache — rebuilt when the project next opens", recommended: true },
64
+ { directory: "Temp", atRoot: true, reason: "Unity's scratch folder for the running editor", recommended: true },
65
+ { directory: "MemoryCaptures", atRoot: true, reason: "Unity memory snapshots — large, and not the project", recommended: true },
66
+ { directory: "Recordings", atRoot: true, reason: "Unity recorder output", recommended: true },
67
+ { directory: "UserSettings", atRoot: true, reason: "Your own editor layout — not part of the project", recommended: true },
68
+ { directory: "DerivedDataCache", atRoot: true, reason: "Unreal's derived data cache — rebuilt on demand", recommended: true },
69
+ { directory: "Intermediate", atRoot: true, reason: "Unreal build intermediates — rebuilt when you build", recommended: true },
70
+ { directory: "Binaries", atRoot: true, reason: "Unreal compiled output — rebuilt when you build", recommended: true },
70
71
  /*
71
72
  Named like the work, and it is not: Unreal keeps logs, crash reports and
72
73
  autosaves here. Offered rather than ticked, because a folder called
73
74
  "Saved" is the one nobody should have excluded on our say-so.
74
75
  */
75
- { directory: "Saved", reason: "Unreal logs and autosaves — check before excluding", recommended: false },
76
- { directory: ".godot", reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
77
- { directory: ".import", reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
78
- { directory: "Logs", reason: "Editor logs", recommended: true },
79
- { directory: "obj", reason: "Compiler intermediates — rebuilt when you build", recommended: true },
76
+ { directory: "Saved", atRoot: true, reason: "Unreal logs and autosaves — check before excluding", recommended: false },
77
+ { directory: ".godot", atRoot: true, reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
78
+ { directory: ".import", atRoot: true, reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
79
+ { directory: "Logs", atRoot: true, reason: "Editor logs", recommended: true },
80
+ { directory: "obj", atRoot: true, reason: "Compiler intermediates — rebuilt when you build", recommended: true },
80
81
  { directory: ".vs", reason: "Visual Studio's local cache", recommended: true },
81
82
  /*
82
83
  Offered, not ticked. JetBrains keeps run configurations in here that some
@@ -103,7 +104,7 @@ const RULES = [
103
104
  { directory: "dist", reason: "Usually build output — check before excluding", recommended: false },
104
105
  { directory: "build", reason: "Usually build output — check before excluding", recommended: false },
105
106
  { directory: "out", reason: "Usually build output — check before excluding", recommended: false },
106
- { directory: "bin", reason: "Usually build output — check before excluding", recommended: false },
107
+ { directory: "bin", atRoot: true, reason: "Usually build output — check before excluding", recommended: false },
107
108
  { extension: ".safetensors", reason: "Model weights — large, and usually downloadable", recommended: false },
108
109
  { extension: ".ckpt", reason: "Model weights — large, and usually downloadable", recommended: false },
109
110
  { extension: ".pt", reason: "Model weights — large, and usually downloadable", recommended: false },
@@ -122,6 +123,83 @@ const norm = (value) => value.split("\\").join("/");
122
123
  * their upload will take and the biggest item answers it. Anything matching
123
124
  * nothing is simply absent — silence is the right output for a tidy folder.
124
125
  */
126
+ /**
127
+ * Which of github/gitignore's templates this folder is asking for.
128
+ *
129
+ * The templates have been vendored all along — three hundred of them, taken
130
+ * from github/gitignore — but nothing ever chose one. A folder with no rules
131
+ * of its own got the generic starter instead, and for a game project that is
132
+ * the wrong list: it carries `build/`, `dist/`, `out/` and `models/**` from
133
+ * the JavaScript and Python worlds, none of which are anchored, all of which
134
+ * can reach into somewhere like `Assets/Art/models`.
135
+ *
136
+ * Recognised from the files a project must have to be that kind of project,
137
+ * rather than from anything a person configures. Order matters: an engine is
138
+ * decided first, because a Unity project also has a package.json somewhere
139
+ * and the engine's own template is the one that knows about `Assets`.
140
+ */
141
+ function templatesFor(entries) {
142
+ const here = new Set(entries.map((name) => name.toLowerCase()));
143
+ const any = (test) => entries.some(test);
144
+ const picked = [];
145
+ const take = (key) => {
146
+ if (!picked.includes(key))
147
+ picked.push(key);
148
+ };
149
+ /* An engine first, and at most one: their templates overlap and disagree. */
150
+ if (here.has("assets") && here.has("projectsettings"))
151
+ take("unity");
152
+ else if (any((name) => name.toLowerCase().endsWith(".uproject"))) {
153
+ take("unrealengine");
154
+ }
155
+ else if (here.has("project.godot"))
156
+ take("godot");
157
+ /*
158
+ Cocos before Node, and this order is the whole reason it is written down.
159
+
160
+ A Cocos project carries a package.json, so recognising Node first called
161
+ it a Node project and handed it Node's rules — which know nothing about
162
+ `library` or `temp`, the caches Cocos rebuilds. Measured on the soak
163
+ shape: eight and a half thousand cache files went from left out to sent.
164
+ */
165
+ else if (here.has("project.json") && here.has("assets"))
166
+ take("cocos");
167
+ /* Then the languages, of which a folder may honestly be several. */
168
+ if (here.has("package.json"))
169
+ take("node");
170
+ if (here.has("pyproject.toml") ||
171
+ here.has("requirements.txt") ||
172
+ here.has("setup.py")) {
173
+ take("python");
174
+ }
175
+ if (here.has("cargo.toml"))
176
+ take("rust");
177
+ if (here.has("go.mod"))
178
+ take("go");
179
+ if (here.has("build.gradle") || here.has("build.gradle.kts"))
180
+ take("gradle");
181
+ /*
182
+ An engine's own project files are not a second kind of project.
183
+
184
+ Unity writes the .sln and the .csproj itself, and every engine here does
185
+ something similar. Reading that as "also a Visual Studio project" and
186
+ stacking GitHub's template on top is not a harmless extra: that template
187
+ ignores `*.meta`, which in the .NET world is build output and in Unity is
188
+ the sidecar carrying every asset's GUID. On a real project it would have
189
+ dropped 13,689 of them, and a Unity project without its metas is exactly
190
+ the broken-materials mess this was all started by.
191
+ */
192
+ const engine = picked.length > 0;
193
+ if (!engine && any((name) => name.toLowerCase().endsWith(".sln"))) {
194
+ take("visualstudio");
195
+ }
196
+ if (!engine && any((name) => name.toLowerCase().endsWith(".xcodeproj"))) {
197
+ take("objective-c");
198
+ }
199
+ if (!engine && here.has("cmakelists.txt"))
200
+ take("cmake");
201
+ return picked;
202
+ }
125
203
  function suggestExclusions(files) {
126
204
  const totals = new Map();
127
205
  for (const entry of files) {
@@ -144,11 +222,17 @@ function suggestExclusions(files) {
144
222
  gets written matching the folder that is there.
145
223
  */
146
224
  const wanted = rule.directory.toLowerCase();
147
- const found = segments
148
- .slice(0, -1)
149
- .find((segment) => segment.toLowerCase() === wanted);
225
+ /*
226
+ At the root, where the rule is about the project's own output, and
227
+ written anchored so it stays there. Unanchored, this offered to
228
+ leave out `Assets/Props/obj` — a folder of 3D models — because a
229
+ C# build also writes to a folder called obj. The file it proposed
230
+ removing was the one the rules had just been fixed to protect.
231
+ */
232
+ const within = rule.atRoot ? segments.slice(0, 1) : segments.slice(0, -1);
233
+ const found = within.find((segment) => segment.toLowerCase() === wanted);
150
234
  if (found)
151
- pattern = `${found}/`;
235
+ pattern = rule.atRoot ? `/${found}/` : `${found}/`;
152
236
  }
153
237
  if (!pattern && rule.file && name === rule.file) {
154
238
  pattern = rule.file;
@@ -7,6 +7,7 @@ exports.parseRules = parseRules;
7
7
  exports.ruleMatches = ruleMatches;
8
8
  exports.lastMatchingRule = lastMatchingRule;
9
9
  exports.decide = decide;
10
+ exports.decidingRule = decidingRule;
10
11
  exports.excludes = excludes;
11
12
  exports.negationReachesInto = negationReachesInto;
12
13
  exports.isIgnored = isIgnored;
@@ -131,17 +132,36 @@ function matcher(pattern) {
131
132
  }
132
133
  return expression;
133
134
  }
135
+ /** A directory pattern with or without its trailing slash is the same rule. */
136
+ function trailingSlashOff(pattern) {
137
+ let end = pattern.length;
138
+ while (end > 0 && pattern[end - 1] === "/")
139
+ end -= 1;
140
+ return pattern.slice(0, end);
141
+ }
134
142
  function ruleMatches(relativePath, isDirectory, rule) {
135
143
  const expression = matcher(rule.pattern);
136
144
  if (rule.directoryOnly) {
137
- if (rule.anchored || rule.pattern.includes("/")) {
138
- return (expression.test(relativePath) ||
139
- relativePath.startsWith(`${rule.pattern.replace(/\/+$/, "")}/`));
140
- }
141
- const segments = relativePath.split("/");
142
- // A file inside an ignored directory is matched by its parent segments.
143
- const scope = isDirectory ? segments : segments.slice(0, -1);
144
- return scope.some((segment) => expression.test(segment));
145
+ /*
146
+ A rule written `dir/` speaks about directories and about nothing else.
147
+ Everything under an excluded directory goes too, but that is git
148
+ declining to look inside it rather than the pattern reaching down, and
149
+ the difference shows the moment a later rule re-includes the directory:
150
+ `build/` then `!build/` leaves `build/keep.txt` travelling, which is the
151
+ escape hatch people are told to use. It shows again in reverse, which is
152
+ how this was found — asking `!Library/PackageCache/a.package/` about a
153
+ file let the whole cache out of a folder only meant to be opened.
154
+
155
+ So the pattern answers only for directories, and a file's ancestors are
156
+ asked separately, outermost in, by `decidingRule`.
157
+ */
158
+ if (!isDirectory)
159
+ return false;
160
+ const bare = trailingSlashOff(rule.pattern);
161
+ if (rule.anchored || bare.includes("/"))
162
+ return expression.test(relativePath);
163
+ const own = relativePath.split("/").pop() ?? relativePath;
164
+ return expression.test(own);
145
165
  }
146
166
  if (rule.anchored || rule.pattern.includes("/")) {
147
167
  return expression.test(relativePath);
@@ -198,7 +218,21 @@ function decide(relativePath, isDirectory, layers) {
198
218
  * is the dangerous direction to differ in: it uploads what somebody thought
199
219
  * they had excluded.
200
220
  */
201
- function excludes(relativePath, isDirectory, layers) {
221
+ /**
222
+ * The rule that settles this path, counting the directories above it.
223
+ *
224
+ * A directory rule is written about the directory — `/[Ll]ibrary/` matches
225
+ * `Library`, not `Library/Artifacts/f1.dat` — so anything asking only about
226
+ * the file finds no rule and calls it included. The upload has always walked
227
+ * the ancestors; the filter screen asked about the file alone, and so showed
228
+ * a Unity project's whole import cache as going up when the save would have
229
+ * left every byte of it behind.
230
+ *
231
+ * Returned as the rule rather than a yes or no, because the screen names the
232
+ * line responsible — "Library/ — 400 files" is the useful form, and a boolean
233
+ * cannot say it.
234
+ */
235
+ function decidingRule(relativePath, isDirectory, layers) {
202
236
  /*
203
237
  Ancestors first, outermost in. The moment one is excluded the answer is
204
238
  settled and nothing below it can appeal.
@@ -208,9 +242,12 @@ function excludes(relativePath, isDirectory, layers) {
208
242
  const ancestor = parts.slice(0, depth).join("/");
209
243
  const above = decide(ancestor, true, layers);
210
244
  if (above && !above.negated)
211
- return true;
245
+ return above;
212
246
  }
213
- const rule = decide(relativePath, isDirectory, layers);
247
+ return decide(relativePath, isDirectory, layers);
248
+ }
249
+ function excludes(relativePath, isDirectory, layers) {
250
+ const rule = decidingRule(relativePath, isDirectory, layers);
214
251
  return Boolean(rule && !rule.negated);
215
252
  }
216
253
  /**
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TransferProgress = void 0;
4
+ /**
5
+ * How much of an upload has happened, and how fast it is going.
6
+ *
7
+ * Lifted out of the upload pass because nearly every number a person watches
8
+ * during a save comes from here, and three separate faults have come out of it
9
+ * being a handful of loose counters in the middle of a very long function:
10
+ *
11
+ * - Six lanes each added their own offset to one shared total, so the figure
12
+ * jumped between six different answers and the bar ran backwards.
13
+ * - The rate was `transferred` over the whole elapsed time while the bar
14
+ * beside it showed something else, so one real upload sat at
15
+ * "152 MB of 1.1 GB · 10 B/s" — not a slow upload, a number describing a
16
+ * different thing.
17
+ * - A lifetime average meant a slow first minute went on dragging the figure
18
+ * down long after the link had recovered, which is the opposite of what
19
+ * somebody watching it wants to know.
20
+ *
21
+ * So the counters and the arithmetic over them live together, with names, and
22
+ * can be tested without starting an upload.
23
+ */
24
+ class TransferProgress {
25
+ windowMs;
26
+ now;
27
+ /** Bytes of files that have finished, whether they travelled or not. */
28
+ settled = 0;
29
+ /** Bytes that actually crossed the wire. */
30
+ moved = 0;
31
+ /** Files the service already held, so nothing crossed the wire for them. */
32
+ reused = 0;
33
+ /*
34
+ How far each lane is through the file it currently holds, kept apart so the
35
+ total is the sum of real work rather than six lanes' guesses about one
36
+ shared number.
37
+ */
38
+ inFlight = new Map();
39
+ samples = [];
40
+ constructor(
41
+ /** How long a window the rate is measured over. */
42
+ windowMs = 5000,
43
+ /** Injected so a test can move time without waiting for it. */
44
+ now = Date.now) {
45
+ this.windowMs = windowMs;
46
+ this.now = now;
47
+ }
48
+ /** A file finished: its own size, and what it cost to send. */
49
+ finished(sourceBytes, wireBytes) {
50
+ this.settled += sourceBytes;
51
+ this.moved += wireBytes;
52
+ }
53
+ /** A file the service already had. Counted apart so "sent" stays honest. */
54
+ alreadyHeld() {
55
+ this.reused += 1;
56
+ }
57
+ /** Where one lane has got to inside the file it is sending. */
58
+ lane(index, offset) {
59
+ this.inFlight.set(index, offset);
60
+ }
61
+ /** That lane has finished, so its offset is now part of the settled total. */
62
+ laneDone(index) {
63
+ this.inFlight.delete(index);
64
+ }
65
+ /** Everything settled, plus how far the open lanes have got. */
66
+ bytes() {
67
+ let total = this.settled;
68
+ for (const offset of this.inFlight.values())
69
+ total += offset;
70
+ return total;
71
+ }
72
+ get sentBytes() {
73
+ return this.settled;
74
+ }
75
+ get transferred() {
76
+ return this.moved;
77
+ }
78
+ get alreadyOnAccount() {
79
+ return this.reused;
80
+ }
81
+ /**
82
+ * How fast the thing on screen is moving, over a short trailing window.
83
+ *
84
+ * The same counter the bar uses, so the two agree. Sampled rather than
85
+ * recorded per call: this is asked once per report, and reports arrive per
86
+ * file, which for small files is thousands a second.
87
+ */
88
+ rate() {
89
+ const now = this.now();
90
+ const bytes = this.bytes();
91
+ const last = this.samples[this.samples.length - 1];
92
+ if (!last || now - last.at >= 250)
93
+ this.samples.push({ at: now, bytes });
94
+ while (this.samples.length > 2 && now - this.samples[0].at > this.windowMs) {
95
+ this.samples.shift();
96
+ }
97
+ const oldest = this.samples[0];
98
+ const seconds = (now - oldest.at) / 1000;
99
+ /*
100
+ Nothing until there is something to divide. Answering zero keeps the
101
+ window quiet rather than showing a figure computed from a fraction of a
102
+ second, which reads as a stall on an upload that has barely begun.
103
+ */
104
+ if (seconds < 0.5)
105
+ return 0;
106
+ return Math.max(0, Math.round((bytes - oldest.bytes) / seconds));
107
+ }
108
+ }
109
+ exports.TransferProgress = TransferProgress;