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