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