Skip to content

Commit

Navigation: the workspace's own page with tabs, pins and recent in the sidebar

One rule for the sidebar: it is the workspace the switcher names. Mission control stays its first item; Overview leaves the list for the switcher (Workspace overview, All projects) and Explore, all of g1t, moves to the top bar beside Docs (an icon on a phone) and stays in the palette. The workspace's page at /<workspace> has a header and tabs, styled as a project's: Overview, Projects (count), Packages, Teams (soon), People (count; Members before), Insights (soon) and, for owners, Settings. The Overview leads with the viewer's pinned projects, then the most active ones and All projects. The new Projects tab handles hundreds: search, filters for visibility, kind, language, Deployments and archived, four sorts, list and grid views, pages of 30, counts, and the keyboard (/ to find, arrows or j/k to move, Enter to open). Its loader asks for the shared project listing, Deployments and pins at once. The sidebar's Projects lists the viewer's pins (drag or Alt+arrows to reorder), then what they opened last, then All projects (N); with no pins it says how to pin. Pin buttons sit on a project's header, on Overview cards and on every Projects row. Opening a project records it after the page is sent. A Workspace label heads Agent fleet to Roadmap. Old addresses keep working: -/members and -/overview redirect, Soon pages for Teams and Insights go to their tabs, and ?tab=repositories and the like open that tab. Skeletons stand in for a tab on its way.

