OXYGENHUB
# 991516a107fa oxygenhub-vscode / src / drops.ts
23.6 KiB · last changed OxygenHub 0.13.0: Drops in the editor with Share as a Drop (closes #7); Flow approvals, expedite, reports, timings and test results (closes #8); ready/draft, landable, trust reports, asset downloads and new repositories (closes #9) 6 days ago source blame history raw
1
// The Drops view (spec §8.4): your drops on each hub you are signed in to,
2
// and others' drops you open; each drop's files open as documents that save
3
// back to the hub as a new change. Share as a Drop makes one from a
4
// selection, an editor or files in the Explorer.
5
6
import * as vscode from "vscode";
7
import type * as api from "./hubapi";
8
import { HubAuth } from "./auth";
9
import { DROP_SCHEME, DropRef, commentLine, dedent, dropBaseOf, dropIdOf, dropLabel, dropUriParts, embedSnippet, parseDropUri, shareNames, snippetName, snippetTitle } from "./dropkit";
10
11
type Node =
12
  | { kind: "hub"; base: string }
13
  | { kind: "drop"; base: string; d: api.Drop; mine: boolean }
14
  | { kind: "file"; base: string; d: api.Drop; f: api.DropFile; mine: boolean };
15
16
export type DropNode = Extract<Node, { kind: "drop" }>;
17
export type DropFileNode = Extract<Node, { kind: "file" }>;
18
19
export function dropUri(r: DropRef): vscode.Uri {
20
  return vscode.Uri.from({ scheme: DROP_SCHEME, ...dropUriParts(r) });
21
}
22
23
export class DropsProvider implements vscode.TreeDataProvider<Node> {
24
  private readonly emitter = new vscode.EventEmitter<void>();
25
  readonly onDidChangeTreeData = this.emitter.event;
26
  /** Others' drops opened this session, by hub. */
27
  private readonly opened = new Map<string, Map<string, api.Drop>>();
28
  private me = new Map<string, string>();
29
30
  constructor(private readonly auth: HubAuth) {}
31
32
  refresh(): void {
33
    this.emitter.fire();
34
  }
35
36
  /** Keep a drop in the view after opening, forking or sharing it. */
37
  remember(base: string, d: api.Drop): void {
38
    if (this.me.get(base) !== d.owner) {
39
      const m = this.opened.get(base) ?? new Map<string, api.Drop>();
40
      m.set(d.id, d);
41
      this.opened.set(base, m);
42
    }
43
    this.refresh();
44
  }
45
46
  forget(base: string, id: string): void {
47
    this.opened.get(base)?.delete(id);
48
    this.refresh();
49
  }
50
51
  private hubs(): string[] {
52
    return this.auth.all().map((s) => s.base);
53
  }
54
55
  getTreeItem(node: Node): vscode.TreeItem {
56
    switch (node.kind) {
57
      case "hub": {
58
        const item = new vscode.TreeItem(node.base.replace(/^https?:\/\//, ""), vscode.TreeItemCollapsibleState.Expanded);
59
        item.iconPath = new vscode.ThemeIcon("server");
60
        item.contextValue = "dropHub";
61
        return item;
62
      }
63
      case "drop": {
64
        const { label, description } = dropLabel(node.d);
65
        const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Collapsed);
66
        item.id = `${node.base}/${node.d.id}`;
67
        item.description = node.mine ? description : `${node.d.owner} · ${description}`;
68
        item.iconPath = new vscode.ThemeIcon(node.d.visibility === "secret" ? "lock" : node.d.forked_from ? "repo-forked" : "note");
69
        const md = new vscode.MarkdownString(`**${label}** by ${node.d.owner}${node.d.description ? `\n\n${node.d.description}` : ""}\n\n${node.d.url}`);
70
        item.tooltip = md;
71
        // dropMine/dropOther, then whether the caller starred it.
72
        item.contextValue = `${node.mine ? "dropMine" : "dropOther"}${node.d.starred ? "Starred" : ""}`;
73
        return item;
74
      }
75
      case "file": {
76
        const item = new vscode.TreeItem(node.f.name, vscode.TreeItemCollapsibleState.None);
77
        item.resourceUri = dropUri({ base: node.base, id: node.d.id, name: node.f.name });
78
        item.description = node.f.size >= 1024 ? `${(node.f.size / 1024).toFixed(1)} KiB` : `${node.f.size} B`;
79
        item.contextValue = node.mine ? "dropFileMine" : "dropFile";
80
        item.command = { title: "Open", command: "vscode.open", arguments: [item.resourceUri] };
81
        return item;
82
      }
83
    }
84
  }
85
86
  async getChildren(node?: Node): Promise<Node[]> {
87
    if (!node) {
88
      const hubs = this.hubs();
89
      return hubs.length === 1 ? this.dropsOf(hubs[0]) : hubs.map((base) => ({ kind: "hub" as const, base }));
90
    }
91
    if (node.kind === "hub") {
92
      return this.dropsOf(node.base);
93
    }
94
    if (node.kind === "drop") {
95
      return node.d.files.map((f) => ({ kind: "file" as const, base: node.base, d: node.d, f, mine: node.mine }));
96
    }
97
    return [];
98
  }
99
100
  private async dropsOf(base: string): Promise<Node[]> {
101
    const client = this.auth.clientFor(base);
102
    try {
103
      if (!this.me.has(base)) {
104
        this.me.set(base, (await client.me()).username);
105
      }
106
      const mine = await client.drops();
107
      const others = [...(this.opened.get(base)?.values() ?? [])].filter((d) => !mine.some((m) => m.id === d.id));
108
      return [...mine.map((d) => ({ kind: "drop" as const, base, d, mine: true })), ...others.map((d) => ({ kind: "drop" as const, base, d, mine: false }))];
109
    } catch (e) {
110
      void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`);
111
      return [];
112
    }
113
  }
114
}
115
116
/**
117
 * Drop files as a file system: reading fetches the drop, writing commits the
118
 * file to it (PATCH, one change per save), deleting removes it. A drop's own
119
 * folder lists its files.
120
 */
121
export class DropFileSystem implements vscode.FileSystemProvider {
122
  private readonly emitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
123
  readonly onDidChangeFile = this.emitter.event;
124
  private readonly cache = new Map<string, { d: api.Drop; at: number }>();
125
126
  constructor(
127
    private readonly auth: HubAuth,
128
    private readonly changed: (base: string, d: api.Drop) => void,
129
  ) {}
130
131
  private ref(uri: vscode.Uri): DropRef {
132
    const r = parseDropUri(uri.path, uri.query);
133
    if (!r) {
134
      throw vscode.FileSystemError.FileNotFound(uri);
135
    }
136
    return r;
137
  }
138
139
  private async load(r: DropRef, fresh = false): Promise<api.Drop> {
140
    const key = `${r.base}/${r.id}`;
141
    const hit = this.cache.get(key);
142
    if (hit && !fresh && Date.now() - hit.at < 2000) {
143
      return hit.d;
144
    }
145
    const d = await this.auth.clientFor(r.base).drop(r.id);
146
    this.cache.set(key, { d, at: Date.now() });
147
    return d;
148
  }
149
150
  watch(): vscode.Disposable {
151
    return new vscode.Disposable(() => undefined);
152
  }
153
154
  async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
155
    const r = this.ref(uri);
156
    const d = await this.load(r);
157
    if (!r.name) {
158
      return { type: vscode.FileType.Directory, ctime: d.created_ms, mtime: d.updated_ms, size: 0 };
159
    }
160
    const f = d.files.find((x) => x.name === r.name);
161
    if (f) {
162
      return { type: vscode.FileType.File, ctime: d.created_ms, mtime: d.updated_ms, size: f.size };
163
    }
164
    if (d.files.some((x) => x.name.startsWith(r.name + "/"))) {
165
      return { type: vscode.FileType.Directory, ctime: d.created_ms, mtime: d.updated_ms, size: 0 };
166
    }
167
    throw vscode.FileSystemError.FileNotFound(uri);
168
  }
169
170
  async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
171
    const r = this.ref(uri);
172
    const d = await this.load(r);
173
    const prefix = r.name ? r.name + "/" : "";
174
    const out = new Map<string, vscode.FileType>();
175
    for (const f of d.files.filter((x) => x.name.startsWith(prefix))) {
176
      const rest = f.name.slice(prefix.length);
177
      const [head, ...tail] = rest.split("/");
178
      out.set(head, tail.length ? vscode.FileType.Directory : vscode.FileType.File);
179
    }
180
    return [...out];
181
  }
182
183
  createDirectory(): void {
184
    throw vscode.FileSystemError.NoPermissions("A drop's folders come from its files' names.");
185
  }
186
187
  async readFile(uri: vscode.Uri): Promise<Uint8Array> {
188
    const r = this.ref(uri);
189
    const d = await this.load(r, true);
190
    const f = d.files.find((x) => x.name === r.name);
191
    if (!f) {
192
      throw vscode.FileSystemError.FileNotFound(uri);
193
    }
194
    // Text up to 512 KiB comes with the drop; anything else from its raw address.
195
    if (f.content !== undefined && f.content !== null) {
196
      return new TextEncoder().encode(f.content);
197
    }
198
    return this.auth.clientFor(r.base).download(f.raw_url);
199
  }
200
201
  async writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean }): Promise<void> {
202
    const r = this.ref(uri);
203
    const d = await this.load(r);
204
    const exists = d.files.some((x) => x.name === r.name);
205
    if (!exists && !options.create) {
206
      throw vscode.FileSystemError.FileNotFound(uri);
207
    }
208
    if (exists && !options.overwrite) {
209
      throw vscode.FileSystemError.FileExists(uri);
210
    }
211
    const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
212
    const next = await this.auth.clientFor(r.base).editDrop(r.id, { files: [{ name: r.name, content: text }], message: `${exists ? "Edit" : "Add"} ${r.name}` }).catch((e: Error) => {
213
      throw vscode.FileSystemError.Unavailable(e.message);
214
    });
215
    this.cache.set(`${r.base}/${r.id}`, { d: next, at: Date.now() });
216
    this.changed(r.base, next);
217
    this.emitter.fire([{ type: exists ? vscode.FileChangeType.Changed : vscode.FileChangeType.Created, uri }]);
218
  }
219
220
  async delete(uri: vscode.Uri): Promise<void> {
221
    const r = this.ref(uri);
222
    if (!r.name) {
223
      throw vscode.FileSystemError.NoPermissions("Delete the drop from the Drops view.");
224
    }
225
    const next = await this.auth.clientFor(r.base).editDrop(r.id, { files: [{ name: r.name, content: null }], message: `Remove ${r.name}` });
226
    this.cache.set(`${r.base}/${r.id}`, { d: next, at: Date.now() });
227
    this.changed(r.base, next);
228
    this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]);
229
  }
230
231
  rename(): void {
232
    throw vscode.FileSystemError.NoPermissions("Renaming a drop's file is not supported yet; add it under the new name and remove the old.");
233
  }
234
}
235
236
/** The hub to make a drop on: the only one signed in to, else ask. */
237
async function pickHub(auth: HubAuth): Promise<string | undefined> {
238
  const hubs = auth.all().map((s) => s.base);
239
  if (hubs.length === 0) {
240
    void vscode.window.showWarningMessage("OxygenHub: sign in to a hub first.", "Sign In").then((p) => p && vscode.commands.executeCommand("oxygenhub.signIn"));
241
    return undefined;
242
  }
243
  return hubs.length === 1 ? hubs[0] : vscode.window.showQuickPick(hubs, { placeHolder: "Make the drop on" });
244
}
245
246
/** Ask for a title (prefilled) and whether the drop is public or secret. */
247
async function askTitleAndVisibility(value: string): Promise<{ title: string; secret: boolean } | undefined> {
248
  const title = await vscode.window.showInputBox({ prompt: "Drop title", value, validateInput: (v) => (v.length > 200 ? "At most 200 characters" : undefined) });
249
  if (title === undefined) {
250
    return undefined;
251
  }
252
  const remembered = vscode.workspace.getConfiguration("oxygenhub").get<string>("dropVisibility", "secret");
253
  const options = [
254
    { label: "$(lock) Secret", description: "unlisted: anyone with its address can read it", secret: true },
255
    { label: "$(globe) Public", description: "listed on your Drops page", secret: false },
256
  ].sort((a) => ((a.secret ? "secret" : "public") === remembered ? -1 : 1));
257
  const pick = await vscode.window.showQuickPick(options, { placeHolder: "Who can find it" });
258
  return pick ? { title, secret: pick.secret } : undefined;
259
}
260
261
/** A new drop is made: say so, copy its address if asked, show it in the view. */
262
async function announce(drops: DropsProvider, base: string, d: api.Drop): Promise<void> {
263
  drops.remember(base, d);
264
  const copy = vscode.workspace.getConfiguration("oxygenhub").get<boolean>("copyDropLink", true);
265
  if (copy) {
266
    await vscode.env.clipboard.writeText(d.url);
267
  }
268
  const pick = await vscode.window.showInformationMessage(`OxygenHub: dropped "${d.title || d.id}"${copy ? "; its address is copied" : ""}.`, "Open on Hub", ...(copy ? [] : ["Copy Link"]));
269
  if (pick === "Open on Hub") {
270
    void vscode.env.openExternal(vscode.Uri.parse(d.url));
271
  } else if (pick === "Copy Link") {
272
    await vscode.env.clipboard.writeText(d.url);
273
  }
274
}
275
276
/** Share as a Drop from the editor: the selection (its lines), or the whole document when nothing is selected. */
277
export async function shareSelection(auth: HubAuth, drops: DropsProvider, uri?: vscode.Uri): Promise<void> {
278
  const editor = vscode.window.activeTextEditor;
279
  const doc = editor && (!uri || editor.document.uri.toString() === uri.toString()) ? editor.document : uri ? await vscode.workspace.openTextDocument(uri) : undefined;
280
  if (!doc) {
281
    void vscode.window.showWarningMessage("OxygenHub: open a file, or select some code, to share as a drop.");
282
    return;
283
  }
284
  const sel = editor && editor.document === doc ? editor.selection : undefined;
285
  const whole = !sel || sel.isEmpty;
286
  const name = snippetName(doc.isUntitled ? undefined : doc.fileName, doc.languageId);
287
  let content = doc.getText();
288
  let start = 1;
289
  let end = doc.lineCount;
290
  if (!whole && sel) {
291
    const last = sel.end.character === 0 && sel.end.line > sel.start.line ? sel.end.line - 1 : sel.end.line;
292
    content = dedent(doc.getText(new vscode.Range(sel.start.line, 0, last, doc.lineAt(last).text.length))) + "\n";
293
    [start, end] = [sel.start.line + 1, last + 1];
294
  }
295
  const base = await pickHub(auth);
296
  if (!base) {
297
    return;
298
  }
299
  const asked = await askTitleAndVisibility(snippetTitle(name, start, end, whole));
300
  if (!asked) {
301
    return;
302
  }
303
  try {
304
    const d = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "OxygenHub: dropping…" }, () =>
305
      auth.clientFor(base).createDrop({ title: asked.title, secret: asked.secret, files: [{ name, content }] }),
306
    );
307
    await announce(drops, base, d);
308
  } catch (e) {
309
    void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
310
  }
311
}
312
313
/** Share files (the Explorer's selection, folders' files included) as one drop. */
314
export async function shareFiles(auth: HubAuth, drops: DropsProvider, uri?: vscode.Uri, uris?: vscode.Uri[]): Promise<void> {
315
  let picked = uris && uris.length ? uris : uri ? [uri] : vscode.window.activeTextEditor ? [vscode.window.activeTextEditor.document.uri] : [];
316
  if (!picked.length) {
317
    picked = (await vscode.window.showOpenDialog({ canSelectMany: true, openLabel: "Share as a Drop" })) ?? [];
318
  }
319
  const files: vscode.Uri[] = [];
320
  for (const u of picked) {
321
    const stat = await vscode.workspace.fs.stat(u);
322
    if (stat.type & vscode.FileType.Directory) {
323
      files.push(...(await vscode.workspace.findFiles(new vscode.RelativePattern(u, "**/*"), "**/{.oxy,.git,node_modules,target}/**", 51)));
324
    } else {
325
      files.push(u);
326
    }
327
  }
328
  if (!files.length) {
329
    return;
330
  }
331
  if (files.length > 50) {
332
    void vscode.window.showWarningMessage("OxygenHub: a drop takes at most 50 files; push larger sets with oxy.");
333
    return;
334
  }
335
  const names = shareNames(files.map((f) => f.path));
336
  const contents: api.NewDropFile[] = [];
337
  for (const [i, f] of files.entries()) {
338
    const bytes = await vscode.workspace.fs.readFile(f);
339
    if (bytes.length > 1 << 20) {
340
      void vscode.window.showWarningMessage(`OxygenHub: ${names[i]} is larger than 1 MiB; a drop's files are at most that.`);
341
      return;
342
    }
343
    try {
344
      contents.push({ name: names[i], content: new TextDecoder("utf-8", { fatal: true }).decode(bytes) });
345
    } catch {
346
      void vscode.window.showWarningMessage(`OxygenHub: ${names[i]} is not text; a drop shares text files.`);
347
      return;
348
    }
349
  }
350
  const base = await pickHub(auth);
351
  if (!base) {
352
    return;
353
  }
354
  const asked = await askTitleAndVisibility(names.length === 1 ? names[0] : `${names[0]} and ${names.length - 1} more`);
355
  if (!asked) {
356
    return;
357
  }
358
  try {
359
    const d = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "OxygenHub: dropping…" }, () =>
360
      auth.clientFor(base).createDrop({ title: asked.title, secret: asked.secret, files: contents }),
361
    );
362
    await announce(drops, base, d);
363
  } catch (e) {
364
    void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
365
  }
366
}
367
368
/** The drop commands that act on the view's items. */
369
export function registerDropCommands(auth: HubAuth, drops: DropsProvider): vscode.Disposable[] {
370
  const client = (base: string) => auth.clientFor(base);
371
  const guard =
372
    <A extends unknown[]>(f: (...a: A) => Promise<unknown>) =>
373
    async (...a: A) => {
374
      try {
375
        await f(...a);
376
      } catch (e) {
377
        void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`);
378
      }
379
    };
380
  const open = async (base: string, d: api.Drop) => {
381
    drops.remember(base, d);
382
    if (d.files.length) {
383
      await vscode.window.showTextDocument(dropUri({ base, id: d.id, name: d.files[0].name }), { preview: false });
384
    }
385
  };
386
  return [
387
    vscode.commands.registerCommand("oxygenhub.dropsRefresh", () => drops.refresh()),
388
    vscode.commands.registerCommand(
389
      "oxygenhub.newDrop",
390
      guard(async () => {
391
        const base = await pickHub(auth);
392
        if (!base) {
393
          return;
394
        }
395
        const name = await vscode.window.showInputBox({ prompt: "First file's name", value: "notes.md", validateInput: (v) => (v.trim() ? undefined : "Every file needs a name") });
396
        if (!name) {
397
          return;
398
        }
399
        const asked = await askTitleAndVisibility("");
400
        if (!asked) {
401
          return;
402
        }
403
        const d = await client(base).createDrop({ title: asked.title, secret: asked.secret, files: [{ name: name.trim(), content: "" }] });
404
        drops.refresh();
405
        await open(base, d);
406
      }),
407
    ),
408
    vscode.commands.registerCommand(
409
      "oxygenhub.openDrop",
410
      guard(async () => {
411
        const text = await vscode.window.showInputBox({ prompt: "A drop's address or id", placeHolder: "https://hub/OWNER/drops/ID", validateInput: (v) => (dropIdOf(v) ? undefined : "A drop's page or its 24-digit id") });
412
        if (!text) {
413
          return;
414
        }
415
        const id = dropIdOf(text)!;
416
        const pasted = dropBaseOf(text);
417
        const base = pasted && auth.bases().includes(pasted) ? pasted : pasted ?? (await pickHub(auth));
418
        if (!base) {
419
          return;
420
        }
421
        await open(base, await client(base).drop(id));
422
      }),
423
    ),
424
    vscode.commands.registerCommand("oxygenhub.openDropOnHub", (n: DropNode) => vscode.env.openExternal(vscode.Uri.parse(n.d.url))),
425
    vscode.commands.registerCommand("oxygenhub.copyDropLink", async (n: DropNode) => {
426
      await vscode.env.clipboard.writeText(n.d.url);
427
      vscode.window.setStatusBarMessage("$(link) Drop address copied", 3000);
428
    }),
429
    vscode.commands.registerCommand("oxygenhub.copyDropClone", async (n: DropNode) => {
430
      await vscode.env.clipboard.writeText(`oxy clone ${n.d.clone_url}`);
431
      vscode.window.setStatusBarMessage("$(terminal) oxy clone command copied", 3000);
432
    }),
433
    vscode.commands.registerCommand("oxygenhub.copyDropEmbed", async (n: DropNode) => {
434
      await vscode.env.clipboard.writeText(embedSnippet(n.d));
435
      vscode.window.setStatusBarMessage("$(code) Embed HTML copied", 3000);
436
    }),
437
    vscode.commands.registerCommand(
438
      "oxygenhub.cloneDrop",
439
      guard(async (n: DropNode) => {
440
        const parent = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, openLabel: "Clone Here" });
441
        if (!parent?.[0]) {
442
          return;
443
        }
444
        const t = vscode.window.createTerminal({ name: "OxygenHub", cwd: parent[0].fsPath });
445
        t.show();
446
        t.sendText(`oxy clone ${n.d.clone_url} drop-${n.d.id.slice(0, 8)}`);
447
      }),
448
    ),
449
    vscode.commands.registerCommand(
450
      "oxygenhub.editDrop",
451
      guard(async (n: DropNode) => {
452
        const title = await vscode.window.showInputBox({ prompt: "Title", value: n.d.title });
453
        if (title === undefined) {
454
          return;
455
        }
456
        const description = await vscode.window.showInputBox({ prompt: "Description", value: n.d.description });
457
        if (description === undefined) {
458
          return;
459
        }
460
        await client(n.base).editDrop(n.d.id, { title, description });
461
        drops.refresh();
462
      }),
463
    ),
464
    vscode.commands.registerCommand(
465
      "oxygenhub.toggleDropVisibility",
466
      guard(async (n: DropNode) => {
467
        const secret = n.d.visibility !== "secret";
468
        await client(n.base).editDrop(n.d.id, { secret });
469
        drops.refresh();
470
        vscode.window.setStatusBarMessage(secret ? "$(lock) The drop is secret now" : "$(globe) The drop is public now", 3000);
471
      }),
472
    ),
473
    vscode.commands.registerCommand(
474
      "oxygenhub.addDropFile",
475
      guard(async (n: DropNode) => {
476
        const name = await vscode.window.showInputBox({ prompt: `New file in "${n.d.title || n.d.id}"`, validateInput: (v) => (!v.trim() ? "Every file needs a name" : n.d.files.some((f) => f.name === v.trim()) ? "The drop has a file by that name" : undefined) });
477
        if (!name) {
478
          return;
479
        }
480
        await client(n.base).editDrop(n.d.id, { files: [{ name: name.trim(), content: "" }], message: `Add ${name.trim()}` });
481
        drops.refresh();
482
        await vscode.window.showTextDocument(dropUri({ base: n.base, id: n.d.id, name: name.trim() }), { preview: false });
483
      }),
484
    ),
485
    vscode.commands.registerCommand(
486
      "oxygenhub.deleteDropFile",
487
      guard(async (n: DropFileNode) => {
488
        if (n.d.files.length === 1) {
489
          void vscode.window.showWarningMessage("OxygenHub: a drop keeps at least one file; delete the drop instead.");
490
          return;
491
        }
492
        const ok = await vscode.window.showWarningMessage(`Remove ${n.f.name} from the drop?`, { modal: true, detail: "Its history keeps it." }, "Remove");
493
        if (ok) {
494
          await client(n.base).editDrop(n.d.id, { files: [{ name: n.f.name, content: null }], message: `Remove ${n.f.name}` });
495
          drops.refresh();
496
        }
497
      }),
498
    ),
499
    vscode.commands.registerCommand(
500
      "oxygenhub.deleteDrop",
501
      guard(async (n: DropNode) => {
502
        const ok = await vscode.window.showWarningMessage(`Delete the drop "${n.d.title || n.d.id}"?`, { modal: true, detail: "It and its history are gone for good, and its address stops working." }, "Delete");
503
        if (ok) {
504
          await client(n.base).deleteDrop(n.d.id);
505
          drops.forget(n.base, n.d.id);
506
        }
507
      }),
508
    ),
509
    vscode.commands.registerCommand(
510
      "oxygenhub.forkDrop",
511
      guard(async (n: DropNode) => {
512
        const d = await client(n.base).forkDrop(n.d.id);
513
        drops.refresh();
514
        await open(n.base, d);
515
        vscode.window.setStatusBarMessage(`$(repo-forked) Forked "${n.d.title || n.d.id}"`, 3000);
516
      }),
517
    ),
518
    vscode.commands.registerCommand(
519
      "oxygenhub.starDrop",
520
      guard(async (n: DropNode) => {
521
        const s = await client(n.base).starDrop(n.d.id, true);
522
        drops.remember(n.base, { ...n.d, ...s });
523
      }),
524
    ),
525
    vscode.commands.registerCommand(
526
      "oxygenhub.unstarDrop",
527
      guard(async (n: DropNode) => {
528
        const s = await client(n.base).starDrop(n.d.id, false);
529
        drops.remember(n.base, { ...n.d, ...s });
530
      }),
531
    ),
532
    vscode.commands.registerCommand(
533
      "oxygenhub.dropComments",
534
      guard(async (n: DropNode) => {
535
        const c = client(n.base);
536
        const me = (await c.me()).username;
537
        const comments = await c.dropComments(n.d.id);
538
        type Item = vscode.QuickPickItem & { add?: boolean; comment?: api.DropComment };
539
        const items: Item[] = [{ label: "$(comment) Add a comment…", add: true }, ...comments.map((x) => ({ ...commentLine(x), comment: x }))];
540
        const pick = await vscode.window.showQuickPick(items, { placeHolder: `${comments.length} comment${comments.length === 1 ? "" : "s"} on "${n.d.title || n.d.id}"`, matchOnDetail: true });
541
        if (pick?.add) {
542
          const body = await vscode.window.showInputBox({ prompt: "Comment (Markdown)" });
543
          if (body?.trim()) {
544
            await c.commentDrop(n.d.id, body);
545
            vscode.window.setStatusBarMessage("$(comment) Commented", 3000);
546
          }
547
        } else if (pick?.comment) {
548
          const mayDelete = pick.comment.author === me || n.d.owner === me;
549
          const act = await vscode.window.showInformationMessage(`${pick.comment.author}: ${pick.comment.body}`, ...(mayDelete ? ["Delete Comment"] : []));
550
          if (act === "Delete Comment") {
551
            await c.deleteDropComment(n.d.id, pick.comment.id);
552
          }
553
        }
554
      }),
555
    ),
556
  ];
557
}