@silverbulletmd/silverbullet 2.10.0 → 2.11.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.
Files changed (100) hide show
  1. package/README.md +4 -4
  2. package/client/asset_bundle/bundle.ts +0 -1
  3. package/client/config.ts +343 -0
  4. package/client/markdown_parser/constants.ts +3 -1
  5. package/client/plugos/hooks/command.ts +0 -5
  6. package/client/plugos/hooks/event.ts +0 -6
  7. package/client/plugos/hooks/mq.ts +0 -3
  8. package/client/plugos/hooks/slash_command.ts +1 -7
  9. package/client/plugos/hooks/syscall.ts +0 -4
  10. package/client/plugos/manifest_cache.ts +0 -32
  11. package/client/plugos/plug.ts +0 -3
  12. package/client/plugos/plug_compile.ts +3 -13
  13. package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
  14. package/client/plugos/syscalls/editor.ts +128 -53
  15. package/client/plugos/syscalls/event.ts +0 -2
  16. package/client/plugos/syscalls/fetch.ts +0 -4
  17. package/client/plugos/syscalls/icon.ts +46 -0
  18. package/client/plugos/syscalls/index.ts +33 -13
  19. package/client/plugos/syscalls/jsonschema.ts +0 -1
  20. package/client/plugos/syscalls/mq.ts +0 -1
  21. package/client/plugos/syscalls/navigator.ts +143 -0
  22. package/client/plugos/syscalls/search.ts +44 -0
  23. package/client/plugos/syscalls/shell.ts +0 -1
  24. package/client/plugos/syscalls/space.ts +250 -7
  25. package/client/plugos/syscalls/sync.ts +56 -4
  26. package/client/plugos/syscalls/system.ts +38 -0
  27. package/client/plugos/system.ts +7 -5
  28. package/client/plugos/worker_runtime.ts +0 -2
  29. package/client/space_lua/aggregates.ts +0 -16
  30. package/client/space_lua/ast.ts +0 -5
  31. package/client/space_lua/ast_narrow.ts +0 -5
  32. package/client/space_lua/budget.ts +113 -0
  33. package/client/space_lua/budget_ui.ts +49 -0
  34. package/client/space_lua/eval.ts +113 -88
  35. package/client/space_lua/labels.ts +0 -4
  36. package/client/space_lua/numeric.ts +0 -5
  37. package/client/space_lua/parse.ts +0 -17
  38. package/client/space_lua/quarantine.ts +127 -0
  39. package/client/space_lua/query_collection.ts +3 -47
  40. package/client/space_lua/query_env.ts +0 -2
  41. package/client/space_lua/render_lua_markdown.ts +2 -7
  42. package/client/space_lua/render_widget.ts +18 -1
  43. package/client/space_lua/runtime.ts +50 -70
  44. package/client/space_lua/stdlib/format.ts +0 -12
  45. package/client/space_lua/stdlib/js.ts +0 -2
  46. package/client/space_lua/stdlib/load.ts +0 -1
  47. package/client/space_lua/stdlib/math.ts +0 -7
  48. package/client/space_lua/stdlib/net.ts +0 -4
  49. package/client/space_lua/stdlib/os.ts +0 -10
  50. package/client/space_lua/stdlib/pattern.ts +10 -15
  51. package/client/space_lua/stdlib/space_lua.ts +1 -2
  52. package/client/space_lua/stdlib/string_pack.ts +0 -3
  53. package/client/space_lua/stdlib/table.ts +0 -5
  54. package/client/space_lua/stdlib.ts +18 -31
  55. package/client/space_lua/tonumber.ts +0 -9
  56. package/dist/plug-compile.js +2 -3
  57. package/package.json +13 -3
  58. package/plug-api/lib/async.ts +1 -6
  59. package/plug-api/lib/collation.ts +22 -0
  60. package/plug-api/lib/crypto.ts +16 -15
  61. package/plug-api/lib/dates.ts +33 -0
  62. package/plug-api/lib/fuzzy.ts +282 -0
  63. package/plug-api/lib/json.ts +0 -6
  64. package/plug-api/lib/limited_map.ts +0 -2
  65. package/plug-api/lib/link_write.ts +27 -0
  66. package/plug-api/lib/ref.ts +28 -5
  67. package/plug-api/lib/resolve.ts +0 -1
  68. package/plug-api/lib/resolve_path.ts +290 -0
  69. package/plug-api/lib/shortcut.ts +15 -0
  70. package/plug-api/lib/tags.ts +0 -3
  71. package/plug-api/lib/transclusion.ts +28 -1
  72. package/plug-api/lib/tree.ts +0 -5
  73. package/plug-api/lib/yaml.ts +3 -42
  74. package/plug-api/syscall.ts +1 -5
  75. package/plug-api/syscalls/config.ts +2 -2
  76. package/plug-api/syscalls/editor.ts +59 -213
  77. package/plug-api/syscalls/icon.ts +18 -0
  78. package/plug-api/syscalls/index.ts +22 -4
  79. package/plug-api/syscalls/search.ts +11 -0
  80. package/plug-api/syscalls/space.ts +122 -5
  81. package/plug-api/syscalls/sync.ts +23 -2
  82. package/plug-api/syscalls/system.ts +46 -0
  83. package/plug-api/syscalls.ts +2 -0
  84. package/plug-api/system_mock.ts +13 -0
  85. package/plug-api/types/client.ts +4 -3
  86. package/plug-api/types/datastore.ts +11 -2
  87. package/plug-api/types/index.ts +10 -0
  88. package/plug-api/types/profile.ts +13 -0
  89. package/plug-api/types/revisions.ts +67 -0
  90. package/plug-api/ui/description.ts +64 -0
  91. package/plug-api/ui/hover.ts +69 -0
  92. package/plug-api/ui/index.ts +58 -2
  93. package/plug-api/ui/scroll.ts +30 -0
  94. package/plug-api/ui/tree_model.ts +263 -0
  95. package/plug-api/ui/tree_types.ts +66 -0
  96. package/plug-api/ui/use_fit_collapse.ts +67 -0
  97. package/plugs/builtin_plugs.ts +0 -1
  98. package/plugs/index/types.ts +9 -0
  99. package/plug-api/lib/memory_cache.ts +0 -21
  100. /package/plug-api/{ui → lib}/panel_styles.ts +0 -0
