OXYGENHUB

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

10×
// Drops in the editor (spec §8.4): the addresses of drop files, the names
20×
// shared files take, and the words the Drops view shows. No vscode imports,
30×
// so it is tested with plain Node.
41×
5
import type * as api from "./hubapi";
60×
import { ago } from "./flowview";
71×
80×
export const DROP_SCHEME = "oxygenhub-drop";
91×
100×
/** 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
}
161×
170×
/** A drop file's URI parts: `/ID/NAME`, the hub in the query. */
181×
export function dropUriParts(r: DropRef): { path: string; query: string } {
191×
  return { path: `/${r.id}${r.name ? `/${r.name}` : ""}`, query: new URLSearchParams({ hub: r.base }).toString() };
204×
}
214×
224×
/** Parse a drop file's URI parts back; undefined if they are not one. */
234×
export function parseDropUri(path: string, query: string): DropRef | undefined {
244×
  const base = new URLSearchParams(query).get("hub");
254×
  const m = /^\/([0-9a-f]{24})(?:\/(.+))?$/.exec(path);
264×
  if (!base || !m) {
272×
    return undefined;
282×
  }
294×
  return { base, id: m[1], name: m[2] ?? "" };
304×
}
311×
320×
/**
330×
 * A drop's id from what someone pastes: the id, or its page on a hub
340×
 * (`https://hub/OWNER/drops/ID` or `https://hub/drops/ID`).
350×
 */
364×
export function dropIdOf(text: string): string | undefined {
374×
  const t = text.trim();
384×
  if (/^[0-9a-f]{24}$/.test(t)) {
391×
    return t;
401×
  }
413×
  const m = /\/drops\/([0-9a-f]{24})(?:[/?#].*)?$/.exec(t);
424×
  return m ? m[1] : undefined;
434×
}
441×
450×
/** The hub a pasted drop page is on, if it is a URL. */
462×
export function dropBaseOf(text: string): string | undefined {
472×
  const m = /^(https?:\/\/[^/]+)\//.exec(text.trim());
482×
  return m ? m[1] : undefined;
492×
}
501×
510×
/**
520×
 * Names for files shared as one drop: each file's path below the folder
530×
 * they all sit in, so two `index.ts` in different folders stay apart and a
540×
 * single file keeps just its name. Paths use `/`.
550×
 */
564×
export function shareNames(paths: string[]): string[] {
574×
  const parts = paths.map((p) => p.split(/[\\/]/).filter(Boolean));
584×
  if (parts.length === 1) {
591×
    return [parts[0][parts[0].length - 1]];
601×
  }
613×
  let common = 0;
623×
  const shortest = Math.min(...parts.map((p) => p.length - 1));
634×
  while (common < shortest && parts.every((p) => p[common] === parts[0][common])) {
648×
    common++;
658×
  }
664×
  return parts.map((p) => p.slice(common).join("/"));
674×
}
681×
690×
/** File extensions for a language with no file behind it (an untitled editor). */
700×
const EXTENSIONS: Record<string, string> = {
710×
  typescript: "ts",
720×
  typescriptreact: "tsx",
730×
  javascript: "js",
740×
  javascriptreact: "jsx",
750×
  rust: "rs",
760×
  python: "py",
770×
  go: "go",
780×
  java: "java",
790×
  c: "c",
800×
  cpp: "cpp",
810×
  csharp: "cs",
820×
  shellscript: "sh",
830×
  markdown: "md",
840×
  json: "json",
850×
  yaml: "yaml",
860×
  toml: "toml",
870×
  html: "html",
880×
  css: "css",
890×
  sql: "sql",
900×
  ruby: "rb",
910×
  php: "php",
920×
  swift: "swift",
930×
  kotlin: "kt",
940×
  plaintext: "txt",
953×
};
963×
973×
/** The name a selection is shared under: the file's own name, else `snippet.EXT` by its language. */
983×
export function snippetName(fileName: string | undefined, languageId: string): string {
993×
  if (fileName) {
1001×
    const base = fileName.split(/[\\/]/).pop();
1011×
    if (base) {
1021×
      return base;
1031×
    }
1041×
  }
1053×
  return `snippet.${EXTENSIONS[languageId] ?? "txt"}`;
1063×
}
1071×
1080×
/** A shared selection's title: the file and its lines. */
1093×
export function snippetTitle(name: string, startLine: number, endLine: number, whole: boolean): string {
1103×
  if (whole) {
1111×
    return name;
1121×
  }
1133×
  return startLine === endLine ? `${name}, line ${startLine}` : `${name}, lines ${startLine}–${endLine}`;
1141×
}
1151×
1161×
/** The text a selection shares: the selected lines' text with its shared indentation removed. */
1171×
export function dedent(text: string): string {
1181×
  const lines = text.replace(/\r\n/g, "\n").split("\n");
1191×
  const indents = lines.filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)![0].length);
1201×
  const cut = indents.length ? Math.min(...indents) : 0;
1211×
  return lines.map((l) => l.slice(cut)).join("\n");
1221×
}
1231×
1241×
/** The HTML that embeds a drop in another page. */
1251×
export function embedSnippet(d: Pick<api.Drop, "embed_url" | "title" | "id">): string {
1261×
  const title = (d.title || d.id).replace(/"/g, "&quot;");
1271×
  return `<iframe src="${d.embed_url ?? ""}" title="${title}" width="100%" height="400" style="border:0" loading="lazy"></iframe>`;
1282×
}
1292×
1302×
/** A drop's line in the Drops view: its title, and what it holds. */
1312×
export function dropLabel(d: api.Drop): { label: string; description: string } {
1322×
  const files = d.files.length;
1332×
  const bits = [d.visibility === "secret" ? "secret" : "", `${files} file${files === 1 ? "" : "s"}`, d.stars ? `★ ${d.stars}` : "", d.forked_from ? "fork" : ""];
1342×
  return { label: d.title || d.files[0]?.name || d.id, description: bits.filter(Boolean).join(" · ") };
1352×
}
1361×
1370×
/** A comment's line, for the comments pick. */
1381×
export function commentLine(c: api.DropComment, now = Date.now()): { label: string; description: string; detail: string } {
1391×
  return { label: c.author, description: ago(c.created_ms, now), detail: c.body.replace(/\s+/g, " ").trim() };
1401×
}