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.

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