@@ -1,8 +1,13 @@
1
- import type { SysCallMapping } from "../system.ts";
2
1
  import type { Client } from "../../client.ts";
2
+ import type { SysCallMapping } from "../system.ts";
3
3
 
4
4
  export function syncSyscalls(client: Client): SysCallMapping {
5
5
  const syncTimeoutMs = 30000;
6
+ // Deferring indexing only pays off while the sync engine is actually
7
+ // delivering files. Once it has gone this long without reporting progress
8
+ // there is nothing left to yield to, and continuing to defer would stall
9
+ // the initial index forever.
10
+ const syncStallMs = 5000;
6
11
 
7
12
  function waitForServiceWorkerActivation(path?: string): Promise<any> {
8
13
  return new Promise<any>((resolve, reject) => {
@@ -70,14 +75,47 @@ export function syncSyscalls(client: Client): SysCallMapping {
70
75
  { type: "boolean", description: "Whether initial sync is complete." },
71
76
  ],
72
77
  },
78
+ "sync.areFilesReadyToIndex": {
79
+ callback: (_ctx, paths: string[]): boolean[] => {
80
+ const allReady =
81
+ !!client.bootConfig.disableServiceWorker ||
82
+ client.fullSyncCompleted ||
83
+ client.fullIndexCompleted ||
84
+ (client.serverPingMs !== undefined && client.serverPingMs < 25) ||
85
+ Date.now() - client.lastSyncProgressAt > syncStallMs;
86
+ return paths.map((path) => allReady || client.syncedPaths.has(path));
87
+ },
88
+ description:
89
+ "For each file, whether indexing it now would read it locally or cheaply (true), or race the initial sync and expensively re-download it (false).",
90
+ parameters: [
91
+ {
92
+ name: "paths",
93
+ type: "string[]",
94
+ description: "Space-relative file paths.",
95
+ },
96
+ ],
97
+ returns: [
98
+ {
99
+ type: "boolean[]",
100
+ description: "Per-path readiness, in input order.",
101
+ },
102
+ ],
103
+ },
73
104
  "sync.performFileSync": {
74
- callback: async (_ctx, path: string): Promise<void> => {
105
+ callback: async (
106
+ _ctx,
107
+ path: string,
108
+ remoteLastModified?: number,
109
+ remoteRevisionHash?: string,
110
+ ): Promise<void> => {
75
111
  await client.postServiceWorkerMessage({
76
112
  type: "perform-file-sync",
77
113
  path,
114
+ remoteLastModified,
115
+ remoteRevisionHash,
78
116
  });
79
117
  // postServiceWorkerMessage returns silently if no SW, so only wait if SW is active
80
- const registration = await navigator.serviceWorker.getRegistration();
118
+ const registration = await navigator.serviceWorker?.getRegistration();
81
119
  if (registration?.active) {
82
120
  return waitForServiceWorkerActivation(path);
83
121
  }
@@ -90,13 +128,27 @@ export function syncSyscalls(client: Client): SysCallMapping {
90
128
  type: "string",
91
129
  description: "Space-relative file path.",
92
130
  },
131
+ {
132
+ name: "remoteLastModified",
133
+ type: "number",
134
+ optional: true,
135
+ description:
136
+ "lastModified of the remote change event, used for echo suppression.",
137
+ },
138
+ {
139
+ name: "remoteRevisionHash",
140
+ type: "string",
141
+ optional: true,
142
+ description:
143
+ "Content revision the remote change event reported, which tells a same-millisecond change from an echo.",
144
+ },
93
145
  ],
94
146
  examples: [{ code: 'sync.performFileSync("notes/important.md")' }],
95
147
  },
96
148
  "sync.performSpaceSync": {
97
149
  callback: async (): Promise<number> => {
98
150
  await client.postServiceWorkerMessage({ type: "perform-space-sync" });
99
- const registration = await navigator.serviceWorker.getRegistration();
151
+ const registration = await navigator.serviceWorker?.getRegistration();
100
152
  if (registration?.active) {
101
153
  return waitForServiceWorkerActivation();
102
154
  }
@@ -3,6 +3,11 @@ import type { Client } from "../../client.ts";
3
3
  import { version as publicVersion } from "../../../version.json";
4
4
  import type { CommandDef } from "@silverbulletmd/silverbullet/type/manifest";
5
5
  import type { SyscallMeta } from "@silverbulletmd/silverbullet/type/index";
6
+ import { loadAccounts, loadProfile } from "../../accounts.ts";
7
+ import type {
8
+ Account,
9
+ ClientProfile,
10
+ } from "../../../plug-api/types/profile.ts";
6
11
 
7
12
  export function systemSyscalls(
8
13
  client: Client,
@@ -97,6 +102,27 @@ export function systemSyscalls(
97
102
  description:
98
103
  "Returns a map of every currently available command definition.",
99
104
  },
105
+ "system.listPaletteCommands": {
106
+ callback: () => client.listPaletteCommands(),
107
+ description: `Returns the commands the command palette would show right now: context- and mode-filtered, hidden ones dropped, each with its prettified key hint and the time it was last run on this client.`,
108
+ signatures: ["system.listPaletteCommands()"],
109
+ },
110
+ "system.runPaletteCommand": {
111
+ callback: async (_ctx, name: string) => {
112
+ await client.registerCommandRun(name);
113
+ try {
114
+ return await client.runCommandByName(name);
115
+ } catch (e: any) {
116
+ client.reportError(e, "Command invocation");
117
+ return false;
118
+ }
119
+ },
120
+ description: `Runs a command as if it had been picked from the command palette: records it as the most recently run, then invokes it and returns its result. A command that throws is reported to the user and answered as false, which in this protocol means "do not move focus on my behalf".`,
121
+ parameters: [
122
+ { name: "name", type: "string", description: "Command name." },
123
+ ],
124
+ returns: [{ description: "Whatever the command returned." }],
125
+ },
100
126
  "system.listSyscalls": {
101
127
  callback: (): SyscallMeta[] => {
102
128
  const syscalls: SyscallMeta[] = [];
@@ -226,6 +252,18 @@ export function systemSyscalls(
226
252
  callback: () => publicVersion,
227
253
  description: "Returns the running SilverBullet version.",
228
254
  },
255
+ "system.getProfile": {
256
+ callback: (): Promise<ClientProfile> => loadProfile(client),
257
+ description:
258
+ 'The current user\'s identity. `username` is "me" when the server has no account for this session.',
259
+ signatures: ["system.getProfile()"],
260
+ },
261
+ "system.listAccounts": {
262
+ callback: (): Promise<Account[]> => loadAccounts(client),
263
+ description:
264
+ "Every account with access to this space, with the current user marked.",
265
+ signatures: ["system.listAccounts()"],
266
+ },
229
267
  "system.getConfig": {
230
268
  callback: (_ctx, key: string, defaultValue: any = undefined) =>
231
269
  client.config.get(key, defaultValue),
@@ -2,7 +2,7 @@ import type { Hook } from "./types.ts";
2
2
  import { EventEmitter } from "./event.ts";
3
3
  import type { SandboxFactory } from "./sandboxes/sandbox.ts";
4
4
  import { Plug } from "./plug.ts";
5
- import { InMemoryManifestCache, type ManifestCache } from "./manifest_cache.ts";
5
+ import type { ManifestCache } from "./manifest_cache.ts";
6
6
  import {
7
7
  builtinPlugNames,
8
8
  builtinPlugPaths,
@@ -55,7 +55,12 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
55
55
  ) {
56
56
  super();
57
57
  if (!options.manifestCache) {
58
- options.manifestCache = new InMemoryManifestCache();
58
+ options.manifestCache = {
59
+ async getManifest(plug) {
60
+ await plug.sandbox.init();
61
+ return plug.sandbox.manifest!;
62
+ },
63
+ };
59
64
  }
60
65
  }
61
66
 
@@ -99,7 +104,6 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
99
104
  * @param args an array of arguments to pass to the function
100
105
  */
101
106
  invokeFunction(name: string, args: any[]): Promise<any> {
102
- // Some sanity type checks
103
107
  if (typeof name !== "string") {
104
108
  throw new Error(
105
109
  `invokeFunction: function name should be a string, got ${typeof name}`,
@@ -112,7 +116,6 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
112
116
  }
113
117
  const [plugName, functionName] = name.split(".");
114
118
  if (!functionName) {
115
- // Sanity check
116
119
  throw new Error(`Missing function name: ${name}`);
117
120
  }
118
121
  const plug = this.loadedPlugs.get(plugName);
@@ -169,7 +172,6 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
169
172
  throw new Error("Plug tried to overwrite internal plug");
170
173
  }
171
174
 
172
- // Validate the manifest
173
175
  let errors: string[] = [];
174
176
  for (const feature of this.enabledHooks) {
175
177
  errors = [...errors, ...feature.validateManifest(manifest)];
@@ -112,7 +112,6 @@ export function setupMessageListener(
112
112
  }
113
113
  })().catch(console.error);
114
114
  });
115
- // Signal initialization with manifest
116
115
  workerPostMessage({
117
116
  type: "manifest",
118
117
  manifest,
@@ -139,7 +138,6 @@ export async function sandboxFetch(
139
138
 
140
139
  globalThis.nativeFetch = globalThis.fetch;
141
140
 
142
- // Monkey patch fetch()
143
141
  export function monkeyPatchFetch() {
144
142
  // @ts-expect-error: monkey patching fetch
145
143
  globalThis.fetch = async (
@@ -112,7 +112,6 @@ function covarIterate(state: CovarState, x: any, y: any): CovarState {
112
112
  return state;
113
113
  }
114
114
 
115
- // Quantile interpolation methods
116
115
  type QuantileMethod =
117
116
  | "linear" // percentile_cont
118
117
  | "lower" // percentile_disc
@@ -126,7 +125,6 @@ interface QuantileState {
126
125
  method: QuantileMethod;
127
126
  }
128
127
 
129
- // Default method based on aggregate invocation name
130
128
  const quantileNameDefaults: Record<string, QuantileMethod> = {
131
129
  percentile_cont: "linear",
132
130
  percentile_disc: "lower",
@@ -159,7 +157,6 @@ function quantileFinish(state: QuantileState): number | null {
159
157
  }
160
158
  }
161
159
 
162
- // Shared spec — branching on `ctx.name` for the default method
163
160
  function makeQuantileSpec(name: string, description: string): AggregateSpec {
164
161
  return {
165
162
  name,
@@ -187,9 +184,7 @@ function makeQuantileSpec(name: string, description: string): AggregateSpec {
187
184
  };
188
185
  }
189
186
 
190
- // Built-in aggregate specs
191
187
  const builtinAggregates: Record<string, AggregateSpec> = {
192
- // General purpose
193
188
  count: {
194
189
  name: "count",
195
190
  description:
@@ -299,7 +294,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
299
294
  return value;
300
295
  }),
301
296
  },
302
- // Collection and format
303
297
  array_agg: {
304
298
  name: "array_agg",
305
299
  description: "Input values concatenated into an array",
@@ -365,7 +359,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
365
359
  return JSON.stringify(state);
366
360
  }),
367
361
  },
368
- // Bitwise and boolean
369
362
  bit_and: {
370
363
  name: "bit_and",
371
364
  description: "Bitwise AND of all non-null input values",
@@ -447,7 +440,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
447
440
  return state.hasValue ? state.result : null;
448
441
  }),
449
442
  },
450
- // Statistical
451
443
  stddev_pop: {
452
444
  name: "stddev_pop",
453
445
  description: "Population standard deviation of non-null inputs",
@@ -558,7 +550,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
558
550
  return state.bestCount > 0 ? state.best : null;
559
551
  }),
560
552
  },
561
- // Quantile and percentile
562
553
  quantile: makeQuantileSpec(
563
554
  "quantile",
564
555
  "Quantile of ordered set of non-null inputs; arguments: value, quantile (0-1), interpolation ('lower', 'higher', 'nearest', 'midpoint' and default: 'linear')",
@@ -617,14 +608,12 @@ export function getAggregateSpec(
617
608
  const spec: any = config.get(`aggregates.${current}`, null);
618
609
  if (!spec) break;
619
610
 
620
- // Check for alias redirect
621
611
  const alias = spec instanceof LuaTable ? spec.rawGet("alias") : spec.alias;
622
612
  if (typeof alias === "string") {
623
613
  current = alias;
624
614
  continue;
625
615
  }
626
616
 
627
- // Full definition in config
628
617
  let candidate: AggregateSpec | null = null;
629
618
  if (spec instanceof LuaTable) {
630
619
  const init = spec.rawGet("initialize");
@@ -700,16 +689,13 @@ export async function executeAggregate(
700
689
  }
701
690
  }
702
691
 
703
- // Initialize
704
692
  let state = await luaCall(spec.initialize, [ctx, ...extraArgs], noCtx, sf);
705
693
 
706
- // Collect filtered items
707
694
  const filteredItems: LuaValue[] = [];
708
695
  const len = items.length;
709
696
  for (let i = 1; i <= len; i++) {
710
697
  const item = items.rawGet(i);
711
698
 
712
- // Filter
713
699
  if (filterExpr) {
714
700
  const filterEnv = buildItemEnv(objectVariable, item, env, sf);
715
701
  const filterResult = await evalExprFn(filterExpr, filterEnv, sf);
@@ -747,7 +733,6 @@ export async function executeAggregate(
747
733
  });
748
734
  }
749
735
 
750
- // Iterate
751
736
  for (const item of filteredItems) {
752
737
  const itemEnv = buildItemEnv(objectVariable, item, env, sf);
753
738
  let value: LuaValue;
@@ -773,7 +758,6 @@ export async function executeAggregate(
773
758
  );
774
759
  }
775
760
 
776
- // Finish
777
761
  if (spec.finish) {
778
762
  state = await luaCall(spec.finish, [state, ctx, ...extraArgs], noCtx, sf);
779
763
  }
@@ -38,7 +38,6 @@ export type LuaComment = {
38
38
  ctx: ASTCtx;
39
39
  };
40
40
 
41
- // STATEMENTS
42
41
  export type LuaReturnStatement = {
43
42
  type: "Return";
44
43
  expressions: LuaExpression[];
@@ -177,7 +176,6 @@ export type LuaFunctionCallStatement = {
177
176
  call: LuaFunctionCallExpression;
178
177
  } & ASTContext;
179
178
 
180
- // EXPRESSIONS
181
179
  export type LuaExpression =
182
180
  | LuaNilLiteral
183
181
  | LuaBooleanLiteral
@@ -294,21 +292,18 @@ export type LuaFunctionDefinition = {
294
292
  body: LuaFunctionBody;
295
293
  } & ASTContext;
296
294
 
297
- // Aggregate with per-row filter
298
295
  export type LuaFilteredCallExpression = {
299
296
  type: "FilteredCall";
300
297
  call: LuaFunctionCallExpression;
301
298
  filter: LuaExpression;
302
299
  } & ASTContext;
303
300
 
304
- // Aggregate call with intra-aggregate order by
305
301
  export type LuaAggregateCallExpression = {
306
302
  type: "AggregateCall";
307
303
  call: LuaFunctionCallExpression;
308
304
  orderBy: LuaOrderBy[];
309
305
  } & ASTContext;
310
306
 
311
- // Query stuff
312
307
  export type LuaQueryExpression = {
313
308
  type: "Query";
314
309
  clauses: LuaQueryClause[];
@@ -2,13 +2,11 @@
2
2
 
3
3
  import type { ASTCtx, LuaExpression, LuaLValue, LuaStatement } from "./ast.ts";
4
4
 
5
- // Extract by `type` discriminant
6
5
  type NarrowByType<
7
6
  U,
8
7
  K extends U extends { type: infer T } ? T : never,
9
8
  > = Extract<U, { type: K }>;
10
9
 
11
- // Expressions
12
10
  export const asStringExpr = (e: LuaExpression) =>
13
11
  e as NarrowByType<LuaExpression, "String">;
14
12
  export const asNumberExpr = (e: LuaExpression) =>
@@ -38,7 +36,6 @@ export const asFunctionDef = (e: LuaExpression) =>
38
36
  export const asQueryExpr = (e: LuaExpression) =>
39
37
  e as NarrowByType<LuaExpression, "Query">;
40
38
 
41
- // L-values
42
39
  export const asLValueVariable = (l: LuaLValue) =>
43
40
  l as Extract<LuaLValue, { type: "Variable" }>;
44
41
  export const asLValueTableAccess = (l: LuaLValue) =>
@@ -46,7 +43,6 @@ export const asLValueTableAccess = (l: LuaLValue) =>
46
43
  export const asLValuePropertyAccess = (l: LuaLValue) =>
47
44
  l as Extract<LuaLValue, { type: "PropertyAccess" }>;
48
45
 
49
- // Statements
50
46
  export const asAssignment = (s: LuaStatement) =>
51
47
  s as NarrowByType<LuaStatement, "Assignment">;
52
48
  export const asLocal = (s: LuaStatement) =>
@@ -79,5 +75,4 @@ export const asGoto = (s: LuaStatement) =>
79
75
  export const asSemicolon = (s: LuaStatement) =>
80
76
  s as NarrowByType<LuaStatement, "Semicolon">;
81
77
 
82
- // Pull ctx with a single shape
83
78
  export const ctxOf = (node: { ctx: ASTCtx }): ASTCtx => node.ctx;
@@ -0,0 +1,113 @@
1
+ export const BUDGET_TICKS = 5000;
2
+ export const YIELD_AFTER_MS = 500;
3
+ export const YIELD_EVERY_MS = 50;
4
+ export const YIELD_COUNT_LIMIT = 200;
5
+ export const BUSY_LIMIT_DEFAULT_MS = 2000;
6
+ export const BUSY_LIMIT_COMMAND_MS = 10000;
7
+ export const KEEP_GOING_MS = 10000;
8
+
9
+ export class LuaBudgetStopped extends Error {
10
+ constructor() {
11
+ super("Script stopped by the user");
12
+ this.name = "LuaBudgetStopped";
13
+ }
14
+ }
15
+
16
+ export type LuaBudget = {
17
+ ticks: number;
18
+ lastCheck: number;
19
+ lastYield: number;
20
+ busyMs: number;
21
+ yieldCount: number;
22
+ awaited: boolean;
23
+ stopped: boolean;
24
+ /** Set once the invocation this budget was created for has completed. */
25
+ finished: boolean;
26
+ limitReported: boolean;
27
+ busyLimitMs: number;
28
+ yieldAfterMs: number;
29
+ onLimit: ((b: LuaBudget) => void) | undefined;
30
+ now: () => number;
31
+ doYield: () => Promise<void>;
32
+ };
33
+
34
+ export function macrotaskYield(): Promise<void> {
35
+ // Do NOT use scheduler.yield() here: it resumes at a *higher* priority than
36
+ // ordinary tasks, so it never lets timers/input/rendering run and defeats
37
+ // the whole point of yielding. MessageChannel posts a genuine macrotask.
38
+ if (typeof MessageChannel === "function") {
39
+ return new Promise<void>((resolve) => {
40
+ const ch = new MessageChannel();
41
+ ch.port1.onmessage = () => {
42
+ ch.port1.close();
43
+ resolve();
44
+ };
45
+ ch.port2.postMessage(undefined);
46
+ });
47
+ }
48
+ return new Promise<void>((resolve) => setTimeout(resolve, 0));
49
+ }
50
+
51
+ export function makeLuaBudget(
52
+ opts: Partial<
53
+ Pick<
54
+ LuaBudget,
55
+ "busyLimitMs" | "yieldAfterMs" | "onLimit" | "now" | "doYield"
56
+ >
57
+ > = {},
58
+ ): LuaBudget {
59
+ const now = opts.now ?? (() => performance.now());
60
+ return {
61
+ ticks: BUDGET_TICKS,
62
+ lastCheck: now(),
63
+ lastYield: now(),
64
+ busyMs: 0,
65
+ yieldCount: 0,
66
+ awaited: false,
67
+ stopped: false,
68
+ finished: false,
69
+ limitReported: false,
70
+ busyLimitMs: opts.busyLimitMs ?? BUSY_LIMIT_DEFAULT_MS,
71
+ yieldAfterMs: opts.yieldAfterMs ?? YIELD_AFTER_MS,
72
+ onLimit: opts.onLimit,
73
+ now,
74
+ doYield: opts.doYield ?? macrotaskYield,
75
+ };
76
+ }
77
+
78
+ export function budgetTick(b: LuaBudget): Promise<void> | undefined {
79
+ if (b.stopped) {
80
+ throw new LuaBudgetStopped();
81
+ }
82
+ b.ticks = BUDGET_TICKS;
83
+
84
+ const now = b.now();
85
+ const sinceCheck = now - b.lastCheck;
86
+ b.lastCheck = now;
87
+ if (b.awaited) {
88
+ b.awaited = false;
89
+ } else {
90
+ b.busyMs += sinceCheck;
91
+ }
92
+
93
+ if (
94
+ !b.limitReported &&
95
+ (b.busyMs >= b.busyLimitMs || b.yieldCount >= YIELD_COUNT_LIMIT)
96
+ ) {
97
+ b.limitReported = true;
98
+ b.onLimit?.(b);
99
+ }
100
+
101
+ const threshold = b.yieldCount === 0 ? b.yieldAfterMs : YIELD_EVERY_MS;
102
+ if (now - b.lastYield < threshold) {
103
+ return undefined;
104
+ }
105
+ b.lastYield = now;
106
+ b.yieldCount++;
107
+ // The event-loop turn spent yielding (rendering, sync, other scripts) must
108
+ // not be billed to this script's busyMs at the next tick, so re-read the
109
+ // clock on resume rather than trusting the pre-yield lastCheck.
110
+ return b.doYield().then(() => {
111
+ b.lastCheck = b.now();
112
+ });
113
+ }
@@ -0,0 +1,49 @@
1
+ import { KEEP_GOING_MS, type LuaBudget } from "./budget.ts";
2
+
3
+ export type BudgetNotifier = {
4
+ flashNotification(
5
+ message: string,
6
+ type?: string,
7
+ options?: {
8
+ timeout?: number;
9
+ actions?: { name: string; run: () => void }[];
10
+ },
11
+ ): void;
12
+ };
13
+
14
+ export function offerStopNotification(
15
+ ui: BudgetNotifier,
16
+ message: string,
17
+ budget: LuaBudget,
18
+ onStop?: () => void,
19
+ ): void {
20
+ ui.flashNotification(message, "warning", {
21
+ timeout: 0,
22
+ actions: [
23
+ {
24
+ name: "Stop",
25
+ run: () => {
26
+ // The script may have finished running (this toast is persistent)
27
+ // between the limit tripping and the user clicking Stop; don't
28
+ // quarantine a script that isn't running anymore.
29
+ if (budget.finished) {
30
+ return;
31
+ }
32
+ budget.stopped = true;
33
+ onStop?.();
34
+ },
35
+ },
36
+ {
37
+ name: "Keep going",
38
+ run: () => {
39
+ budget.busyLimitMs = budget.busyMs + KEEP_GOING_MS;
40
+ // Resetting to 0 would restore the 500ms first-yield threshold,
41
+ // making the tab unresponsive again right after the user asked
42
+ // to continue.
43
+ budget.yieldCount = 1;
44
+ budget.limitReported = false;
45
+ },
46
+ },
47
+ ],
48
+ });
49
+ }