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