Skip to content
534 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.1/**
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.2 * Artifacts mode's sidebar (beside the rail; docs.g1t.sh/guides/artifacts/,
3 * "The sidebar"): search, Home, New and Templates; Favorites; the spaces
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.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">
g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.366 <div className="flex h-9 shrink-0 items-center gap-1 pr-1 pl-3">
367 <h2 className="min-w-0 grow truncate text-xs font-medium text-faint">Artifacts</h2>
Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.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}