Skip to content

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

Commit

The app's frame and your own pages fail one part at a time. Root loader: the viewer stays strict; the sidebar's projects, the open counts, Shared with you and the live feed's ticket go through part() and do without quietly, so one of them failing no longer drops the sidebar to its bare form or, for a visitor, takes down every page. A repository's frame: the repository and its project stay strict (404s, renames, transfers); its open counts and the project lookup do without. Profile: activity, workspaces, stars and contributions each say so in place. Notifications, invitations ("No invitations waiting" was what a failure said), pricing's usage and price changes, importing from GitHub on New, and the installations already on GitHub. Settings: account's erasure and deletion check show Unavailable instead of their forms; notifications' email, security and new-repository sections show Unavailable instead of forms that could save over unknown values (the page used to blank entirely), and watched repositories in place; GitHub ("not set up here" was what a failure said); integrations' GitHub link and grants; two-factor's message is Unavailable. Kept strict on purpose: sign-in, registration, invites, OAuth, device, two-factor sign-in and email confirmation, and every page that reads only its subject. Kept quiet on purpose: profile descriptions, notification tab counts, billing on pricing, hasPassword (it only asks for a password, which identity checks anyway), sounds. Checked: apps/web tsc -b; npm test (987, 1 skipped).

G1t-Session:
ses_01m4zzpc96ect8d41mffgr1a45
syntaqxcommitted Parent74236deBrowse files
13 files+257−1240/13 viewed
+15−5
6565 import { getViewer, viewerMiddleware } from "./lib/session.server";
6666 import { shortcutOf, workspaceProjects } from "./lib/workspace-projects.server";
6767 import { addresses } from "./lib/addresses.server";
68+import { orElse, part } from "./lib/partial";
6869 import {
6970 RELOADED_KEY,
7071 RELOAD_GIVE_UP_MS,
161162 shell,
162163 // For a page opened with an access token: the feed socket's ticket,
163164 // minted here so the feed opens without asking for one (lib/live-socket.ts).
165+ // Without one the feed asks for its own ticket, so a ticket that could
166+ // not be minted is no reason to lose the page.
164167 liveTicket: user?.token?.website
165− ? await socketTicketFor(context, request, "/-/live")
168+ ? orElse(await part(socketTicketFor(context, request, "/-/live"), "The live feed's ticket"), null)
166169 : null,
167170 // Whether they folded the sidebar away, so the page is drawn that way from the start.
168171 sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)),
191194 params.owner && params.repo
192195 ? { namespace: params.owner, name: params.repo }
193196 : null;
194− const counts = path ? await countsFor(context, params) : null;
197+ // The counts are the sidebar's, never the page's: without them it shows none.
198+ const counts = path ? orElse(await part(countsFor(context, params), "Open counts"), null) : null;
195199 return {
196200 workspace: null,
197201 repos: [],
288292 ] = await Promise.all([
289293 // Every project, for the palette and the count; the sidebar lists only
290294 // the person's pinned and recent ones (lib/pins.ts).
291− workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null),
292− path ? countsFor(context, params) : Promise.resolve(null),
295+ // Each of these is one part of the sidebar: one that fails leaves only
296+ // itself out (logged by `part`), never the rest of the sidebar with it.
297+ workspace
298+ ? part(workspaceProjects(workspace.slug, user), "Projects").then((value) => orElse(value, null))
299+ : Promise.resolve(null),
300+ path
301+ ? part(countsFor(context, params), "Open counts").then((value) => orElse(value, null))
302+ : Promise.resolve(null),
293303 // Whether billing is on, without reading the account: that asks the
294304 // card processor about the workspace's cards, too slow for every page.
295305 kept("status", () => billing.status()).catch(() => null),
300310 kept("entitlements", () => billing.entitlements(workspace!.slug)).catch(
301311 () => null,
302312 ),
303− sharedRepos(user),
313+ part(sharedRepos(user), "Shared with you").then((value) => orElse(value, [])),
304314 // The bell's count: one query, never kept, so marking an item shows at once.
305315 inbox.counts(user.username).catch(() => null),
306316 // Never kept: opening a project moves it up Recent.
+11−3
22
33 import type { Route } from "./+types/invitations";
44 import { InvitationList } from "../components/invitation-list";
5−import { answerInvitation, loadInvitations } from "../lib/invitations.server";
5+import { answerInvitation } from "../lib/invitations.server";
66 import { declinedLine } from "../lib/invitations";
77 import { page } from "../lib/meta";
88 import { assertSameOrigin, nextPath, requireUser } from "../lib/session.server";
99 import { Card } from "../components/ui/card";
10+import { identity } from "../lib/services.server";
11+import { Unavailable } from "../components/boundary";
12+import { isFailed, part } from "../lib/partial";
1013
1114 export function meta(args: Route.MetaArgs) {
1215 return page(args, { title: "Invitations · g1t" });
2124 const user = requireUser(context, request);
2225 const next = nextPath(request);
2326 return {
24− invitations: await loadInvitations(user),
27+ // Read here rather than with loadInvitations, which reads a failure as
28+ // none: on this page that would say "No invitations waiting" when some
29+ // may be. An account still to confirm its address is shown none, as there.
30+ invitations: user.verified ? await part(identity.listInvitations(user), "Your invitations") : [],
2531 hasWorkspace: (user.workspaces ?? []).length > 0,
2632 next: next === "/" ? null : next,
2733 };
4753 </p>
4854 )}
4955 <div className="mt-6">
50− {invitations.length > 0 ? (
56+ {isFailed(invitations) ? (
57+ <Unavailable what="Your invitations" />
58+ ) : invitations.length > 0 ? (
5159 <InvitationList
5260 invitations={invitations}
5361 error={actionData?.invitationError}
+11−2
1313 import { page } from "../lib/meta";
1414 import { assertSameOrigin, requireUser, roleIn } from "../lib/session.server";
1515 import { currentWorkspace } from "../lib/current-workspace.server";
16+import { Unavailable } from "../components/boundary";
17+import { isFailed, part } from "../lib/partial";
1618
1719 export function meta(args: Route.MetaArgs) {
1820 return page(args, { title: "Import from GitHub · g1t" });
2931 // an installation made there directly is offered here to be added.
3032 const [status, visible] = await Promise.all([
3133 githubApp.status(user, workspace),
32− githubApp.visibleInstallations(user).catch(() => null),
34+ part(githubApp.visibleInstallations(user), "Accounts the app is already installed on"),
3335 ]);
3436 if (!status.ok || !status.value.configured) throw redirect("/new");
3537 const { installations, linked } = status.value;
3638 const recorded = new Set(installations.map((item) => item.id));
3739 const claimable =
38− linked && visible?.ok
40+ linked && !isFailed(visible) && visible.ok
3941 ? notYetAdded(visible.value, workspace)
4042 .filter((item) => !recorded.has(item.id))
4143 .map((item) => ({ ...item, summary: installationSummary(item) }))
5759 linked,
5860 installations,
5961 claimable,
62+ // Not asked is not "none": the page says so where they would be offered.
63+ claimableUnknown: linked && isFailed(visible),
6064 installation,
6165 repositories,
6266 error,
156160 Your request went to the organization's owners on GitHub. Once one approves it, add the account here again.
157161 </p>
158162 )}
163+ {loaderData.claimableUnknown && (
164+ <div className="mt-3">
165+ <Unavailable what="Accounts the app is already installed on" compact />
166+ </div>
167+ )}
159168 {claimable.length > 0 && (
160169 <div className="mt-3 rounded-lg border border-accent/30 bg-accent/5 p-4">
161170 <h3 className="text-sm font-medium">Already installed on GitHub</h3>
+12−2
1313 import { githubApp } from "../lib/github.server";
1414 import { assertSameOrigin, requireUser } from "../lib/session.server";
1515 import { currentWorkspace } from "../lib/current-workspace.server";
16+import { Unavailable } from "../components/boundary";
17+import { isFailed, part } from "../lib/partial";
1618
1719 export function meta(args: Route.MetaArgs) {
1820 return page(args, { title: "New project · g1t" });
2628 if (!current) throw redirect("/workspaces/new");
2729 const selected = current.slug;
2830 // With g1t's GitHub App configured, repositories can come from GitHub.
29− const github = await githubApp.status(user, selected).catch(() => null);
30− return { selected, name: current.name?.trim() || current.slug, github: Boolean(github?.ok && github.value.configured) };
31+ // Not asked is not "not set up": the other sources stay, and the picker
32+ // says that importing from GitHub could not be checked.
33+ const github = await part(githubApp.status(user, selected), "Importing from GitHub");
34+ return {
35+ selected,
36+ name: current.name?.trim() || current.slug,
37+ github: !isFailed(github) && github.ok && github.value.configured,
38+ githubUnknown: isFailed(github),
39+ };
3140 }
3241
3342 export async function action({ request, context }: Route.ActionArgs) {
116125 />
117126 ))}
118127 </RadioGroup>
128+ {loaderData.githubUnknown && <Unavailable what="Importing from GitHub" compact />}
119129 </FieldSet>
120130
121131 {source === "import" && (
+15−7
1010
1111 import type { Route } from "./+types/notifications";
1212 import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox";
13−import { Card } from "../components/ui/card";
1413 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
1514 import { cn } from "../lib/cn";
1615 import { REASON_FILTERS, inboxReason, inboxTab, markFromForm, severityOf, tabCount } from "../lib/inbox";
1817 import { notFound } from "../lib/not-found.server";
1918 import { page } from "../lib/meta";
2019 import { inbox } from "../lib/services.server";
20+import { Unavailable } from "../components/boundary";
21+import { isFailed, part } from "../lib/partial";
2122 import { assertSameOrigin, requireUser } from "../lib/session.server";
2223
2324 const PAGE_ITEMS = 50;
3940 const reason = inboxReason(url.searchParams.get("reason"));
4041 const before = url.searchParams.get("before");
4142 const [list, counts] = await Promise.all([
42− inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }).catch(() => null),
43+ part(inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }), "Notifications"),
44+ // Only the tabs' numbers: without them the tabs show none.
4345 inbox.counts(user.username).catch(() => null),
4446 ]);
45− return { tab, view, reason, before, items: list?.items ?? null, next: list?.next ?? null, counts };
47+ return {
48+ tab,
49+ view,
50+ reason,
51+ before,
52+ items: isFailed(list) ? list : list.items,
53+ next: isFailed(list) ? null : list.next,
54+ counts,
55+ };
4656 }
4757
4858 export async function action({ request, context }: Route.ActionArgs) {
117127 </div>
118128
119129 <div className="mt-4">
120− {items == null ? (
121− <Card asChild tone="plain" radius="lg" className="px-4 py-6 text-sm text-muted">
122− <p>Notifications could not be loaded. Try again in a moment.</p>
123− </Card>
130+ {isFailed(items) ? (
131+ <Unavailable what="Notifications" />
124132 ) : items.length === 0 ? (
125133 <InboxEmpty tab={tab} view={view} />
126134 ) : (
+19−3
1010 import { TimeAgo } from "../components/ui";
1111 import { Card } from "../components/ui/card";
1212 import { billing } from "../lib/services.server";
13+import { Unavailable } from "../components/boundary";
14+import { isFailed, orElse, part } from "../lib/partial";
1315
1416 export function meta(args: Route.MetaArgs) {
1517 const plan = args.loaderData?.book?.plans?.find((p) => p.feature === "plan") ?? DEFAULT_PLAN;
2527 }
2628
2729 export async function loader() {
28− const [book, status] = await Promise.all([billing.prices().catch(() => null), billing.status().catch(() => null)]);
29− return { book, free: status?.free ?? false };
30+ // The page reads without the price book: the plan and the rest are its
31+ // defaults, and the usage table and price changes say they could not be
32+ // loaded instead of looking empty.
33+ const [book, status] = await Promise.all([part(billing.prices(), "The price book"), billing.status().catch(() => null)]);
34+ return { book: orElse(book, null), unpriced: isFailed(book), free: status?.free ?? false };
3035 }
3136
3237 /** A price, with the fraction of a cent it is set to: `$0.024`, `$0.25`. */
279284 }
280285
281286 export default function Pricing({ loaderData }: Route.ComponentProps) {
282− const { book, free } = loaderData;
287+ const { book, unpriced, free } = loaderData;
283288 const checked = book?.prices.map((p) => p.checkedAt).filter((at): at is string => !!at).sort().at(-1);
284289 const given = Object.fromEntries(Object.entries(book?.free ?? {}).filter(([, value]) => value != null));
285290 const tier: Required<FreeTier> = { ...DEFAULT_FREE, ...given };
635640 <td className="hidden px-4 py-3 tabular-nums sm:table-cell">—</td>
636641 <td className="px-4 py-3 text-muted">Not charged</td>
637642 </tr>
643+ {unpriced && (
644+ <tr>
645+ <td colSpan={4} className="px-4 py-3">
646+ <Unavailable what="The metered prices" compact />
647+ </td>
648+ </tr>
649+ )}
638650 {(book?.prices ?? []).filter((price) => !SHOWN_APART.has(price.meter)).map((price) => (
639651 <tr key={price.meter}>
640652 <td className="px-4 py-3">
731743 })}
732744 </ul>
733745 </Card>
746+ ) : unpriced ? (
747+ <div className="mt-4">
748+ <Unavailable what="Price changes" />
749+ </div>
734750 ) : (
735751 <p className="mt-2 text-sm text-muted">None yet. When a price moves, it is listed here with why.</p>
736752 )}
+7−3
2727 import { accessFor, countsFor, projectFor, repoFor } from "../../lib/access.server";
2828 import { inbox, projects, repos, work } from "../../lib/services.server";
2929 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
30+import { isFailed, orElse, part } from "../../lib/partial";
3031
3132 /** How many forks the header counts before it says "100+". */
3233 const FORKS_COUNTED = 100;
4243 const member = viewer ? roleIn(viewer, params.owner) != null : false;
4344 const [repo, counts, found, watching, shortcuts, stars, forks, issues] = await Promise.all([
4445 repoFor(context, params),
45− countsFor(context, params),
46+ // The repository is what the frame is about; its open counts are only
47+ // the tabs' badges, which do without them.
48+ part(countsFor(context, params), "Open counts"),
4649 projectFor(context, params),
4750 // How the person watches it, for the header's Watch menu, as soon as
4851 // the repository is known.
6972 let project: Project | null = found.ok ? found.value : null;
7073 // A repository made a moment ago, before its project: make it now.
7174 if (!project && repo.ok && !repo.value.forkOf) {
72− const own = await projects.byRepo(repo.value.id);
75+ // The repository still has its page when its project cannot be read: drawn by its own name.
76+ const own = orElse(await part(projects.byRepo(repo.value.id), "Its project"), []);
7377 project = own.find((p) => p.slug === params.repo.toLowerCase()) ?? own[0] ?? null;
7478 }
7579 // Opened just now: it leads their Recent, recorded after the page is sent.
9296 welcome,
9397 repo: value,
9498 project,
95− open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
99+ open: !isFailed(counts) && counts.ok ? counts.value : { issues: 0, pulls: 0 },
96100 access,
97101 member: access.insider,
98102 watching,
+21−11
99 import { githubSignIn } from "../../lib/github.server";
1010 import { page } from "../../lib/meta";
1111 import { accounts, privacy } from "../../lib/services.server";
12+import { Unavailable } from "../../components/boundary";
13+import { isFailed, part } from "../../lib/partial";
1214 import { assertSameOrigin, clientOf, endSession, requireUser, sessionTokenOf } from "../../lib/session.server";
1315
1416 export function meta(args: Route.MetaArgs) {
1921 const user = requireUser(context, request);
2022 // What deleting it would take, and anything in the way, shown before
2123 // anyone types: workspaces you own alone, each with what billing needs.
24+ // Each section reads its own: one that could not be read says so in its
25+ // place, and its form is not offered on what is not known. Whether they
26+ // have a password only says whether deleting asks for one, and identity
27+ // checks that anyway: asking for it is the safe guess.
2228 const [deletion, github, erasure] = await Promise.all([
23− accounts.checkAccountDeletion(user).catch(() => null),
29+ part(accounts.checkAccountDeletion(user), "What deleting your account would take"),
2430 githubSignIn.account(user).catch(() => null),
25− privacy.mine(user).catch(() => undefined),
31+ part(privacy.mine(user), "Your erasure request"),
2632 ]);
2733 return {
2834 erasure,
2935 username: user.username,
30− deletion: deletion?.ok ? deletion.value : null,
36+ deletion: isFailed(deletion) ? deletion : deletion.ok ? deletion.value : null,
3137 hasPassword: github?.hasPassword ?? true,
3238 };
3339 }
95101 <section aria-labelledby="erasure-heading" className="space-y-3">
96102 <h2 id="erasure-heading" className="font-medium">Erase my personal data</h2>
97103 <p className="text-sm text-muted">Request erasure even if workspace ownership or billing prevents account closure. We review shared content and any records we must retain, then explain the outcome. This request does not immediately delete your account.</p>
98− {loaderData.erasure ? <p className="text-sm">Request {loaderData.erasure.id}: {loaderData.erasure.status}. Response due {loaderData.erasure.due_at.slice(0, 10)}.</p> : loaderData.erasure === undefined ? <p className="text-sm">Request status is unavailable. Try again or email <a href="mailto:support@g1t.sh" className="underline">support@g1t.sh</a>.</p> : null}
104+ {isFailed(loaderData.erasure) ? <Unavailable what="Your erasure request" /> : loaderData.erasure ? <p className="text-sm">Request {loaderData.erasure.id}: {loaderData.erasure.status}. Response due {loaderData.erasure.due_at.slice(0, 10)}.</p> : null}
99105 {actionData?.erasureNotice && <p role="status" className="text-sm">{actionData.erasureNotice}</p>}
100106 <ErrorText>{actionData?.erasureError}</ErrorText>
101− {(!loaderData.erasure || ["completed", "refused"].includes(loaderData.erasure.status)) && <Form method="post">
107+ {!isFailed(loaderData.erasure) && (!loaderData.erasure || ["completed", "refused"].includes(loaderData.erasure.status)) && <Form method="post">
102108 <SubmitButton name="intent" value="request-erasure" pending="Recording request...">Request erasure</SubmitButton>
103109 </Form>}
104110 <p className="text-sm text-muted">For specific content, a request without signing in, or a question about an existing request, email <a href="mailto:support@g1t.sh" className="underline">support@g1t.sh</a>.</p>
105111 </section>
106112 <DangerZone>
107− <DeleteAccountAction
108− username={username}
109− deletion={deletion}
110− hasPassword={hasPassword}
111− error={actionData?.deleteError}
112− />
113+ {isFailed(deletion) ? (
114+ <Unavailable what="What deleting your account would take" />
115+ ) : (
116+ <DeleteAccountAction
117+ username={username}
118+ deletion={deletion}
119+ hasPassword={hasPassword}
120+ error={actionData?.deleteError}
121+ />
122+ )}
113123 </DangerZone>
114124 </div>
115125 );
+5−1
77 import { assertSameOrigin, requireUser } from "../../lib/session.server";
88 import { GithubMark } from "../../components/github";
99 import { githubSignIn } from "../../lib/github.server";
10+import { Unavailable } from "../../components/boundary";
11+import { isFailed, part } from "../../lib/partial";
1012
1113 export function meta(args: Route.MetaArgs) {
1214 return page(args, { title: "GitHub · Settings · g1t" });
1416
1517 export async function loader({ request, context }: Route.LoaderArgs) {
1618 const user = requireUser(context, request);
17− return { github: await githubSignIn.account(user).catch(() => null) };
19+ // Not read is not "not set up": the page says it could not be loaded.
20+ return { github: await part(githubSignIn.account(user), "Your GitHub link") };
1821 }
1922
2023 export async function action({ request, context }: Route.ActionArgs) {
3033
3134 export default function GithubSettings({ loaderData, actionData }: Route.ComponentProps) {
3235 const { github } = loaderData;
36+ if (isFailed(github)) return <Unavailable what="Your GitHub link" />;
3337 if (!github?.enabled) {
3438 return <p className="text-sm text-muted">Signing in with GitHub is not set up here.</p>;
3539 }
+19−6
99 import { page } from "../../lib/meta";
1010 import { identity } from "../../lib/services.server";
1111 import { requireUser } from "../../lib/session.server";
12+import { Unavailable } from "../../components/boundary";
13+import { isFailed, part } from "../../lib/partial";
1214
1315 export function meta(args: Route.MetaArgs) {
1416 return page(args, { title: "Integrations · Settings · g1t" });
2123 */
2224 export async function loader({ request, context }: Route.LoaderArgs) {
2325 const user = requireUser(context, request);
26+ // What is connected marks the directory's tiles; one that could not be
27+ // read is said above it, so a tile is never shown unconnected when it
28+ // may well be connected.
2429 const [github, grants] = await Promise.all([
25− githubSignIn.account(user).catch(() => null),
26− identity.listOAuthGrants(user).catch(() => []),
30+ part(githubSignIn.account(user), "Your GitHub link"),
31+ part(identity.listOAuthGrants(user), "Applications on the MCP server"),
2732 ]);
33+ const unread = [github, grants].filter(isFailed).map((failed) => failed.what);
2834 const connected: Record<string, ConnectedState> = {};
29− if (github?.account) {
35+ if (!isFailed(github) && github?.account) {
3036 connected.github = {
3137 detail: `Linked to @${github.account.login}`,
3238 problem: github.account.authorized ? null : "Access ended: link it again to import repositories.",
3339 manage: null,
3440 };
3541 }
36− if (grants.length > 0) {
42+ if (!isFailed(grants) && grants.length > 0) {
3743 connected.mcp = {
3844 detail: grants.length === 1 ? grants[0]!.clientName : `${grants.length} applications`,
3945 problem: null,
4147 };
4248 }
4349 const workspaces = (user.workspaces ?? []).map((membership) => ({ slug: membership.slug, name: membership.name ?? membership.slug }));
44− return { connected, workspaces };
50+ return { connected, workspaces, unread };
4551 }
4652
4753 export default function PersonalIntegrations({ loaderData }: Route.ComponentProps) {
48− const { connected, workspaces } = loaderData;
54+ const { connected, workspaces, unread } = loaderData;
4955 return (
5056 <div>
57+ {unread.length > 0 && (
58+ <div className="mb-4 space-y-1">
59+ {unread.map((what) => (
60+ <Unavailable key={what} what={what} compact />
61+ ))}
62+ </div>
63+ )}
5164 <ConnectorDirectory
5265 scope="personal"
5366 views={connectorsFor("personal")}
+85−61
2121 import { page } from "../../lib/meta";
2222 import { identity, inbox } from "../../lib/services.server";
2323 import { assertSameOrigin, requireUser } from "../../lib/session.server";
24+import { Unavailable } from "../../components/boundary";
25+import { isFailed, part } from "../../lib/partial";
2426 // Live notifications: pop-ups and browser notifications (services/notify).
2527 import { LiveNotificationSettings } from "../../components/notifications/settings";
2628 // Sounds, Do not disturb and desktop notifications (identity, notify).
3234
3335 export async function loader({ request, context }: Route.LoaderArgs) {
3436 const user = requireUser(context, request);
37+ // Each section stands on its own read: one that could not be read says so
38+ // in its place, and its form is not offered over values not known. The
39+ // sounds save one change at a time, so without them the section starts
40+ // from what the page already has (the root's read) and loses nothing.
3541 const [settings, watched, sounds] = await Promise.all([
36− inbox.settings(user.username).catch(() => null),
37− inbox.watched(user.username).catch(() => null),
42+ part(inbox.settings(user.username), "Notification settings"),
43+ part(inbox.watched(user.username), "Repositories you watch"),
3844 identity.soundSettings(user).catch(() => null),
3945 ]);
4046 return { settings, watched, sounds, workspaces: user.workspaces ?? [] };
8490 >;
8591
8692 export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) {
87− const { settings, watched, sounds, workspaces } = loaderData;
93+ const { watched, sounds, workspaces } = loaderData;
94+ const settings = isFailed(loaderData.settings) ? null : loaderData.settings;
8895 const navigation = useNavigation();
8996 const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null);
90− if (!settings) {
91− return <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">Your notification settings could not be loaded. Try again in a moment.</p>;
92− }
9397 return (
9498 <div className="space-y-10">
9599 <SoundSettingsSection initial={sounds} />
101105 <p className="mt-1 text-sm text-muted">
102106 Besides your <Link to="/notifications" className="text-fg underline underline-offset-4">notifications</Link>, email me at my primary address when:
103107 </p>
104− <Form method="post" className="mt-4 space-y-3">
105− <input type="hidden" name="intent" value="email" />
106− {EMAIL_REASONS.map((entry) => (
107− <CheckboxOption
108− key={entry.reason}
109− name="email"
110− value={entry.reason}
111− defaultChecked={settings.email.includes(entry.reason)}
112− label={entry.label}
113− description={entry.detail}
114− />
115− ))}
116− <div className="flex items-center gap-3 pt-2">
117− <SubmitButton variant="outline" match={{ intent: "email" }} pending="Saving…">
118− Save email settings
119− </SubmitButton>
120− {said("email")?.saved && <p className="text-xs text-success">{said("email")?.saved}</p>}
108+ {settings ? (
109+ <Form method="post" className="mt-4 space-y-3">
110+ <input type="hidden" name="intent" value="email" />
111+ {EMAIL_REASONS.map((entry) => (
112+ <CheckboxOption
113+ key={entry.reason}
114+ name="email"
115+ value={entry.reason}
116+ defaultChecked={settings.email.includes(entry.reason)}
117+ label={entry.label}
118+ description={entry.detail}
119+ />
120+ ))}
121+ <div className="flex items-center gap-3 pt-2">
122+ <SubmitButton variant="outline" match={{ intent: "email" }} pending="Saving…">
123+ Save email settings
124+ </SubmitButton>
125+ {said("email")?.saved && <p className="text-xs text-success">{said("email")?.saved}</p>}
126+ </div>
127+ <ErrorText>{said("email")?.error}</ErrorText>
128+ </Form>
129+ ) : (
130+ <div className="mt-4">
131+ <Unavailable what="Your email settings" />
121132 </div>
122− <ErrorText>{said("email")?.error}</ErrorText>
123− </Form>
133+ )}
124134 <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p>
125135 </section>
126136
133143 and a vulnerability report reach you at once; the rest, as you choose here. The workspaces you own, or manage security for, tell you of their
134144 urgent alerts whatever you choose.
135145 </p>
136− <Form method="post" className="mt-4 space-y-4">
137− <input type="hidden" name="intent" value="security" />
138− <SwitchCard name="security_alerts" title="Critical and high alerts at once" defaultChecked={settings.securityAlerts}>
139− On repositories you watch for security alerts. Off, you hear of them only in the repositories you own or manage security for.
140− </SwitchCard>
141− <div>
142− <p className="mb-2 text-sm font-medium">Medium and low alerts</p>
143− <RadioGroup name="security_digest" defaultValue={settings.securityDigest}>
144− {SECURITY_DIGEST_CHOICES.map((choice) => (
145− <RadioOption key={choice.cadence} value={choice.cadence} label={choice.label} description={choice.detail} />
146− ))}
147− </RadioGroup>
148− </div>
149− <div className="flex items-center gap-3">
150− <SubmitButton variant="outline" match={{ intent: "security" }} pending="Saving…">
151− Save security settings
152− </SubmitButton>
153− {said("security")?.saved && <p className="text-xs text-success">{said("security")?.saved}</p>}
146+ {settings ? (
147+ <Form method="post" className="mt-4 space-y-4">
148+ <input type="hidden" name="intent" value="security" />
149+ <SwitchCard name="security_alerts" title="Critical and high alerts at once" defaultChecked={settings.securityAlerts}>
150+ On repositories you watch for security alerts. Off, you hear of them only in the repositories you own or manage security for.
151+ </SwitchCard>
152+ <div>
153+ <p className="mb-2 text-sm font-medium">Medium and low alerts</p>
154+ <RadioGroup name="security_digest" defaultValue={settings.securityDigest}>
155+ {SECURITY_DIGEST_CHOICES.map((choice) => (
156+ <RadioOption key={choice.cadence} value={choice.cadence} label={choice.label} description={choice.detail} />
157+ ))}
158+ </RadioGroup>
159+ </div>
160+ <div className="flex items-center gap-3">
161+ <SubmitButton variant="outline" match={{ intent: "security" }} pending="Saving…">
162+ Save security settings
163+ </SubmitButton>
164+ {said("security")?.saved && <p className="text-xs text-success">{said("security")?.saved}</p>}
165+ </div>
166+ <ErrorText>{said("security")?.error}</ErrorText>
167+ </Form>
168+ ) : (
169+ <div className="mt-4">
170+ <Unavailable what="Your security notification settings" />
154171 </div>
155− <ErrorText>{said("security")?.error}</ErrorText>
156− </Form>
172+ )}
157173 <p className="mt-3 text-xs text-faint">Email for security alerts follows the Email section above: on by default.</p>
158174 </section>
159175
162178 Repositories you create
163179 </h2>
164180 <p className="mt-1 text-sm text-muted">How you watch a repository when you make it. Change any one from its Watch menu.</p>
165− <Form method="post" className="mt-4">
166− <input type="hidden" name="intent" value="default_watch" />
167− <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}>
168− {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => (
169− <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} />
170− ))}
171− </RadioGroup>
172− <div className="mt-4 flex items-center gap-3">
173− <SubmitButton variant="outline" match={{ intent: "default_watch" }} pending="Saving…">
174− Save
175− </SubmitButton>
176− {said("default_watch")?.saved && <p className="text-xs text-success">{said("default_watch")?.saved}</p>}
181+ {settings ? (
182+ <Form method="post" className="mt-4">
183+ <input type="hidden" name="intent" value="default_watch" />
184+ <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}>
185+ {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => (
186+ <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} />
187+ ))}
188+ </RadioGroup>
189+ <div className="mt-4 flex items-center gap-3">
190+ <SubmitButton variant="outline" match={{ intent: "default_watch" }} pending="Saving…">
191+ Save
192+ </SubmitButton>
193+ {said("default_watch")?.saved && <p className="text-xs text-success">{said("default_watch")?.saved}</p>}
194+ </div>
195+ <ErrorText>{said("default_watch")?.error}</ErrorText>
196+ </Form>
197+ ) : (
198+ <div className="mt-4">
199+ <Unavailable what="How you watch what you create" />
177200 </div>
178− <ErrorText>{said("default_watch")?.error}</ErrorText>
179− </Form>
201+ )}
180202 </section>
181203
182204 <section aria-labelledby="watched-heading">
184206 Repositories you watch
185207 </h2>
186208 <p className="mt-1 text-sm text-muted">Those you watch other than the default way, which is only what you take part in.</p>
187− {watched == null ? (
188− <p className="mt-4 text-sm text-muted">This list could not be loaded. Try again in a moment.</p>
209+ {isFailed(watched) ? (
210+ <div className="mt-4">
211+ <Unavailable what="Repositories you watch" />
212+ </div>
189213 ) : watched.length === 0 ? (
190214 <Card asChild tone="plain" radius="lg" className="mt-4 border-dashed px-4 py-6 text-center text-sm text-muted">
191215 <p>
+2−1
99 import { Badge } from "../../components/ui/badge";
1010 import { Button } from "../../components/ui/button";
1111 import { Card } from "../../components/ui/card";
12+import { Unavailable } from "../../components/boundary";
1213 import { githubSignIn } from "../../lib/github.server";
1314 import { page } from "../../lib/meta";
1415 import { otpInputProps } from "../../lib/otp";
8990 const { status, hasPassword } = loaderData;
9091 const setup = actionData?.setup;
9192 if (!status) {
92− return <p className="text-sm text-muted">Two-factor authentication could not be loaded just now. Try again in a moment.</p>;
93+ return <Unavailable what="Two-factor authentication" />;
9394 }
9495 return (
9596 <div className="space-y-6">
+35−19
4141 import { type ProfileTab, movedTab, profilePath, profileTab } from "../lib/tab-paths";
4242 import { identity, projects, repos, work } from "../lib/services.server";
4343 import { getViewer } from "../lib/session.server";
44+import { Unavailable } from "../components/boundary";
45+import { type Failed, isFailed, part } from "../lib/partial";
4446
4547 type Tab = ProfileTab;
4648
106108 .then((person) => (person ? repos.publicNamespaces(person.id) : []))
107109 .catch(() => [] as string[])
108110 .then((publicIn) => identity.profileWorkspaces(username, viewer, publicIn));
111+ // The person is what the page is about; each of these is one part of it,
112+ // and one that fails says so in its own place.
109113 // The card is drawn for no one in particular, so its version uses what
110114 // everyone sees; for a signed-out viewer that is these counts already.
111115 const [authored, shown, starred, year] = await Promise.all([
112− work.byAuthor(username, viewer, filter),
113− workspaces,
116+ part(work.byAuthor(username, viewer, filter), "Activity"),
117+ part(workspaces, "Workspaces"),
114118 // What they starred that the viewer can see, on its own tab.
115− tab === "stars" ? repos.starred(username, viewer).catch(() => [] as StarredRepo[]) : ([] as StarredRepo[]),
119+ tab === "stars" ? part(repos.starred(username, viewer), "Stars") : ([] as StarredRepo[]),
116120 // Their year, on the overview: counted only where the viewer can see.
117121 tab === "overview"
118− ? work
119− .contributions(username, viewer)
120− .then((result) => (result.ok ? result.value : null))
121− .catch(() => null as Contributions | null)
122− : null,
122+ ? part(
123+ work.contributions(username, viewer).then((result) => (result.ok ? result.value : null)),
124+ "Contributions",
125+ )
126+ : (null as Contributions | null),
123127 ]);
124− const activity = authored.ok ? authored.value : EMPTY;
125− // A repository has no description: its project's is shown.
126− const about = await projects.descriptions(starred.map(({ repo }) => repo.id)).catch(() => ({}) as Record<string, string>);
128+ const activity = !isFailed(authored) && authored.ok ? authored.value : EMPTY;
129+ // A repository has no description: its project's is shown, and without it none.
130+ const about = isFailed(starred)
131+ ? ({} as Record<string, string>)
132+ : await projects.descriptions(starred.map(({ repo }) => repo.id)).catch(() => ({}) as Record<string, string>);
127133 // The calendar ends today, as the work service counted it (UTC).
128− const today = (year && lastDay(year.from)) || new Date().toISOString().slice(0, 10);
134+ const today = (year && !isFailed(year) && lastDay(year.from)) || new Date().toISOString().slice(0, 10);
129135 return {
130136 profile,
131137 tab,
132138 filter: { state: state ?? null, repo: repo ?? null, sort: sort ?? "created", before: before ?? null },
133139 activity,
134− failed: !authored.ok,
140+ failed: isFailed(authored) || !authored.ok,
135141 workspaces: shown,
136− starred: starred.map((star) => ({ ...star, description: about[star.repo.id] ?? null })),
137− contributions: year ? { ...year, today } : null,
142+ starred: isFailed(starred) ? starred : starred.map((star) => ({ ...star, description: about[star.repo.id] ?? null })),
143+ contributions: year && !isFailed(year) ? { ...year, today } : year,
138144 isSelf: viewer?.username === profile.username,
139145 publicCounts: viewer ? null : activity.counts,
140146 };
176182 isSelf,
177183 }: {
178184 profile: Profile;
179− workspaces: { slug: string; name: string; avatar: string | null }[];
185+ workspaces: { slug: string; name: string; avatar: string | null }[] | Failed;
180186 isSelf: boolean;
181187 }) {
182188 return (
239245 <Detail icon={<CalendarDays size={15} />}>Joined {joined(profile.createdAt)}</Detail>
240246 </ul>
241247
242− {workspaces.length > 0 && (
248+ {isFailed(workspaces) ? (
243249 <div className="mt-6 border-t border-line pt-5">
244250 <h2 className="text-sm font-medium">Workspaces</h2>
251+ <div className="mt-3">
252+ <Unavailable what="Workspaces" compact />
253+ </div>
254+ </div>
255+ ) : workspaces.length > 0 && (
256+ <div className="mt-6 border-t border-line pt-5">
257+ <h2 className="text-sm font-medium">Workspaces</h2>
245258 <ul className="mt-3 space-y-1">
246259 {workspaces.map((workspace) => (
247260 <li key={workspace.slug}>
312325
313326 // --- Stars -------------------------------------------------------------------------
314327
315−function StarredList({ starred, isSelf }: { starred: (StarredRepo & { description: string | null })[]; isSelf: boolean }) {
328+function StarredList({ starred, isSelf }: { starred: (StarredRepo & { description: string | null })[] | Failed; isSelf: boolean }) {
329+ if (isFailed(starred)) return <Unavailable what="Stars" />;
316330 if (starred.length === 0) {
317331 return (
318332 <EmptyState title={isSelf ? "You have not starred anything yet" : "No stars yet"}>
355369 const who = profile.name ?? shownUsername(profile);
356370 return (
357371 <div className="space-y-8">
358− {contributions && (
372+ {isFailed(contributions) ? (
373+ <Unavailable what="Contributions" />
374+ ) : contributions && (
359375 <ContributionCalendar
360376 days={contributions.days}
361377 total={contributions.total}

Comments

Sign in to comment.