| 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 |
} |