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.

487 linesCodeBlameRaw
1/**
2 * A doc's editor: BlockNote on the folio's live Yjs document, with g1t's
3 * own blocks (./blocks.tsx), live cursors and presence, comments anchored
4 * to text, and agents' suggestions marked in place. Loaded lazily and
5 * only in the browser (./body.tsx), so the page's server render stays
6 * light: until it loads, the reader sees the doc's Markdown.
7 */
8import "@blocknote/shadcn/style.css";
9
10import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core";
11import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments";
12import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs";
13import { syntaxHighlighter } from "@blocknote/code-block";
14import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
15import { BlockNoteView } from "@blocknote/shadcn";
16import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, Result } from "@g1t/contracts";
17import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
18import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
19
20import { canDo, cursorColour } from "../../../lib/folios";
21import { useDrawnTheme } from "../../../lib/theme";
22import { schema, type DocEditorInstance } from "./blocks";
23import { CiteDialog } from "./code";
24import { EditorSkeleton } from "./editor-skeleton";
25import type { PageThread } from "./page-parts";
26import type { FolioProvider } from "../provider";
27import type { Presence } from "../shell";
28import { whoAre } from "../who";
29
30/** Someone who can be mentioned: a person, by username. */
31export type DocMentionable = { kind: "user"; id: string; name: string; display_name: string; avatar: string | null };
32
33export type DocEditorProps = {
34 slug: string;
35 folioId: string;
36 /** The folio's live document, opened by the page as it hydrated (routes/workspace/folios/folio.tsx). */
37 provider: FolioProvider;
38 role: DocRole;
39 me: { key: string; name: string; display_name: string; avatar: string | null };
40 mentionables: DocMentionable[];
41 usercontent: string;
42 suggestions: FolioSuggestion[];
43 showComments: boolean;
44 onPresence?: (people: Presence[]) => void;
45 /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
46 renderSuggestion?: (suggestion: FolioSuggestion) => ReactNode;
47 /** Comments on the whole doc (not on a passage), live from the document. */
48 onPageThreads?: (threads: PageThread[]) => void;
49 /** The projects the doc is about: Cite code offers them first. */
50 projects?: string[];
51};
52
53/** Nobody can do anything with threads: a reader's view. */
54class ReadOnlyAuth extends ThreadStoreAuth {
55 canCreateThread() {
56 return false;
57 }
58 canAddComment(_t: ThreadData) {
59 return false;
60 }
61 canUpdateComment(_c: CommentData) {
62 return false;
63 }
64 canDeleteComment(_c: CommentData) {
65 return false;
66 }
67 canDeleteThread(_t: ThreadData) {
68 return false;
69 }
70 canResolveThread(_t: ThreadData) {
71 return false;
72 }
73 canUnresolveThread(_t: ThreadData) {
74 return false;
75 }
76 canAddReaction(_c: CommentData) {
77 return false;
78 }
79 canDeleteReaction(_c: CommentData) {
80 return false;
81 }
82}
83
84export default function DocEditor(props: DocEditorProps) {
85 const { provider } = props;
86 // A document the page carried is here already: the editor opens on it
87 // now, and the room's answer (what changed since the save) merges in
88 // as it arrives. Without one, the editor waits for the room's copy, so
89 // it never starts from an empty document of its own; offline, after a
90 // moment, from what we have.
91 const [ready, setReady] = useState(provider.seeded || provider.status === "synced");
92 useEffect(() => {
93 if (provider.seeded) return;
94 const fallback = setTimeout(() => setReady(true), 4000);
95 const off = provider.onStatus((status) => {
96 if (status === "synced") setReady(true);
97 });
98 return () => {
99 clearTimeout(fallback);
100 off();
101 };
102 }, [provider]);
103
104 if (!ready) return <EditorSkeleton />;
105 return <LiveEditor {...props} />;
106}
107
108function LiveEditor({
109 slug,
110 folioId,
111 role,
112 me,
113 mentionables,
114 usercontent,
115 suggestions,
116 showComments,
117 onPresence,
118 renderSuggestion,
119 onPageThreads,
120 projects,
121 provider,
122}: DocEditorProps) {
123 const editable = canDo(role, "edit");
124 // BlockNote's own parts follow the page's Appearance (lib/theme.ts).
125 const theme = useDrawnTheme();
126 const [citing, setCiting] = useState(false);
127 const colour = cursorColour(me.name);
128 // Below 1280px the comments sit under the page: opening them goes there.
129 const comments = useRef<HTMLElement>(null);
130 useEffect(() => {
131 if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" });
132 }, [showComments]);
133
134 const threadStore = useMemo(() => {
135 const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
136 return new RESTYjsThreadStore(`/${slug}/-/artifacts/threads/${folioId}`, {}, provider.doc.getMap("threads"), auth);
137 }, [slug, folioId, role, me.key, editable, provider]);
138
139 const editor = useCreateBlockNote(
140 withCollaboration({
141 schema,
142 collaboration: {
143 fragment: provider.doc.getXmlFragment("document-store"),
144 user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" },
145 provider,
146 showCursorLabels: "activity",
147 },
148 extensions: [
149 CommentsExtension({
150 threadStore,
151 resolveUsers: async (ids: string[]) => {
152 const found = await whoAre(slug, ids);
153 return ids.map((id) => {
154 const p = found.get(id);
155 const avatar = id === me.key ? me.avatar : (p?.avatar ?? null);
156 return {
157 id,
158 username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"),
159 avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "",
160 };
161 });
162 },
163 }),
164 syntaxHighlighter,
165 ],
166 uploadFile: async (file: File) => {
167 const response = await fetch(`/${slug}/-/artifacts/upload?folio=${encodeURIComponent(folioId)}&name=${encodeURIComponent(file.name)}`, {
168 method: "POST",
169 headers: { "content-type": file.type || "application/octet-stream" },
170 body: file,
171 });
172 const result = (await response.json()) as Result<DocFile>;
173 if (!result.ok) throw new Error(result.error.message);
174 return `${usercontent}${result.value.url}`;
175 },
176 }),
177 [provider, threadStore],
178 ) as unknown as DocEditorInstance;
179
180 // Who is here: everyone's awareness state, people and agents.
181 useEffect(() => {
182 const read = () => {
183 const out: Presence[] = [];
184 provider.awareness.getStates().forEach((state, client) => {
185 const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user;
186 if (!user?.name) return;
187 out.push({
188 client,
189 key: user.key ?? String(client),
190 name: user.name,
191 kind: user.kind === "agent" ? "agent" : "user",
192 color: user.color ?? "#b8a6ff",
193 avatar: user.avatar || null,
194 me: client === provider.doc.clientID,
195 });
196 });
197 // One face per person, however many tabs they have open.
198 const seen = new Set<string>();
199 onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key)));
200 };
201 read();
202 provider.awareness.on("change", read);
203 return () => provider.awareness.off("change", read);
204 }, [provider, onPresence]);
205
206 // Comments on the whole doc, as the document holds them.
207 useEffect(() => {
208 if (!onPageThreads) return;
209 const map = provider.doc.getMap("threads");
210 const read = () => {
211 const out: PageThread[] = [];
212 map.forEach((value) => {
213 const t = value as { get(key: string): unknown };
214 if (typeof t?.get !== "function" || t.get("deletedAt")) return;
215 const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {};
216 if (!meta.page_level) return;
217 const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? [];
218 out.push({
219 id: String(t.get("id")),
220 resolved: !!t.get("resolved"),
221 comments: comments
222 .filter((c) => !c.get("deletedAt"))
223 .map((c) => ({
224 id: String(c.get("id")),
225 author: String(c.get("userId")),
226 text: commentText(c.get("body")),
227 created_at: Number(c.get("createdAt")) || 0,
228 })),
229 });
230 });
231 onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0)));
232 };
233 read();
234 map.observeDeep(read);
235 return () => map.unobserveDeep(read);
236 }, [provider, onPageThreads]);
237
238 // `[[` links an artifact, as `@` mentions someone.
239 const searchPages = async (query: string) => {
240 try {
241 const r = await fetch(`/${slug}/-/artifacts/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } });
242 const result = (await r.json()) as Result<FolioSearchHit[]>;
243 return result.ok ? result.value : [];
244 } catch {
245 return [];
246 }
247 };
248
249 const slashItems = (query: string) => {
250 const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never);
251 const custom = [
252 {
253 title: "Info callout",
254 subtext: "A note set apart from the text",
255 aliases: ["callout", "note", "info"],
256 group: "Callouts",
257 icon: <Info size={18} />,
258 onItemClick: insert({ type: "callout", props: { kind: "info" } }),
259 },
260 {
261 title: "Warning callout",
262 subtext: "Something to be careful about",
263 aliases: ["callout", "warning", "caution"],
264 group: "Callouts",
265 icon: <AlertTriangle size={18} />,
266 onItemClick: insert({ type: "callout", props: { kind: "warning" } }),
267 },
268 {
269 title: "Success callout",
270 subtext: "A tip, or what good looks like",
271 aliases: ["callout", "success", "tip"],
272 group: "Callouts",
273 icon: <CheckCircle2 size={18} />,
274 onItemClick: insert({ type: "callout", props: { kind: "success" } }),
275 },
276 {
277 title: "Diagram",
278 subtext: "A Mermaid diagram, drawn as you type",
279 aliases: ["mermaid", "flowchart", "sequence", "chart"],
280 group: "Advanced",
281 icon: <Workflow size={18} />,
282 onItemClick: insert({ type: "mermaid" }),
283 },
284 {
285 title: "Math",
286 subtext: "A formula, typeset with KaTeX",
287 aliases: ["math", "latex", "katex", "equation", "formula"],
288 group: "Advanced",
289 icon: <Sigma size={18} />,
290 onItemClick: insert({ type: "math" }),
291 },
292 {
293 title: "Embed from g1t",
294 subtext: "A work item, pull request, channel, project or artifact, live",
295 aliases: ["embed", "item", "work", "pull", "pr", "channel", "project"],
296 group: "g1t",
297 icon: <GitPullRequest size={18} />,
298 onItemClick: insert({ type: "embed" }),
299 },
300 {
301 title: "Mention",
302 subtext: "A person",
303 aliases: ["mention", "person", "@"],
304 group: "g1t",
305 icon: <AtSign size={18} />,
306 onItemClick: () => editor.insertInlineContent("@" as never),
307 },
308 {
309 title: "Link to an artifact",
310 subtext: "A doc or another artifact",
311 aliases: ["link", "page", "artifact", "doc", "[["],
312 group: "g1t",
313 icon: <Link2 size={18} />,
314 onItemClick: () => editor.insertInlineContent("[[" as never),
315 },
316 {
317 title: "Cite code",
318 subtext: "A file, folder, symbol, endpoint or variable this doc describes",
319 aliases: ["cite", "code", "citation", "path", "file", "symbol", "endpoint", "env", "variable"],
320 group: "g1t",
321 icon: <FileCode2 size={18} />,
322 onItemClick: () => setCiting(true),
323 },
324 {
325 title: "Date",
326 subtext: "Today's date, which you can change",
327 aliases: ["date", "today", "when"],
328 group: "g1t",
329 icon: <Calendar size={18} />,
330 onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never),
331 },
332 ];
333 return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query);
334 };
335
336 const mentionItems = (query: string) => {
337 const q = query.toLowerCase();
338 return mentionables
339 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
340 .slice(0, 8)
341 .map((p) => ({
342 title: p.display_name,
343 subtext: `@${p.name}`,
344 icon: <AtSign size={16} />,
345 onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never),
346 }));
347 };
348
349 const pageItems = async (query: string) => {
350 const pages = await searchPages(query);
351 return pages.slice(0, 8).map((p) => ({
352 title: p.title || "Untitled",
353 subtext: p.space_name ?? "Private",
354 icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />,
355 onItemClick: () =>
356 editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never),
357 }));
358 };
359
360 // Suggestions: the blocks each one changes are struck through in place.
361 const struck = suggestions
362 .flatMap((s) => s.block_ids)
363 .filter((id) => /^[0-9a-zA-Z-]+$/.test(id))
364 .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`)
365 .join(",\n");
366
367 return (
368 <div className="g1t-editor relative">
369 {struck && (
370 <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style>
371 )}
372 <BlockNoteView
373 editor={editor as never}
374 editable={editable}
375 theme={theme}
376 renderEditor={false}
377 slashMenu={false}
378 comments={canDo(role, "view")}
379 className="g1t-bn"
380 >
381 <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}>
382 <div className="relative min-w-0">
383 <BlockNoteViewEditor />
384 {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />}
385 </div>
386 {showComments && (
387 <aside ref={comments} aria-label="Comments" className="scroll-mt-20 max-xl:border-t max-xl:border-line max-xl:pt-6">
388 <div className="xl:sticky xl:top-[calc(var(--topbar-h)+1.5rem)] xl:max-h-[calc(100dvh-var(--topbar-h)-2.5rem)] xl:overflow-y-auto xl:scrollbar-thin">
389 <ThreadsSidebar filter="all" sort="position" />
390 </div>
391 </aside>
392 )}
393 </div>
394 <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} />
395 <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} />
396 <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} />
397 </BlockNoteView>
398 {editable && (
399 <CiteDialog
400 slug={slug}
401 open={citing}
402 onOpenChange={setCiting}
403 projects={projects}
404 onCite={(c) => {
405 editor.focus();
406 editor.insertInlineContent([{ type: "citation", props: c }, " "] as never);
407 }}
408 />
409 )}
410 </div>
411 );
412}
413
414/**
415 * Each suggestion beside the blocks it changes, in the margin on a wide
416 * screen (the doc lists them above the editor on a narrow one).
417 */
418function SuggestionRail({
419 editor,
420 suggestions,
421 render,
422 hidden,
423}: {
424 editor: DocEditorInstance;
425 suggestions: FolioSuggestion[];
426 render: (s: FolioSuggestion) => ReactNode;
427 hidden: boolean;
428}) {
429 const box = useRef<HTMLDivElement>(null);
430 const [tops, setTops] = useState<Record<string, number>>({});
431 useLayoutEffect(() => {
432 const place = () => {
433 const root = box.current?.parentElement;
434 if (!root) return;
435 const base = root.getBoundingClientRect().top;
436 const next: Record<string, number> = {};
437 let floor = 0;
438 for (const s of suggestions) {
439 const first = s.block_ids[0];
440 const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null;
441 const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40;
442 // Cards never overlap: each starts below the one before.
443 const top = Math.max(want, floor);
444 next[s.id] = top;
445 floor = top + 190;
446 }
447 setTops(next);
448 };
449 place();
450 const off = editor.onChange(() => requestAnimationFrame(place));
451 const observer = new ResizeObserver(place);
452 if (box.current?.parentElement) observer.observe(box.current.parentElement);
453 return () => {
454 off?.();
455 observer.disconnect();
456 };
457 }, [editor, suggestions]);
458 if (hidden || !suggestions.length) return null;
459 return (
460 <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions">
461 {suggestions.map((s) => (
462 <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}>
463 {render(s)}
464 </div>
465 ))}
466 </div>
467 );
468}
469
470/** A comment's text: its blocks' inline text, mentions as @name. */
471function commentText(body: unknown): string {
472 const out: string[] = [];
473 const walk = (v: unknown) => {
474 if (Array.isArray(v)) v.forEach(walk);
475 else if (v && typeof v === "object") {
476 const o = v as Record<string, unknown>;
477 if (o.type === "text" && typeof o.text === "string") out.push(o.text);
478 else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`);
479 else {
480 walk(o.content);
481 walk(o.children);
482 }
483 }
484 };
485 walk(body);
486 return out.join("");
487}