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.

301 linesCodeBlameRaw
1import { Check, Copy, LoaderCircle } from "lucide-react";
2import { type ComponentProps, Fragment, type ReactNode, useState } from "react";
3import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router";
4
5import { cn } from "../../lib/cn";
6import { linkPaths } from "../../lib/compute";
7import { type Submission, isPending } from "../../lib/pending";
8import { exactTime, timeAgo } from "../../lib/time-ago";
9import { Button, type ButtonSize, type ButtonVariant, buttonVariants } from "./button";
10import { Hint } from "./hint";
11
12export function Field({ label, hint, htmlFor, children }: { label: string; hint?: ReactNode; htmlFor?: string; children: ReactNode }) {
13 return (
14 <label className="block" htmlFor={htmlFor}>
15 <span className="mb-1.5 block text-sm font-medium text-muted">{label}</span>
16 {children}
17 {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
18 </label>
19 );
20}
21
22const CONTROL =
23 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
24
25/**
26 * Attributes for a field that is not part of signing in. Password managers
27 * guess from names such as "name", "title" and "username" and offer to
28 * fill a login into them; these tell 1Password, LastPass, Bitwarden and
29 * Dashlane to leave the field alone. A field that states what it takes with
30 * `autoComplete` (other than `off`) is left to them.
31 */
32export function notACredential(autoComplete?: string) {
33 if (autoComplete && autoComplete.toLowerCase() !== "off") return {};
34 return {
35 autoComplete: "off",
36 "data-1p-ignore": true,
37 "data-lpignore": "true",
38 "data-bwignore": true,
39 "data-form-type": "other",
40 };
41}
42
43export function Input(props: ComponentProps<"input">) {
44 return <input {...notACredential(props.autoComplete)} {...props} className={CONTROL} />;
45}
46
47export function Textarea(props: ComponentProps<"textarea">) {
48 return <textarea {...notACredential(props.autoComplete)} {...props} className={`${CONTROL} font-mono placeholder:font-sans`} />;
49}
50
51/** One tab in the row under a repository's or a workspace's header. */
52export function TabLink({
53 to,
54 also,
55 end,
56 icon,
57 count,
58 children,
59}: {
60 to: string;
61 /** Another path prefix under which this tab is the current one. */
62 also?: string;
63 end?: boolean;
64 icon: ReactNode;
65 count?: number;
66 children: ReactNode;
67}) {
68 const { pathname } = useLocation();
69 return (
70 <NavLink
71 to={to}
72 end={end}
73 className={({ isActive }) =>
74 `-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${
75 isActive || (also && pathname.startsWith(also + "/")) ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"
76 }`
77 }
78 >
79 {icon}
80 {children}
81 {count != null && count > 0 && <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">{count}</span>}
82 </NavLink>
83 );
84}
85
86/** Soon, beside something that is coming: the same small pill in the sidebar and in tabs. */
87export function SoonPill() {
88 return <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Soon</span>;
89}
90
91/**
92 * Whether the submission `fields` names is still working (lib/pending.ts):
93 * the page's own navigation, or `fetcher`'s when the form is a fetcher's.
94 */
95export function usePending(fields?: Record<string, string | null | undefined>, fetcher?: Submission): boolean {
96 const navigation = useNavigation();
97 return isPending(fetcher ?? navigation, fields);
98}
99
100/**
101 * A form's submit button that says it is working: turned off, with a
102 * spinner and `pending` ("Saving…") in place of its words, from the moment
103 * it is pressed until the page has loaded what it changed. Its own
104 * `name`/`value` say which submission is its; `match` names it otherwise,
105 * such as the form's hidden `intent`. `fetcher` when the form is a
106 * fetcher's. It is drawn as the Button in ./button, with its `variant` and
107 * `size`; `icon` says the spinner takes the place of everything inside
108 * it. Words with a leading icon should pass `pending`, so the spinner
109 * replaces both.
110 */
111export function SubmitButton({
112 variant,
113 size,
114 pending,
115 match,
116 fetcher,
117 busy,
118 icon,
119 disabled,
120 className,
121 children,
122 ...props
123}: Omit<ComponentProps<"button">, "type"> & {
124 variant?: ButtonVariant;
125 size?: ButtonSize;
126 /** The words while it works, such as "Saving…"; its own words when absent. */
127 pending?: ReactNode;
128 match?: Record<string, string | null | undefined>;
129 fetcher?: Submission;
130 /** Working for a reason this button cannot see by itself. */
131 busy?: boolean;
132 /** An icon button: while it works, the spinner is all it shows. */
133 icon?: boolean;
134}) {
135 const own = typeof props.name === "string" && props.value != null ? { [props.name]: String(props.value) } : undefined;
136 const working = usePending({ ...own, ...match }, fetcher) || Boolean(busy);
137 return (
138 <button
139 {...props}
140 type="submit"
141 disabled={disabled || working}
142 aria-busy={working || undefined}
143 data-slot="button"
144 className={cn(buttonVariants({ variant, size }), className)}
145 >
146 {working ? (
147 <>
148 <LoaderCircle size={14} aria-hidden="true" className="shrink-0 animate-spin" />
149 {icon ? null : (pending ?? children)}
150 </>
151 ) : (
152 children
153 )}
154 </button>
155 );
156}
157
158/** A link that looks like the Button in ./button, with its `variant` and `size`. */
159export function ButtonLink({ variant, size, className, ...props }: LinkProps & { variant?: ButtonVariant; size?: ButtonSize }) {
160 return <Link data-slot="button" {...props} className={cn(buttonVariants({ variant, size }), className)} />;
161}
162
163/**
164 * A message with the g1t pages it names as links, such as the billing page
165 * a refusal to start compute points to.
166 */
167export function Linked({ text }: { text: string }) {
168 return (
169 <>
170 {linkPaths(text).map((piece, at) =>
171 piece.href ? (
172 <Link key={at} to={piece.href} className="underline underline-offset-2">
173 {piece.text}
174 </Link>
175 ) : (
176 piece.text
177 ),
178 )}
179 </>
180 );
181}
182
183export function ErrorText({ children }: { children: ReactNode }) {
184 if (!children) return null;
185 if (typeof children !== "string") return <p className="text-sm text-danger">{children}</p>;
186 return (
187 <p className="text-sm text-danger">
188 <Linked text={children} />
189 </p>
190 );
191}
192
193/**
194 * What a workspace's plan says before someone tries to start compute it
195 * would refuse ("Workflows need a paid workspace or the free trial"), with
196 * where to fix it.
197 */
198export function ComputeNote({ note }: { note: string | null | undefined }) {
199 return note ? (
200 <p className="text-sm text-muted">
201 <Linked text={note} />
202 </p>
203 ) : null;
204}
205
206/** A line of text (usually a command) with a copy button. */
207export function CopyLine({
208 text,
209 prompt,
210 disabled,
211 breakAtSlashes,
212}: {
213 text: string;
214 prompt?: boolean;
215 /** Shown, so it is clear what will be there, but not yet usable: dimmed, with no copy. */
216 disabled?: boolean;
217 /**
218 * For a narrow box: an address longer than the line may wrap after a
219 * `/`, so all of it shows, rather than scroll.
220 */
221 breakAtSlashes?: boolean;
222}) {
223 const [copied, setCopied] = useState(false);
224 return (
225 <div
226 aria-disabled={disabled || undefined}
227 className={`group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem] ${disabled ? "cursor-not-allowed text-faint select-none" : ""}`}
228 >
229 {/* Wraps between words rather than hides: a command is no use half
230 seen. A word itself, an address or a flag, is never broken in
231 two, which would change what is copied by eye; one longer than
232 the line scrolls sideways instead. */}
233 <code className="min-w-0 grow overflow-x-auto whitespace-pre-wrap scrollbar-thin">
234 {prompt && <span className="mr-2 text-faint select-none">$</span>}
235 {text.split(/(\s+)/).map((part, index) =>
236 /\S/.test(part) ? (
237 breakAtSlashes ? (
238 // Each piece whole, with a chance to wrap after each `/`.
239 <span key={index}>
240 {part.split(/(?<=\/)(?!\/)/).map((piece, at) => (
241 <Fragment key={at}>
242 {at > 0 && <wbr />}
243 <span className="whitespace-nowrap">{piece}</span>
244 </Fragment>
245 ))}
246 </span>
247 ) : (
248 <span key={index} className="whitespace-nowrap">
249 {part}
250 </span>
251 )
252 ) : (
253 part
254 ),
255 )}
256 </code>
257 <Button
258 type="button"
259 aria-label="Copy"
260 disabled={disabled}
261 variant="ghost"
262 size="inline"
263 className="p-1.5 text-faint pointer-coarse:p-2.5"
264 onClick={() => {
265 void navigator.clipboard.writeText(text);
266 setCopied(true);
267 setTimeout(() => setCopied(false), 1500);
268 }}
269 >
270 {copied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
271 </Button>
272 </div>
273 );
274}
275
276/**
277 * Compact relative time such as "5m ago", or the date once it is weeks
278 * old (lib/time-ago.ts), with the moment in full on hover.
279 */
280export function TimeAgo({ at }: { at: string | number }) {
281 // Timestamps are RFC 3339 strings; a few older ones are still numbers.
282 const time = new Date(at);
283 if (Number.isNaN(time.getTime())) return null;
284 return (
285 <Hint label={<span suppressHydrationWarning>{exactTime(at)}</span>}>
286 {/* The server and the browser render at slightly different times, and zones. */}
287 <time dateTime={time.toISOString()} suppressHydrationWarning>
288 {timeAgo(at)}
289 </time>
290 </Hint>
291 );
292}
293
294export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
295 return (
296 <div className="rounded-lg border border-dashed border-line px-6 py-12 text-center">
297 <p className="font-medium">{title}</p>
298 {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
299 </div>
300 );
301}