// The Drops view (spec §8.4): your drops on each hub you are signed in to, // and others' drops you open; each drop's files open as documents that save // back to the hub as a new change. Share as a Drop makes one from a // selection, an editor or files in the Explorer. import * as vscode from "vscode"; import type * as api from "./hubapi"; import { HubAuth } from "./auth"; import { DROP_SCHEME, DropRef, commentLine, dedent, dropBaseOf, dropIdOf, dropLabel, dropUriParts, embedSnippet, parseDropUri, shareNames, snippetName, snippetTitle } from "./dropkit"; type Node = | { kind: "hub"; base: string } | { kind: "drop"; base: string; d: api.Drop; mine: boolean } | { kind: "file"; base: string; d: api.Drop; f: api.DropFile; mine: boolean }; export type DropNode = Extract; export type DropFileNode = Extract; export function dropUri(r: DropRef): vscode.Uri { return vscode.Uri.from({ scheme: DROP_SCHEME, ...dropUriParts(r) }); } export class DropsProvider implements vscode.TreeDataProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.emitter.event; /** Others' drops opened this session, by hub. */ private readonly opened = new Map>(); private me = new Map(); constructor(private readonly auth: HubAuth) {} refresh(): void { this.emitter.fire(); } /** Keep a drop in the view after opening, forking or sharing it. */ remember(base: string, d: api.Drop): void { if (this.me.get(base) !== d.owner) { const m = this.opened.get(base) ?? new Map(); m.set(d.id, d); this.opened.set(base, m); } this.refresh(); } forget(base: string, id: string): void { this.opened.get(base)?.delete(id); this.refresh(); } private hubs(): string[] { return this.auth.all().map((s) => s.base); } getTreeItem(node: Node): vscode.TreeItem { switch (node.kind) { case "hub": { const item = new vscode.TreeItem(node.base.replace(/^https?:\/\//, ""), vscode.TreeItemCollapsibleState.Expanded); item.iconPath = new vscode.ThemeIcon("server"); item.contextValue = "dropHub"; return item; } case "drop": { const { label, description } = dropLabel(node.d); const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Collapsed); item.id = `${node.base}/${node.d.id}`; item.description = node.mine ? description : `${node.d.owner} · ${description}`; item.iconPath = new vscode.ThemeIcon(node.d.visibility === "secret" ? "lock" : node.d.forked_from ? "repo-forked" : "note"); const md = new vscode.MarkdownString(`**${label}** by ${node.d.owner}${node.d.description ? `\n\n${node.d.description}` : ""}\n\n${node.d.url}`); item.tooltip = md; // dropMine/dropOther, then whether the caller starred it. item.contextValue = `${node.mine ? "dropMine" : "dropOther"}${node.d.starred ? "Starred" : ""}`; return item; } case "file": { const item = new vscode.TreeItem(node.f.name, vscode.TreeItemCollapsibleState.None); item.resourceUri = dropUri({ base: node.base, id: node.d.id, name: node.f.name }); item.description = node.f.size >= 1024 ? `${(node.f.size / 1024).toFixed(1)} KiB` : `${node.f.size} B`; item.contextValue = node.mine ? "dropFileMine" : "dropFile"; item.command = { title: "Open", command: "vscode.open", arguments: [item.resourceUri] }; return item; } } } async getChildren(node?: Node): Promise { if (!node) { const hubs = this.hubs(); return hubs.length === 1 ? this.dropsOf(hubs[0]) : hubs.map((base) => ({ kind: "hub" as const, base })); } if (node.kind === "hub") { return this.dropsOf(node.base); } if (node.kind === "drop") { return node.d.files.map((f) => ({ kind: "file" as const, base: node.base, d: node.d, f, mine: node.mine })); } return []; } private async dropsOf(base: string): Promise { const client = this.auth.clientFor(base); try { if (!this.me.has(base)) { this.me.set(base, (await client.me()).username); } const mine = await client.drops(); const others = [...(this.opened.get(base)?.values() ?? [])].filter((d) => !mine.some((m) => m.id === d.id)); return [...mine.map((d) => ({ kind: "drop" as const, base, d, mine: true })), ...others.map((d) => ({ kind: "drop" as const, base, d, mine: false }))]; } catch (e) { void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); return []; } } } /** * Drop files as a file system: reading fetches the drop, writing commits the * file to it (PATCH, one change per save), deleting removes it. A drop's own * folder lists its files. */ export class DropFileSystem implements vscode.FileSystemProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeFile = this.emitter.event; private readonly cache = new Map(); constructor( private readonly auth: HubAuth, private readonly changed: (base: string, d: api.Drop) => void, ) {} private ref(uri: vscode.Uri): DropRef { const r = parseDropUri(uri.path, uri.query); if (!r) { throw vscode.FileSystemError.FileNotFound(uri); } return r; } private async load(r: DropRef, fresh = false): Promise { const key = `${r.base}/${r.id}`; const hit = this.cache.get(key); if (hit && !fresh && Date.now() - hit.at < 2000) { return hit.d; } const d = await this.auth.clientFor(r.base).drop(r.id); this.cache.set(key, { d, at: Date.now() }); return d; } watch(): vscode.Disposable { return new vscode.Disposable(() => undefined); } async stat(uri: vscode.Uri): Promise { const r = this.ref(uri); const d = await this.load(r); if (!r.name) { return { type: vscode.FileType.Directory, ctime: d.created_ms, mtime: d.updated_ms, size: 0 }; } const f = d.files.find((x) => x.name === r.name); if (f) { return { type: vscode.FileType.File, ctime: d.created_ms, mtime: d.updated_ms, size: f.size }; } if (d.files.some((x) => x.name.startsWith(r.name + "/"))) { return { type: vscode.FileType.Directory, ctime: d.created_ms, mtime: d.updated_ms, size: 0 }; } throw vscode.FileSystemError.FileNotFound(uri); } async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { const r = this.ref(uri); const d = await this.load(r); const prefix = r.name ? r.name + "/" : ""; const out = new Map(); for (const f of d.files.filter((x) => x.name.startsWith(prefix))) { const rest = f.name.slice(prefix.length); const [head, ...tail] = rest.split("/"); out.set(head, tail.length ? vscode.FileType.Directory : vscode.FileType.File); } return [...out]; } createDirectory(): void { throw vscode.FileSystemError.NoPermissions("A drop's folders come from its files' names."); } async readFile(uri: vscode.Uri): Promise { const r = this.ref(uri); const d = await this.load(r, true); const f = d.files.find((x) => x.name === r.name); if (!f) { throw vscode.FileSystemError.FileNotFound(uri); } // Text up to 512 KiB comes with the drop; anything else from its raw address. if (f.content !== undefined && f.content !== null) { return new TextEncoder().encode(f.content); } return this.auth.clientFor(r.base).download(f.raw_url); } async writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean }): Promise { const r = this.ref(uri); const d = await this.load(r); const exists = d.files.some((x) => x.name === r.name); if (!exists && !options.create) { throw vscode.FileSystemError.FileNotFound(uri); } if (exists && !options.overwrite) { throw vscode.FileSystemError.FileExists(uri); } const text = new TextDecoder("utf-8", { fatal: true }).decode(content); 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) => { throw vscode.FileSystemError.Unavailable(e.message); }); this.cache.set(`${r.base}/${r.id}`, { d: next, at: Date.now() }); this.changed(r.base, next); this.emitter.fire([{ type: exists ? vscode.FileChangeType.Changed : vscode.FileChangeType.Created, uri }]); } async delete(uri: vscode.Uri): Promise { const r = this.ref(uri); if (!r.name) { throw vscode.FileSystemError.NoPermissions("Delete the drop from the Drops view."); } const next = await this.auth.clientFor(r.base).editDrop(r.id, { files: [{ name: r.name, content: null }], message: `Remove ${r.name}` }); this.cache.set(`${r.base}/${r.id}`, { d: next, at: Date.now() }); this.changed(r.base, next); this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]); } rename(): void { throw vscode.FileSystemError.NoPermissions("Renaming a drop's file is not supported yet; add it under the new name and remove the old."); } } /** The hub to make a drop on: the only one signed in to, else ask. */ async function pickHub(auth: HubAuth): Promise { const hubs = auth.all().map((s) => s.base); if (hubs.length === 0) { void vscode.window.showWarningMessage("OxygenHub: sign in to a hub first.", "Sign In").then((p) => p && vscode.commands.executeCommand("oxygenhub.signIn")); return undefined; } return hubs.length === 1 ? hubs[0] : vscode.window.showQuickPick(hubs, { placeHolder: "Make the drop on" }); } /** Ask for a title (prefilled) and whether the drop is public or secret. */ async function askTitleAndVisibility(value: string): Promise<{ title: string; secret: boolean } | undefined> { const title = await vscode.window.showInputBox({ prompt: "Drop title", value, validateInput: (v) => (v.length > 200 ? "At most 200 characters" : undefined) }); if (title === undefined) { return undefined; } const remembered = vscode.workspace.getConfiguration("oxygenhub").get("dropVisibility", "secret"); const options = [ { label: "$(lock) Secret", description: "unlisted: anyone with its address can read it", secret: true }, { label: "$(globe) Public", description: "listed on your Drops page", secret: false }, ].sort((a) => ((a.secret ? "secret" : "public") === remembered ? -1 : 1)); const pick = await vscode.window.showQuickPick(options, { placeHolder: "Who can find it" }); return pick ? { title, secret: pick.secret } : undefined; } /** A new drop is made: say so, copy its address if asked, show it in the view. */ async function announce(drops: DropsProvider, base: string, d: api.Drop): Promise { drops.remember(base, d); const copy = vscode.workspace.getConfiguration("oxygenhub").get("copyDropLink", true); if (copy) { await vscode.env.clipboard.writeText(d.url); } const pick = await vscode.window.showInformationMessage(`OxygenHub: dropped "${d.title || d.id}"${copy ? "; its address is copied" : ""}.`, "Open on Hub", ...(copy ? [] : ["Copy Link"])); if (pick === "Open on Hub") { void vscode.env.openExternal(vscode.Uri.parse(d.url)); } else if (pick === "Copy Link") { await vscode.env.clipboard.writeText(d.url); } } /** Share as a Drop from the editor: the selection (its lines), or the whole document when nothing is selected. */ export async function shareSelection(auth: HubAuth, drops: DropsProvider, uri?: vscode.Uri): Promise { const editor = vscode.window.activeTextEditor; const doc = editor && (!uri || editor.document.uri.toString() === uri.toString()) ? editor.document : uri ? await vscode.workspace.openTextDocument(uri) : undefined; if (!doc) { void vscode.window.showWarningMessage("OxygenHub: open a file, or select some code, to share as a drop."); return; } const sel = editor && editor.document === doc ? editor.selection : undefined; const whole = !sel || sel.isEmpty; const name = snippetName(doc.isUntitled ? undefined : doc.fileName, doc.languageId); let content = doc.getText(); let start = 1; let end = doc.lineCount; if (!whole && sel) { const last = sel.end.character === 0 && sel.end.line > sel.start.line ? sel.end.line - 1 : sel.end.line; content = dedent(doc.getText(new vscode.Range(sel.start.line, 0, last, doc.lineAt(last).text.length))) + "\n"; [start, end] = [sel.start.line + 1, last + 1]; } const base = await pickHub(auth); if (!base) { return; } const asked = await askTitleAndVisibility(snippetTitle(name, start, end, whole)); if (!asked) { return; } try { const d = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "OxygenHub: dropping…" }, () => auth.clientFor(base).createDrop({ title: asked.title, secret: asked.secret, files: [{ name, content }] }), ); await announce(drops, base, d); } catch (e) { void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); } } /** Share files (the Explorer's selection, folders' files included) as one drop. */ export async function shareFiles(auth: HubAuth, drops: DropsProvider, uri?: vscode.Uri, uris?: vscode.Uri[]): Promise { let picked = uris && uris.length ? uris : uri ? [uri] : vscode.window.activeTextEditor ? [vscode.window.activeTextEditor.document.uri] : []; if (!picked.length) { picked = (await vscode.window.showOpenDialog({ canSelectMany: true, openLabel: "Share as a Drop" })) ?? []; } const files: vscode.Uri[] = []; for (const u of picked) { const stat = await vscode.workspace.fs.stat(u); if (stat.type & vscode.FileType.Directory) { files.push(...(await vscode.workspace.findFiles(new vscode.RelativePattern(u, "**/*"), "**/{.oxy,.git,node_modules,target}/**", 51))); } else { files.push(u); } } if (!files.length) { return; } if (files.length > 50) { void vscode.window.showWarningMessage("OxygenHub: a drop takes at most 50 files; push larger sets with oxy."); return; } const names = shareNames(files.map((f) => f.path)); const contents: api.NewDropFile[] = []; for (const [i, f] of files.entries()) { const bytes = await vscode.workspace.fs.readFile(f); if (bytes.length > 1 << 20) { void vscode.window.showWarningMessage(`OxygenHub: ${names[i]} is larger than 1 MiB; a drop's files are at most that.`); return; } try { contents.push({ name: names[i], content: new TextDecoder("utf-8", { fatal: true }).decode(bytes) }); } catch { void vscode.window.showWarningMessage(`OxygenHub: ${names[i]} is not text; a drop shares text files.`); return; } } const base = await pickHub(auth); if (!base) { return; } const asked = await askTitleAndVisibility(names.length === 1 ? names[0] : `${names[0]} and ${names.length - 1} more`); if (!asked) { return; } try { const d = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "OxygenHub: dropping…" }, () => auth.clientFor(base).createDrop({ title: asked.title, secret: asked.secret, files: contents }), ); await announce(drops, base, d); } catch (e) { void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); } } /** The drop commands that act on the view's items. */ export function registerDropCommands(auth: HubAuth, drops: DropsProvider): vscode.Disposable[] { const client = (base: string) => auth.clientFor(base); const guard = (f: (...a: A) => Promise) => async (...a: A) => { try { await f(...a); } catch (e) { void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); } }; const open = async (base: string, d: api.Drop) => { drops.remember(base, d); if (d.files.length) { await vscode.window.showTextDocument(dropUri({ base, id: d.id, name: d.files[0].name }), { preview: false }); } }; return [ vscode.commands.registerCommand("oxygenhub.dropsRefresh", () => drops.refresh()), vscode.commands.registerCommand( "oxygenhub.newDrop", guard(async () => { const base = await pickHub(auth); if (!base) { return; } const name = await vscode.window.showInputBox({ prompt: "First file's name", value: "notes.md", validateInput: (v) => (v.trim() ? undefined : "Every file needs a name") }); if (!name) { return; } const asked = await askTitleAndVisibility(""); if (!asked) { return; } const d = await client(base).createDrop({ title: asked.title, secret: asked.secret, files: [{ name: name.trim(), content: "" }] }); drops.refresh(); await open(base, d); }), ), vscode.commands.registerCommand( "oxygenhub.openDrop", guard(async () => { 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") }); if (!text) { return; } const id = dropIdOf(text)!; const pasted = dropBaseOf(text); const base = pasted && auth.bases().includes(pasted) ? pasted : pasted ?? (await pickHub(auth)); if (!base) { return; } await open(base, await client(base).drop(id)); }), ), vscode.commands.registerCommand("oxygenhub.openDropOnHub", (n: DropNode) => vscode.env.openExternal(vscode.Uri.parse(n.d.url))), vscode.commands.registerCommand("oxygenhub.copyDropLink", async (n: DropNode) => { await vscode.env.clipboard.writeText(n.d.url); vscode.window.setStatusBarMessage("$(link) Drop address copied", 3000); }), vscode.commands.registerCommand("oxygenhub.copyDropClone", async (n: DropNode) => { await vscode.env.clipboard.writeText(`oxy clone ${n.d.clone_url}`); vscode.window.setStatusBarMessage("$(terminal) oxy clone command copied", 3000); }), vscode.commands.registerCommand("oxygenhub.copyDropEmbed", async (n: DropNode) => { await vscode.env.clipboard.writeText(embedSnippet(n.d)); vscode.window.setStatusBarMessage("$(code) Embed HTML copied", 3000); }), vscode.commands.registerCommand( "oxygenhub.cloneDrop", guard(async (n: DropNode) => { const parent = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, openLabel: "Clone Here" }); if (!parent?.[0]) { return; } const t = vscode.window.createTerminal({ name: "OxygenHub", cwd: parent[0].fsPath }); t.show(); t.sendText(`oxy clone ${n.d.clone_url} drop-${n.d.id.slice(0, 8)}`); }), ), vscode.commands.registerCommand( "oxygenhub.editDrop", guard(async (n: DropNode) => { const title = await vscode.window.showInputBox({ prompt: "Title", value: n.d.title }); if (title === undefined) { return; } const description = await vscode.window.showInputBox({ prompt: "Description", value: n.d.description }); if (description === undefined) { return; } await client(n.base).editDrop(n.d.id, { title, description }); drops.refresh(); }), ), vscode.commands.registerCommand( "oxygenhub.toggleDropVisibility", guard(async (n: DropNode) => { const secret = n.d.visibility !== "secret"; await client(n.base).editDrop(n.d.id, { secret }); drops.refresh(); vscode.window.setStatusBarMessage(secret ? "$(lock) The drop is secret now" : "$(globe) The drop is public now", 3000); }), ), vscode.commands.registerCommand( "oxygenhub.addDropFile", guard(async (n: DropNode) => { 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) }); if (!name) { return; } await client(n.base).editDrop(n.d.id, { files: [{ name: name.trim(), content: "" }], message: `Add ${name.trim()}` }); drops.refresh(); await vscode.window.showTextDocument(dropUri({ base: n.base, id: n.d.id, name: name.trim() }), { preview: false }); }), ), vscode.commands.registerCommand( "oxygenhub.deleteDropFile", guard(async (n: DropFileNode) => { if (n.d.files.length === 1) { void vscode.window.showWarningMessage("OxygenHub: a drop keeps at least one file; delete the drop instead."); return; } const ok = await vscode.window.showWarningMessage(`Remove ${n.f.name} from the drop?`, { modal: true, detail: "Its history keeps it." }, "Remove"); if (ok) { await client(n.base).editDrop(n.d.id, { files: [{ name: n.f.name, content: null }], message: `Remove ${n.f.name}` }); drops.refresh(); } }), ), vscode.commands.registerCommand( "oxygenhub.deleteDrop", guard(async (n: DropNode) => { 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"); if (ok) { await client(n.base).deleteDrop(n.d.id); drops.forget(n.base, n.d.id); } }), ), vscode.commands.registerCommand( "oxygenhub.forkDrop", guard(async (n: DropNode) => { const d = await client(n.base).forkDrop(n.d.id); drops.refresh(); await open(n.base, d); vscode.window.setStatusBarMessage(`$(repo-forked) Forked "${n.d.title || n.d.id}"`, 3000); }), ), vscode.commands.registerCommand( "oxygenhub.starDrop", guard(async (n: DropNode) => { const s = await client(n.base).starDrop(n.d.id, true); drops.remember(n.base, { ...n.d, ...s }); }), ), vscode.commands.registerCommand( "oxygenhub.unstarDrop", guard(async (n: DropNode) => { const s = await client(n.base).starDrop(n.d.id, false); drops.remember(n.base, { ...n.d, ...s }); }), ), vscode.commands.registerCommand( "oxygenhub.dropComments", guard(async (n: DropNode) => { const c = client(n.base); const me = (await c.me()).username; const comments = await c.dropComments(n.d.id); type Item = vscode.QuickPickItem & { add?: boolean; comment?: api.DropComment }; const items: Item[] = [{ label: "$(comment) Add a comment…", add: true }, ...comments.map((x) => ({ ...commentLine(x), comment: x }))]; const pick = await vscode.window.showQuickPick(items, { placeHolder: `${comments.length} comment${comments.length === 1 ? "" : "s"} on "${n.d.title || n.d.id}"`, matchOnDetail: true }); if (pick?.add) { const body = await vscode.window.showInputBox({ prompt: "Comment (Markdown)" }); if (body?.trim()) { await c.commentDrop(n.d.id, body); vscode.window.setStatusBarMessage("$(comment) Commented", 3000); } } else if (pick?.comment) { const mayDelete = pick.comment.author === me || n.d.owner === me; const act = await vscode.window.showInformationMessage(`${pick.comment.author}: ${pick.comment.body}`, ...(mayDelete ? ["Delete Comment"] : [])); if (act === "Delete Comment") { await c.deleteDropComment(n.d.id, pick.comment.id); } } }), ), ]; }