@silverbulletmd/silverbullet 2.10.0 → 2.12.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 (105) hide show
  1. package/README.md +6 -6
  2. package/client/asset_bundle/bundle.ts +0 -1
  3. package/client/config.ts +343 -0
  4. package/client/data/kv_primitives.ts +2 -2
  5. package/client/markdown_parser/constants.ts +5 -1
  6. package/client/plugos/hooks/command.ts +8 -6
  7. package/client/plugos/hooks/event.ts +60 -24
  8. package/client/plugos/hooks/mq.ts +0 -3
  9. package/client/plugos/hooks/slash_command.ts +3 -20
  10. package/client/plugos/hooks/syscall.ts +0 -4
  11. package/client/plugos/manifest_cache.ts +2 -34
  12. package/client/plugos/plug.ts +0 -3
  13. package/client/plugos/plug_compile.ts +3 -13
  14. package/client/plugos/sandboxes/sandbox.ts +2 -2
  15. package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
  16. package/client/plugos/syscalls/capture.ts +104 -0
  17. package/client/plugos/syscalls/editor.ts +197 -56
  18. package/client/plugos/syscalls/event.ts +12 -9
  19. package/client/plugos/syscalls/fetch.ts +0 -4
  20. package/client/plugos/syscalls/icon.ts +46 -0
  21. package/client/plugos/syscalls/index.ts +33 -13
  22. package/client/plugos/syscalls/jsonschema.ts +0 -1
  23. package/client/plugos/syscalls/mq.ts +0 -1
  24. package/client/plugos/syscalls/navigator.ts +138 -0
  25. package/client/plugos/syscalls/search.ts +44 -0
  26. package/client/plugos/syscalls/shell.ts +0 -1
  27. package/client/plugos/syscalls/space.ts +250 -7
  28. package/client/plugos/syscalls/sync.ts +70 -4
  29. package/client/plugos/syscalls/system.ts +39 -0
  30. package/client/plugos/system.ts +9 -7
  31. package/client/plugos/types.ts +6 -6
  32. package/client/plugos/worker_runtime.ts +0 -2
  33. package/client/space_lua/aggregates.ts +6 -22
  34. package/client/space_lua/ast.ts +0 -5
  35. package/client/space_lua/ast_narrow.ts +0 -5
  36. package/client/space_lua/budget.ts +113 -0
  37. package/client/space_lua/budget_ui.ts +49 -0
  38. package/client/space_lua/eval.ts +113 -88
  39. package/client/space_lua/labels.ts +0 -4
  40. package/client/space_lua/numeric.ts +2 -7
  41. package/client/space_lua/parse.ts +0 -17
  42. package/client/space_lua/quarantine.ts +127 -0
  43. package/client/space_lua/query_collection.ts +18 -51
  44. package/client/space_lua/query_env.ts +0 -2
  45. package/client/space_lua/render_lua_markdown.ts +2 -7
  46. package/client/space_lua/render_widget.ts +18 -1
  47. package/client/space_lua/runtime.ts +64 -77
  48. package/client/space_lua/stdlib/format.ts +0 -12
  49. package/client/space_lua/stdlib/js.ts +0 -2
  50. package/client/space_lua/stdlib/load.ts +0 -1
  51. package/client/space_lua/stdlib/math.ts +0 -7
  52. package/client/space_lua/stdlib/net.ts +0 -4
  53. package/client/space_lua/stdlib/os.ts +0 -10
  54. package/client/space_lua/stdlib/pattern.ts +18 -23
  55. package/client/space_lua/stdlib/space_lua.ts +1 -2
  56. package/client/space_lua/stdlib/string_pack.ts +4 -7
  57. package/client/space_lua/stdlib/table.ts +0 -5
  58. package/client/space_lua/stdlib.ts +18 -31
  59. package/client/space_lua/tonumber.ts +0 -9
  60. package/dist/plug-compile.js +3 -4
  61. package/package.json +52 -41
  62. package/plug-api/lib/async.ts +1 -6
  63. package/plug-api/lib/collation.ts +22 -0
  64. package/plug-api/lib/crypto.ts +16 -15
  65. package/plug-api/lib/dates.ts +33 -0
  66. package/plug-api/lib/fuzzy.ts +282 -0
  67. package/plug-api/lib/json.ts +0 -6
  68. package/plug-api/lib/limited_map.ts +0 -2
  69. package/plug-api/lib/link_write.ts +27 -0
  70. package/plug-api/lib/ref.ts +28 -5
  71. package/plug-api/lib/resolve.ts +1 -9
  72. package/plug-api/lib/resolve_path.ts +290 -0
  73. package/plug-api/lib/shortcut.ts +15 -0
  74. package/plug-api/lib/tags.ts +0 -3
  75. package/plug-api/lib/transclusion.ts +28 -1
  76. package/plug-api/lib/tree.ts +0 -5
  77. package/plug-api/lib/yaml.ts +5 -44
  78. package/plug-api/syscall.ts +1 -5
  79. package/plug-api/syscalls/config.ts +2 -2
  80. package/plug-api/syscalls/editor.ts +75 -213
  81. package/plug-api/syscalls/icon.ts +18 -0
  82. package/plug-api/syscalls/index.ts +22 -4
  83. package/plug-api/syscalls/search.ts +11 -0
  84. package/plug-api/syscalls/space.ts +122 -5
  85. package/plug-api/syscalls/sync.ts +31 -2
  86. package/plug-api/syscalls/system.ts +46 -0
  87. package/plug-api/syscalls.ts +2 -0
  88. package/plug-api/system_mock.ts +13 -0
  89. package/plug-api/types/client.ts +20 -4
  90. package/plug-api/types/datastore.ts +11 -2
  91. package/plug-api/types/index.ts +10 -0
  92. package/plug-api/types/manifest.ts +7 -0
  93. package/plug-api/types/profile.ts +13 -0
  94. package/plug-api/types/revisions.ts +67 -0
  95. package/plug-api/ui/description.ts +64 -0
  96. package/plug-api/ui/hover.ts +69 -0
  97. package/plug-api/ui/index.ts +58 -2
  98. package/plug-api/ui/scroll.ts +30 -0
  99. package/plug-api/ui/tree_model.ts +285 -0
  100. package/plug-api/ui/tree_types.ts +67 -0
  101. package/plug-api/ui/use_fit_collapse.ts +67 -0
  102. package/plugs/builtin_plugs.ts +0 -1
  103. package/plugs/index/types.ts +9 -0
  104. package/plug-api/lib/memory_cache.ts +0 -21
  105. /package/plug-api/{ui → lib}/panel_styles.ts +0 -0
