g1t/apps/web/app/routes/workspace/settings.tsx

536 lines22,577 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1import { useEffect, useState } from "react";
2import { Form, data, redirect, useFetcher, useNavigation } from "react-router";
Agents as a team: lifecycle, merge queue, billing and a new shell3
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look4import {
Merge branch 'worktree-agent-a2013627e5ea4ab13'5 type DataResidency,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6 RENAME_COOLDOWN_HOURS,
7 SLUG_HOLD_DAYS,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member8 WORKSPACE_RESTORE_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look9 type Workspace,
10 type WorkspaceDeletion,
11 isValidNamespace,
12} from "@g1t/contracts";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains13
Agents as a team: lifecycle, merge queue, billing and a new shell14import type { Route } from "./+types/settings";
Social cards for every page: og.g1t.sh15import { page } from "../../lib/meta";
Merge branch 'worktree-agent-aaf03bdceac799c89'16import { useAddresses } from "../../lib/addresses";
Workspace names and icons, and a component kit for every control17import { AvatarField } from "../../components/avatar-field";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look18import { DangerAction, DangerZone } from "../../components/danger-zone";
Merge branch 'worktree-agent-a985c01f43538af01'19import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains20import {
21 AlertDialog,
22 AlertDialogCancel,
23 AlertDialogContent,
24 AlertDialogDescription,
25 AlertDialogFooter,
26 AlertDialogHeader,
27 AlertDialogTitle,
28} from "../../components/ui/alert-dialog";
29import { FieldDescription, FieldLabel, Field as FormField } from "../../components/ui/field";
30import { InputAddon, InputGroup, Input as TextInput } from "../../components/ui/input";
Merge branch 'worktree-agent-a2013627e5ea4ab13'31import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
Workspace names and icons, and a component kit for every control32import { readAvatarUpload } from "../../lib/avatar-upload";
Merge branch 'worktree-agent-a2013627e5ea4ab13'33import { deployments, identity, repos } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell34import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look35import { forgetWorkspace } from "../../lib/workspace-choice";
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member36import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion";
Agents as a team: lifecycle, merge queue, billing and a new shell37
Social cards for every page: og.g1t.sh38export function meta({ params, ...args }: Route.MetaArgs) {
39 return page(args, { title: `Settings · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell40}
41
Agents and memory, checks and conflicts, profiles, slug renames, custom domains42/** What the address field shows about a new slug, asked for with `?check=`. */
43type Check = { slug: string; available: boolean; message: string | null };
44
45export async function loader({ params, context, request }: Route.LoaderArgs) {
Agents as a team: lifecycle, merge queue, billing and a new shell46 // Owners only; to anyone else the page does not exist.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains47 const viewer = getViewer(context);
48 if (roleIn(viewer, params.owner) !== "owner") {
Agents as a team: lifecycle, merge queue, billing and a new shell49 throw data(null, { status: 404 });
50 }
51 const workspace = await identity.getWorkspace(params.owner);
52 if (!workspace) throw data(null, { status: 404 });
Agents and memory, checks and conflicts, profiles, slug renames, custom domains53 // The address field asks whether a new slug is free as the owner types.
54 // It is a read, so a GET: nothing else on the page reloads for it.
55 const wanted = new URL(request.url).searchParams.get("check");
56 let check: Check | null = null;
57 if (wanted != null && viewer) {
58 const slug = wanted.trim().toLowerCase();
59 const result = await identity.checkWorkspaceRename(viewer, params.owner, slug);
60 check = result.ok
61 ? { slug, available: result.value, message: result.value ? null : "That address is not available." }
62 : { slug, available: false, message: result.error.message };
63 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member64 // What deleting it would take with it, and anything in the way, shown
65 // before anyone types. Its apps are counted by deployments.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66 let deletion: WorkspaceDeletion | null = null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member67 let apps: number | null = null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look68 if (viewer) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member69 const [found, usage] = await Promise.all([
70 identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null),
71 deployments.usage(params.owner, viewer).catch(() => null),
72 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look73 deletion = found?.ok ? found.value : null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member74 apps = usage?.ok ? usage.value.apps : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 }
Merge branch 'worktree-agent-a2013627e5ea4ab13'76 // Where its repositories are kept: offered once g1t can keep them in the
77 // EU, and shown to a workspace that chose it whatever happens since.
78 const [storage, residency] = await Promise.all([
79 repos.storageOptions().catch(() => ({ euAvailable: false })),
80 identity.workspaceResidency(params.owner).catch(() => null),
81 ]);
82 return { workspace, check, deletion, apps, euAvailable: storage.euAvailable, residency: residency ?? "anywhere" };
Agents as a team: lifecycle, merge queue, billing and a new shell83}
84
85export async function action({ request, params, context }: Route.ActionArgs) {
86 assertSameOrigin(request);
87 const user = requireUser(context, request);
88 const form = await request.formData();
Workspace names and icons, and a component kit for every control89 const intent = form.get("intent");
90 // The icon: identity checks the owner and the image's bytes again.
91 if (intent === "avatar" || intent === "remove-avatar") {
92 let image: string | null = null;
93 if (intent === "avatar") {
94 const upload = await readAvatarUpload(form);
95 if ("error" in upload) return { avatarError: upload.error };
96 image = upload.image;
97 }
98 const result = await identity.setWorkspaceAvatar(user, params.owner, image);
99 if (!result.ok) return { avatarError: result.error.message };
100 return { saved: "avatar" as const };
101 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains102 // A new address: identity checks the owner, the name and the cooldown,
103 // and keeps the old one as a redirect.
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member104 // Deletion: identity checks the owner, the typed name, that it is not
105 // protected and that billing has settled it. Everything in it goes with
106 // it; the owner goes back to their own home, where it is no longer listed.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look107 if (intent === "delete") {
108 const result = await identity.deleteWorkspace(user, params.owner, String(form.get("confirm") ?? ""));
109 if (!result.ok) return { deleteError: result.error.message };
110 const secure = new URL(request.url).protocol === "https:";
111 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
112 }
Merge branch 'worktree-agent-a2013627e5ea4ab13'113 // Where new repositories keep their data: identity checks the owner.
114 if (intent === "residency") {
115 const wanted = form.get("residency") === "eu" ? "eu" : "anywhere";
116 const result = await identity.setWorkspaceResidency(user, params.owner, wanted);
117 if (!result.ok) return { residencyError: result.error.message };
118 return { saved: "residency" as const };
119 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains120 if (intent === "rename") {
121 const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase();
122 const result = await identity.renameWorkspace(user, params.owner, newSlug);
123 if (!result.ok) return { renameError: result.error.message };
124 throw redirect(`/${result.value.slug}/-/settings`);
125 }
Agents as a team: lifecycle, merge queue, billing and a new shell126 const result = await identity.updateWorkspace(user, params.owner, {
127 name: String(form.get("displayName") ?? ""),
128 description: String(form.get("description") ?? ""),
129 });
130 if (!result.ok) return { error: result.error.message };
131 throw redirect(`/${result.value.slug}`);
132}
133
134export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) {
135 const { workspace } = loaderData;
136 return (
Workspace names and icons, and a component kit for every control137 <div className="max-w-lg space-y-10">
138 <section>
139 <h2 className="font-medium">Icon</h2>
140 <div className="mt-4">
141 <AvatarField
142 name={workspace.slug}
143 image={workspace.avatar}
144 square
145 error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
146 about="Shown beside the workspace's name everywhere on g1t, and on its link previews. Without one, g1t draws its first letter."
Agents as a team: lifecycle, merge queue, billing and a new shell147 />
Workspace names and icons, and a component kit for every control148 </div>
149 </section>
150
151 <section>
152 <h2 className="font-medium">Workspace details</h2>
153 <Form method="post" className="mt-5 space-y-4">
Merge branch 'worktree-agent-a985c01f43538af01'154 <input type="hidden" name="intent" value="details" />
Workspace names and icons, and a component kit for every control155 <Field
156 label="Display name"
157 hint="How people see the workspace: in the sidebar, at the top of its page and in link previews. Up to 80 characters; spaces and capitals are fine."
158 >
159 <Input name="displayName" maxLength={80} defaultValue={workspace.name} placeholder={workspace.slug} />
160 </Field>
161 <Field label="Description" hint="One line, shown at the top of the workspace's page.">
162 <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
163 </Field>
164 <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
Merge branch 'worktree-agent-a985c01f43538af01'165 <SubmitButton match={{ intent: "details" }} pending="Saving…">
166 Save
167 </SubmitButton>
Workspace names and icons, and a component kit for every control168 </Form>
169 </section>
170
Agents and memory, checks and conflicts, profiles, slug renames, custom domains171 <AddressSection
Merge branch 'worktree-agent-a985c01f43538af01'172 // A new address starts it again, so the dialog is closed on the renamed page.
173 key={workspace.slug}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains174 workspace={workspace}
175 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
176 />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look177
Merge branch 'worktree-agent-a2013627e5ea4ab13'178 {(loaderData.euAvailable || loaderData.residency === "eu") && (
179 <ResidencySection
Merge branch 'worktree-agent-a985c01f43538af01'180 // Starts from the saved choice whenever it changes.
181 key={loaderData.residency}
Merge branch 'worktree-agent-a2013627e5ea4ab13'182 residency={loaderData.residency}
183 euAvailable={loaderData.euAvailable}
184 saved={Boolean(actionData && "saved" in actionData && actionData.saved === "residency")}
185 error={actionData && "residencyError" in actionData ? actionData.residencyError : undefined}
186 />
187 )}
188
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look189 <DangerZone>
190 <DeleteAction
191 workspace={workspace}
192 deletion={loaderData.deletion}
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member193 apps={loaderData.apps}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look194 error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined}
195 />
196 </DangerZone>
Agents as a team: lifecycle, merge queue, billing and a new shell197 </div>
198 );
199}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains200
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201/**
Merge branch 'worktree-agent-a2013627e5ea4ab13'202 * Where the workspace's new repositories keep their git data. Shown only
203 * once g1t has EU storage (or to a workspace that already chose it), so
204 * nobody is offered a choice that does nothing.
205 */
206function ResidencySection({
207 residency,
208 euAvailable,
209 saved,
210 error,
211}: {
212 residency: DataResidency;
213 euAvailable: boolean;
214 saved: boolean;
215 error?: string;
216}) {
217 const [choice, setChoice] = useState<DataResidency>(residency);
218 const navigation = useNavigation();
219 const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "residency";
220 return (
221 <section>
222 <h2 className="font-medium">Data residency</h2>
223 <p className="mt-1.5 text-xs text-faint">
224 Where the git data of repositories made from now on is stored. Repositories the workspace already has stay
225 where they are; ask support to move them. Issues, pull requests and settings are not affected.
226 </p>
227 <Form method="post" className="mt-5 space-y-4">
228 <input type="hidden" name="intent" value="residency" />
229 <RadioGroup
230 name="residency"
231 value={choice}
232 onValueChange={(value) => setChoice(value as DataResidency)}
233 aria-label="Where new repositories are stored"
234 >
235 <RadioOption value="anywhere" label="Anywhere" description="Wherever g1t stores repositories. The default." />
236 <RadioOption
237 value="eu"
238 label="EU only"
239 description={
240 euAvailable
241 ? "New repositories are stored in the EU, and are not made if EU storage cannot take them."
242 : "EU storage cannot take new repositories right now."
243 }
244 disabled={!euAvailable && residency !== "eu"}
245 />
246 </RadioGroup>
247 <ErrorText>{error}</ErrorText>
248 {saved && !error && (
249 <p role="status" className="text-xs text-muted">
250 Saved.
251 </p>
252 )}
253 <Button type="submit" disabled={saving || choice === residency}>
254 Save
255 </Button>
256 </Form>
257 </section>
258 );
259}
260
261/**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member262 * Deleting the workspace, with everything in it, in one step. Owners only,
263 * as the whole page is; typed out to confirm. It is kept for
264 * `WORKSPACE_RESTORE_DAYS`, when support can restore it. A protected
265 * workspace says so instead of offering the button.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look266 */
267function DeleteAction({
268 workspace,
269 deletion,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member270 apps,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look271 error,
272}: {
273 workspace: Workspace;
274 deletion: WorkspaceDeletion | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member275 apps: number | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look276 error?: string;
277}) {
278 const [open, setOpen] = useState(Boolean(error));
279 const [confirm, setConfirm] = useState("");
280 const navigation = useNavigation();
281 const deleting = navigation.state !== "idle" && navigation.formData?.get("intent") === "delete";
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member282 const refusal = deletionRefusal(workspace.slug, deletion);
283 const goes = deletion ? whatGoes(deletion, apps) : [];
284 if (deletion?.protected) {
285 return (
286 <DangerAction title="Delete this workspace" action={null}>
287 <span role="status">{refusal}</span>
288 </DangerAction>
289 );
290 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look291 return (
292 <DangerAction
293 title="Delete this workspace"
294 action={
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member295 <Button type="button" variant="danger" disabled={Boolean(refusal)} onClick={() => setOpen(true)}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look296 Delete workspace
297 </Button>
298 }
299 >
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member300 {refusal ? (
301 <span role="status">{refusal}</span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look302 ) : (
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member303 <>
304 Everything in it goes with it: repositories, projects, apps, members' access and tokens. It is kept for{" "}
305 {WORKSPACE_RESTORE_DAYS} days, and support can restore it until then.
306 </>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look307 )}
308 <AlertDialog
309 open={open}
310 onOpenChange={(next) => {
311 setOpen(next);
312 setConfirm("");
313 }}
314 >
315 <AlertDialogContent>
316 <Form method="post" className="grid gap-4">
317 <input type="hidden" name="intent" value="delete" />
318 <AlertDialogHeader>
319 <AlertDialogTitle>Delete {workspace.slug}?</AlertDialogTitle>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member320 <AlertDialogDescription>
321 Everything in it goes now. For {WORKSPACE_RESTORE_DAYS} days, support can restore all of it; after
322 that it is gone for good.
323 </AlertDialogDescription>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look324 </AlertDialogHeader>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member325 {goes.length > 0 ? (
326 <div className="grid gap-1.5">
327 <p className="text-sm font-medium">What goes with it</p>
328 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
329 {goes.map((line) => (
330 <li key={line}>{line}</li>
331 ))}
332 </ul>
333 </div>
334 ) : null}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look335 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member336 <li>Everyone loses access to it at once, and its access tokens stop working.</li>
337 <li>Its pages, repositories and apps are no longer found, and nothing builds or runs for it.</li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look338 <li>
339 Anything it owes is charged to its card now, and its plan ends today. Its statements, invoices and
340 audit log are kept.
341 </li>
342 <li>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member343 To get it back within {WORKSPACE_RESTORE_DAYS} days, an owner writes to support. After that its
344 repositories, projects, apps, webhooks, integrations, secrets and memory are removed for good.
345 </li>
346 <li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look347 The name <span className="font-mono text-fg">{workspace.slug}</span> is never given to another
348 workspace. Repositories that were transferred out keep redirecting from it.
349 </li>
350 <li>Members keep their own accounts.</li>
351 </ul>
352 <FormField>
353 <FieldLabel htmlFor="confirm-delete">
354 Type <span className="font-mono text-fg">{workspace.slug}</span> to confirm
355 </FieldLabel>
356 <TextInput
357 id="confirm-delete"
358 name="confirm"
359 value={confirm}
360 spellCheck={false}
361 autoCapitalize="off"
362 autoComplete="off"
363 onChange={(event) => setConfirm(event.target.value)}
364 className="font-mono"
365 />
366 </FormField>
367 <ErrorText>{error}</ErrorText>
368 <AlertDialogFooter>
369 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member370 <Button type="submit" variant="danger" disabled={!confirmsSlug(workspace.slug, confirm) || deleting}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look371 {deleting ? "Deleting…" : "Delete workspace"}
372 </Button>
373 </AlertDialogFooter>
374 </Form>
375 </AlertDialogContent>
376 </AlertDialog>
377 </DangerAction>
378 );
379}
380
Agents and memory, checks and conflicts, profiles, slug renames, custom domains381/**
382 * The workspace's slug, the first part of every address under it. Owners
383 * change it here, apart from the display name; the old one redirects for
384 * `SLUG_HOLD_DAYS`.
385 */
386function AddressSection({ workspace, error }: { workspace: Workspace; error?: string }) {
387 const [slug, setSlug] = useState(workspace.slug);
388 const [open, setOpen] = useState(false);
389 const [confirm, setConfirm] = useState("");
390 const checker = useFetcher<typeof loader>();
Merge branch 'worktree-agent-aaf03bdceac799c89'391 const { site } = useAddresses();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains392 const navigation = useNavigation();
393 const renaming = navigation.state !== "idle" && navigation.formData?.get("intent") === "rename";
394
395 const wanted = slug.trim().toLowerCase();
396 const unchanged = wanted === workspace.slug;
397 const wellFormed = isValidNamespace(wanted);
398
399 // The shape is checked here; whether the name is free, once the owner
400 // stops typing.
401 const load = checker.load;
402 useEffect(() => {
403 if (unchanged || !wellFormed) return;
404 const timer = setTimeout(() => {
405 load(`/${workspace.slug}/-/settings?check=${encodeURIComponent(wanted)}`);
406 }, 400);
407 return () => clearTimeout(timer);
408 }, [load, wanted, unchanged, wellFormed, workspace.slug]);
409
410 // An answer only counts for what is in the field now.
411 const answer = checker.data?.check?.slug === wanted ? checker.data.check : null;
412 const available = !unchanged && wellFormed && answer?.available === true;
413
414 let status: { text: string; tone: "muted" | "ok" | "bad" } | null = null;
415 if (!wanted) {
416 status = { text: "Enter the new address.", tone: "muted" };
417 } else if (unchanged) {
418 status = null;
419 } else if (!wellFormed) {
420 status = {
421 text: "Use lowercase letters, numbers and single hyphens, up to 39 characters, starting and ending with a letter or number. Some words, such as new and settings, are reserved.",
422 tone: "bad",
423 };
424 } else if (!answer) {
425 status = { text: "Checking…", tone: "muted" };
426 } else if (answer.available) {
427 status = { text: "Available.", tone: "ok" };
428 } else {
429 status = { text: answer.message ?? "That address is not available.", tone: "bad" };
430 }
431
432 return (
433 <section>
434 <h2 className="font-medium">Address</h2>
435 <p className="mt-1.5 text-xs text-faint">
436 The workspace's slug: the first part of every page, clone URL and API path under it. Changing it does
437 not change the display name. The old address redirects for {SLUG_HOLD_DAYS} days.
438 </p>
439 <div className="mt-5 space-y-4">
440 <FormField>
441 <FieldLabel htmlFor="workspace-slug">Slug</FieldLabel>
442 <InputGroup>
443 <InputAddon className="border-r border-line bg-surface font-mono">g1t.sh/</InputAddon>
444 <TextInput
445 id="workspace-slug"
446 value={slug}
447 maxLength={39}
448 spellCheck={false}
449 autoCapitalize="off"
450 onChange={(event) => setSlug(event.target.value.toLowerCase())}
451 aria-invalid={status?.tone === "bad" || undefined}
452 className="pl-2 font-mono"
453 />
454 </InputGroup>
455 <FieldDescription>
456 <span className="font-mono text-muted">
457 g1t.sh/<span className={unchanged ? undefined : "text-fg"}>{wanted || workspace.slug}</span>
458 </span>
459 {status && (
460 <span
461 role="status"
462 className={`mt-1 block ${
463 status.tone === "ok" ? "text-accent" : status.tone === "bad" ? "text-danger" : ""
464 }`}
465 >
466 {status.text}
467 </span>
468 )}
469 </FieldDescription>
470 </FormField>
471 {!open && <ErrorText>{error}</ErrorText>}
472 <AlertDialog
473 open={open}
474 onOpenChange={(next) => {
475 setOpen(next);
476 setConfirm("");
477 }}
478 >
479 <Button type="button" variant="quiet" disabled={!available} onClick={() => setOpen(true)}>
480 Change address
481 </Button>
482 <AlertDialogContent>
483 {/* The dialog stays open while the form posts: success leaves
484 the page for the new address, and a failure shows here. */}
485 <Form method="post" className="grid gap-4">
486 <input type="hidden" name="intent" value="rename" />
487 <input type="hidden" name="newSlug" value={wanted} />
488 <AlertDialogHeader>
489 <AlertDialogTitle>Change the address to g1t.sh/{wanted}?</AlertDialogTitle>
490 <AlertDialogDescription>
491 Every page, repository and API path under g1t.sh/{workspace.slug} moves to g1t.sh/{wanted}.
492 </AlertDialogDescription>
493 </AlertDialogHeader>
494 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
495 <li>Old links and git remotes redirect to the new address for {SLUG_HOLD_DAYS} days.</li>
496 <li>
497 Deployed apps move to <span className="font-mono">*.g1t.page</span> addresses with the new
498 name. The old addresses redirect for {SLUG_HOLD_DAYS} days.
499 </li>
500 <li>
501 Update your git remotes, for example{" "}
502 <code className="font-mono text-xs break-all text-fg">
Merge branch 'worktree-agent-aaf03bdceac799c89'503 git remote set-url origin {site}/{wanted}/&lt;repo&gt;.git
Agents and memory, checks and conflicts, profiles, slug renames, custom domains504 </code>
505 , and any URLs written into code, API clients and MCP clients.
506 </li>
507 <li>After {SLUG_HOLD_DAYS} days, anyone can take the name {workspace.slug}.</li>
508 <li>You can change the address again after {RENAME_COOLDOWN_HOURS} hours.</li>
509 </ul>
510 <FormField>
511 <FieldLabel htmlFor="confirm-slug">
512 Type <span className="font-mono text-fg">{wanted}</span> to confirm
513 </FieldLabel>
514 <TextInput
515 id="confirm-slug"
516 value={confirm}
517 spellCheck={false}
518 autoCapitalize="off"
519 onChange={(event) => setConfirm(event.target.value)}
520 className="font-mono"
521 />
522 </FormField>
523 <ErrorText>{error}</ErrorText>
524 <AlertDialogFooter>
525 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
526 <Button type="submit" disabled={confirm.trim().toLowerCase() !== wanted || renaming}>
527 {renaming ? "Changing…" : "Change address"}
528 </Button>
529 </AlertDialogFooter>
530 </Form>
531 </AlertDialogContent>
532 </AlertDialog>
533 </div>
534 </section>
535 );
536}

This file's history is long; its oldest lines are credited to the oldest commit read.