Skip to content
535 linesCodeBlameRaw
1/**
2 * Artifacts mode's sidebar (beside the rail; docs.g1t.sh/guides/artifacts/,
3 * "The sidebar"): search, Home, New and Templates; Favorites; the spaces
4 * shown (joined open spaces, team spaces, members-only spaces) each with
5 * its tree; Private, everything of yours in no space; Shared, the tops of
6 * what others shared with you; then what's possibly out of date,
7 * projects' docs folders (read-only) and the trash. Rows are dragged to
8 * reorder them or to put one inside a doc.
9 */
10import { FOLIO_KINDS, folioIdFrom, folioSlug, type DocRepoSpace, type FolioKind, type FolioRef, type FolioTreeNode, type FoliosSidebarSpace } from "@g1t/contracts";
11import { AlertTriangle, ChevronDown, ChevronRight, Compass, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, X } from "lucide-react";
12import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react";
13import { NavLink, useLocation, useNavigate, useParams, useRevalidator } from "react-router";
14
15import { buildTree, canDo, pathTo, repoFilePath, repoFolders, spacePath, type RepoFolder, type TreeItem } from "../../lib/folios";
16import { Button } from "../ui/button";
17import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
18import { Hint } from "../ui/hint";
19import { Skeleton } from "../ui/skeleton";
20import { foliosRequest, useFoliosAction, useFoliosData } from "./actions";
21import { RepoDocsDialog } from "./doc/code";
22import { FOLIO_KIND_UI, FolioGlyph, KindIcon } from "./kinds";
23import { SpaceIcon } from "./parts";
24
25const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
26const ROW_BUTTON = "flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100 pointer-coarse:size-9 pointer-coarse:opacity-100";
27
28function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
29 return (
30 <NavLink to={to} end={end} prefetch="intent" className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}>
31 <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span>
32 <span className="min-w-0 grow truncate">{children}</span>
33 {trailing}
34 </NavLink>
35 );
36}
37
38function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) {
39 const [open, setOpen] = useState(initially);
40 return (
41 <section className="mt-4">
42 <div className="mb-0.5 flex items-center gap-1 pr-1">
43 <button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex grow items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted">
44 <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
45 {title}
46 </button>
47 {action}
48 </div>
49 {open && children}
50 </section>
51 );
52}
53
54/** "New ▾": the four kinds, the ones not here yet shown as coming. */
55export function NewMenu({ onMake, children, align = "end" }: { onMake: (kind: FolioKind) => void; children: ReactNode; align?: "start" | "end" }) {
56 return (
57 <DropdownMenu>
58 <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
59 <DropdownMenuContent align={align} className="w-52">
60 {FOLIO_KINDS.map((kind) => {
61 const ui = FOLIO_KIND_UI[kind];
62 return (
63 <DropdownMenuItem key={kind} disabled={!ui.ready} onSelect={() => onMake(kind)}>
64 <KindIcon kind={kind} size={13} box={22} />
65 <span className="grow">{ui.label}</span>
66 {!ui.ready && <span className="text-[0.625rem] text-faint">Coming soon</span>}
67 </DropdownMenuItem>
68 );
69 })}
70 </DropdownMenuContent>
71 </DropdownMenu>
72 );
73}
74
75type Drag = { id: string; place: string } | null;
76
77/** Where a tree lives: a space (by id) or the viewer's Private (null). */
78type Place = { space_id: string | null; key: string; editable: boolean };
79
80function TreeRow({ slug, place, item, current, open, toggle, drag, setDrag, onDrop, onAdd }: { slug: string; place: Place; item: TreeItem; current: string | null; open: Set<string>; toggle: (id: string) => void; drag: Drag; setDrag: (d: Drag) => void; onDrop: (target: TreeItem, where: "before" | "inside") => void; onAdd: (parent: string) => void }) {
81 const [over, setOver] = useState<"before" | "inside" | null>(null);
82 const expanded = open.has(item.id);
83 const active = current === item.id;
84 const holds = item.kind === "doc";
85 const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => {
86 if (!holds) return "before";
87 const box = e.currentTarget.getBoundingClientRect();
88 return e.clientY - box.top < box.height * 0.35 ? "before" : "inside";
89 };
90 return (
91 <li>
92 <div
93 draggable={place.editable}
94 onDragStart={(e) => {
95 e.dataTransfer.effectAllowed = "move";
96 e.dataTransfer.setData("text/plain", item.id);
97 setDrag({ id: item.id, place: place.key });
98 }}
99 onDragEnd={() => setDrag(null)}
100 onDragOver={(e) => {
101 if (!drag || drag.id === item.id || !place.editable) return;
102 e.preventDefault();
103 setOver(zone(e));
104 }}
105 onDragLeave={() => setOver(null)}
106 onDrop={(e) => {
107 e.preventDefault();
108 const where = zone(e);
109 setOver(null);
110 if (drag && drag.id !== item.id) onDrop(item, where);
111 }}
112 className={`${ROW} relative ${active ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"} ${over === "inside" ? "ring-1 ring-accent/60 ring-inset" : ""}`}
113 style={{ paddingLeft: 4 + item.depth * 14 }}
114 >
115 {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />}
116 <button
117 type="button"
118 onClick={() => toggle(item.id)}
119 aria-label={expanded ? "Collapse" : "Expand"}
120 aria-expanded={expanded}
121 className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`}
122 >
123 <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} />
124 </button>
125 <NavLink to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5">
126 <span className="flex w-4 shrink-0 justify-center">
127 <FolioGlyph folio={item} size={14} />
128 </span>
129 <span className="min-w-0 truncate">{item.title || "Untitled"}</span>
130 {item.restricted && (
131 <Hint label="Only people invited">
132 <Lock size={11} className="shrink-0 text-faint" aria-label="Only people invited" />
133 </Hint>
134 )}
135 {item.stale && (
136 <Hint label="Possibly out of date: code it cites changed">
137 <span className="ml-auto size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" />
138 </Hint>
139 )}
140 </NavLink>
141 {place.editable && holds && (
142 <Hint label="Add a doc inside">
143 <button type="button" onClick={() => onAdd(item.id)} aria-label={`Add a doc inside ${item.title || "Untitled"}`} className={ROW_BUTTON}>
144 <Plus size={13} />
145 </button>
146 </Hint>
147 )}
148 </div>
149 {expanded && item.children.length > 0 && (
150 <ul>
151 {item.children.map((child) => (
152 <TreeRow key={child.id} slug={slug} place={place} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} />
153 ))}
154 </ul>
155 )}
156 </li>
157 );
158}
159
160/** A tree of folios in one place, opened to the one being read; drag to reorder or nest. */
161function Tree({ slug, place, nodes, current, drag, setDrag, empty }: { slug: string; place: Place; nodes: FolioTreeNode[]; current: string | null; drag: Drag; setDrag: (d: Drag) => void; empty: string }) {
162 const navigate = useNavigate();
163 const { send } = useFoliosAction(slug);
164 const tree = useMemo(() => buildTree(nodes), [nodes]);
165 const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(nodes, current)));
166 // Opening an artifact opens the way to it.
167 useEffect(() => {
168 if (!current) return;
169 const path = pathTo(nodes, current);
170 if (path.length) setOpen((was) => new Set([...was, ...path]));
171 }, [current, nodes]);
172 const toggle = (id: string) =>
173 setOpen((was) => {
174 const next = new Set(was);
175 if (next.has(id)) next.delete(id);
176 else next.add(id);
177 return next;
178 });
179 const add = async (parent: string) => {
180 const made = await send<{ path: string }>("create", { folio: { kind: "doc", parent_id: parent } });
181 if (made.ok) {
182 setOpen((was) => new Set([...was, parent]));
183 navigate(made.value.path);
184 }
185 };
186 const drop = async (target: TreeItem, where: "before" | "inside") => {
187 if (!drag) return;
188 const move = where === "inside" ? { space_id: place.space_id, parent_id: target.id, before_id: null } : { space_id: place.space_id, parent_id: target.parent_id, before_id: target.id };
189 setDrag(null);
190 await send("move", { folio_id: drag.id, move });
191 if (where === "inside") setOpen((was) => new Set([...was, target.id]));
192 };
193 return (
194 <ul>
195 {tree.map((item) => (
196 <TreeRow key={item.id} slug={slug} place={place} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} />
197 ))}
198 {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{empty}</li>}
199 </ul>
200 );
201}
202
203function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: FoliosSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) {
204 const navigate = useNavigate();
205 const { send } = useFoliosAction(slug);
206 const [collapsed, setCollapsed] = useState(false);
207 const editable = canDo(space.viewer_role, "edit");
208 const place: Place = { space_id: space.id, key: space.id, editable };
209 return (
210 <li className="mt-1">
211 <div
212 className={`${ROW} text-fg-soft`}
213 onDragOver={(e) => {
214 if (drag && editable) e.preventDefault();
215 }}
216 onDrop={async (e) => {
217 // Dropped on the space itself: to its top, at the end.
218 e.preventDefault();
219 if (!drag) return;
220 const id = drag.id;
221 setDrag(null);
222 await send("move", { folio_id: id, move: { space_id: space.id, parent_id: null, before_id: null } });
223 }}
224 >
225 <Button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.name}` : `Hide ${space.name}`} variant="ghost" size="icon" className="size-5 rounded text-faint hover:bg-line">
226 <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
227 </Button>
228 <NavLink to={spacePath(slug, space.slug)} end prefetch="intent" className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`}>
229 <span className="flex w-4 shrink-0 justify-center">
230 <SpaceIcon space={space} size={14} />
231 </span>
232 <span className="min-w-0 truncate">{space.name}</span>
233 </NavLink>
234 {canDo(space.viewer_role, "manage") && (
235 <Hint label="Space settings">
236 <NavLink to={`${spacePath(slug, space.slug)}/settings`} aria-label={`${space.name} settings`} className={ROW_BUTTON}>
237 <Settings size={13} />
238 </NavLink>
239 </Hint>
240 )}
241 {editable && (
242 <Hint label={`New doc in ${space.name}`}>
243 <button
244 type="button"
245 onClick={async () => {
246 const made = await send<{ path: string }>("create", { folio: { kind: "doc", space_id: space.id } });
247 if (made.ok) navigate(made.value.path);
248 }}
249 aria-label={`New doc in ${space.name}`}
250 className={ROW_BUTTON}
251 >
252 <Plus size={13} />
253 </button>
254 </Hint>
255 )}
256 </div>
257 {!collapsed && <Tree slug={slug} place={place} nodes={space.tree} current={current} drag={drag} setDrag={setDrag} empty="Nothing here yet." />}
258 </li>
259 );
260}
261
262function RepoFolderRows({ slug, repo, folder, depth, current }: { slug: string; repo: string; folder: RepoFolder; depth: number; current: string }) {
263 const [open, setOpen] = useState<Set<string>>(() => new Set(folder.folders.filter((f) => current.includes(`/${f.path}/`)).map((f) => f.path)));
264 return (
265 <>
266 {folder.files.map((f) => (
267 <li key={f.path}>
268 <NavLink to={repoFilePath(slug, repo, f.path)} prefetch="intent" className={({ isActive }) => `${ROW} ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} style={{ paddingLeft: 26 + depth * 14 }}>
269 <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" />
270 <span className="min-w-0 truncate">{f.title}</span>
271 </NavLink>
272 </li>
273 ))}
274 {folder.folders.map((sub) => {
275 const expanded = open.has(sub.path);
276 return (
277 <li key={sub.path}>
278 <button
279 type="button"
280 aria-expanded={expanded}
281 onClick={() =>
282 setOpen((was) => {
283 const next = new Set(was);
284 if (next.has(sub.path)) next.delete(sub.path);
285 else next.add(sub.path);
286 return next;
287 })
288 }
289 className={`${ROW} w-full text-muted hover:bg-raised/60 hover:text-fg`}
290 style={{ paddingLeft: 4 + (depth + 1) * 14 }}
291 >
292 <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} />
293 <Folder size={14} className="shrink-0 text-faint" aria-hidden="true" />
294 <span className="min-w-0 truncate">{sub.name}</span>
295 </button>
296 {expanded && (
297 <ul>
298 <RepoFolderRows slug={slug} repo={repo} folder={sub} depth={depth + 1} current={current} />
299 </ul>
300 )}
301 </li>
302 );
303 })}
304 </>
305 );
306}
307
308/** A project's docs folder: read-only, its files as folders, from the repository's default branch. */
309function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) {
310 const { pathname } = useLocation();
311 const base = `/${slug}/-/artifacts/repo/${space.repo}/`;
312 const here = decodeURIComponent(pathname).startsWith(base);
313 const [collapsed, setCollapsed] = useState(!here);
314 const root = useMemo(() => repoFolders(space.files), [space.files]);
315 return (
316 <li className="mt-1">
317 <div className={`${ROW} text-fg-soft`}>
318 <Button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.repo}'s docs` : `Hide ${space.repo}'s docs`} variant="ghost" size="icon" className="size-5 rounded text-faint hover:bg-line">
319 <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
320 </Button>
321 <span className="flex w-4 shrink-0 justify-center">
322 <FolderGit2 size={14} className="text-faint" aria-hidden="true" />
323 </span>
324 <span className="min-w-0 grow truncate font-medium">{space.repo}</span>
325 </div>
326 {!collapsed && (
327 <ul>
328 <RepoFolderRows slug={slug} repo={space.repo} folder={root} depth={0} current={decodeURIComponent(pathname)} />
329 {space.files.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{space.indexed_at ? "No docs folder or README." : "Reading its docs…"}</li>}
330 </ul>
331 )}
332 </li>
333 );
334}
335
336function RefRow({ folio, trailing }: { folio: FolioRef; trailing?: ReactNode }) {
337 return (
338 <li>
339 <SideLink to={folio.path} icon={<FolioGlyph folio={folio} size={14} />} trailing={trailing}>
340 {folio.title || "Untitled"}
341 </SideLink>
342 </li>
343 );
344}
345
346export function FoliosSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) {
347 const data = useFoliosData();
348 const navigate = useNavigate();
349 const params = useParams();
350 const current = folioIdFrom(params.folio);
351 const [drag, setDrag] = useState<Drag>(null);
352 const [query, setQuery] = useState("");
353 const [addingRepo, setAddingRepo] = useState(false);
354 const [error, setError] = useState<string | null>(null);
355 const { revalidate } = useRevalidator();
356 const sidebar = data?.sidebar;
357 const make = async (kind: FolioKind) => {
358 setError(null);
359 const made = await foliosRequest<{ path: string }>(slug, "create", { folio: { kind } });
360 if (made.ok) {
361 void revalidate();
362 navigate(made.value.path);
363 } else setError(made.error.message);
364 };
365 return (
366 <div className="flex h-full flex-col">
367 <div className="flex h-9 shrink-0 items-center gap-1 pr-1 pl-3">
368 <h2 className="min-w-0 grow truncate text-xs font-medium text-faint">Artifacts</h2>
369 <NewMenu onMake={make}>
370 <Button type="button" aria-label="New" variant="ghost" className="h-8 gap-0.5 px-1.5 text-faint">
371 <Plus size={16} />
372 <ChevronDown size={12} />
373 </Button>
374 </NewMenu>
375 {onClose && (
376 <Button type="button" aria-label="Close menu" onClick={onClose} variant="ghost" size="icon-sm" className="text-faint">
377 <X size={16} />
378 </Button>
379 )}
380 </div>
381 <nav aria-label="Artifacts" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
382 <form
383 role="search"
384 onSubmit={(e) => {
385 e.preventDefault();
386 navigate(`/${slug}/-/artifacts?q=${encodeURIComponent(query.trim())}`);
387 }}
388 className="mb-2"
389 >
390 <label className="flex h-8 items-center gap-2 rounded-md border border-line bg-bg px-2 text-[0.8125rem] text-faint focus-within:border-accent/60">
391 <Search size={14} aria-hidden="true" />
392 <span className="sr-only">Search artifacts</span>
393 <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search artifacts" className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" />
394 </label>
395 </form>
396 {error && <p className="mb-2 px-2 text-xs text-danger">{error}</p>}
397 <div className="space-y-px">
398 <SideLink to={`/${slug}/-/artifacts`} end icon={<Home size={15} />}>
399 Home
400 </SideLink>
401 <SideLink to={`/${slug}/-/artifacts/templates`} icon={<LayoutTemplate size={15} />}>
402 Templates
403 </SideLink>
404 </div>
405 {sidebar === undefined || sidebar === null ? (
406 sidebar === null ? (
407 <p className="mt-4 px-2 text-xs leading-relaxed text-faint">Artifacts didn&apos;t answer. Your spaces will show here once it does.</p>
408 ) : (
409 <div className="mt-5 space-y-3 px-2" aria-busy="true">
410 {[0, 1, 2, 3].map((i) => (
411 <Skeleton key={i} className="h-3 w-36" />
412 ))}
413 </div>
414 )
415 ) : (
416 <>
417 {sidebar.favorites.length > 0 && (
418 <Section title="Favorites">
419 <ul className="space-y-px">
420 {sidebar.favorites.map((f) => (
421 <RefRow key={f.id} folio={f} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />} />
422 ))}
423 </ul>
424 </Section>
425 )}
426 <Section
427 title="Spaces"
428 action={
429 <DropdownMenu>
430 <Hint label="Browse or make spaces">
431 <DropdownMenuTrigger asChild>
432 <Button type="button" aria-label="Browse or make spaces" variant="ghost" size="icon" className="size-6 rounded text-faint">
433 <Plus size={13} />
434 </Button>
435 </DropdownMenuTrigger>
436 </Hint>
437 <DropdownMenuContent align="end" className="w-44">
438 <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces`)}>
439 <Compass size={14} /> Browse spaces
440 </DropdownMenuItem>
441 <DropdownMenuSeparator />
442 <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces/new`)}>
443 <Plus size={14} /> New space
444 </DropdownMenuItem>
445 </DropdownMenuContent>
446 </DropdownMenu>
447 }
448 >
449 <ul>
450 {sidebar.spaces.map((space) => (
451 <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} />
452 ))}
453 {sidebar.spaces.length === 0 && <li className="px-2 py-1 text-xs text-faint">No spaces yet.</li>}
454 </ul>
455 </Section>
456 <Section
457 title="Private"
458 action={
459 <Hint label="New private doc">
460 <Button type="button" onClick={() => make("doc")} aria-label="New private doc" variant="ghost" size="icon" className="size-6 rounded text-faint">
461 <Plus size={13} />
462 </Button>
463 </Hint>
464 }
465 >
466 <div
467 onDragOver={(e) => {
468 if (drag) e.preventDefault();
469 }}
470 onDrop={async (e) => {
471 // Dropped on Private's empty space: to its top, at the end.
472 if (e.target !== e.currentTarget || !drag) return;
473 e.preventDefault();
474 const id = drag.id;
475 setDrag(null);
476 const moved = await foliosRequest(slug, "move", { folio_id: id, move: { space_id: null, parent_id: null, before_id: null } });
477 if (moved.ok) void revalidate();
478 else setError(moved.error.message);
479 }}
480 >
481 <Tree slug={slug} place={{ space_id: null, key: "private", editable: true }} nodes={sidebar.private_tree} current={current} drag={drag} setDrag={setDrag} empty="Only you can see what's here." />
482 </div>
483 </Section>
484 {sidebar.shared.length > 0 && (
485 <Section title="Shared">
486 <ul className="space-y-px">
487 {sidebar.shared.map((f) => (
488 <RefRow key={f.id} folio={f} />
489 ))}
490 </ul>
491 </Section>
492 )}
493 <div className="mt-4 space-y-px">
494 {!!sidebar.stale_count && (
495 <SideLink to={`/${slug}/-/artifacts/stale`} icon={<AlertTriangle size={15} className="text-warn" />} trailing={<span className="text-[0.6875rem] text-warn tabular-nums">{sidebar.stale_count}</span>}>
496 Possibly out of date
497 </SideLink>
498 )}
499 </div>
500 <Section
501 title="Projects' docs"
502 open={(sidebar.repos ?? []).length > 0}
503 action={
504 <Hint label="Show a project's docs">
505 <Button type="button" onClick={() => setAddingRepo(true)} aria-label="Show a project's docs" variant="ghost" size="icon" className="size-6 rounded text-faint">
506 <Plus size={13} />
507 </Button>
508 </Hint>
509 }
510 >
511 <ul>
512 {(sidebar.repos ?? []).map((space) => (
513 <RepoTree key={space.id} slug={slug} space={space} />
514 ))}
515 {(sidebar.repos ?? []).length === 0 && (
516 <li>
517 <button type="button" onClick={() => setAddingRepo(true)} className="px-2 py-1 text-left text-xs text-faint hover:text-fg">
518 Show a repository&apos;s docs folder here, read-only.
519 </button>
520 </li>
521 )}
522 </ul>
523 </Section>
524 <div className="mt-4 space-y-px">
525 <SideLink to={`/${slug}/-/artifacts/trash`} icon={<Trash2 size={15} />} trailing={sidebar.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined}>
526 Trash
527 </SideLink>
528 </div>
529 <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(sidebar.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} />
530 </>
531 )}
532 </nav>
533 </div>
534 );
535}