@@ -69,16 +69,25 @@ import {
69
69
  type Ref,
70
70
  } from "@silverbulletmd/silverbullet/lib/ref";
71
71
  import type {
72
+ DocumentCapability,
73
+ DocumentCapabilityDescriptor,
72
74
  FilterOption,
73
75
  NotificationAction,
74
76
  NotificationType,
77
+ PanelMode,
75
78
  UploadFile,
76
79
  } from "@silverbulletmd/silverbullet/type/client";
77
80
  import type { VimConfig } from "@silverbulletmd/silverbullet/type/config";
78
81
  import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
79
82
  import { updateBakedSections } from "../../baked_sections/bake.ts";
80
83
  import type { Client } from "../../client.ts";
84
+ import { copyToClipboard } from "../../clipboard.ts";
81
85
  import { refreshLintEffect } from "../../codemirror/lint.ts";
86
+ import { resolveDocumentCapability } from "../../document_editor_resolver.ts";
87
+ import { isMobileDevice, isNarrowScreen } from "../../lib/mobile.ts";
88
+ import { browserMediaCapabilities } from "../../media.ts";
89
+ import { hide as hideNavigatorSlot } from "../../navigator/navigator.ts";
90
+ import type { PanelSlot } from "../../types/ui.ts";
82
91
  import { getVimModule } from "../../vim_loader.ts";
83
92
  import type { SysCallMapping } from "../system.ts";
84
93
 
@@ -137,6 +146,68 @@ export function editorSyscalls(client: Client): SysCallMapping {
137
146
  "Returns page metadata ordered from most to least recently opened.",
138
147
  returns: [{ type: "PageMeta[]", description: "Recently opened pages." }],
139
148
  },
