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