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.

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