149
+ "editor.getLastOpenedMap": {
150
+ callback: (): Record<string, number> => {
151
+ const out: Record<string, number> = {};
152
+ for (const pageMeta of client.ui.viewState.allPages) {
153
+ if (pageMeta.lastOpened) out[pageMeta.name] = pageMeta.lastOpened;
154
+ }
155
+ return out;
156
+ },
157
+ description: `Returns a map of page name to the time it was last opened (epoch milliseconds), for the pages that have ever been opened on this client. This lives outside the object index.`,
158
+ returns: [
159
+ {
160
+ type: "Record<string, number>",
161
+ description: "Page name to last-opened timestamp.",
162
+ },
163
+ ],
164
+ },
165
+ "editor.getViewableExtensions": {
166
+ callback: (): string[] => {
167
+ return [
168
+ ...new Set(
169
+ Array.from(
170
+ client.clientSystem.documentEditorHook.documentEditors.values(),
171
+ ).flatMap(({ extensions }) => extensions),
172
+ ),
173
+ ];
174
+ },
175
+ description: `Returns the file extensions claimed by explicitly registered document editors. Extensions carry no leading dot. Built-in text and media fallbacks are not included.`,
176
+ returns: [
177
+ {
178
+ type: "string[]",
179
+ description: "Extensions with a registered document editor.",
180
+ },
181
+ ],
182
+ },
183
+ "editor.getDocumentCapabilities": {
184
+ callback: (
185
+ _ctx,
186
+ documents: DocumentCapabilityDescriptor[],
187
+ ): DocumentCapability[] =>
188
+ documents.map((document) =>
189
+ resolveDocumentCapability(
190
+ document,
191
+ client.clientSystem.documentEditorHook.documentEditors,
192
+ browserMediaCapabilities,
193
+ ),
194
+ ),
195
+ description:
196
+ "Resolves the available editor capability for each document from metadata only, in input order.",
197
+ parameters: [
198
+ {
199
+ name: "documents",
200
+ type: "DocumentCapabilityDescriptor[]",
201
+ description: "Document metadata to resolve without reading bodies.",
202
+ },
203
+ ],
204
+ returns: [
205
+ {
206
+ type: "DocumentCapability[]",
207
+ description: "Capabilities in the same order as the input.",
208
+ },
209
+ ],
210
+ },
140
211
  "editor.getText": {
141
212
  callback: () => {
142
213
  return client.editorView.state.sliceDoc();
@@ -353,9 +424,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
353
424
  },
354
425
  ],
355
426
  },
356
- // Re-evaluate every baked section on the current page and rewrite each body
357
- // with its latest output (same as the "Baked Sections: Update" command),
358
- // exposed for programmatic use.
359
427
  "editor.updateBakedSections": {
360
428
  callback: (): Promise<void> => {
361
429
  return updateBakedSections(client);
@@ -430,6 +498,29 @@ export function editorSyscalls(client: Client): SysCallMapping {
430
498
  },
431
499
  ],
432
500
  },
