Skip to content
324 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 * Share an artifact (docs.g1t.sh/guides/artifacts/, "Sharing"): invite people,
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.3 * agents and teams with a role, see everyone who has access and where it
4 * comes from, choose whether it follows its space or parent or only
5 * people invited, open it to the workspace or to anyone in it with the
6 * link, and say how agents may change it. People who can't share see it
7 * read-only.
8 */
9import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, FOLIO_GENERAL_ROLES, type DocRole, type Folio, type FolioAccessChange, type FolioAccessList, type FolioGeneralAccess, type FolioGeneralRole } from "@g1t/contracts";
10import { Check, Globe, Link2, Lock, Users } from "lucide-react";
11import { type ReactNode, useEffect, useMemo, useState } from "react";
12import { Link } from "react-router";
13
14import { spacePath } from "../../lib/folios";
15import { Button, ErrorText } from "../ui";
16import { Combobox } from "../ui/combobox";
17import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
18import { Hint } from "../ui/hint";
19import { SelectField } from "../ui/select";
20import { Skeleton } from "../ui/skeleton";
21import { foliosQuery, foliosRequest, useFoliosData } from "./actions";
22import { Face } from "./parts";
23
24const ROLES: DocRole[] = ["view", "comment", "edit", "manage"];
25
26type Candidate = { value: string; label: string; kind: "user" | "agent" | "team"; name: string };
27
28export function ShareDialog({ slug, folio, open, onOpenChange, onChanged }: { slug: string; folio: Pick<Folio, "id" | "title" | "path" | "space" | "parent_id" | "viewer_role">; open: boolean; onOpenChange: (o: boolean) => void; onChanged?: () => void }) {
29 const layout = useFoliosData();
30 const [access, setAccess] = useState<FolioAccessList | null>(null);
31 const [teams, setTeams] = useState<{ slug: string; name: string }[]>([]);
32 const [error, setError] = useState<string | null>(null);
33 const [saved, setSaved] = useState(false);
34 const [busy, setBusy] = useState(false);
35 const [who, setWho] = useState("");
36 const [role, setRole] = useState<DocRole>("edit");
37 const [note, setNote] = useState("");
38 const [copied, setCopied] = useState(false);
39
40 useEffect(() => {
41 if (!open) return;
42 setAccess(null);
43 setError(null);
44 setSaved(false);
45 void foliosQuery<FolioAccessList>(slug, { access: folio.id }).then((r) => (r.ok ? setAccess(r.value) : setError(r.error.message)));
46 void foliosQuery<{ slug: string; name: string }[]>(slug, { teams: "1" }).then((r) => r.ok && setTeams(r.value));
47 }, [open, slug, folio.id]);
48
49 const change = async (body: FolioAccessChange) => {
50 setBusy(true);
51 setError(null);
52 setSaved(false);
53 const done = await foliosRequest<FolioAccessList>(slug, "access", { folio_id: folio.id, access: body });
54 setBusy(false);
55 if (!done.ok) {
56 setError(done.error.message);
57 return false;
58 }
59 setAccess(done.value);
60 setSaved(true);
61 onChanged?.();
62 return true;
63 };
64
65 const manager = folio.viewer_role === "manage";
66 const canShare = !!access?.can_share;
67 const listed = new Set([...(access?.rows ?? []).map((r) => r.principal), access ? `user:${access.owner.id}` : ""]);
68 const candidates: Candidate[] = useMemo(
69 () => [
70 ...(layout?.mentionables ?? []).map((m): Candidate => (m.kind === "agent" ? { value: `agent:${m.id}`, label: `${m.display_name} (agent)`, kind: "agent", name: m.name } : { value: `username:${m.name}`, label: `${m.display_name} (@${m.name})`, kind: "user", name: m.name })),
71 ...teams.map((t): Candidate => ({ value: `team:${t.slug}`, label: `${t.name} (team)`, kind: "team", name: t.slug })),
72 ],
73 [layout?.mentionables, teams],
74 );
75 const usernameOf = new Map((access?.rows ?? []).filter((r) => r.profile.kind === "user").map((r) => [r.profile.name, r.principal]));
76 const offered = candidates.filter((c) => !listed.has(c.value) && !(c.kind === "user" && (usernameOf.has(c.name) || access?.owner.name === c.name)));
77
78 const invite = async () => {
79 if (!who) return;
80 const principal = who.startsWith("username:") ? await memberKey(slug, who.slice(9)) : who;
81 if (!principal) return setError("That person isn't in the workspace.");
82 if (await change({ op: "grant", principal, role, notify: note.trim() || null })) {
83 setWho("");
84 setNote("");
85 }
86 };
87
88 const root = !folio.parent_id || access?.inherit === false;
89 const general: FolioGeneralAccess = access?.general_access ?? "none";
90 const generalRole: FolioGeneralRole = access?.general_role ?? "view";
91 const roleOptions = ROLES.filter((r) => manager || r !== "manage").map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }));
92
93 return (
94 <Dialog open={open} onOpenChange={onOpenChange}>
95 <DialogContent className="max-w-xl">
96 <DialogHeader>
97 <DialogTitle className="truncate pr-6">Share “{folio.title || "Untitled"}”</DialogTitle>
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.98 <DialogDescription>{canShare ? "People you add get a notification with a link." : `Only people with full access can change who can open it. Ask ${access?.owner.display_name ?? "its owner"} to share it.`}</DialogDescription>
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.99 </DialogHeader>
100
101 {canShare && (
102 <div className="space-y-2">
103 <div className="flex flex-wrap gap-2">
104 <Combobox options={offered.map((c) => ({ value: c.value, label: c.label }))} value={who} onValueChange={setWho} placeholder="Add people, agents or teams" searchPlaceholder="Search the workspace" emptyText="Nobody else to add." className="h-9 min-w-0 grow basis-56" />
105 <SelectField aria-label="Their role" value={role} onValueChange={(r) => setRole(r as DocRole)} options={roleOptions} className="h-9 w-36" />
106 </div>
107 {who && (
108 <div className="flex gap-2">
109 <input aria-label="Message" placeholder="Add a message (optional)" value={note} onChange={(e) => setNote(e.target.value)} maxLength={2000} className="h-9 min-w-0 grow rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
110 <Button type="button" variant="accent" disabled={busy} onClick={invite}>
111 Invite
112 </Button>
113 </div>
114 )}
115 </div>
116 )}
117
118 <section className="mt-2">
119 <h3 className="mb-1.5 flex items-center gap-2 text-xs font-medium text-faint">
120 Who has access
121 {saved && (
122 <span className="inline-flex items-center gap-1 text-success">
123 <Check size={12} /> Updated
124 </span>
125 )}
126 </h3>
127 {!access ? (
128 <div className="space-y-2 py-1" aria-busy="true">
129 <Skeleton className="h-6 w-56" />
130 <Skeleton className="h-6 w-44" />
131 </div>
132 ) : (
133 <ul className="max-h-64 space-y-0.5 overflow-y-auto [scrollbar-width:thin]">
134 <Row face={<Face who={access.owner} size={26} />} name={access.owner.display_name} sub={`@${access.owner.name}`} end={<span className="text-xs text-faint">Owner</span>} />
135 {access.rows.map((r) => {
136 const face = r.profile.kind === "team" ? <TeamFace /> : <Face who={r.profile} size={26} />;
137 const sub = r.profile.kind === "team" ? "Team" : r.profile.kind === "agent" ? "Agent" : `@${r.profile.name}`;
138 if (r.source.kind === "grant") {
139 const editable = canShare && (manager || r.role !== "manage");
140 return (
141 <Row
142 key={r.principal}
143 face={face}
144 name={r.profile.display_name}
145 sub={sub}
146 end={
147 editable ? (
148 <SelectField
149 aria-label={`${r.profile.display_name}'s access`}
150 value={r.role}
151 onValueChange={(v) => (v === "remove" ? change({ op: "revoke", principal: r.principal }) : change({ op: "grant", principal: r.principal, role: v as DocRole }))}
152 options={[...roleOptions, { value: "remove", label: "Remove access" }]}
153 className="h-8 w-36"
154 />
155 ) : (
156 <span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span>
157 )
158 }
159 />
160 );
161 }
162 const from = r.source;
163 return (
164 <Row
165 key={r.principal}
166 face={face}
167 name={r.profile.display_name}
168 sub={
169 <>
170 From{" "}
171 <Link to={from.kind === "folio" ? from.path : `/${slug}/-/artifacts`} onClick={() => onOpenChange(false)} className="text-muted hover:text-fg hover:underline">
172 {from.kind === "folio" ? from.title : from.kind === "space" ? from.name : "its owner"}
173 </Link>
174 </>
175 }
176 end={<span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span>}
177 />
178 );
179 })}
180 </ul>
181 )}
182 </section>
183
184 {access && (
185 <section className="mt-2 space-y-3 border-t border-line pt-4">
186 <h3 className="text-xs font-medium text-faint">General access</h3>
187 {(folio.space || folio.parent_id) && (
188 <div className="flex items-start gap-3">
189 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{access.inherit ? <Users size={15} /> : <Lock size={15} />}</span>
190 <div className="min-w-0 grow">
191 <SelectField
192 aria-label="Follows its space or parent"
193 disabled={!manager || busy}
194 value={access.inherit ? "inherit" : "restrict"}
195 onValueChange={(v) => change({ op: "inherit", inherit: v === "inherit" })}
196 options={[
197 { value: "inherit", label: access.inherited_from ? `Everyone in ${access.inherited_from.name}` : folio.space ? `Everyone in ${folio.space.name}` : "Follows the doc it's in" },
198 { value: "restrict", label: "Only people invited" },
199 ]}
200 className="h-8 w-full max-w-xs"
201 />
202 <p className="mt-1 text-xs text-faint">
203 {access.inherit ? "Whoever can open " : "Access stops here: whoever can open "}
204 {folio.parent_id ? "the doc it's in" : folio.space ? <Link to={spacePath(slug, folio.space.slug)} className="hover:text-fg hover:underline">{folio.space.name}</Link> : "its space"}
205 {access.inherit ? " can open this, with the same role." : " can't open this unless invited."}
206 </p>
207 </div>
208 </div>
209 )}
210 {root ? (
211 <div className="flex items-start gap-3">
212 <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{general === "workspace" ? <Globe size={15} /> : general === "link" ? <Link2 size={15} /> : <Lock size={15} />}</span>
213 <div className="flex min-w-0 grow flex-wrap items-center gap-2">
214 <SelectField
215 aria-label="General access"
216 disabled={!manager || busy}
217 value={general}
218 onValueChange={(v) => change(v === "none" ? { op: "general", access: "none", role: null } : { op: "general", access: v as FolioGeneralAccess, role: generalRole })}
219 options={[
220 { value: "none", label: "Restricted" },
221 { value: "workspace", label: `Everyone in ${layout?.slug ?? "the workspace"}` },
222 { value: "link", label: "Anyone in the workspace with the link" },
223 ]}
224 className="h-8 min-w-0 grow basis-56"
225 />
226 {general !== "none" && (
227 <SelectField
228 aria-label="Their role"
229 disabled={!manager || busy}
230 value={generalRole}
231 onValueChange={(v) => change({ op: "general", access: general, role: v as FolioGeneralRole })}
232 options={FOLIO_GENERAL_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }))}
233 className="h-8 w-36"
234 />
235 )}
236 <p className="w-full text-xs text-faint">
237 {general === "none" ? "Only the people above can open it." : general === "workspace" ? "Every member can find and open it." : "Members who open the link can. It isn't listed or searchable until they do."}
238 </p>
239 </div>
240 </div>
241 ) : (
242 <p className="text-xs text-faint">It follows the doc it&apos;s in. Change who else can open it there, or choose &ldquo;Only people invited&rdquo;.</p>
243 )}
244 <div className="flex items-center gap-3">
245 <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-faint">
246 <Globe size={15} />
247 </span>
248 <Hint label="Links for people outside the workspace aren't available yet.">
249 <span className="text-sm text-faint" tabIndex={0}>
250 Public link: off for this workspace
251 </span>
252 </Hint>
253 </div>
254 <div className="flex flex-wrap items-center gap-2">
255 <span className="text-sm text-muted">Agents may</span>
256 <SelectField
257 aria-label="How agents change it"
258 disabled={!manager || busy}
259 value={access.agent_mode ?? "default"}
260 onValueChange={(v) => change({ op: "agent_mode", agent_mode: v === "default" ? null : (v as "suggest" | "edit") })}
261 options={[
262 { value: "default", label: folio.space ? `As ${folio.space.name} says` : "Suggest changes (default)" },
263 { value: "suggest", label: DOC_AGENT_MODE_LABELS.suggest },
264 { value: "edit", label: DOC_AGENT_MODE_LABELS.edit },
265 ]}
266 className="h-8 w-52"
267 />
268 </div>
269 </section>
270 )}
271
272 {error && <ErrorText>{error}</ErrorText>}
273
274 <div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-4">
275 <Button
276 type="button"
277 variant="quiet"
278 onClick={async () => {
279 try {
280 await navigator.clipboard.writeText(`${window.location.origin}${folio.path}`);
281 setCopied(true);
282 setTimeout(() => setCopied(false), 1600);
283 } catch {
284 setError("Your browser didn't allow copying.");
285 }
286 }}
287 >
288 {copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? "Copied" : "Copy link"}
289 </Button>
290 <Button type="button" variant="accent" onClick={() => onOpenChange(false)}>
291 Done
292 </Button>
293 </div>
294 </DialogContent>
295 </Dialog>
296 );
297}
298
299function Row({ face, name, sub, end }: { face: ReactNode; name: string; sub: ReactNode; end: ReactNode }) {
300 return (
301 <li className="flex items-center gap-3 rounded-md px-1 py-1.5">
302 {face}
303 <span className="min-w-0 grow">
304 <span className="block truncate text-sm text-fg">{name}</span>
305 <span className="block truncate text-xs text-faint">{sub}</span>
306 </span>
307 {end}
308 </li>
309 );
310}
311
312function TeamFace() {
313 return (
314 <span className="flex size-[26px] shrink-0 items-center justify-center rounded-full bg-raised text-muted">
315 <Users size={13} />
316 </span>
317 );
318}
319
320/** A person's member key (`user:<id>`), from their username, as the site resolves it. */
321export async function memberKey(slug: string, username: string): Promise<string | null> {
322 const found = await foliosQuery<string>(slug, { person: username });
323 return found.ok ? found.value : null;
324}