Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

64 linesCodeBlameRaw
1/**
2 * Pinned and recent projects in the sidebar: the viewer's pins in the
3 * workspace (up to eight, in their order), then what they opened last that
4 * they have not pinned (up to five), then All projects. The projects
5 * service keeps both; opening a project records it after the page is sent.
6 */
7
8import type { ProjectKind } from "@g1t/contracts";
9
10/** A project as the sidebar lists it. */
11export type ShortcutProject = { namespace: string; name: string; title?: string; isPrivate: boolean; kind?: ProjectKind };
12
13export const MAX_PINS = 8;
14export const MAX_RECENT = 5;
15
16const same = (a: { namespace: string; name: string }, b: { namespace: string; name: string }) =>
17 a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
18
19/**
20 * Recent, with the project being looked at first when it is the
21 * workspace's and not pinned: it was opened just now, before the projects
22 * service has heard.
23 */
24export function recentWith(recent: ShortcutProject[], pinned: ShortcutProject[], current: ShortcutProject | null, workspace: string | null): ShortcutProject[] {
25 const here = current && workspace && current.namespace.toLowerCase() === workspace.toLowerCase() && !pinned.some((p) => same(p, current)) ? current : null;
26 const rest = recent.filter((project) => !pinned.some((p) => same(p, project)) && !(here && same(project, here)));
27 return [...(here ? [here] : []), ...rest].slice(0, MAX_RECENT);
28}
29
30/** One change to the viewer's pins, as a form posts it. */
31export type PinChange = { intent: "pin"; slug: string; position: number | null } | { intent: "unpin"; slug: string } | { intent: "reorder"; slugs: string[] };
32
33/** What a pin form asks for; null when it asks for nothing that makes sense. */
34export function pinFromForm(form: FormData): PinChange | null {
35 const intent = form.get("intent");
36 const slug = String(form.get("slug") ?? "")
37 .trim()
38 .toLowerCase();
39 if (intent === "reorder") {
40 const slugs = form
41 .getAll("slug")
42 .map((value) => String(value).trim().toLowerCase())
43 .filter(Boolean);
44 return slugs.length > 0 && new Set(slugs).size === slugs.length ? { intent, slugs } : null;
45 }
46 if (!slug) return null;
47 if (intent === "unpin") return { intent, slug };
48 if (intent !== "pin") return null;
49 const position = Number.parseInt(String(form.get("position") ?? ""), 10);
50 return { intent, slug, position: Number.isFinite(position) && position >= 0 ? position : null };
51}
52
53/**
54 * The pins in a new order after moving the one at `from` by `by` places
55 * (-1 up, 1 down); null when it cannot move that way.
56 */
57export function movedPin<T>(pins: T[], from: number, by: number): T[] | null {
58 const to = from + by;
59 if (from < 0 || from >= pins.length || to < 0 || to >= pins.length || by === 0) return null;
60 const next = [...pins];
61 const [moving] = next.splice(from, 1);
62 next.splice(to, 0, moving!);
63 return next;
64}