501
+ "editor.downloadSpaceFile": {
502
+ callback: async (_ctx, name: string) => {
503
+ const { data, meta } =
504
+ await client.space.spacePrimitives.readFile(name);
505
+ const url = URL.createObjectURL(
506
+ new Blob([new Uint8Array(data)], {
507
+ type: meta.contentType || "application/octet-stream",
508
+ }),
509
+ );
510
+ try {
511
+ const link = document.createElement("a");
512
+ link.href = url;
513
+ link.download = name.split("/").at(-1)!;
514
+ link.click();
515
+ } finally {
516
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
517
+ }
518
+ },
519
+ description: "Downloads a space file using its current contents.",
520
+ parameters: [
521
+ { name: "name", type: "string", description: "Space file path." },
522
+ ],
523
+ },
433
524
  "editor.uploadFile": {
434
525
  callback: (
435
526
  _ctx,
@@ -447,6 +538,7 @@ export function editorSyscalls(client: Client): SysCallMapping {
447
538
  }
448
539
 
449
540
  input.onchange = () => {
541
+ input.remove();
450
542
  const file = input.files?.item(0);
451
543
  if (!file) {
452
544
  reject(new Error("No file found"));
@@ -473,11 +565,21 @@ export function editorSyscalls(client: Client): SysCallMapping {
473
565
  input.onabort = (e) => {
474
566
  reject(e);
475
567
  };
568
+ input.oncancel = () => {
569
+ input.remove();
570
+ };
476
571
 
477
572
  input.style.display = "none";
478
573
  document.body.appendChild(input);
479
- input.click();
480
- setTimeout(() => document.body.removeChild(input), 1000);
574
+ if (isMobileDevice()) {
575
+ try {
576
+ input.showPicker();
577
+ } catch {
578
+ input.click();
579
+ }
580
+ } else {
581
+ input.click();
582
+ }
481
583
  });
482
584
  },
483
585
  description:
@@ -594,13 +696,20 @@ export function editorSyscalls(client: Client): SysCallMapping {
594
696
  ],
595
697
  },
596
698
  "editor.showPanel": {
597
- callback: (
699
+ callback: async (
598
700
  _ctx,
599
701
  id: string,
600
- mode: number,
702
+ mode: PanelMode,
601
703
  html: HTMLElement | HTMLElement[] | string,
602
704
  script: string,
603
705
  ) => {
706
+ // The other half of the shared-slot arbitration in editor_ui.tsx: a
707
+ // plug opening one of these slots closes the navigator's occupant.
708
+ if (id === "modal" || id === "bhs") {
709
+ await hideNavigatorSlot(id, undefined, {
710
+ restoreDisplaced: false,
711
+ });
712
+ }
604
713
  client.ui.viewDispatch({
605
714
  type: "show-panel",
606
715
  id: id as any,
@@ -620,7 +729,7 @@ export function editorSyscalls(client: Client): SysCallMapping {
620
729
  },
621
730
  {
622
731
  name: "mode",
623
- type: "number",
732
+ type: "number | string",
624
733
  description: "The panel display mode or size.",
625
734
  },
626
735
  {
@@ -641,8 +750,33 @@ export function editorSyscalls(client: Client): SysCallMapping {
641
750
  },
642
751
  description: "Returns focus to the main editor.",
643
752
  },
753
+ "editor.getFocusedPanelSlot": {
754
+ callback: (): PanelSlot | undefined => {
755
+ const active = document.activeElement;
756
+ if (!(active instanceof HTMLIFrameElement)) return undefined;
757
+ const slot = active.dataset.slot;
758
+ return slot === "lhs" ||
759
+ slot === "rhs" ||
760
+ slot === "bhs" ||
761
+ slot === "modal"
762
+ ? slot
763
+ : undefined;
764
+ },
765
+ description:
766
+ "Returns the slot of the panel (keyed or legacy) whose iframe currently holds focus, or undefined if none does.",
767
+ returns: [
768
+ {
769
+ type: '"lhs" | "rhs" | "bhs" | "modal" | undefined',
770
+ description:
771
+ "The focused panel's slot, or undefined if no panel iframe has focus.",
772
+ },
773
+ ],
774
+ },
644
775
  "editor.hidePanel": {
645
- callback: (_ctx, id: string) => {
776
+ callback: async (_ctx, id: string) => {
777
+ // The navigator owns this slot whenever it is the thing showing
778
+ // there, so "hide the panel at this location" has to reach it too.
779
+ await hideNavigatorSlot(id);
646
780
  client.ui.viewDispatch({
647
781
  type: "hide-panel",
648
782
  id: id as any,
@@ -664,14 +798,19 @@ export function editorSyscalls(client: Client): SysCallMapping {
664
798
  "editor.showProgress": {
665
799
  callback: (
666
800
  _ctx,
801
+ progressType: "sync" | "index",
667
802
  progressPercentage?: number,
668
- progressType?: "sync" | "index",
669
803
  ) => {
670
- client.ui.showProgress(progressPercentage, progressType);
804
+ client.ui.showProgress(progressType, progressPercentage);
671
805
  },
672
806
  description:
673
807
  "Shows, updates, or hides a sync or indexing progress indicator.",
674
808
  parameters: [
809
+ {
810
+ name: "progressType",
811
+ type: "sync | index",
812
+ description: "The operation represented by the indicator.",
813
+ },
675
814
  {
676
815
  name: "progressPercentage",
677
816
  type: "number",
@@ -679,12 +818,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
679
818
  "Completion percentage, or undefined to hide the indicator.",
680
819
  optional: true,
681
820
  },
682
- {
683
- name: "progressType",
684
- type: "sync | index",
685
- description: "The operation represented by the indicator.",
686
- optional: true,
687
- },
688
821
  ],
689
822
  },
690
823
  "editor.insertAtPos": {
@@ -828,11 +961,8 @@ export function editorSyscalls(client: Client): SysCallMapping {
828
961
  },
829
962
  "editor.moveCursorToLine": {
830
963
  callback: (_ctx, line: number, column = 1, center = false) => {
831
- // CodeMirror already keeps information about lines
832
964
  const cmLine = client.editorView.state.doc.line(line);
833
- // How much to move inside the line, column number starts from 1
834
965
  const offset = Math.max(0, Math.min(cmLine.length, column - 1));
835
- // Just reuse the implementation above
836
966
  const moveCursor = syscalls["editor.moveCursor"];
837
967
  const moveCursorCallback =
838
968
  typeof moveCursor === "function" ? moveCursor : moveCursor.callback;
@@ -1068,44 +1198,30 @@ export function editorSyscalls(client: Client): SysCallMapping {
1068
1198
  throw new Error("Vim module not loaded.");
1069
1199
  }
1070
1200
  const { Vim } = vimMod;
1071
- // Override the default "o" binding to be more intelligent and follow the markdown editor's behavior
1072
1201
  Vim.mapCommand("o", "action", "newline-continue-markup", {}, {});
1073
1202
  Vim.mapCommand("O", "action", "back-newline-continue-markup", {}, {});
1074
1203
  Vim.unmap("<C-q>", undefined as any);
1075
1204
  Vim.defineAction("newline-continue-markup", (cm) => {
1076
- // Append at end of line
1077
1205
  Vim.handleKey(cm, "A", "+input");
1078
- // Insert newline continuing markup where appropriate
1079
1206
  insertNewlineContinueMarkup(client.editorView) ||
1080
1207
  insertNewlineAndIndent(client.editorView);
1081
1208
  });
1082
1209
  Vim.defineAction("back-newline-continue-markup", (cm) => {
1083
- // Determine current line
1084
1210
  const pos = client.editorView.state.selection.main.from;
1085
1211
  const line = client.editorView.state.doc.lineAt(pos).number;
1086
1212
  if (line === 1) {
1087
- // We're on the top line
1088
- // Go to 0:0
1089
1213
  Vim.handleKey(cm, "0", "+input");
1090
- // Insert a newline
1091
1214
  insertNewline(client.editorView);
1092
- // Go up to the new line
1093
1215
  Vim.handleKey(cm, "k", "+input");
1094
- // Into insert mode
1095
1216
  Vim.handleKey(cm, "i", "+input");
1096
1217
  } else {
1097
- // We're elsewhere in the document
1098
- // Go up
1099
1218
  Vim.handleKey(cm, "k", "+input");
1100
- // Append mode at the end of the line
1101
1219
  Vim.handleKey(cm, "A", "+input");
1102
- // Insert a newline using the continue markup thing
1103
1220
  insertNewlineContinueMarkup(client.editorView) ||
1104
1221
  insertNewlineAndIndent(client.editorView);
1105
1222
  }
1106
1223
  });
1107
1224
 
1108
- // Load the config if any
1109
1225
  const config = client.config.get<VimConfig>("vim", {});
1110
1226
  if (config) {
1111
1227
  config.unmap?.forEach((binding) => {
@@ -1143,9 +1259,9 @@ export function editorSyscalls(client: Client): SysCallMapping {
1143
1259
  },
1144
1260
  "editor.openPageNavigator": {
1145
1261
  callback: (_ctx, mode: "page" | "meta" | "document" | "all" = "page") => {
1146
- client.startPageNavigate(mode);
1262
+ void client.startPageNavigate(mode);
1147
1263
  },
1148
- description: "Opens the page navigator in the requested browsing mode.",
1264
+ description: `Opens the page picker in the requested browsing mode. Each mode maps to a segment of the \`std.pages\` navigator view.`,
1149
1265
  parameters: [
1150
1266
  {
1151
1267
  name: "mode",
@@ -1161,6 +1277,30 @@ export function editorSyscalls(client: Client): SysCallMapping {
1161
1277
  },
1162
1278
  description: "Opens the command palette.",
1163
1279
  },
1280
+ "editor.openNavigator": {
1281
+ callback: (
1282
+ _ctx,
1283
+ name: string,
1284
+ opts?: {
1285
+ segment?: string;
1286
+ phrase?: string;
1287
+ dropdown?: unknown;
1288
+ focus?: boolean;
1289
+ },
1290
+ ) => client.openNavigatorView(name, opts),
1291
+ description: `Opens a navigator view, returning whether it opened. False means the view isn't there to open -- typically because it's defined in Space Lua that hasn't been indexed yet -- so a caller can fall back to something else.`,
1292
+ parameters: [
1293
+ { name: "name", type: "string", description: "The view's name." },
1294
+ {
1295
+ name: "opts",
1296
+ type: "table",
1297
+ description:
1298
+ "Optional `segment` (segment label), `phrase`, `dropdown` (dropdown value to select), and `focus` (`false` opens without taking focus).",
1299
+ optional: true,
1300
+ },
1301
+ ],
1302
+ returns: [{ type: "boolean", description: "Whether a view opened." }],
1303
+ },
1164
1304
  "editor.deleteLine": {
1165
1305
  callback: () => {
1166
1306
  deleteLine(client.editorView);
@@ -1222,7 +1362,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
1222
1362
  },
1223
1363
  description: "Moves the current line or selected lines downward.",
1224
1364
  },
1225
- // Folding
1226
1365
  "editor.fold": {
1227
1366
  callback: () => {
1228
1367
  foldCode(client.editorView);
@@ -1265,7 +1404,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
1265
1404
  },
1266
1405
  description: "Redoes the most recently undone editor change.",
1267
1406
  },
1268
- // Cursor motion syscalls
1269
1407
  "editor.cursorCharLeft": {
1270
1408
  callback: () => cursorCharLeft(client.editorView),
1271
1409
  description:
@@ -1310,7 +1448,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
1310
1448
  callback: () => cursorDocEnd(client.editorView),
1311
1449
  description: "Moves the cursor to the end of the document.",
1312
1450
  },
1313
- // Cursor motions that also navigate the completion popup if it's open
1314
1451
  "editor.cursorLineUp": {
1315
1452
  callback: () => {
1316
1453
  const view = client.editorView;
@@ -1347,7 +1484,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
1347
1484
  description:
1348
1485
  "Moves completion selection down one page when open, otherwise moves the cursor down one viewport page.",
1349
1486
  },
1350
- // Selection-extending motions
1351
1487
  "editor.selectCharLeft": {
1352
1488
  callback: () => selectCharLeft(client.editorView),
1353
1489
  description:
@@ -1411,7 +1547,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
1411
1547
  callback: () => selectPageDown(client.editorView),
1412
1548
  description: "Extends the selection downward by one viewport page.",
1413
1549
  },
1414
- // Delete
1415
1550
  "editor.deleteCharBackward": {
1416
1551
  callback: () => deleteCharBackward(client.editorView),
1417
1552
  description: "Deletes the selection or the character before the cursor.",
@@ -1444,7 +1579,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
1444
1579
  callback: () => transposeChars(client.editorView),
1445
1580
  description: "Transposes the characters around the cursor.",
1446
1581
  },
1447
- // Enter: accept completion if popup is open, else newline-and-indent
1448
1582
  "editor.insertNewline": {
1449
1583
  callback: () => {
1450
1584
  const view = client.editorView;
@@ -1454,7 +1588,6 @@ export function editorSyscalls(client: Client): SysCallMapping {
1454
1588
  description:
1455
1589
  "Accepts the active completion, or inserts a newline with appropriate indentation.",
1456
1590
  },
1457
- // Completion popup control
1458
1591
  "editor.acceptCompletion": {
1459
1592
  callback: () => acceptCompletion(client.editorView),
1460
1593
  description:
@@ -1483,20 +1616,14 @@ export function editorSyscalls(client: Client): SysCallMapping {
1483
1616
  "editor.copyToClipboard": {
1484
1617
  callback: async (_ctx, data: string | Blob) => {
1485
1618
  try {
1486
- if (typeof data === "string") {
1487
- await navigator.clipboard.writeText(data);
1488
- } else {
1489
- await navigator.clipboard.write([
1490
- new ClipboardItem({ [data.type]: data }),
1491
- ]);
1492
- }
1619
+ await copyToClipboard(data);
1493
1620
  } catch (e) {
1494
1621
  console.error(e);
1495
1622
  client.ui.flashNotification(`Could not copy to clipboard: ${e}`);
1496
1623
  }
1497
1624
  },
1498
1625
  description:
1499
- "Copies text or binary Blob data to the system clipboard. Clipboard access requires a secure HTTPS context.",
1626
+ "Copies text or binary Blob data to the system clipboard. Binary clipboard access requires HTTPS or localhost; text copying also works over HTTP when supported by the browser.",
1500
1627
  parameters: [
1501
1628
  {
1502
1629
  name: "data",
@@ -1510,7 +1637,7 @@ export function editorSyscalls(client: Client): SysCallMapping {
1510
1637
  callback: (_ctx, type: string, data: any) => {
1511
1638
  if (!client.contentManager.isDocumentEditor()) return;
1512
1639
 
1513
- client.contentManager.documentEditor.sendPublicMessage({
1640
+ client.contentManager.documentEditor.sendPublicMessage?.({
1514
1641
  type,
1515
1642
  data,
1516
1643
  });
@@ -1529,10 +1656,7 @@ export function editorSyscalls(client: Client): SysCallMapping {
1529
1656
  },
1530
1657
 
1531
1658
  "editor.isMobile": {
1532
- callback: () => {
1533
- const mouseDetected = globalThis.matchMedia("(pointer:fine)").matches;
1534
- return !mouseDetected;
1535
- },
1659
+ callback: () => isMobileDevice(),
1536
1660
  description:
1537
1661
  "Checks whether the current device lacks a fine pointer and should be treated as mobile.",
1538
1662
  returns: [
@@ -1543,6 +1667,23 @@ export function editorSyscalls(client: Client): SysCallMapping {
1543
1667
  },
1544
1668
  ],
1545
1669
  },
1670
+
1671
+ // Deliberately separate from `editor.isMobile`: that one answers "what
1672
+ // kind of pointer is this", which is the right question for touch
1673
+ // affordances and the wrong one for layout. A narrow window on a desktop
1674
+ // gets the narrow layout with a mouse attached; a tablet in landscape gets
1675
+ // the wide one without.
1676
+ "editor.isNarrowScreen": {
1677
+ callback: () => isNarrowScreen(),
1678
+ description:
1679
+ "Checks whether the client is currently laid out for a narrow screen, i.e. below the breakpoint where sidebar panels become full-width drawers.",
1680
+ returns: [
1681
+ {
1682
+ type: "boolean",
1683
+ description: "Whether the narrow-screen layout is in effect.",
1684
+ },
1685
+ ],
1686
+ },
1546
1687
  };
1547
1688
 
1548
1689
  return syscalls;
@@ -3,6 +3,11 @@ import type { EventHookI } from "../eventhook.ts";
3
3
  import type { EventSubscription } from "@silverbulletmd/silverbullet/type/event";
4
4
  import { LuaStackFrame, luaValueToJS } from "../../space_lua/runtime.ts";
5
5
  import type { Client } from "../../client.ts";
6
+ import { luaDefinitionRef } from "../../space_lua.ts";
7
+ import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";
8
+
9
+ export const listenerDefinition = Symbol("listenerDefinition");
10
+ export type DefinedListener = Function & { [listenerDefinition]?: Ref | null };
6
11
 
7
12
  export function eventSyscalls(
8
13
  eventHook: EventHookI,
@@ -38,15 +43,13 @@ export function eventSyscalls(
38
43
  */
39
44
  "event.listen": {
40
45
  callback: (_ctx, def: EventSubscription) => {
41
- // console.log("Registering Lua event listener: ", def.name);
42
- client.config.insert(
43
- ["eventListeners", def.name],
44
- async (...args: any[]) => {
45
- // Convert return value to JS
46
- const val = await def.run(...args);
47
- return luaValueToJS(val, LuaStackFrame.lostFrame);
48
- },
49
- );
46
+ const definition = luaDefinitionRef(def.run);
47
+ const listener = async (...args: any[]) => {
48
+ const val = await def.run(...args);
49
+ return luaValueToJS(val, LuaStackFrame.lostFrame);
50
+ };
51
+ (listener as DefinedListener)[listenerDefinition] = definition;
52
+ client.config.insert(["eventListeners", def.name], listener);
50
53
  },
51
54
  description: "Registers a Space Lua listener on the event bus.",
52
55
  parameters: [
@@ -22,7 +22,6 @@ export function sandboxFetchSyscalls(client: Client): SysCallMapping {
22
22
  options: ProxyFetchRequest = {},
23
23
  ): Promise<ProxyFetchResponse> => {
24
24
  console.warn("Deprecated: use net.proxyFetch() instead");
25
- // JSONify any non-serializable body
26
25
  if (
27
26
  options?.body &&
28
27
  typeof options.body !== "string" &&
@@ -44,7 +43,6 @@ export function sandboxFetchSyscalls(client: Client): SysCallMapping {
44
43
  buildProxyUrl(client, url),
45
44
  fetchOptions,
46
45
  );
47
- // Do sensible things with the body based on the content type
48
46
  // Read as ArrayBuffer first to safely handle empty responses (e.g.
49
47
  // PUT/DELETE returning 204 with Content-Type: application/json).
50
48
  // resp.arrayBuffer() never throws on an empty body, whereas
@@ -100,7 +98,6 @@ export function sandboxFetchSyscalls(client: Client): SysCallMapping {
100
98
  url: string,
101
99
  options?: ProxyFetchRequest64,
102
100
  ): Promise<ProxyFetchResponse64> => {
103
- // console.log("Got sandbox fetch ", url, op);
104
101
  const fetchOptions = options
105
102
  ? {
106
103
  method: options.method,
@@ -147,7 +144,6 @@ export function sandboxFetchSyscalls(client: Client): SysCallMapping {
147
144
 
148
145
  function buildProxyUrl(client: Client, url: string) {
149
146
  url = url.replace(/^https?:\/\//, "");
150
- // Strip off the /.fs and replace with /.proxy
151
147
  return (
152
148
  client.httpSpacePrimitives.url.slice(0, -fsEndpoint.length) +
153
149
  "/.proxy/" +
@@ -0,0 +1,46 @@
1
+ import { resolveFeatherIcons } from "../../lib/feather_icons.ts";
2
+ import type { SysCallMapping } from "../system.ts";
3
+
4
+ export function iconSyscalls(): SysCallMapping {
5
+ return {
6
+ "icon.feather": {
7
+ callback: (_ctx, name: string): string | null => {
8
+ if (!name) return null;
9
+ return resolveFeatherIcons([name])[name] ?? null;
10
+ },
11
+ description:
12
+ "Renders a Feather icon to SVG markup by name, or nil when the name isn't a Feather icon. The markup carries no color of its own (`currentColor`) and a default 24x24 size, both of which CSS at the injection site can override.",
13
+ parameters: [
14
+ {
15
+ name: "name",
16
+ type: "string",
17
+ description: "A Feather icon name, e.g. `trash-2`.",
18
+ },
19
+ ],
20
+ returns: [
21
+ {
22
+ type: "string",
23
+ description: "The icon's SVG markup, or nil when unknown.",
24
+ },
25
+ ],
26
+ examples: [{ code: 'local lock = icon.feather("lock")' }],
27
+ },
28
+ "icon.resolveFeather": {
29
+ callback: (_ctx, names: string[]): Record<string, string> => {
30
+ return resolveFeatherIcons(names ?? []);
31
+ },
32
+ description:
33
+ "Renders a batch of Feather icons to SVG markup in one round trip, so panels can show icons without bundling the icon set themselves. Unknown names are omitted from the result. The markup carries no color of its own (`currentColor`) and a default 24x24 size, both of which CSS at the injection site can override.",
34
+ parameters: [
35
+ {
36
+ name: "names",
37
+ type: "table",
38
+ description: "Feather icon names, e.g. `trash-2`.",
39
+ },
40
+ ],
41
+ returns: [
42
+ { type: "table", description: "Map of icon name to SVG markup." },
43
+ ],
44
+ },
45
+ };
46
+ }