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.

67 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(
25 recent: ShortcutProject[],
26 pinned: ShortcutProject[],
27 current: ShortcutProject | null,
28 workspace: string | null,
29): ShortcutProject[] {
30 const here = current && workspace && current.namespace.toLowerCase() === workspace.toLowerCase() && !pinned.some((p) => same(p, current)) ? current : null;
31 const rest = recent.filter((project) => !pinned.some((p) => same(p, project)) && !(here && same(project, here)));
32 return [...(here ? [here] : []), ...rest].slice(0, MAX_RECENT);
33}
34
35/** One change to the viewer's pins, as a form posts it. */
36export type PinChange =
37 | { intent: "pin"; slug: string; position: number | null }
38 | { intent: "unpin"; slug: string }
39 | { intent: "reorder"; slugs: string[] };
40
41/** What a pin form asks for; null when it asks for nothing that makes sense. */
42export function pinFromForm(form: FormData): PinChange | null {
43 const intent = form.get("intent");
44 const slug = String(form.get("slug") ?? "").trim().toLowerCase();
45 if (intent === "reorder") {
46 const slugs = form.getAll("slug").map((value) => String(value).trim().toLowerCase()).filter(Boolean);
47 return slugs.length > 0 && new Set(slugs).size === slugs.length ? { intent, slugs } : null;
48 }
49 if (!slug) return null;
50 if (intent === "unpin") return { intent, slug };
51 if (intent !== "pin") return null;
52 const position = Number.parseInt(String(form.get("position") ?? ""), 10);
53 return { intent, slug, position: Number.isFinite(position) && position >= 0 ? position : null };
54}
55
56/**
57 * The pins in a new order after moving the one at `from` by `by` places
58 * (-1 up, 1 down); null when it cannot move that way.
59 */
60export function movedPin<T>(pins: T[], from: number, by: number): T[] | null {
61 const to = from + by;
62 if (from < 0 || from >= pins.length || to < 0 || to >= pins.length || by === 0) return null;
63 const next = [...pins];
64 const [moving] = next.splice(from, 1);
65 next.splice(to, 0, moving!);
66 return next;
67}