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