991516a1 Andy McCall 6 days ago ↶
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
}