| 1 |
// Drops in the editor (spec §8.4): the addresses of drop files, the names |
| 2 |
// shared files take, and the words the Drops view shows. No vscode imports, |
| 3 |
// so it is tested with plain Node. |
| 4 |
|
| 5 |
import type * as api from "./hubapi"; |
| 6 |
import { ago } from "./flowview"; |
| 7 |
|
| 8 |
export const DROP_SCHEME = "oxygenhub-drop"; |
| 9 |
|
| 10 |
/** Which drop file a document is: the hub, the drop's id, and the file's name ("" for the drop itself). */ |
| 11 |
export interface DropRef { |
| 12 |
base: string; |
| 13 |
id: string; |
| 14 |
name: string; |
| 15 |
} |
| 16 |
|
| 17 |
/** A drop file's URI parts: `/ID/NAME`, the hub in the query. */ |
| 18 |
export function dropUriParts(r: DropRef): { path: string; query: string } { |
| 19 |
return { path: `/${r.id}${r.name ? `/${r.name}` : ""}`, query: new URLSearchParams({ hub: r.base }).toString() }; |
| 20 |
} |
| 21 |
|
| 22 |
/** Parse a drop file's URI parts back; undefined if they are not one. */ |
| 23 |
export function parseDropUri(path: string, query: string): DropRef | undefined { |
| 24 |
const base = new URLSearchParams(query).get("hub"); |
| 25 |
const m = /^\/([0-9a-f]{24})(?:\/(.+))?$/.exec(path); |
| 26 |
if (!base || !m) { |
| 27 |
return undefined; |
| 28 |
} |
| 29 |
return { base, id: m[1], name: m[2] ?? "" }; |
| 30 |
} |
| 31 |
|
| 32 |
/** |
| 33 |
* A drop's id from what someone pastes: the id, or its page on a hub |
| 34 |
* (`https://hub/OWNER/drops/ID` or `https://hub/drops/ID`). |
| 35 |
*/ |
| 36 |
export function dropIdOf(text: string): string | undefined { |
| 37 |
const t = text.trim(); |
| 38 |
if (/^[0-9a-f]{24}$/.test(t)) { |
| 39 |
return t; |
| 40 |
} |
| 41 |
const m = /\/drops\/([0-9a-f]{24})(?:[/?#].*)?$/.exec(t); |
| 42 |
return m ? m[1] : undefined; |
| 43 |
} |
| 44 |
|
| 45 |
/** The hub a pasted drop page is on, if it is a URL. */ |
| 46 |
export function dropBaseOf(text: string): string | undefined { |
| 47 |
const m = /^(https?:\/\/[^/]+)\//.exec(text.trim()); |
| 48 |
return m ? m[1] : undefined; |
| 49 |
} |
| 50 |
|
| 51 |
/** |
| 52 |
* Names for files shared as one drop: each file's path below the folder |
| 53 |
* they all sit in, so two `index.ts` in different folders stay apart and a |
| 54 |
* single file keeps just its name. Paths use `/`. |
| 55 |
*/ |
| 56 |
export function shareNames(paths: string[]): string[] { |
| 57 |
const parts = paths.map((p) => p.split(/[\\/]/).filter(Boolean)); |
| 58 |
if (parts.length === 1) { |
| 59 |
return [parts[0][parts[0].length - 1]]; |
| 60 |
} |
| 61 |
let common = 0; |
| 62 |
const shortest = Math.min(...parts.map((p) => p.length - 1)); |
| 63 |
while (common < shortest && parts.every((p) => p[common] === parts[0][common])) { |
| 64 |
common++; |
| 65 |
} |
| 66 |
return parts.map((p) => p.slice(common).join("/")); |
| 67 |
} |
| 68 |
|
| 69 |
/** File extensions for a language with no file behind it (an untitled editor). */ |
| 70 |
const EXTENSIONS: Record<string, string> = { |
| 71 |
typescript: "ts", |
| 72 |
typescriptreact: "tsx", |
| 73 |
javascript: "js", |
| 74 |
javascriptreact: "jsx", |
| 75 |
rust: "rs", |
| 76 |
python: "py", |
| 77 |
go: "go", |
| 78 |
java: "java", |
| 79 |
c: "c", |
| 80 |
cpp: "cpp", |
| 81 |
csharp: "cs", |
| 82 |
shellscript: "sh", |
| 83 |
markdown: "md", |
| 84 |
json: "json", |
| 85 |
yaml: "yaml", |
| 86 |
toml: "toml", |
| 87 |
html: "html", |
| 88 |
css: "css", |
| 89 |
sql: "sql", |
| 90 |
ruby: "rb", |
| 91 |
php: "php", |
| 92 |
swift: "swift", |
| 93 |
kotlin: "kt", |
| 94 |
plaintext: "txt", |
| 95 |
}; |
| 96 |
|
| 97 |
/** The name a selection is shared under: the file's own name, else `snippet.EXT` by its language. */ |
| 98 |
export function snippetName(fileName: string | undefined, languageId: string): string { |
| 99 |
if (fileName) { |
| 100 |
const base = fileName.split(/[\\/]/).pop(); |
| 101 |
if (base) { |
| 102 |
return base; |
| 103 |
} |
| 104 |
} |
| 105 |
return `snippet.${EXTENSIONS[languageId] ?? "txt"}`; |
| 106 |
} |
| 107 |
|
| 108 |
/** A shared selection's title: the file and its lines. */ |
| 109 |
export function snippetTitle(name: string, startLine: number, endLine: number, whole: boolean): string { |
| 110 |
if (whole) { |
| 111 |
return name; |
| 112 |
} |
| 113 |
return startLine === endLine ? `${name}, line ${startLine}` : `${name}, lines ${startLine}–${endLine}`; |
| 114 |
} |
| 115 |
|
| 116 |
/** The text a selection shares: the selected lines' text with its shared indentation removed. */ |
| 117 |
export function dedent(text: string): string { |
| 118 |
const lines = text.replace(/\r\n/g, "\n").split("\n"); |
| 119 |
const indents = lines.filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)![0].length); |
| 120 |
const cut = indents.length ? Math.min(...indents) : 0; |
| 121 |
return lines.map((l) => l.slice(cut)).join("\n"); |
| 122 |
} |
| 123 |
|
| 124 |
/** The HTML that embeds a drop in another page. */ |
| 125 |
export function embedSnippet(d: Pick<api.Drop, "embed_url" | "title" | "id">): string { |
| 126 |
const title = (d.title || d.id).replace(/"/g, """); |
| 127 |
return `<iframe src="${d.embed_url ?? ""}" title="${title}" width="100%" height="400" style="border:0" loading="lazy"></iframe>`; |
| 128 |
} |
| 129 |
|
| 130 |
/** A drop's line in the Drops view: its title, and what it holds. */ |
| 131 |
export function dropLabel(d: api.Drop): { label: string; description: string } { |
| 132 |
const files = d.files.length; |
| 133 |
const bits = [d.visibility === "secret" ? "secret" : "", `${files} file${files === 1 ? "" : "s"}`, d.stars ? `★ ${d.stars}` : "", d.forked_from ? "fork" : ""]; |
| 134 |
return { label: d.title || d.files[0]?.name || d.id, description: bits.filter(Boolean).join(" · ") }; |
| 135 |
} |
| 136 |
|
| 137 |
/** A comment's line, for the comments pick. */ |
| 138 |
export function commentLine(c: api.DropComment, now = Date.now()): { label: string; description: string; detail: string } { |
| 139 |
return { label: c.author, description: ago(c.created_ms, now), detail: c.body.replace(/\s+/g, " ").trim() }; |
| 140 |
} |