syntaqxcommitted Parent912cf34Browse files
22 files+1782−1140/22 viewed
+57−0
1+/**
2+ * Pinning a project, so it stays at the top of the workspace's sidebar:
3+ * a button on the project's header and on each row of the Projects page.
4+ * Posts to the workspace's pins route (routes/workspace/pins.ts); while it
5+ * is on its way, it shows as done.
6+ */
7+import { Pin } from "lucide-react";
8+import { useFetcher } from "react-router";
9+
10+import { cn } from "../lib/cn";
11+
12+export function PinButton({
13+ workspace,
14+ slug,
15+ name,
16+ pinned,
17+ compact,
18+ className,
19+}: {
20+ workspace: string;
21+ slug: string;
22+ /** For what the button says to a screen reader. */
23+ name: string;
24+ pinned: boolean;
25+ /** Only the pin, as on a row; the header says it in words too. */
26+ compact?: boolean;
27+ className?: string;
28+}) {
29+ const fetcher = useFetcher<{ error: string | null }>({ key: `pin:${workspace}/${slug}` });
30+ const asked = fetcher.formData?.get("intent");
31+ const shown = asked === "pin" ? true : asked === "unpin" ? false : pinned;
32+ const label = shown ? `Unpin ${name}` : `Pin ${name}`;
33+ const error = fetcher.state === "idle" ? fetcher.data?.error : null;
34+ return (
35+ <fetcher.Form method="post" action={`/${workspace}/-/pins`} className={cn("relative flex", className)}>
36+ <input type="hidden" name="intent" value={shown ? "unpin" : "pin"} />
37+ <input type="hidden" name="slug" value={slug} />
38+ <button
39+ type="submit"
40+ aria-label={label}
41+ aria-pressed={shown}
42+ title={error ?? (shown ? "Unpin from the sidebar" : "Pin to the sidebar")}
43+ className={cn(
44+ "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-[0.8125rem] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent",
45+ compact
46+ ? "relative z-10 size-8 hover:bg-raised"
47+ : "h-8 border border-line px-2.5 text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
48+ shown ? "text-accent" : compact ? "text-faint hover:text-fg" : "",
49+ error && "text-danger",
50+ )}
51+ >
52+ <Pin size={14} className={shown ? "fill-current" : undefined} />
53+ {!compact && <span className="hidden sm:inline">{shown ? "Unpin" : "Pin"}</span>}
54+ </button>
55+ </fetcher.Form>
56+ );
57+}
+162−51
1−import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
2727 import { withNext } from "../lib/next";
2828 import { useSignUpCopy } from "../lib/registration";
2929 import { STATUS_URL, statusTitle } from "../lib/status";
30+import { type ShortcutProject, movedPin, recentWith } from "../lib/pins";
3031 import type { AccountMenuData } from "../routes/settings-menu-json";
3132
3233 /**
3738 export type ShellData = {
3839 /** The workspace the sidebar is about: the one being looked at, or their first. */
3940 workspace: Membership | null;
40− /** Its projects, by name: `name` is the slug in their address. */
41− repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
41+ /** All its projects, by name, for the palette: `name` is the slug in their address. */
42+ repos: ShortcutProject[];
43+ /** The person's pinned projects in it, in their order (lib/pins.ts). */
44+ pinned?: ShortcutProject[];
45+ /** What they opened there last, latest first, leaving out the pinned. */
46+ recent?: ShortcutProject[];
4247 /** Repositories shared with them in workspaces they do not belong to. */
4348 shared?: { namespace: string; name: string; isPrivate: boolean }[];
4449 /** The project being looked at, if any, whoever owns it. */
250255 <ChevronsUpDown size={14} />
251256 </DropdownMenuTrigger>
252257 <DropdownMenuContent align="start" className="w-64">
258+ {workspace && (
259+ <>
260+ {/* The workspace's own page: its projects, packages and people. */}
261+ <DropdownMenuItem asChild>
262+ <Link to={`/${workspace.slug}`}>
263+ <LayoutGrid />
264+ Workspace overview
265+ </Link>
266+ </DropdownMenuItem>
267+ <DropdownMenuItem asChild>
268+ <Link to={`/${workspace.slug}/-/projects`}>
269+ <Box />
270+ All projects
271+ </Link>
272+ </DropdownMenuItem>
273+ <DropdownMenuSeparator />
274+ </>
275+ )}
253276 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
254277 {(user.workspaces ?? []).map((membership) => (
255278 <DropdownMenuItem asChild key={membership.slug}>
550573 return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />;
551574 }
552575
576+/** A quiet heading inside a group: Pinned, Recent. */
577+function SidebarSubhead({ children }: { children: ReactNode }) {
578+ return <h3 className="px-2 pt-1.5 pb-0.5 text-[0.6875rem] font-medium tracking-wide text-faint">{children}</h3>;
579+}
580+
553581 /**
582+ * The workspace's projects as the sidebar keeps them, however many there
583+ * are: the person's pins in their order, what they opened last, and the
584+ * way to all of them. Pins move by dragging, or with Alt and the arrow
585+ * keys; the projects service keeps the order.
586+ */
587+function SidebarProjects({ slug, shell }: { slug: string; shell: ShellData }) {
588+ const reorder = useFetcher({ key: `pins:${slug}` });
589+ const saved = shell.pinned ?? [];
590+ // While a new order is on its way, it shows as made.
591+ const asked = reorder.formData?.get("intent") === "reorder" ? reorder.formData.getAll("slug").map(String) : null;
592+ const pinned = asked
593+ ? asked.map((name) => saved.find((project) => project.name === name)).filter((project): project is ShortcutProject => project != null)
594+ : saved;
595+ const active = shell.repo ? { namespace: shell.repo.namespace, name: shell.repo.name, isPrivate: false } : null;
596+ const recent = recentWith(shell.recent ?? [], pinned, active, slug);
597+ const [dragging, setDragging] = useState<number | null>(null);
598+ const move = (from: number, by: number) => {
599+ const next = movedPin(pinned, from, by);
600+ if (!next) return;
601+ const form = new FormData();
602+ form.set("intent", "reorder");
603+ for (const project of next) form.append("slug", project.name);
604+ reorder.submit(form, { method: "post", action: `/${slug}/-/pins` });
605+ };
606+ const row = (project: ShortcutProject) => (
607+ <SidebarLink to={`/${project.namespace}/${project.name}`} icon={project.isPrivate ? <Lock size={15} /> : <Box size={15} />} drill="hover">
608+ {project.title ?? project.name}
609+ </SidebarLink>
610+ );
611+ return (
612+ <SidebarGroup
613+ title="Projects"
614+ action={
615+ <Link to={`/new?workspace=${slug}`} aria-label="New project" className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg">
616+ <Plus size={13} />
617+ </Link>
618+ }
619+ >
620+ <SidebarSubhead>Pinned</SidebarSubhead>
621+ {pinned.length === 0 ? (
622+ <p className="px-2 pb-1 text-xs text-faint">Pin projects to keep them here.</p>
623+ ) : (
624+ <ul aria-label="Pinned projects" className="space-y-px">
625+ {pinned.map((project, index) => (
626+ <li
627+ key={project.name}
628+ draggable={pinned.length > 1}
629+ onDragStart={(event) => {
630+ setDragging(index);
631+ event.dataTransfer.effectAllowed = "move";
632+ }}
633+ onDragOver={(event) => {
634+ if (dragging != null) event.preventDefault();
635+ }}
636+ onDrop={(event) => {
637+ event.preventDefault();
638+ if (dragging != null && dragging !== index) move(dragging, index - dragging);
639+ setDragging(null);
640+ }}
641+ onDragEnd={() => setDragging(null)}
642+ onKeyDown={(event) => {
643+ if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
644+ event.preventDefault();
645+ move(index, event.key === "ArrowUp" ? -1 : 1);
646+ }}
647+ title={pinned.length > 1 ? "Drag, or Alt and an arrow key, to reorder" : undefined}
648+ className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`}
649+ >
650+ {row(project)}
651+ {pinned.length > 1 && (
652+ <GripVertical
653+ size={12}
654+ aria-hidden="true"
655+ className="pointer-events-none absolute top-1/2 -left-1.5 -translate-y-1/2 text-faint opacity-0 transition-opacity group-hover/pin:opacity-100"
656+ />
657+ )}
658+ </li>
659+ ))}
660+ </ul>
661+ )}
662+ {recent.length > 0 && (
663+ <>
664+ <SidebarSubhead>Recent</SidebarSubhead>
665+ {recent.map((project) => (
666+ <div key={`${project.namespace}/${project.name}`}>{row(project)}</div>
667+ ))}
668+ </>
669+ )}
670+ <div className="pt-1">
671+ <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length}>
672+ All projects
673+ </SidebarLink>
674+ </div>
675+ </SidebarGroup>
676+ );
677+}
678+
679+/**
554680 * The main list: where you go, the projects, what the workspace builds and
555681 * runs with across them, then its usage, support and settings.
556682 */
557683 function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) {
558684 const ws = shell.workspace;
559− const active = shell.repo;
560− // The repository being looked at is listed even when it is someone else's.
561− const listed =
562− active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
563− ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
564− : shell.repos;
565685 // A visitor browses: no workspace, no projects of their own.
566686 if (!user) {
567687 return (
578698 }
579699 return (
580700 <nav aria-label="g1t" className={PANEL}>
701+ {/* Everything here is the workspace the switcher names: its home first.
702+ The workspace's own page is the switcher's; Explore, all of g1t,
703+ is in the top bar. */}
581704 <div className="mt-3 space-y-px">
582705 <SidebarLink to="/" end icon={<House size={15} />}>
583706 Mission control
584− </SidebarLink>
585− {ws && (
586− <SidebarLink to={`/${ws.slug}`} end icon={<LayoutGrid size={15} />}>
587− Overview
588− </SidebarLink>
589− )}
590− <SidebarLink to="/explore" icon={<Compass size={15} />}>
591− Explore
592707 </SidebarLink>
593708 </div>
594709
595710 <Rule />
596− <SidebarGroup
597− title="Projects"
598− action={
599− ws && (
600− <Link
601− to={`/new?workspace=${ws.slug}`}
602− aria-label="New project"
603− className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
604− >
605− <Plus size={13} />
606− </Link>
607− )
608− }
609− >
610− {listed.length === 0 && <p className="px-2 py-1 text-xs text-faint">None yet.</p>}
611− {listed.map((repo) => (
612− <SidebarLink
613− key={`${repo.namespace}/${repo.name}`}
614− to={`/${repo.namespace}/${repo.name}`}
615− icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
616− drill="hover"
617− >
618− {repo.namespace !== ws?.slug && <span className="font-mono text-faint">{repo.namespace}/</span>}
619− {"title" in repo && repo.title ? repo.title : repo.name}
620− </SidebarLink>
621− ))}
622− </SidebarGroup>
711+ {ws ? (
712+ <SidebarProjects slug={ws.slug} shell={shell} />
713+ ) : (
714+ <SidebarGroup title="Projects">
715+ <p className="px-2 py-1 text-xs text-faint">None yet.</p>
716+ </SidebarGroup>
717+ )}
623718 {(shell.shared ?? []).length > 0 && (
624719 <SidebarGroup title="Shared with you" className="mt-3">
625720 {(shell.shared ?? []).map((repo) => (
639734 {ws && (
640735 <>
641736 <Rule />
642− <div className="space-y-px">
737+ <SidebarGroup title="Workspace">
643738 <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
644739 Agent fleet
645740 </SidebarLink>
655750 <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}>
656751 Packages
657752 </SidebarLink>
658− {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => (
753+ {roadmapIn("Workspace").filter((item) => item.key !== "teams" && item.key !== "insights").map((item) => (
659754 <SidebarSoonLink
660755 key={item.key}
661756 to={`/${ws.slug}/-/soon/${item.key}`}
665760 {item.title === "Board" ? "Boards" : item.title}
666761 </SidebarSoonLink>
667762 ))}
668− </div>
763+ </SidebarGroup>
669764
670765 <Rule />
671766 <div className="space-y-px">
672767 {/* Who belongs, for every member to see; owners invite and manage there. */}
673768 <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
674− Members
769+ People
675770 </SidebarLink>
676771 {roadmapIn("Workspace")
677772 .filter((item) => item.key === "teams")
678773 .map((item) => (
679− <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}>
774+ <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}>
680775 {item.title}
681776 </SidebarSoonLink>
682777 ))}
11311226 const back = !user
11321227 ? { to: "/explore", label: "Explore" }
11331228 : home
1134− ? { to: `/${home.slug}`, label: "All projects" }
1229+ ? { to: `/${home.slug}/-/projects`, label: "All projects" }
11351230 : { to: "/", label: "Mission control" };
11361231 trail.push({
11371232 key,
12191314 code: "Files",
12201315 secrets: "Secrets and variables",
12211316 settings: "Settings",
1222− people: "Members",
1317+ people: "People",
1318+ projects: "Projects",
1319+ teams: "Teams",
12231320 tokens: "Access tokens",
12241321 usage: "Usage",
12251322 billing: "Billing and plans",
13861483 for (const membership of user.workspaces ?? []) {
13871484 commands.push(
13881485 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
1486+ { label: "All projects", hint: membership.slug, to: `/${membership.slug}/-/projects`, icon: <LayoutGrid size={15} /> },
1487+ { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> },
13891488 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
13901489 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
13911490 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
15661665 </kbd>
15671666 </Form>
15681667 <div className="ml-auto flex items-center gap-1.5 md:ml-0">
1668+ {/* All of g1t's public projects: not any one workspace's, so here, not in the sidebar. */}
1669+ <NavLink
1670+ to="/explore"
1671+ prefetch="intent"
1672+ aria-label="Explore"
1673+ className={({ isActive }) =>
1674+ `flex h-9 items-center gap-1.5 rounded-md px-2 text-sm transition-colors hover:bg-raised hover:text-fg sm:px-2.5 ${isActive ? "text-fg" : "text-muted"}`
1675+ }
1676+ >
1677+ <Compass size={16} className="sm:hidden" />
1678+ <span className="hidden sm:inline">Explore</span>
1679+ </NavLink>
15691680 <a
15701681 href="https://docs.g1t.sh/"
15711682 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
+98−0
1+/**
2+ * The shapes of a workspace's tabs while they load: the same layout as
3+ * the real thing, so nothing moves when it arrives (components/ui/skeleton.tsx).
4+ */
5+import type { WorkspaceTabKey } from "../lib/workspace-nav";
6+import { Skeleton, SkeletonRows, SkeletonText } from "./ui/skeleton";
7+
8+/** A project card, as the Overview and the Projects grid show them. */
9+export function ProjectCardSkeleton() {
10+ return (
11+ <li aria-hidden="true" className="flex flex-col rounded-2xl border border-line bg-surface p-5">
12+ <div className="flex items-start gap-3">
13+ <Skeleton className="size-9 shrink-0 rounded-lg" />
14+ <div className="grow space-y-2 pt-1">
15+ <Skeleton className="h-3.5 w-32" />
16+ <Skeleton className="h-3 w-48" />
17+ </div>
18+ </div>
19+ <div className="mt-8 flex gap-4 border-t border-line pt-3">
20+ <Skeleton className="h-3 w-12" />
21+ <Skeleton className="h-3 w-8" />
22+ <Skeleton className="h-3 w-8" />
23+ </div>
24+ </li>
25+ );
26+}
27+
28+/** The Projects page's rows. */
29+export function ProjectRowsSkeleton({ rows = 8 }: { rows?: number }) {
30+ return (
31+ <div aria-hidden="true" className="overflow-hidden rounded-xl border border-line bg-surface">
32+ {Array.from({ length: rows }, (_, index) => (
33+ <div key={index} className="flex items-center gap-3 border-b border-line px-4 py-3.5 last:border-b-0">
34+ <Skeleton className="size-8 shrink-0 rounded-md" />
35+ <div className="grow space-y-2">
36+ <Skeleton className="h-3.5" style={{ width: `${30 + ((index * 13) % 25)}%` }} />
37+ <Skeleton className="h-3" style={{ width: `${45 + ((index * 7) % 30)}%` }} />
38+ </div>
39+ <Skeleton className="hidden h-3 w-16 sm:block" />
40+ <Skeleton className="size-8 shrink-0 rounded-md" />
41+ </div>
42+ ))}
43+ </div>
44+ );
45+}
46+
47+/** The Projects page: its search and filters, then rows. */
48+export function ProjectsSkeleton() {
49+ return (
50+ <div aria-busy="true" aria-label="Loading projects" className="space-y-4">
51+ <div className="flex flex-wrap gap-2">
52+ <Skeleton className="h-9 w-full rounded-md sm:w-72" />
53+ <Skeleton className="h-9 w-28 rounded-md" />
54+ <Skeleton className="h-9 w-28 rounded-md" />
55+ <Skeleton className="h-9 w-36 rounded-md" />
56+ </div>
57+ <ProjectRowsSkeleton />
58+ </div>
59+ );
60+}
61+
62+/** The Overview: pinned cards, then recent ones beside the aside. */
63+export function OverviewSkeleton() {
64+ return (
65+ <div aria-busy="true" aria-label="Loading" className="grid gap-10 lg:grid-cols-[1fr_18rem]">
66+ <div className="space-y-10">
67+ <div>
68+ <Skeleton className="h-3.5 w-20" />
69+ <ul className="mt-3 grid gap-4 sm:grid-cols-2">
70+ {Array.from({ length: 4 }, (_, index) => (
71+ <ProjectCardSkeleton key={index} />
72+ ))}
73+ </ul>
74+ </div>
75+ <div>
76+ <Skeleton className="h-3.5 w-28" />
77+ <SkeletonRows rows={4} className="mt-3 rounded-xl border border-line bg-surface" />
78+ </div>
79+ </div>
80+ <div className="space-y-4">
81+ <Skeleton className="h-40 rounded-xl" />
82+ <SkeletonText lines={4} />
83+ </div>
84+ </div>
85+ );
86+}
87+
88+/** Whichever tab is on its way. */
89+export function WorkspaceTabSkeleton({ tab }: { tab: WorkspaceTabKey }) {
90+ if (tab === "overview") return <OverviewSkeleton />;
91+ if (tab === "projects") return <ProjectsSkeleton />;
92+ return (
93+ <div aria-busy="true" aria-label="Loading" className="space-y-4">
94+ <Skeleton className="h-3.5 w-2/3 max-w-xl" />
95+ <SkeletonRows rows={6} className="rounded-xl border border-line bg-surface" />
96+ </div>
97+ );
98+}
+49−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { MAX_RECENT, movedPin, pinFromForm, recentWith } from "./pins.ts";
5+
6+const p = (name: string, namespace = "acme") => ({ namespace, name, isPrivate: false });
7+const names = (list: { name: string }[]) => list.map((project) => project.name);
8+
9+test("the project being looked at leads recent, once", () => {
10+ assert.deepEqual(names(recentWith([p("a"), p("b")], [], p("c"), "acme")), ["c", "a", "b"]);
11+ assert.deepEqual(names(recentWith([p("a"), p("b")], [], p("b"), "acme")), ["b", "a"]);
12+});
13+
14+test("pinned projects are never also recent", () => {
15+ assert.deepEqual(names(recentWith([p("a"), p("b")], [p("a")], p("b"), "acme")), ["b"]);
16+ assert.deepEqual(names(recentWith([p("a")], [p("c")], p("c"), "acme")), ["a"]);
17+});
18+
19+test("another workspace's project is not this one's recent", () => {
20+ assert.deepEqual(names(recentWith([p("a")], [], p("x", "other"), "acme")), ["a"]);
21+ assert.deepEqual(names(recentWith([p("a")], [], p("x"), null)), ["a"]);
22+});
23+
24+test("recent holds a few", () => {
25+ const many = Array.from({ length: 9 }, (_, i) => p(`r${i}`));
26+ assert.equal(recentWith(many, [], p("now"), "acme").length, MAX_RECENT);
27+});
28+
29+test("a pin form says what to change", () => {
30+ const form = (entries: [string, string][]) => {
31+ const data = new FormData();
32+ for (const [key, value] of entries) data.append(key, value);
33+ return data;
34+ };
35+ assert.deepEqual(pinFromForm(form([["intent", "pin"], ["slug", " Web "]])), { intent: "pin", slug: "web", position: null });
36+ assert.deepEqual(pinFromForm(form([["intent", "pin"], ["slug", "web"], ["position", "2"]])), { intent: "pin", slug: "web", position: 2 });
37+ assert.deepEqual(pinFromForm(form([["intent", "unpin"], ["slug", "web"]])), { intent: "unpin", slug: "web" });
38+ assert.deepEqual(pinFromForm(form([["intent", "reorder"], ["slug", "b"], ["slug", "a"]])), { intent: "reorder", slugs: ["b", "a"] });
39+ assert.equal(pinFromForm(form([["intent", "reorder"], ["slug", "a"], ["slug", "a"]])), null);
40+ assert.equal(pinFromForm(form([["intent", "pin"]])), null);
41+ assert.equal(pinFromForm(form([["intent", "star"], ["slug", "web"]])), null);
42+});
43+
44+test("a pin moves up or down within the list", () => {
45+ assert.deepEqual(movedPin(["a", "b", "c"], 2, -1), ["a", "c", "b"]);
46+ assert.deepEqual(movedPin(["a", "b", "c"], 0, 1), ["b", "a", "c"]);
47+ assert.equal(movedPin(["a", "b", "c"], 0, -1), null);
48+ assert.equal(movedPin(["a", "b", "c"], 2, 1), null);
49+});
+65−0
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+
8+/** A project as the sidebar lists it. */
9+export type ShortcutProject = { namespace: string; name: string; title?: string; isPrivate: boolean };
10+
11+export const MAX_PINS = 8;
12+export const MAX_RECENT = 5;
13+
14+const same = (a: { namespace: string; name: string }, b: { namespace: string; name: string }) =>
15+ a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
16+
17+/**
18+ * Recent, with the project being looked at first when it is the
19+ * workspace's and not pinned: it was opened just now, before the projects
20+ * service has heard.
21+ */
22+export function recentWith(
23+ recent: ShortcutProject[],
24+ pinned: ShortcutProject[],
25+ current: ShortcutProject | null,
26+ workspace: string | null,
27+): ShortcutProject[] {
28+ const here = current && workspace && current.namespace.toLowerCase() === workspace.toLowerCase() && !pinned.some((p) => same(p, current)) ? current : null;
29+ const rest = recent.filter((project) => !pinned.some((p) => same(p, project)) && !(here && same(project, here)));
30+ return [...(here ? [here] : []), ...rest].slice(0, MAX_RECENT);
31+}
32+
33+/** One change to the viewer's pins, as a form posts it. */
34+export type PinChange =
35+ | { intent: "pin"; slug: string; position: number | null }
36+ | { intent: "unpin"; slug: string }
37+ | { intent: "reorder"; slugs: string[] };
38+
39+/** What a pin form asks for; null when it asks for nothing that makes sense. */
40+export function pinFromForm(form: FormData): PinChange | null {
41+ const intent = form.get("intent");
42+ const slug = String(form.get("slug") ?? "").trim().toLowerCase();
43+ if (intent === "reorder") {
44+ const slugs = form.getAll("slug").map((value) => String(value).trim().toLowerCase()).filter(Boolean);
45+ return slugs.length > 0 && new Set(slugs).size === slugs.length ? { intent, slugs } : null;
46+ }
47+ if (!slug) return null;
48+ if (intent === "unpin") return { intent, slug };
49+ if (intent !== "pin") return null;
50+ const position = Number.parseInt(String(form.get("position") ?? ""), 10);
51+ return { intent, slug, position: Number.isFinite(position) && position >= 0 ? position : null };
52+}
53+
54+/**
55+ * The pins in a new order after moving the one at `from` by `by` places
56+ * (-1 up, 1 down); null when it cannot move that way.
57+ */
58+export function movedPin<T>(pins: T[], from: number, by: number): T[] | null {
59+ const to = from + by;
60+ if (from < 0 || from >= pins.length || to < 0 || to >= pins.length || by === 0) return null;
61+ const next = [...pins];
62+ const [moving] = next.splice(from, 1);
63+ next.splice(to, 0, moving!);
64+ return next;
65+}
+147−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ DEFAULT_QUERY,
6+ type Listed,
7+ PAGE_SIZE,
8+ facetsOf,
9+ filterProjects,
10+ isFiltered,
11+ nextRow,
12+ pageOf,
13+ projectQueryString,
14+ readProjectQuery,
15+} from "./project-list.ts";
16+
17+function project(slug: string, more: Partial<Listed> = {}): Listed {
18+ return {
19+ id: `prj_${slug}`,
20+ slug,
21+ name: slug,
22+ description: null,
23+ private: false,
24+ archived: false,
25+ kind: "app",
26+ ecosystem: null,
27+ updatedAt: "2026-10-01T00:00:00.000Z",
28+ pushedAt: null,
29+ activity: 0,
30+ deploying: false,
31+ ...more,
32+ };
33+}
34+
35+const q = (search: string) => readProjectQuery(new URLSearchParams(search));
36+
37+test("an empty address is the default list", () => {
38+ assert.deepEqual(q(""), DEFAULT_QUERY);
39+ assert.equal(projectQueryString(DEFAULT_QUERY), "");
40+ assert.equal(isFiltered(DEFAULT_QUERY), false);
41+});
42+
43+test("what the address says is read, and nonsense is the default", () => {
44+ const query = q("q=%20api%20&visibility=private&kind=library&language=Rust&deployments=on&archived=only&sort=active&view=grid&page=3");
45+ assert.deepEqual(query, {
46+ q: "api",
47+ visibility: "private",
48+ kind: "library",
49+ language: "Rust",
50+ deployments: true,
51+ archived: "only",
52+ sort: "active",
53+ view: "grid",
54+ page: 3,
55+ });
56+ assert.deepEqual(q("visibility=secret&sort=random&page=-2&view=table"), DEFAULT_QUERY);
57+});
58+
59+test("the address round-trips, and a change goes back to page one", () => {
60+ const query = q("q=web&sort=name&page=4");
61+ assert.equal(projectQueryString(query, { page: 4 }), "?q=web&sort=name&page=4");
62+ assert.equal(projectQueryString(query, { kind: "library" }), "?q=web&kind=library&sort=name");
63+ assert.deepEqual(q(projectQueryString(query, { page: 4 }).slice(1)), query);
64+});
65+
66+test("archived projects are left out unless asked for", () => {
67+ const list = [project("live"), project("old", { archived: true })];
68+ assert.deepEqual(filterProjects(list, DEFAULT_QUERY).map((p) => p.slug), ["live"]);
69+ assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, archived: "only" }).map((p) => p.slug), ["old"]);
70+ assert.equal(filterProjects(list, { ...DEFAULT_QUERY, archived: "include" }).length, 2);
71+});
72+
73+test("filters narrow by visibility, kind, language and deployments", () => {
74+ const list = [
75+ project("site", { deploying: true, ecosystem: "npm" }),
76+ project("lib", { kind: "library", ecosystem: "cargo", private: true }),
77+ project("tool", { ecosystem: "go" }),
78+ ];
79+ const slugs = (change: object) => filterProjects(list, { ...DEFAULT_QUERY, ...change }).map((p) => p.slug).sort();
80+ assert.deepEqual(slugs({ visibility: "private" }), ["lib"]);
81+ assert.deepEqual(slugs({ visibility: "public" }), ["site", "tool"]);
82+ assert.deepEqual(slugs({ kind: "library" }), ["lib"]);
83+ assert.deepEqual(slugs({ language: "Rust" }), ["lib"]);
84+ assert.deepEqual(slugs({ language: "JavaScript" }), ["site"]);
85+ assert.deepEqual(slugs({ deployments: true }), ["site"]);
86+});
87+
88+test("search matches every word, in the name, slug or description, names that start with it first", () => {
89+ const list = [
90+ project("docs-site", { name: "Docs site", description: "The API reference" }),
91+ project("api", { name: "API" }),
92+ project("billing", { description: "charges cards" }),
93+ ];
94+ assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "api" }).map((p) => p.slug), ["api", "docs-site"]);
95+ assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "api reference" }).map((p) => p.slug), ["docs-site"]);
96+ assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "CARDS" }).map((p) => p.slug), ["billing"]);
97+});
98+
99+test("each sort orders as it says, ties by name", () => {
100+ const list = [
101+ project("b", { updatedAt: "2026-10-01T00:00:00Z", pushedAt: "2026-10-05T00:00:00Z", activity: 1 }),
102+ project("a", { updatedAt: "2026-10-03T00:00:00Z", activity: 9 }),
103+ project("c", { updatedAt: "2026-10-02T00:00:00Z", pushedAt: "2026-10-04T00:00:00Z", activity: 1 }),
104+ ];
105+ const order = (sort: string) => filterProjects(list, { ...DEFAULT_QUERY, sort: sort as never }).map((p) => p.slug);
106+ assert.deepEqual(order("name"), ["a", "b", "c"]);
107+ // Updated is the later of its own changes and its last push.
108+ assert.deepEqual(order("updated"), ["b", "c", "a"]);
109+ assert.deepEqual(order("pushed"), ["b", "c", "a"]);
110+ assert.deepEqual(order("active"), ["a", "b", "c"]);
111+});
112+
113+test("pages hold PAGE_SIZE, and a page past the end is the last", () => {
114+ const list = Array.from({ length: 412 }, (_, i) => i);
115+ const first = pageOf(list, 1);
116+ assert.equal(first.items.length, PAGE_SIZE);
117+ assert.equal(first.pages, 14);
118+ assert.deepEqual([first.from, first.to], [1, 30]);
119+ const last = pageOf(list, 99);
120+ assert.equal(last.page, 14);
121+ assert.deepEqual([last.from, last.to], [391, 412]);
122+ assert.deepEqual(pageOf([], 1), { items: [], page: 1, pages: 1, from: 0, to: 0 });
123+});
124+
125+test("facets count the whole workspace", () => {
126+ const facets = facetsOf([
127+ project("a", { ecosystem: "npm", deploying: true }),
128+ project("b", { ecosystem: "npm", private: true, kind: "library" }),
129+ project("c", { ecosystem: "go", archived: true }),
130+ ]);
131+ assert.deepEqual(facets.visibility, { public: 2, private: 1 });
132+ assert.deepEqual(facets.kind, { app: 2, library: 1 });
133+ assert.deepEqual(facets.languages, [{ name: "JavaScript", count: 2 }, { name: "Go", count: 1 }]);
134+ assert.equal(facets.deploying, 1);
135+ assert.equal(facets.archived, 1);
136+});
137+
138+test("the keyboard moves through rows and stops at the ends", () => {
139+ assert.equal(nextRow("ArrowDown", -1, 3), 0);
140+ assert.equal(nextRow("j", 0, 3), 1);
141+ assert.equal(nextRow("ArrowDown", 2, 3), 2);
142+ assert.equal(nextRow("k", 0, 3), 0);
143+ assert.equal(nextRow("End", 0, 3), 2);
144+ assert.equal(nextRow("Home", 2, 3), 0);
145+ assert.equal(nextRow("x", 0, 3), null);
146+ assert.equal(nextRow("ArrowDown", 0, 0), null);
147+});
+214−0
1+/**
2+ * A workspace's Projects page, for a workspace with hundreds of them: what
3+ * the address asks for (a search, filters, a sort, a view and a page), and
4+ * the list that answers it. Read from and written to the address, so a
5+ * filtered list can be shared, bookmarked and gone back to.
6+ */
7+
8+import type { Project, ProjectEcosystem } from "@g1t/contracts";
9+
10+export type ProjectSort = "updated" | "name" | "active" | "pushed";
11+export type ProjectView = "list" | "grid";
12+
13+export type ProjectQuery = {
14+ /** Words in the name, slug or description. */
15+ q: string;
16+ visibility: "all" | "public" | "private";
17+ kind: "all" | "app" | "library";
18+ /** A language, from the manifests at the project's root. */
19+ language: string | null;
20+ /** Only projects with Deployments on. */
21+ deployments: boolean;
22+ /** Archived projects: left out (the default), shown too, or only them. */
23+ archived: "hide" | "include" | "only";
24+ sort: ProjectSort;
25+ view: ProjectView;
26+ /** 1 first. */
27+ page: number;
28+};
29+
30+/** Projects to a page. */
31+export const PAGE_SIZE = 30;
32+
33+export const DEFAULT_QUERY: ProjectQuery = {
34+ q: "",
35+ visibility: "all",
36+ kind: "all",
37+ language: null,
38+ deployments: false,
39+ archived: "hide",
40+ sort: "updated",
41+ view: "list",
42+ page: 1,
43+};
44+
45+export const SORTS: { value: ProjectSort; label: string }[] = [
46+ { value: "updated", label: "Recently updated" },
47+ { value: "pushed", label: "Recently pushed" },
48+ { value: "active", label: "Most active" },
49+ { value: "name", label: "Name" },
50+];
51+
52+/** The language a project's manifests say it is written in. */
53+export const LANGUAGE_OF: Record<ProjectEcosystem, string> = {
54+ npm: "JavaScript",
55+ cargo: "Rust",
56+ go: "Go",
57+ composer: "PHP",
58+ python: "Python",
59+};
60+
61+/** What the list needs of a project, with whether its Deployments are on. */
62+export type Listed = Pick<
63+ Project,
64+ "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity"
65+> & { deploying: boolean };
66+
67+const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T =>
68+ (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback;
69+
70+/** What the address asks for; anything it does not say, or says wrong, is the default. */
71+export function readProjectQuery(params: URLSearchParams): ProjectQuery {
72+ const page = Number.parseInt(params.get("page") ?? "", 10);
73+ return {
74+ q: (params.get("q") ?? "").trim().slice(0, 100),
75+ visibility: one(params.get("visibility"), ["all", "public", "private"] as const, "all"),
76+ kind: one(params.get("kind"), ["all", "app", "library"] as const, "all"),
77+ language: params.get("language")?.trim() || null,
78+ deployments: params.get("deployments") === "on",
79+ archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"),
80+ sort: one(params.get("sort"), ["updated", "name", "active", "pushed"] as const, "updated"),
81+ view: one(params.get("view"), ["list", "grid"] as const, "list"),
82+ page: Number.isFinite(page) && page > 0 ? page : 1,
83+ };
84+}
85+
86+/**
87+ * The address for `query` with `change` made, leaving out what is the
88+ * default. Changing anything but the page goes back to the first page.
89+ */
90+export function projectQueryString(query: ProjectQuery, change: Partial<ProjectQuery> = {}): string {
91+ const next = { ...query, ...change };
92+ if (!("page" in change)) next.page = 1;
93+ const params = new URLSearchParams();
94+ if (next.q) params.set("q", next.q);
95+ if (next.visibility !== "all") params.set("visibility", next.visibility);
96+ if (next.kind !== "all") params.set("kind", next.kind);
97+ if (next.language) params.set("language", next.language);
98+ if (next.deployments) params.set("deployments", "on");
99+ if (next.archived !== "hide") params.set("archived", next.archived);
100+ if (next.sort !== "updated") params.set("sort", next.sort);
101+ if (next.view !== "list") params.set("view", next.view);
102+ if (next.page > 1) params.set("page", String(next.page));
103+ const text = params.toString();
104+ return text ? `?${text}` : "";
105+}
106+
107+/** Whether anything narrows the list, beyond leaving archived projects out. */
108+export function isFiltered(query: ProjectQuery): boolean {
109+ return Boolean(
110+ query.q || query.visibility !== "all" || query.kind !== "all" || query.language || query.deployments || query.archived !== "hide",
111+ );
112+}
113+
114+/** When it last changed: its own settings, or a push, whichever is later. */
115+export function lastUpdated(project: Pick<Listed, "updatedAt" | "pushedAt">): string {
116+ return project.pushedAt && project.pushedAt > project.updatedAt ? project.pushedAt : project.updatedAt;
117+}
118+
119+function matches(project: Listed, words: string[]): boolean {
120+ const text = `${project.name} ${project.slug} ${project.description ?? ""}`.toLowerCase();
121+ return words.every((word) => text.includes(word));
122+}
123+
124+/** The projects `query` asks for, in its order, before paging. */
125+export function filterProjects(projects: Listed[], query: ProjectQuery): Listed[] {
126+ const words = query.q.toLowerCase().split(/\s+/).filter(Boolean);
127+ const shown = projects.filter(
128+ (project) =>
129+ (query.archived === "include" || (query.archived === "only") === project.archived) &&
130+ (query.visibility === "all" || (query.visibility === "private") === project.private) &&
131+ (query.kind === "all" || project.kind === query.kind) &&
132+ (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) &&
133+ (!query.deployments || project.deploying) &&
134+ (words.length === 0 || matches(project, words)),
135+ );
136+ return sortProjects(shown, query.sort, words);
137+}
138+
139+const byName = (a: Listed, b: Listed) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.slug.localeCompare(b.slug);
140+
141+/**
142+ * In `sort`'s order, ties by name. While searching, a name that starts
143+ * with what was typed comes first.
144+ */
145+export function sortProjects(projects: Listed[], sort: ProjectSort, words: string[] = []): Listed[] {
146+ const first = words[0];
147+ const leads = (project: Listed) =>
148+ first != null && (project.name.toLowerCase().startsWith(first) || project.slug.startsWith(first)) ? 0 : 1;
149+ const order: Record<ProjectSort, (a: Listed, b: Listed) => number> = {
150+ name: byName,
151+ updated: (a, b) => lastUpdated(b).localeCompare(lastUpdated(a)),
152+ pushed: (a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? ""),
153+ active: (a, b) => b.activity - a.activity,
154+ };
155+ return [...projects].sort((a, b) => leads(a) - leads(b) || order[sort](a, b) || byName(a, b));
156+}
157+
158+/** One page of them, and where it sits among the rest. */
159+export function pageOf<T>(list: T[], page: number, size = PAGE_SIZE): { items: T[]; page: number; pages: number; from: number; to: number } {
160+ const pages = Math.max(1, Math.ceil(list.length / size));
161+ const current = Math.min(Math.max(1, page), pages);
162+ const start = (current - 1) * size;
163+ const items = list.slice(start, start + size);
164+ return { items, page: current, pages, from: items.length ? start + 1 : 0, to: start + items.length };
165+}
166+
167+export type Facets = {
168+ visibility: { public: number; private: number };
169+ kind: { app: number; library: number };
170+ languages: { name: string; count: number }[];
171+ deploying: number;
172+ archived: number;
173+};
174+
175+/** How many of the workspace's projects each filter would show, before any is chosen. */
176+export function facetsOf(projects: Listed[]): Facets {
177+ const languages = new Map<string, number>();
178+ const facets: Facets = { visibility: { public: 0, private: 0 }, kind: { app: 0, library: 0 }, languages: [], deploying: 0, archived: 0 };
179+ for (const project of projects) {
180+ facets.visibility[project.private ? "private" : "public"]++;
181+ facets.kind[project.kind]++;
182+ if (project.deploying) facets.deploying++;
183+ if (project.archived) facets.archived++;
184+ if (project.ecosystem) {
185+ const language = LANGUAGE_OF[project.ecosystem];
186+ languages.set(language, (languages.get(language) ?? 0) + 1);
187+ }
188+ }
189+ facets.languages = [...languages].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
190+ return facets;
191+}
192+
193+/**
194+ * The index of the row to move to from `at` by key, in a list of `count`
195+ * rows: arrows and j/k move one, Home and End to either end. Null for any
196+ * other key.
197+ */
198+export function nextRow(key: string, at: number, count: number): number | null {
199+ if (count === 0) return null;
200+ switch (key) {
201+ case "ArrowDown":
202+ case "j":
203+ return Math.min(count - 1, at + 1);
204+ case "ArrowUp":
205+ case "k":
206+ return Math.max(0, at - 1);
207+ case "Home":
208+ return 0;
209+ case "End":
210+ return count - 1;
211+ default:
212+ return null;
213+ }
214+}
+13−0
6666 today: { label: "Outcomes", path: "plans" },
6767 },
6868 {
69+ key: "insights",
70+ title: "Insights",
71+ section: "Workspace",
72+ summary: "How the whole workspace delivers: lead time, reviews, what agents do and what it costs, across every project.",
73+ why: "Each project will have its own numbers. The workspace's put them side by side, so you can see which projects ship steadily, where work waits, and where the money goes.",
74+ plans: [
75+ "Delivery metrics for every project, side by side",
76+ "Where work waits: review, the merge queue, checks",
77+ "Agents' share of merged changes, and how often it lands first time",
78+ "Cost by project, by kind of work and per merged change",
79+ ],
80+ },
81+ {
6982 key: "milestones",
7083 title: "Milestones",
7184 section: "Issues",
+53−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts";
5+
6+test("everyone sees Overview, Projects and Packages; members and owners see more", () => {
7+ const keys = (member: boolean, owner: boolean) => workspaceTabs("acme", { member, owner }).map((tab) => tab.key);
8+ assert.deepEqual(keys(false, false), ["overview", "projects", "packages"]);
9+ assert.deepEqual(keys(true, false), ["overview", "projects", "packages", "teams", "people", "insights"]);
10+ assert.deepEqual(keys(true, true), ["overview", "projects", "packages", "teams", "people", "insights", "settings"]);
11+});
12+
13+test("tabs carry counts, and say which are coming", () => {
14+ const tabs = workspaceTabs("acme", { member: true, owner: false, projects: 412, people: 7 });
15+ assert.equal(tabs.find((tab) => tab.key === "projects")?.count, 412);
16+ assert.equal(tabs.find((tab) => tab.key === "people")?.count, 7);
17+ assert.equal(tabs.find((tab) => tab.key === "teams")?.soon, true);
18+ assert.equal(tabs.find((tab) => tab.key === "overview")?.to, "/acme");
19+ assert.equal(tabs.find((tab) => tab.key === "projects")?.to, "/acme/-/projects");
20+});
21+
22+test("a path is a tab, or one of the workspace's other pages", () => {
23+ assert.equal(workspaceTab("/acme", "acme"), "overview");
24+ assert.equal(workspaceTab("/Acme/", "acme"), "overview");
25+ assert.equal(workspaceTab("/acme/-/projects", "acme"), "projects");
26+ assert.equal(workspaceTab("/acme/-/people", "acme"), "people");
27+ assert.equal(workspaceTab("/acme/-/packages", "acme"), "packages");
28+ // One package has a page of its own; settings and the rest have headings.
29+ assert.equal(workspaceTab("/acme/-/packages/npm/web", "acme"), null);
30+ assert.equal(workspaceTab("/acme/-/settings", "acme"), null);
31+ assert.equal(workspaceTab("/acme/-/agents", "acme"), null);
32+ assert.equal(workspaceTab("/acme/web", "acme"), null);
33+ assert.equal(workspaceTab("/other", "acme"), null);
34+});
35+
36+test("old addresses go to where their pages are now", () => {
37+ assert.equal(workspaceRedirect("/acme/-/members"), "/acme/-/people");
38+ assert.equal(workspaceRedirect("/acme/-/overview"), "/acme");
39+ assert.equal(workspaceRedirect("/acme/-/soon/teams"), "/acme/-/teams");
40+ assert.equal(workspaceRedirect("/acme/-/soon/insights", "?x=1"), "/acme/-/insights?x=1");
41+ assert.equal(workspaceRedirect("/acme/-/soon/board"), null);
42+ assert.equal(workspaceRedirect("/acme/-/people"), null);
43+ assert.equal(workspaceRedirect("/acme/web"), null);
44+});
45+
46+test("?tab= opens that tab, keeping the rest of the query", () => {
47+ assert.equal(workspaceRedirect("/acme", "?tab=repositories"), "/acme/-/projects");
48+ assert.equal(workspaceRedirect("/acme", "?tab=projects&q=api"), "/acme/-/projects?q=api");
49+ assert.equal(workspaceRedirect("/acme", "?tab=members"), "/acme/-/people");
50+ assert.equal(workspaceRedirect("/acme", "?tab=overview"), "/acme");
51+ assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null);
52+ assert.equal(workspaceRedirect("/acme", ""), null);
53+});
+103−0
1+/**
2+ * A workspace's own page at `g1t.sh/<workspace>`: its header, and tabs for
3+ * what it has (Overview, Projects, Packages, Teams, People, Insights and,
4+ * for owners, Settings). The sidebar is always the current workspace's;
5+ * this page is where the workspace itself is shown, to members and to
6+ * everyone else.
7+ */
8+
9+export type WorkspaceTabKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights" | "settings";
10+
11+export type WorkspaceTab = {
12+ key: WorkspaceTabKey;
13+ label: string;
14+ to: string;
15+ /** Shown beside the label; left out when unknown. */
16+ count?: number | null;
17+ /** Not built yet: its tab opens what it will be. */
18+ soon?: boolean;
19+};
20+
21+/** The workspace's pages under `-/` that are drawn under its tabs. */
22+export const TAB_PAGES = ["projects", "packages", "teams", "people", "insights"] as const;
23+
24+/** Tabs whose pages say what they will be, until they are built. */
25+export const SOON_TABS = new Set<WorkspaceTabKey>(["teams", "insights"]);
26+
27+/**
28+ * The tabs, in order. People, Teams and Insights are for members, and
29+ * Settings for owners; everyone sees the rest.
30+ */
31+export function workspaceTabs(
32+ slug: string,
33+ options: { member: boolean; owner: boolean; projects?: number | null; people?: number | null },
34+): WorkspaceTab[] {
35+ const base = `/${slug}`;
36+ const tabs: (WorkspaceTab | false)[] = [
37+ { key: "overview", label: "Overview", to: base },
38+ { key: "projects", label: "Projects", to: `${base}/-/projects`, count: options.projects ?? null },
39+ { key: "packages", label: "Packages", to: `${base}/-/packages` },
40+ options.member && { key: "teams", label: "Teams", to: `${base}/-/teams`, soon: true },
41+ options.member && { key: "people", label: "People", to: `${base}/-/people`, count: options.people ?? null },
42+ options.member && { key: "insights", label: "Insights", to: `${base}/-/insights`, soon: true },
43+ options.owner && { key: "settings", label: "Settings", to: `${base}/-/settings` },
44+ ];
45+ return tabs.filter((tab): tab is WorkspaceTab => tab !== false);
46+}
47+
48+/**
49+ * Which tab a path is, under `/<workspace>`: null for the workspace's other
50+ * pages (its settings, Agent fleet, Usage and the rest), which have a page
51+ * heading of their own, and for one package's page.
52+ */
53+export function workspaceTab(pathname: string, slug: string): WorkspaceTabKey | null {
54+ const parts = pathname.split("/").filter(Boolean);
55+ if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
56+ if (parts.length === 1) return "overview";
57+ if (parts[1] !== "-" || parts.length !== 3) return null;
58+ return (TAB_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspaceTabKey) : null;
59+}
60+
61+/** `?tab=` as people write it, from habit: the tab it means. */
62+const TAB_WORDS: Record<string, string> = {
63+ overview: "",
64+ projects: "-/projects",
65+ repositories: "-/projects",
66+ repos: "-/projects",
67+ packages: "-/packages",
68+ people: "-/people",
69+ members: "-/people",
70+ teams: "-/teams",
71+ insights: "-/insights",
72+};
73+
74+/** Workspace pages that moved, by their old name under `-/`. */
75+const MOVED: Record<string, string> = {
76+ members: "-/people",
77+ overview: "",
78+ "soon/teams": "-/teams",
79+ "soon/insights": "-/insights",
80+};
81+
82+/**
83+ * Where an old address of a workspace's pages is now, keeping its query;
84+ * null when it has not moved. `/<workspace>?tab=projects` and the like
85+ * open that tab.
86+ */
87+export function workspaceRedirect(pathname: string, search = ""): string | null {
88+ const parts = pathname.split("/").filter(Boolean);
89+ const slug = parts[0];
90+ if (!slug) return null;
91+ const params = new URLSearchParams(search);
92+ if (parts.length === 1 && params.has("tab")) {
93+ const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
94+ if (to === undefined) return null;
95+ params.delete("tab");
96+ const rest = params.toString();
97+ return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
98+ }
99+ if (parts[1] !== "-") return null;
100+ const to = MOVED[parts.slice(2).join("/")];
101+ if (to === undefined) return null;
102+ return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
103+}
+22−0
1+import type { Project, Result, Viewer } from "@g1t/contracts";
2+
3+import { shortCache } from "./cache.server";
4+import { projects } from "./services.server";
5+
6+/** How long one isolate keeps a person's list of a workspace's projects. */
7+export const PROJECTS_TTL_MS = 15_000;
8+
9+/**
10+ * A workspace's projects as the viewer may see them: one listing, kept for
11+ * a few seconds per person and workspace (lib/cache.server.ts), so the
12+ * sidebar, the workspace's tabs and its Projects page share it.
13+ */
14+export function workspaceProjects(slug: string, viewer: Viewer): Promise<Result<Project[]>> {
15+ const workspace = slug.toLowerCase();
16+ return shortCache(`shell:projects:${viewer?.id ?? "visitor"}:${workspace}`, PROJECTS_TTL_MS, () => projects.list(workspace, viewer));
17+}
18+
19+/** What a project is in the sidebar and the command palette. */
20+export function shortcutOf(project: Project): { namespace: string; name: string; title: string; isPrivate: boolean } {
21+ return { namespace: project.workspace, name: project.slug, title: project.name, isPrivate: project.private };
22+}
+10−10
5858 import { countsFor, readableRepos } from "./lib/access.server";
5959 import { shortCache } from "./lib/cache.server";
6060 import { getViewer, viewerMiddleware } from "./lib/session.server";
61+import { shortcutOf, workspaceProjects } from "./lib/workspace-projects.server";
6162 import { registrationMode } from "./lib/registration.server";
6263 import { addresses } from "./lib/addresses.server";
6364 import { useSignUpCopy } from "./lib/registration";
140141 // something (lib/cache.server.ts).
141142 const kept = <T,>(what: string, load: () => Promise<T>) =>
142143 workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null);
143− const [listed, counts, status, usage, limit, entitlements, shared, unread] = await Promise.all([
144− kept("projects", () => projects.list(workspace!.slug, user)),
144+ const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts] = await Promise.all([
145+ // Every project, for the palette and the count; the sidebar lists only
146+ // the person's pinned and recent ones (lib/pins.ts).
147+ workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null),
145148 path ? countsFor(context, params) : Promise.resolve(null),
146149 // Whether billing is on, without reading the account: that asks the
147150 // card processor about the workspace's cards, too slow for every page.
152155 sharedRepos(user),
153156 // The bell's count: one query, never kept, so marking an item shows at once.
154157 inbox.counts(user.username).catch(() => null),
158+ // Never kept: opening a project moves it up Recent.
159+ workspace ? projects.shortcuts(workspace.slug, user).catch(() => null) : Promise.resolve(null),
155160 ]);
156161 return {
157162 workspace,
158163 // Projects are what the sidebar lists: what the workspace builds and runs.
159− repos: listed?.ok
160− ? listed.value.map((project) => ({
161− namespace: project.workspace,
162− name: project.slug,
163− title: project.name,
164− isPrivate: project.private,
165− }))
166− : [],
164+ repos: listed?.ok ? listed.value.map(shortcutOf) : [],
165+ pinned: (shortcuts?.pinned ?? []).map(shortcutOf),
166+ recent: (shortcuts?.recent ?? []).map(shortcutOf),
167167 repo:
168168 path && counts?.ok
169169 ? {
+9−0
6262 // A workspace's own pages sit under `-`, which no repository can be named.
6363 route(":owner", "routes/workspace/layout.tsx", [
6464 index("routes/workspace/overview.tsx"),
65+ // Its tabs: Overview (above), Projects, Packages, Teams, People, Insights.
66+ route("-/projects", "routes/workspace/projects.tsx"),
6567 route("-/people", "routes/workspace/people.tsx"),
68+ route("-/teams", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/teams" }),
69+ route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }),
70+ // Pinning its projects, for the person signed in.
71+ route("-/pins", "routes/workspace/pins.ts"),
72+ // Pages that moved: Members is People, and the overview is the workspace.
73+ route("-/members", "routes/workspace/moved.ts", { id: "routes/workspace/moved-members" }),
74+ route("-/overview", "routes/workspace/moved.ts", { id: "routes/workspace/moved-overview" }),
6675 route("-/tokens", "routes/workspace/tokens.tsx"),
6776 route("-/usage", "routes/workspace/usage.tsx"),
6877 route("-/billing", "routes/workspace/billing.tsx"),
+20−5
1+import { waitUntil } from "cloudflare:workers";
12 import { Box, Lock } from "lucide-react";
23 import type { ReactNode } from "react";
34 import { Link, NavLink, Outlet, type ShouldRevalidateFunctionArgs, data, useLocation, useRouteLoaderData } from "react-router";
1011 import { type Tab as PageTab, tabsFor } from "../../lib/project-nav";
1112 import { Pill } from "../../components/ui";
1213 import { WatchMenu } from "../../components/notifications";
14+import { PinButton } from "../../components/pin-button";
1315 import { ArchivedBanner } from "../../components/repo-lifecycle";
1416 import { WelcomeBanner } from "../../components/welcome";
1517 import { clearWelcome, welcomes } from "../../lib/invites";
1719 import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server";
1820 import { accessFor, countsFor, repoFor } from "../../lib/access.server";
1921 import { inbox, projects } from "../../lib/services.server";
20−import { getViewer, unwrap } from "../../lib/session.server";
22+import { getViewer, roleIn, unwrap } from "../../lib/session.server";
2123
2224 export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
2325 return page(args, { title: `${loaded?.project?.name ?? params.repo} · ${params.owner} · g1t` });
2628 export async function loader({ params, context, request }: Route.LoaderArgs) {
2729 const viewer = getViewer(context);
2830 const path = { namespace: params.owner, name: params.repo };
29− const [repo, counts, found, watching] = await Promise.all([
31+ // Members pin the workspace's projects, and what they open is their Recent.
32+ const member = viewer ? roleIn(viewer, params.owner) != null : false;
33+ const [repo, counts, found, watching, shortcuts] = await Promise.all([
3034 repoFor(context, params),
3135 countsFor(context, params),
3236 projects.get(params.owner, params.repo, viewer),
3539 viewer
3640 ? repoFor(context, params).then((found) => (found.ok ? inbox.watching(viewer.username, found.value.id).catch(() => null) : null))
3741 : null,
42+ // Whether they pinned it, for the header's Pin button.
43+ member ? projects.shortcuts(params.owner, viewer).catch(() => null) : null,
3844 ]);
3945 if (!repo.ok && !found.ok) {
4046 // Under a workspace's old name, after a rename: the project is at the new one.
4955 const own = await projects.byRepo(repo.value.id);
5056 project = own.find((p) => p.slug === params.repo.toLowerCase()) ?? own[0] ?? null;
5157 }
58+ // Opened just now: it leads their Recent, recorded after the page is sent.
59+ if (member && project) waitUntil(projects.visited(viewer!, project.id).catch(() => undefined));
5260 const value = unwrap(repo);
5361 // The viewer's role on the repository and what it lets them do, for the
5462 // pages under it and the sidebar.
6472 access,
6573 member: access.insider,
6674 watching,
75+ // Null when they cannot pin it: not one of their workspaces.
76+ pinned: member && project ? (shortcuts?.pinned ?? []).some((pinned) => pinned.id === project.id) : null,
6777 }, { headers });
6878 }
6979
161171 }
162172
163173 export default function ProjectLayout({ loaderData }: Route.ComponentProps) {
164− const { repo, project, member, access, welcome, watching } = loaderData;
174+ const { repo, project, member, access, welcome, watching, pinned } = loaderData;
165175 const base = `/${repo.namespace}/${repo.name}`;
166176 // The project's own description, else the repository's as it is now.
167177 const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description;
184194 // The files' own About says it there, as the one place.
185195 description={filesPage ? null : description}
186196 actions={
187− watching ? (
188− <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} />
197+ watching || (project && pinned != null) ? (
198+ <>
199+ {project && pinned != null && (
200+ <PinButton workspace={project.workspace} slug={project.slug} name={project.name} pinned={pinned} />
201+ )}
202+ {watching && <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} />}
203+ </>
189204 ) : null
190205 }
191206 />
+87−13
11 import { Plus } from "lucide-react";
2−import { Outlet, data, useLocation } from "react-router";
2+import { NavLink, Outlet, data, redirect, useLocation, useNavigation } from "react-router";
33
44 import type { Route } from "./+types/layout";
55 import { page } from "../../lib/meta";
99 import { notFound } from "../../lib/not-found.server";
1010 import { redirectIfRenamed } from "../../lib/renamed.server";
1111 import { rememberWorkspace } from "../../lib/workspace-choice";
12+import { type WorkspaceTab, type WorkspaceTabKey, workspaceRedirect, workspaceTab, workspaceTabs } from "../../lib/workspace-nav";
13+import { workspaceProjects } from "../../lib/workspace-projects.server";
14+import { WorkspaceTabSkeleton } from "../../components/workspace-skeletons";
1215 import { identity } from "../../lib/services.server";
1316 import { getViewer, roleIn } from "../../lib/session.server";
1417
1720 }
1821
1922 export async function loader({ params, context, request }: Route.LoaderArgs) {
20− const workspace = await identity.getWorkspace(params.owner);
23+ const url = new URL(request.url);
24+ // `?tab=projects` and the like, from habit: that tab's own address.
25+ const moved = url.search ? workspaceRedirect(url.pathname, url.search) : null;
26+ if (moved) throw redirect(moved);
27+ const viewer = getViewer(context);
28+ // Under the tabs, the count of projects beside Projects: the same
29+ // listing the sidebar and the Projects page read, asked at once.
30+ const tabbed = workspaceTab(url.pathname, params.owner) != null;
31+ const [workspace, listed] = await Promise.all([
32+ identity.getWorkspace(params.owner),
33+ tabbed ? workspaceProjects(params.owner, viewer).catch(() => null) : null,
34+ ]);
2135 if (!workspace) {
2236 // A workspace's old name, after a rename: its pages are at the new one.
2337 await redirectIfRenamed(request, params.owner);
2438 throw notFound("workspace");
2539 }
26− const role = roleIn(getViewer(context), workspace.slug);
40+ const role = roleIn(viewer, workspace.slug);
41+ const projectCount = listed?.ok ? listed.value.length : null;
2742 // Opening one of your workspaces makes it the one you are in.
28− if (!role) return { workspace, role, welcome: false };
43+ if (!role) return { workspace, role, welcome: false, projectCount };
2944 const secure = new URL(request.url).protocol === "https:";
3045 const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) });
3146 // Just joined with an invite: welcomed once (routes/invite.tsx).
3247 const welcome = welcomes(request.headers.get("cookie"), workspace.slug);
3348 if (welcome) headers.append("Set-Cookie", clearWelcome(secure));
34− return data({ workspace, role, welcome }, { headers });
49+ return data({ workspace, role, welcome, projectCount }, { headers });
3550 }
3651
3752 /** A workspace's own pages, each with its title and what it is for. */
3853 const PAGES: Record<string, { title: string; about: string }> = {
3954 settings: { title: "General", about: "The workspace's name, icon, address and description, and deleting it." },
4055 people: {
41− title: "Members",
56+ title: "People",
4257 about: "Members create repositories and have the base permission on each one. Owners are Admins on every repository, and also manage members, tokens, billing and integrations.",
4358 },
59+ projects: {
60+ title: "Projects",
61+ about: "Everything the workspace builds and runs. Search, filter and sort them, and pin the ones you use most to keep them in your sidebar.",
62+ },
4463 repositories: {
4564 title: "Repositories",
4665 about: "Every repository in the workspace: who can see it, whether it is archived, and the ones deleted recently.",
97116 },
98117 };
99118
119+/**
120+ * The workspace's tabs, as a project's are: a row under its header that
121+ * scrolls sideways on a phone, without a scrollbar. Soon tabs say so with
122+ * a dot.
123+ */
124+function WorkspaceTabs({ tabs, current }: { tabs: WorkspaceTab[]; current: WorkspaceTabKey | null }) {
125+ return (
126+ <nav aria-label="Workspace" className="relative -mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
127+ {tabs.map((tab) => (
128+ <NavLink
129+ key={tab.key}
130+ to={tab.to}
131+ end={tab.key === "overview"}
132+ prefetch="intent"
133+ aria-current={current === tab.key ? "page" : undefined}
134+ className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${
135+ current === tab.key
136+ ? "border-accent font-medium text-fg"
137+ : tab.soon
138+ ? "border-transparent text-faint hover:text-muted"
139+ : "border-transparent text-muted hover:text-fg"
140+ }`}
141+ >
142+ {tab.label}
143+ {tab.count != null && (
144+ <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted">{tab.count}</span>
145+ )}
146+ {tab.soon && (
147+ <>
148+ <span aria-hidden="true" className="size-1.5 rounded-full bg-accent/70" />
149+ <span className="sr-only">(soon)</span>
150+ </>
151+ )}
152+ </NavLink>
153+ ))}
154+ </nav>
155+ );
156+}
157+
100158 export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
101− const { workspace, role, welcome } = loaderData;
102− // The sidebar finds the workspace's pages, for everyone, so its pages
159+ const { workspace, role, welcome, projectCount } = loaderData;
160+ const { pathname } = useLocation();
161+ const going = useNavigation().location;
162+ const tab = workspaceTab(pathname, workspace.slug);
163+ // The sidebar finds the workspace's other pages, for everyone, so they
103164 // need a title, not the workspace's whole header again.
104− const parts = (useLocation().pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
105− const page = PAGES[parts[0] ?? ""];
165+ const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
166+ const page = tab ? undefined : PAGES[parts[0] ?? ""];
106167 // A page within one (a single package) has its own heading.
107168 if (page && parts.length > 1) {
108169 return (
122183 </div>
123184 );
124185 }
186+ const tabs = workspaceTabs(workspace.slug, {
187+ member: role != null,
188+ owner: role === "owner",
189+ projects: projectCount,
190+ people: role ? workspace.memberCount : null,
191+ });
192+ // On the way to another of its tabs: that tab's shape until it arrives.
193+ const next = going && going.pathname !== pathname ? workspaceTab(going.pathname, workspace.slug) : null;
125194 return (
126195 <>
127− {/* The workspace's own header band. */}
196+ {/* The workspace's own header band, with its tabs along the bottom. */}
128197 <div className="border-b border-line bg-surface/60">
129− <div className="mx-auto max-w-6xl px-4 pt-8 pb-8">
198+ <div className="mx-auto max-w-6xl px-4 pt-8">
130199 {welcome && (
131200 <div className="mb-6">
132201 <WelcomeBanner title={`You're in ${workspace.name}`}>
155224 {workspace.description && (
156225 <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p>
157226 )}
227+ <div className="mt-6">
228+ <WorkspaceTabs tabs={tabs} current={next ?? tab} />
229+ </div>
158230 </div>
159231 </div>
160232 <div className="mx-auto max-w-6xl px-4 py-8">
161− <Outlet />
233+ {/* What a tab is for, said once above it. */}
234+ {tab && tab !== "overview" && !next && PAGES[tab] && <p className="mb-6 max-w-3xl text-sm text-muted">{PAGES[tab].about}</p>}
235+ {next ? <WorkspaceTabSkeleton tab={next} /> : <Outlet />}
162236 </div>
163237 </>
164238 );
+15−0
1+/**
2+ * Workspace pages that moved: `-/members` is People now, and `-/overview`
3+ * the workspace's own page (lib/workspace-nav.ts).
4+ */
5+import { data, redirect } from "react-router";
6+
7+import type { Route } from "./+types/moved";
8+import { workspaceRedirect } from "../../lib/workspace-nav";
9+
10+export function loader({ request }: Route.LoaderArgs) {
11+ const url = new URL(request.url);
12+ const to = workspaceRedirect(url.pathname, url.search);
13+ if (!to) throw data(null, { status: 404 });
14+ return redirect(to, 301);
15+}
+93−32
1−import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react";
1+import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Pin, Plus, Rocket } from "lucide-react";
22 import { Link } from "react-router";
33
44 import type { PackageSummary, Project, ProjectDeploys, User } from "@g1t/contracts";
1212 import { openedBy } from "../../lib/opened-by";
1313 import { cloneUrl, useAddresses } from "../../lib/addresses";
1414 import { libraryPackages, packageLine, packagePath } from "../../lib/project-kind";
15+import { PinButton } from "../../components/pin-button";
16+import { sortProjects } from "../../lib/project-list";
1517 import { billing, deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
1618 import { getViewer, roleIn } from "../../lib/session.server";
19+import { workspaceProjects } from "../../lib/workspace-projects.server";
1720
18−/** Projects whose open issues and pull requests are counted. */
21+/** Projects whose open issues and pull requests are counted: the most active. */
1922 const MAX_COUNTED = 30;
23+/** Projects shown under Recently active, after the pins. */
24+const MAX_ACTIVE = 6;
2025 /** Projects whose pull requests are listed under "In progress". */
2126 const MAX_LISTED = 8;
2227 const MAX_PULLS = 8;
5863 const viewer = getViewer(context);
5964 const slug = params.owner.toLowerCase();
6065 const role = roleIn(viewer, slug);
61− const [listed, members, deploys, usage, published] = await Promise.all([
62− projectsApi.list(slug, viewer),
66+ const [listed, members, deploys, usage, published, shortcuts] = await Promise.all([
67+ // The listing the sidebar and the Projects tab share.
68+ workspaceProjects(slug, viewer),
6369 role ? identity.listMembers(slug, viewer) : null,
6470 role ? deployments.overview(slug, viewer) : null,
6571 role ? usageFor(slug, viewer) : null,
6672 // One listing for every card: a library's card shows its package where an app's shows production.
6773 packages.list(slug, viewer).catch(() => null),
74+ // The viewer's pins lead the page, as they lead the sidebar.
75+ role ? projectsApi.shortcuts(slug, viewer).catch(() => null) : null,
6876 ]);
6977 const projects = listed.ok ? listed.value : [];
78+ const pinnedIds = new Set((shortcuts?.pinned ?? []).map((project) => project.id));
79+ const byId = new Map(projects.map((project) => [project.id, project]));
80+ const pinned = (shortcuts?.pinned ?? []).map((pin) => byId.get(pin.id)).filter((project): project is Project => project != null);
81+ // However many there are: the most active first, archived ones last.
82+ const byActivity = sortProjects(
83+ projects.map((project) => ({ ...project, pushedAt: project.pushedAt ?? null, activity: project.activity ?? 0, deploying: false })),
84+ "active",
85+ )
86+ .sort((a, b) => Number(a.archived) - Number(b.archived))
87+ .map((listedProject) => byId.get(listedProject.id)!);
88+ const active = byActivity.filter((project) => !pinnedIds.has(project.id)).slice(0, MAX_ACTIVE);
7089
7190 const pathOf = (project: Project) =>
7291 project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug };
73− const counted = projects.slice(0, MAX_COUNTED);
92+ const counted = [...new Map([...pinned, ...active, ...byActivity].map((project) => [project.id, project])).values()].slice(0, Math.max(MAX_COUNTED, pinned.length + active.length));
7493 const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer)));
7594 const open: Record<string, { issues: number; pulls: number }> = {};
7695 counted.forEach((project, i) => {
93112 if (pkg.repo) byRepo.set(pkg.repo.id, [...(byRepo.get(pkg.repo.id) ?? []), pkg]);
94113 }
95114 const packageOf: Record<string, PackageSummary> = {};
96− for (const project of projects) {
115+ for (const project of [...pinned, ...active]) {
97116 if (project.kind !== "library" || project.source.kind !== "hosted") continue;
98117 const first = libraryPackages(byRepo.get(project.source.repoId) ?? [])[0];
99118 if (first) packageOf[project.id] = first;
102121 return {
103122 slug,
104123 role,
105− projects,
124+ total: projects.length,
125+ pinned,
126+ active,
106127 open,
107128 deploys: bySlug,
108129 packageOf,
154175 deploys,
155176 pkg,
156177 member,
178+ pinned,
157179 }: {
158180 project: Project;
159181 open: { issues: number; pulls: number } | undefined;
161183 /** For a library, the package it publishes. */
162184 pkg: PackageSummary | undefined;
163185 member: boolean;
186+ /** Whether the viewer pinned it; null when they cannot pin. */
187+ pinned: boolean | null;
164188 }) {
165189 const library = project.kind === "library";
166190 const base = `/${project.workspace}/${project.slug}`;
204228 {project.archived && <Pill>archived</Pill>}
205229 </span>
206230 )}
231+ {pinned != null && (
232+ <PinButton workspace={project.workspace} slug={project.slug} name={project.name} pinned={pinned} compact className="-mt-1.5 -mr-2" />
233+ )}
207234 </div>
208235
209236 {project.description && (production || (library && pkg)) && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
248275 }
249276
250277 export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
251− const { slug, role, projects, open, deploys, pulls, members, usage } = loaderData;
278+ const { slug, role, total, pinned, active, open, deploys, pulls, members, usage } = loaderData;
279+ const card = (project: Project, isPinned: boolean) => (
280+ <ProjectCard
281+ key={project.id}
282+ project={project}
283+ open={open[project.id]}
284+ deploys={deploys[project.slug]}
285+ pkg={loaderData.packageOf[project.id]}
286+ member={role != null}
287+ pinned={role ? isPinned : null}
288+ />
289+ );
252290 const totals = Object.values(open).reduce(
253291 (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }),
254292 { issues: 0, pulls: 0 },
257295 return (
258296 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
259297 <div className="min-w-0 space-y-10">
260− {projects.length === 0 && role ? (
298+ {total === 0 && role ? (
261299 <GetStarted slug={slug} />
262300 ) : (
263301 <>
264302 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
265− <Stat value={projects.length} label={projects.length === 1 ? "Project" : "Projects"} />
303+ <Stat value={total} label={total === 1 ? "Project" : "Projects"} />
266304 <Stat value={liveApps} label="In production" />
267305 <Stat value={totals.issues} label="Open issues" />
268306 <Stat value={totals.pulls} label="Pull requests in progress" />
269307 </div>
270308
271− <section>
272− <div className="flex items-center justify-between">
273− <h2 className="text-sm font-medium text-muted">Projects</h2>
274− {role && (
275− <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
276− <Plus size={13} />
277− New project
278− </Link>
309+ {role && (
310+ <section aria-labelledby="pinned">
311+ <h2 id="pinned" className="text-sm font-medium text-muted">
312+ Pinned
313+ </h2>
314+ {pinned.length > 0 ? (
315+ <ul className="mt-3 grid gap-4 sm:grid-cols-2">{pinned.map((project) => card(project, true))}</ul>
316+ ) : (
317+ <p className="mt-3 flex items-center gap-2 rounded-xl border border-dashed border-line px-4 py-3 text-sm text-muted">
318+ <Pin size={14} className="shrink-0 text-faint" />
319+ Pin the projects you use most, from their page or from Projects, to keep them here and in your sidebar.
320+ </p>
279321 )}
280− </div>
281− <ul className="mt-3 grid gap-4 sm:grid-cols-2">
282− {projects.map((project) => (
283− <ProjectCard
284− key={project.id}
285− project={project}
286− open={open[project.id]}
287− deploys={deploys[project.slug]}
288− pkg={loaderData.packageOf[project.id]}
289− member={role != null}
290− />
291− ))}
292− </ul>
293− </section>
322+ </section>
323+ )}
324+
325+ {active.length > 0 && (
326+ <section aria-labelledby="active">
327+ <div className="flex items-center justify-between">
328+ <h2 id="active" className="text-sm font-medium text-muted">
329+ {role ? "Recently active" : "Projects"}
330+ </h2>
331+ {role && (
332+ <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
333+ <Plus size={13} />
334+ New project
335+ </Link>
336+ )}
337+ </div>
338+ <ul className="mt-3 grid gap-4 sm:grid-cols-2">{active.map((project) => card(project, false))}</ul>
339+ </section>
340+ )}
341+
342+ <Link
343+ to={`/${slug}/-/projects`}
344+ prefetch="intent"
345+ className="flex items-center justify-between rounded-xl border border-line bg-surface px-4 py-3 text-sm transition-colors hover:border-line-strong"
346+ >
347+ <span>
348+ All projects <span className="tabular-nums text-muted">({total})</span>
349+ </span>
350+ <span className="flex items-center gap-1 text-xs text-muted">
351+ Search, filter and sort
352+ <ArrowRight size={13} />
353+ </span>
354+ </Link>
294355
295356 {pulls.length > 0 && (
296357 <section>
+1−1
2727 } from "../../lib/session.server";
2828
2929 export function meta({ params, ...args }: Route.MetaArgs) {
30− return page(args, { title: `Members · ${params.owner} · g1t` });
30+ return page(args, { title: `People · ${params.owner} · g1t` });
3131 }
3232
3333 export async function loader({ params, context, request }: Route.LoaderArgs) {
+38−0
1+/**
2+ * Pinning a workspace's projects, for the person signed in: `intent` pin
3+ * (with `slug`, and `position` to put it somewhere other than the end),
4+ * unpin (with `slug`), or reorder (with every pinned `slug`, in order).
5+ * Posted by the pin buttons on a project's header and the Projects page,
6+ * and by dragging pins in the sidebar (lib/pins.ts). The projects service
7+ * keeps them; the sidebar and pages reload after.
8+ */
9+import { data, redirect } from "react-router";
10+
11+import type { Route } from "./+types/pins";
12+import { pinFromForm } from "../../lib/pins";
13+import { projects } from "../../lib/services.server";
14+import { assertSameOrigin, requireUser } from "../../lib/session.server";
15+
16+/** Nothing to see here: the workspace's projects are. */
17+export function loader({ params }: Route.LoaderArgs) {
18+ return redirect(`/${params.owner}/-/projects`);
19+}
20+
21+export async function action({ request, params, context }: Route.ActionArgs) {
22+ assertSameOrigin(request);
23+ const user = requireUser(context, request);
24+ const change = pinFromForm(await request.formData());
25+ if (!change) return data({ error: "Nothing to do." }, { status: 400 });
26+ try {
27+ const result =
28+ change.intent === "pin"
29+ ? await projects.pin(user, params.owner, change.slug, change.position)
30+ : change.intent === "unpin"
31+ ? await projects.unpin(user, params.owner, change.slug)
32+ : await projects.reorderPins(user, params.owner, change.slugs);
33+ if (!result.ok) return data({ error: result.error.message }, { status: result.error.code === "not_found" ? 404 : 400 });
34+ return { error: null, pinned: result.value.map((project) => project.slug) };
35+ } catch {
36+ return data({ error: "That did not save. Try again in a moment." }, { status: 503 });
37+ }
38+}
+489−0
1+import { ArrowUpRight, Box, ChevronLeft, ChevronRight, LayoutGrid, List, Lock, Rocket, Search } from "lucide-react";
2+import { type KeyboardEvent, useEffect, useRef } from "react";
3+import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router";
4+
5+import type { Route } from "./+types/projects";
6+import { host } from "../../components/deploy";
7+import { PinButton } from "../../components/pin-button";
8+import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui";
9+import { page as pageMeta } from "../../lib/meta";
10+import {
11+ LANGUAGE_OF,
12+ type Listed,
13+ type ProjectQuery,
14+ SORTS,
15+ facetsOf,
16+ filterProjects,
17+ isFiltered,
18+ lastUpdated,
19+ nextRow,
20+ pageOf,
21+ projectQueryString,
22+ readProjectQuery,
23+} from "../../lib/project-list";
24+import { deployments, projects } from "../../lib/services.server";
25+import { getViewer, roleIn } from "../../lib/session.server";
26+import { workspaceProjects } from "../../lib/workspace-projects.server";
27+
28+export function meta({ params, ...args }: Route.MetaArgs) {
29+ return pageMeta(args, { title: `Projects · ${params.owner} · g1t` });
30+}
31+
32+/**
33+ * Every project the viewer can see, filtered, sorted and paged here: one
34+ * listing (shared with the sidebar's), with Deployments and the viewer's
35+ * pins asked for at the same time.
36+ */
37+export async function loader({ params, context, request }: Route.LoaderArgs) {
38+ const viewer = getViewer(context);
39+ const slug = params.owner.toLowerCase();
40+ const role = roleIn(viewer, slug);
41+ const query = readProjectQuery(new URL(request.url).searchParams);
42+ const [listed, deploys, shortcuts] = await Promise.all([
43+ workspaceProjects(slug, viewer),
44+ role ? deployments.overview(slug, viewer).catch(() => null) : null,
45+ role ? projects.shortcuts(slug, viewer).catch(() => null) : null,
46+ ]);
47+ const live = new Map((deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry]));
48+ const all: Listed[] = (listed.ok ? listed.value : []).map((project) => ({
49+ id: project.id,
50+ slug: project.slug,
51+ name: project.name,
52+ description: project.description,
53+ private: project.private,
54+ archived: project.archived,
55+ kind: project.kind,
56+ ecosystem: project.ecosystem,
57+ updatedAt: project.updatedAt,
58+ pushedAt: project.pushedAt ?? null,
59+ activity: project.activity ?? 0,
60+ deploying: live.get(project.slug)?.enabled ?? false,
61+ }));
62+ const matched = filterProjects(all, query);
63+ const shown = pageOf(matched, query.page);
64+ return {
65+ slug,
66+ role,
67+ query: { ...query, page: shown.page },
68+ total: all.length,
69+ matched: matched.length,
70+ page: { page: shown.page, pages: shown.pages, from: shown.from, to: shown.to },
71+ items: shown.items.map((project) => ({ ...project, production: live.get(project.slug)?.production?.url ?? null })),
72+ facets: facetsOf(all),
73+ pinned: (shortcuts?.pinned ?? []).map((project) => project.id),
74+ failed: !listed.ok,
75+ };
76+}
77+
78+type Item = Route.ComponentProps["loaderData"]["items"][number];
79+
80+const CONTROL =
81+ "h-9 rounded-md border border-line bg-surface px-2.5 text-sm text-fg outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
82+
83+/** Its language, its kind when it is not an app, and its state, as small words. */
84+function Facts({ project }: { project: Item }) {
85+ const language = project.ecosystem ? LANGUAGE_OF[project.ecosystem] : null;
86+ return (
87+ <>
88+ {language && <span>{language}</span>}
89+ {project.kind === "library" && <span>Library</span>}
90+ {project.deploying && (
91+ <span className="inline-flex items-center gap-1 text-accent">
92+ <Rocket size={11} />
93+ Deploys
94+ </span>
95+ )}
96+ <span className="text-faint">
97+ Updated <TimeAgo at={lastUpdated(project)} />
98+ </span>
99+ </>
100+ );
101+}
102+
103+function ProjectRow({ project, slug, pinned, member }: { project: Item; slug: string; pinned: boolean; member: boolean }) {
104+ const to = `/${slug}/${project.slug}`;
105+ return (
106+ <li className="group relative flex items-center gap-3 px-4 py-3 transition-colors focus-within:bg-raised/60 hover:bg-raised/60">
107+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
108+ {project.private ? <Lock size={14} /> : <Box size={14} />}
109+ </span>
110+ <div className="min-w-0 grow">
111+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
112+ <Link
113+ to={to}
114+ prefetch="intent"
115+ data-row
116+ className="truncate font-medium outline-none after:absolute after:inset-0 hover:underline focus-visible:underline"
117+ >
118+ {project.name}
119+ </Link>
120+ {project.name.toLowerCase() !== project.slug && <span className="truncate font-mono text-xs text-faint">{project.slug}</span>}
121+ {project.private && <Pill>private</Pill>}
122+ {project.archived && <Pill>archived</Pill>}
123+ </div>
124+ {project.description && <p className="mt-0.5 truncate text-sm text-muted">{project.description}</p>}
125+ <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted">
126+ <Facts project={project} />
127+ </p>
128+ </div>
129+ {project.production && (
130+ <a
131+ href={project.production}
132+ className="relative z-10 hidden shrink-0 items-center gap-1 font-mono text-xs text-muted hover:text-accent md:flex"
133+ >
134+ {host(project.production)}
135+ <ArrowUpRight size={11} />
136+ </a>
137+ )}
138+ {member && <PinButton workspace={slug} slug={project.slug} name={project.name} pinned={pinned} compact />}
139+ </li>
140+ );
141+}
142+
143+function ProjectTile({ project, slug, pinned, member }: { project: Item; slug: string; pinned: boolean; member: boolean }) {
144+ return (
145+ <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors focus-within:border-line-strong hover:border-line-strong">
146+ <div className="flex items-start gap-3">
147+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
148+ {project.private ? <Lock size={15} /> : <Box size={15} />}
149+ </span>
150+ <div className="min-w-0 grow">
151+ <Link
152+ to={`/${slug}/${project.slug}`}
153+ prefetch="intent"
154+ data-row
155+ className="block truncate font-medium outline-none after:absolute after:inset-0 hover:underline focus-visible:underline"
156+ >
157+ {project.name}
158+ </Link>
159+ {project.production ? (
160+ <a href={project.production} className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent">
161+ {host(project.production)}
162+ <ArrowUpRight size={11} className="shrink-0" />
163+ </a>
164+ ) : (
165+ <p className="mt-0.5 truncate font-mono text-xs text-faint">{project.slug}</p>
166+ )}
167+ </div>
168+ {member && <PinButton workspace={slug} slug={project.slug} name={project.name} pinned={pinned} compact className="-mt-1 -mr-2" />}
169+ </div>
170+ <p className="mt-3 line-clamp-2 min-h-10 text-sm text-muted">{project.description ?? ""}</p>
171+ <p className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-line pt-3 text-xs text-muted">
172+ {project.private && <span>Private</span>}
173+ {project.archived && <span>Archived</span>}
174+ <Facts project={project} />
175+ </p>
176+ </li>
177+ );
178+}
179+
180+/** The pages around the current one: the first, the last, and two either side. */
181+function pageNumbers(page: number, pages: number): (number | null)[] {
182+ const wanted = new Set([1, pages, page - 2, page - 1, page, page + 1, page + 2].filter((n) => n >= 1 && n <= pages));
183+ const sorted = [...wanted].sort((a, b) => a - b);
184+ return sorted.flatMap((n, i) => (i > 0 && n - sorted[i - 1]! > 1 ? [null, n] : [n]));
185+}
186+
187+function Pages({ query, page, pages }: { query: ProjectQuery; page: number; pages: number }) {
188+ if (pages <= 1) return null;
189+ const link = "flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm transition-colors";
190+ return (
191+ <nav aria-label="Pages" className="mt-6 flex flex-wrap items-center justify-center gap-1">
192+ {page > 1 ? (
193+ <Link to={projectQueryString(query, { page: page - 1 })} className={`${link} gap-1 text-muted hover:bg-raised hover:text-fg`}>
194+ <ChevronLeft size={14} />
195+ Previous
196+ </Link>
197+ ) : null}
198+ {pageNumbers(page, pages).map((n, i) =>
199+ n == null ? (
200+ <span key={`gap${i}`} className="px-1 text-faint">
201+ …
202+ </span>
203+ ) : (
204+ <Link
205+ key={n}
206+ to={projectQueryString(query, { page: n })}
207+ aria-current={n === page ? "page" : undefined}
208+ className={`${link} tabular-nums ${n === page ? "bg-raised font-medium text-fg ring-1 ring-line" : "text-muted hover:bg-raised hover:text-fg"}`}
209+ >
210+ {n}
211+ </Link>
212+ ),
213+ )}
214+ {page < pages ? (
215+ <Link to={projectQueryString(query, { page: page + 1 })} className={`${link} gap-1 text-muted hover:bg-raised hover:text-fg`}>
216+ Next
217+ <ChevronRight size={14} />
218+ </Link>
219+ ) : null}
220+ </nav>
221+ );
222+}
223+
224+export default function WorkspaceProjects({ loaderData }: Route.ComponentProps) {
225+ const { slug, role, query, total, matched, page, items, facets, pinned, failed } = loaderData;
226+ const submit = useSubmit();
227+ const navigation = useNavigation();
228+ const { pathname } = useLocation();
229+ const form = useRef<HTMLFormElement>(null);
230+ const searchBox = useRef<HTMLInputElement>(null);
231+ const typing = useRef<ReturnType<typeof setTimeout> | null>(null);
232+ const member = role != null;
233+ const pins = new Set(pinned);
234+ // A filter or page of this list on its way: the list says it is busy.
235+ const busy = navigation.state === "loading" && navigation.location?.pathname === pathname;
236+
237+ const apply = (delay = 0) => {
238+ if (typing.current) clearTimeout(typing.current);
239+ typing.current = setTimeout(() => {
240+ if (form.current) submit(form.current, { replace: true, preventScrollReset: true });
241+ }, delay);
242+ };
243+ useEffect(() => () => void (typing.current && clearTimeout(typing.current)), []);
244+ // Back and forward change the address, not the fields: they follow it,
245+ // except the one being used.
246+ useEffect(() => {
247+ const fields = form.current?.elements;
248+ if (!fields) return;
249+ const values: Record<string, string> = {
250+ q: query.q,
251+ visibility: query.visibility,
252+ kind: query.kind,
253+ language: query.language ?? "",
254+ archived: query.archived,
255+ sort: query.sort,
256+ };
257+ for (const [name, value] of Object.entries(values)) {
258+ const field = fields.namedItem(name) as HTMLInputElement | HTMLSelectElement | null;
259+ if (field && field !== document.activeElement && "value" in field) field.value = value;
260+ }
261+ const deploys = fields.namedItem("deployments") as HTMLInputElement | null;
262+ if (deploys && deploys !== document.activeElement) deploys.checked = query.deployments;
263+ }, [query]);
264+
265+ // `/` goes to the search, from anywhere on the page that is not a field.
266+ useEffect(() => {
267+ const onKey = (event: globalThis.KeyboardEvent) => {
268+ const target = event.target as HTMLElement | null;
269+ if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
270+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
271+ event.preventDefault();
272+ searchBox.current?.focus();
273+ };
274+ window.addEventListener("keydown", onKey);
275+ return () => window.removeEventListener("keydown", onKey);
276+ }, []);
277+
278+ // The arrows (and j and k) move between projects; Enter opens one.
279+ const list = useRef<HTMLDivElement>(null);
280+ const onListKey = (event: KeyboardEvent<HTMLDivElement>) => {
281+ const rows = [...(list.current?.querySelectorAll<HTMLAnchorElement>("a[data-row]") ?? [])];
282+ const at = rows.indexOf(document.activeElement as HTMLAnchorElement);
283+ const key = event.key === "ArrowRight" ? "ArrowDown" : event.key === "ArrowLeft" ? "ArrowUp" : event.key;
284+ if (at < 0 && key !== "ArrowDown" && key !== "j") return;
285+ const to = nextRow(key, at, rows.length);
286+ if (to == null || event.metaKey || event.ctrlKey || event.altKey) return;
287+ event.preventDefault();
288+ rows[to]?.focus();
289+ };
290+ // From the search box, down goes to the first project.
291+ const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
292+ if (event.key === "ArrowDown") {
293+ event.preventDefault();
294+ list.current?.querySelector<HTMLAnchorElement>("a[data-row]")?.focus();
295+ }
296+ if (event.key === "Escape" && event.currentTarget.value) {
297+ event.currentTarget.value = "";
298+ apply();
299+ }
300+ };
301+
302+ const filtered = isFiltered(query);
303+ const option = (label: string, count: number) => `${label} (${count})`;
304+
305+ return (
306+ <div>
307+ <Form ref={form} id="project-filters" method="get" role="search" aria-label="Find projects" className="flex flex-wrap items-center gap-2">
308+ <div className="relative w-full sm:w-72">
309+ <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
310+ <input
311+ ref={searchBox}
312+ type="search"
313+ name="q"
314+ defaultValue={query.q}
315+ {...notACredential()}
316+ placeholder="Find a project…"
317+ aria-label="Find a project"
318+ aria-keyshortcuts="/"
319+ onChange={() => apply(250)}
320+ onKeyDown={onSearchKey}
321+ className={`${CONTROL} w-full pr-8 pl-8 placeholder:text-faint`}
322+ />
323+ <kbd className="pointer-events-none absolute top-1/2 right-2 hidden -translate-y-1/2 rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line sm:block">
324+ /
325+ </kbd>
326+ </div>
327+ <select name="visibility" aria-label="Visibility" defaultValue={query.visibility} onChange={() => apply()} className={CONTROL}>
328+ <option value="all">All visibility</option>
329+ <option value="public">{option("Public", facets.visibility.public)}</option>
330+ <option value="private">{option("Private", facets.visibility.private)}</option>
331+ </select>
332+ <select name="kind" aria-label="Kind" defaultValue={query.kind} onChange={() => apply()} className={CONTROL}>
333+ <option value="all">All kinds</option>
334+ <option value="app">{option("Apps", facets.kind.app)}</option>
335+ <option value="library">{option("Libraries", facets.kind.library)}</option>
336+ </select>
337+ {(facets.languages.length > 0 || query.language) && (
338+ <select name="language" aria-label="Language" defaultValue={query.language ?? ""} onChange={() => apply()} className={CONTROL}>
339+ <option value="">Any language</option>
340+ {facets.languages.map((language) => (
341+ <option key={language.name} value={language.name}>
342+ {option(language.name, language.count)}
343+ </option>
344+ ))}
345+ </select>
346+ )}
347+ {(facets.archived > 0 || query.archived !== "hide") && (
348+ <select name="archived" aria-label="Archived" defaultValue={query.archived} onChange={() => apply()} className={CONTROL}>
349+ <option value="hide">Without archived</option>
350+ <option value="include">With archived</option>
351+ <option value="only">{option("Only archived", facets.archived)}</option>
352+ </select>
353+ )}
354+ {member && (
355+ <label className={`${CONTROL} flex cursor-pointer items-center gap-2 select-none`}>
356+ <input
357+ type="checkbox"
358+ name="deployments"
359+ value="on"
360+ defaultChecked={query.deployments}
361+ onChange={() => apply()}
362+ className="accent-[var(--color-accent)]"
363+ />
364+ Deploys
365+ <span className="text-xs tabular-nums text-faint">{facets.deploying}</span>
366+ </label>
367+ )}
368+ {query.view !== "list" && <input type="hidden" name="view" value={query.view} />}
369+ <noscript>
370+ <button type="submit" className={CONTROL}>
371+ Apply
372+ </button>
373+ </noscript>
374+ </Form>
375+
376+ <div className="mt-4 flex flex-wrap items-center gap-3 text-sm">
377+ <p className="text-muted" aria-live="polite">
378+ {filtered ? (
379+ <>
380+ <span className="font-medium text-fg tabular-nums">{matched}</span> of {total} {total === 1 ? "project" : "projects"}
381+ {query.q && (
382+ <>
383+ {" "}
384+ matching <span className="text-fg">“{query.q}”</span>
385+ </>
386+ )}
387+ </>
388+ ) : (
389+ <>
390+ <span className="font-medium text-fg tabular-nums">{total}</span> {total === 1 ? "project" : "projects"}
391+ </>
392+ )}
393+ {page.pages > 1 && (
394+ <span className="text-faint">
395+ {" "}
396+ · showing {page.from}–{page.to}
397+ </span>
398+ )}
399+ </p>
400+ {filtered && (
401+ <Link to={projectQueryString({ ...query, q: "", visibility: "all", kind: "all", language: null, deployments: false, archived: "hide" })} className="text-xs text-muted hover:text-fg">
402+ Clear filters
403+ </Link>
404+ )}
405+ <div className="ml-auto flex items-center gap-2">
406+ {/* Part of the filters' form, beside the view it orders. */}
407+ <select form="project-filters" name="sort" aria-label="Sort" defaultValue={query.sort} onChange={() => apply()} className={`${CONTROL} h-8`}>
408+ {SORTS.map((sort) => (
409+ <option key={sort.value} value={sort.value}>
410+ {sort.label}
411+ </option>
412+ ))}
413+ </select>
414+ <nav aria-label="View" className="flex items-center gap-0.5 rounded-lg border border-line bg-bg p-0.5">
415+ {(
416+ [
417+ ["list", "List", <List key="list" size={14} />],
418+ ["grid", "Grid", <LayoutGrid key="grid" size={14} />],
419+ ] as const
420+ ).map(([view, label, icon]) => (
421+ <Link
422+ key={view}
423+ to={projectQueryString(query, { view, page: query.page })}
424+ replace
425+ preventScrollReset
426+ aria-label={`${label} view`}
427+ aria-current={query.view === view ? "true" : undefined}
428+ className={`flex size-7 items-center justify-center rounded-md transition-colors ${
429+ query.view === view ? "bg-raised text-fg ring-1 ring-line" : "text-faint hover:text-fg"
430+ }`}
431+ >
432+ {icon}
433+ </Link>
434+ ))}
435+ </nav>
436+ </div>
437+ </div>
438+
439+ <div ref={list} onKeyDown={onListKey} aria-busy={busy || undefined} className={`mt-4 transition-opacity ${busy ? "opacity-60" : ""}`}>
440+ {failed ? (
441+ <EmptyState title="Projects cannot be listed right now">Try again in a moment.</EmptyState>
442+ ) : total === 0 ? (
443+ <EmptyState title="No projects yet">
444+ {member ? (
445+ <>
446+ <Link to={`/new?workspace=${slug}`} className="text-accent hover:underline">
447+ Create one
448+ </Link>
449+ , or push a repository and it becomes one.
450+ </>
451+ ) : (
452+ "This workspace has no public projects."
453+ )}
454+ </EmptyState>
455+ ) : items.length === 0 ? (
456+ <EmptyState title="No projects match">
457+ Try other words or filters, or{" "}
458+ <Link to={projectQueryString({ ...query, q: "", visibility: "all", kind: "all", language: null, deployments: false, archived: "hide" })} className="text-accent hover:underline">
459+ clear them
460+ </Link>
461+ .
462+ </EmptyState>
463+ ) : query.view === "grid" ? (
464+ <ul aria-label="Projects" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
465+ {items.map((project) => (
466+ <ProjectTile key={project.id} project={project} slug={slug} pinned={pins.has(project.id)} member={member} />
467+ ))}
468+ </ul>
469+ ) : (
470+ <ul aria-label="Projects" className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
471+ {items.map((project) => (
472+ <ProjectRow key={project.id} project={project} slug={slug} pinned={pins.has(project.id)} member={member} />
473+ ))}
474+ </ul>
475+ )}
476+ </div>
477+ <Pages query={query} page={page.page} pages={page.pages} />
478+ {items.length > 0 && (
479+ <p className="mt-4 hidden text-center text-xs text-faint sm:block">
480+ <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">/</kbd> to find,{" "}
481+ <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">↑</kbd>{" "}
482+ <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">↓</kbd> to move,{" "}
483+ <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">Enter</kbd> to open
484+ </p>
485+ )}
486+ </div>
487+ );
488+}
489+
+6−2
1−import { data } from "react-router";
1+import { data, redirect } from "react-router";
22
33 import type { Route } from "./+types/soon";
44 import { page } from "../../lib/meta";
55 import { roadmapItem } from "../../lib/roadmap";
66 import { projects } from "../../lib/services.server";
77 import { getViewer } from "../../lib/session.server";
8+import { workspaceRedirect } from "../../lib/workspace-nav";
89 import { SoonView } from "../repo/soon";
910
1011 export function meta({ params, ...args }: Route.MetaArgs) {
1617 });
1718 }
1819
19−export async function loader({ params, context }: Route.LoaderArgs) {
20+export async function loader({ params, context, request }: Route.LoaderArgs) {
21+ // Teams and Insights are tabs of the workspace's page now.
22+ const moved = workspaceRedirect(new URL(request.url).pathname);
23+ if (moved) throw redirect(moved, 301);
2024 const item = roadmapItem(params.feature);
2125 // Only what spans projects lives under the workspace.
2226 if (!item || item.section !== "Workspace") throw data(null, { status: 404 });
+31−0
1+import { data } from "react-router";
2+
3+import type { Route } from "./+types/tab-soon";
4+import { page } from "../../lib/meta";
5+import { roadmapItem } from "../../lib/roadmap";
6+import { getViewer, roleIn } from "../../lib/session.server";
7+import { SoonView } from "../repo/soon";
8+
9+/** The tab is the last part of the address: `-/teams` or `-/insights`. */
10+const tabOf = (pathname: string) => pathname.replace(/\/+$/, "").split("/").pop() ?? "";
11+
12+export function meta({ params, ...args }: Route.MetaArgs) {
13+ const item = roadmapItem(tabOf(args.location.pathname));
14+ return page(args, {
15+ title: `${item?.title ?? "Soon"} · ${params.owner} · g1t`,
16+ description: item ? `Soon on g1t: ${item.summary}` : null,
17+ });
18+}
19+
20+export function loader({ params, context, request }: Route.LoaderArgs) {
21+ // For the workspace's members, as their tabs are.
22+ if (!roleIn(getViewer(context), params.owner)) throw data(null, { status: 404 });
23+ const item = roadmapItem(tabOf(new URL(request.url).pathname));
24+ if (!item || item.section !== "Workspace") throw data(null, { status: 404 });
25+ return { item };
26+}
27+
28+/** A tab of the workspace's page that is coming: Teams, Insights. */
29+export default function WorkspaceTabSoon({ loaderData, params }: Route.ComponentProps) {
30+ return <SoonView item={loaderData.item} base={`/${params.owner}/-`} newProject={`/new?workspace=${params.owner}`} />;
31+}