@agent-native/core-corpus 0.212.0 → 0.213.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/onboarding-journeys/SKILL.md +29 -14
  3. package/corpus/templates/analytics/CHANGELOG.md +11 -0
  4. package/corpus/templates/analytics/actions/estimate-onboarding-journey-cost.ts +23 -6
  5. package/corpus/templates/analytics/actions/get-onboarding-journey.ts +20 -5
  6. package/corpus/templates/analytics/actions/provider-api-register.ts +2 -7
  7. package/corpus/templates/analytics/changelog/2026-10-21-analytics-recognizes-questions-about-connected-data-product-.md +6 -0
  8. package/corpus/templates/analytics/changelog/2026-10-21-onboarding-journeys-now-honor-larger-event-row-budgets.md +6 -0
  9. package/corpus/templates/analytics/changelog/2026-10-21-preserve-unknown-replay-availability-in-partial-journeys.md +6 -0
  10. package/corpus/templates/analytics/package.json +6 -0
  11. package/corpus/templates/analytics/scripts/journey-capture-plan.ts +18 -7
  12. package/corpus/templates/analytics/scripts/journey-capture.ts +3 -1
  13. package/corpus/templates/analytics/server/lib/db-admin-connections.ts +19 -12
  14. package/corpus/templates/analytics/server/lib/journey-tree.ts +24 -9
  15. package/corpus/templates/analytics/server/lib/onboarding-journey-cost.ts +163 -94
  16. package/corpus/templates/analytics/server/lib/onboarding-journey.ts +162 -74
  17. package/corpus/templates/assets/server/lib/org-admin.ts +20 -16
  18. package/corpus/templates/brain/server/lib/zoom-governance.ts +11 -6
  19. package/corpus/templates/clips/CHANGELOG.md +1 -0
  20. package/corpus/templates/clips/changelog/2026-10-16-rewind-status-says-when-it-remembers-audio.md +6 -0
  21. package/corpus/templates/clips/desktop/src/app.tsx +7 -4
  22. package/corpus/templates/clips/desktop/src/components/RewindCaptureStatus.tsx +25 -0
  23. package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +6 -1
  24. package/corpus/templates/clips/desktop/src/shared/config.ts +3 -0
  25. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2 -0
  26. package/corpus/templates/clips/server/lib/recordings.ts +4 -19
  27. package/corpus/templates/community/qr/package.json +1 -1
  28. package/corpus/templates/content/CHANGELOG.md +8 -0
  29. package/corpus/templates/content/actions/_content-space-access.ts +95 -6
  30. package/corpus/templates/content/actions/_database-utils.ts +20 -16
  31. package/corpus/templates/content/actions/_github-source-freshness.ts +72 -17
  32. package/corpus/templates/content/actions/_github-source-metadata.ts +63 -17
  33. package/corpus/templates/content/actions/_github-source.ts +141 -77
  34. package/corpus/templates/content/actions/check-github-connection.ts +15 -0
  35. package/corpus/templates/content/actions/create-github-collection.ts +180 -0
  36. package/corpus/templates/content/actions/disconnect-content-database-source.ts +5 -0
  37. package/corpus/templates/content/actions/link-github-folder.ts +104 -36
  38. package/corpus/templates/content/actions/refresh-content-database-source.ts +37 -4
  39. package/corpus/templates/content/actions/sync-github-folder.ts +23 -5
  40. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +172 -19
  41. package/corpus/templates/content/app/components/editor/database/GitHubLinkDialog.tsx +938 -0
  42. package/corpus/templates/content/app/components/editor/database/GitHubSourcePanel.tsx +228 -0
  43. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +561 -0
  44. package/corpus/templates/content/app/components/editor/database/GitHubUnlinkDialog.tsx +96 -0
  45. package/corpus/templates/content/app/components/editor/database/github-availability.ts +147 -0
  46. package/corpus/templates/content/app/components/editor/database/github-source-text.ts +280 -0
  47. package/corpus/templates/content/app/components/layout/Layout.tsx +2 -0
  48. package/corpus/templates/content/app/hooks/content-action-refresh.ts +6 -0
  49. package/corpus/templates/content/app/hooks/use-content-database.ts +3 -1
  50. package/corpus/templates/content/app/hooks/use-github-folder.ts +199 -0
  51. package/corpus/templates/content/app/i18n-data.ts +154 -1
  52. package/corpus/templates/content/app/lib/github-link-dialog.ts +86 -0
  53. package/corpus/templates/content/changelog/2026-10-18-connected-ai-assistants-can-link-a-new-collection-to-a-github.md +1 -1
  54. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +6 -0
  55. package/corpus/templates/content/changelog/2026-10-21-organization-favorites-include-documents-shared-with-the-cur.md +6 -0
  56. package/corpus/templates/content/docs/product/capabilities/content.source.sync-policy.md +32 -7
  57. package/corpus/templates/content/docs/product/encyclopedia.md +3 -3
  58. package/corpus/templates/content/docs/product/features/content.feature.trust-your-connected-sources.md +2 -2
  59. package/corpus/templates/content/docs/product/roadmap.md +1 -1
  60. package/corpus/templates/content/parity/matrix.md +42 -42
  61. package/corpus/templates/content/parity/matrix.ts +14 -0
  62. package/corpus/templates/content/server/lib/github-source-client.ts +50 -1
  63. package/corpus/templates/content/shared/api.ts +3 -0
  64. package/corpus/templates/content/shared/github-source.ts +68 -0
  65. package/corpus/templates/design/CHANGELOG.md +1 -0
  66. package/corpus/templates/design/actions/connect-assets-mcp.ts +5 -9
  67. package/corpus/templates/design/changelog/2026-10-21-journey-canvases-accept-partial-analytics-samples-when-repla.md +6 -0
  68. package/corpus/templates/design/shared/journey-canvas.ts +6 -4
  69. package/corpus/templates/dispatch/.agents/skills/a2a-protocol/SKILL.md +16 -13
  70. package/corpus/templates/dispatch/CHANGELOG.md +10 -0
  71. package/corpus/templates/dispatch/changelog/2026-10-21-customer-ai-credit-questions-route-to-account-health-instead.md +6 -0
  72. package/corpus/templates/dispatch/changelog/2026-10-21-workspace-apps-load-reliably-with-organization-sign-in-and-f.md +6 -0
  73. package/corpus/templates/factory/server/lib/require-workspace-member.ts +5 -17
  74. package/corpus/templates/mail/server/lib/queued-drafts.ts +9 -4
  75. package/corpus/templates/slides/server/workspace-defaults.ts +12 -15
  76. package/package.json +1 -1
@@ -0,0 +1,938 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import { startWorkspaceProviderOAuth } from "@agent-native/core/client/integrations";
3
+ import type { ContentDatabaseSource } from "@shared/api";
4
+ import {
5
+ githubFolderName,
6
+ type GitHubFolderPreview,
7
+ type GitHubLinkBlockedReason,
8
+ } from "@shared/github-source";
9
+ import {
10
+ IconBrandGithub,
11
+ IconExternalLink,
12
+ IconFileText,
13
+ IconFolder,
14
+ IconGitBranch,
15
+ } from "@tabler/icons-react";
16
+ import { useQueryClient } from "@tanstack/react-query";
17
+ import {
18
+ useEffect,
19
+ useRef,
20
+ useState,
21
+ type ClipboardEvent,
22
+ type FormEvent,
23
+ type ReactNode,
24
+ } from "react";
25
+ import { useLocation, useNavigate } from "react-router";
26
+ import { toast } from "sonner";
27
+
28
+ import { TYPE_ICONS } from "@/components/editor/DocumentProperties";
29
+ import { Button } from "@/components/ui/button";
30
+ import {
31
+ Dialog,
32
+ DialogContent,
33
+ DialogDescription,
34
+ DialogFooter,
35
+ DialogHeader,
36
+ DialogTitle,
37
+ } from "@/components/ui/dialog";
38
+ import { Input } from "@/components/ui/input";
39
+ import { Spinner } from "@/components/ui/spinner";
40
+ import {
41
+ startPageOpenDocumentReads,
42
+ useUpdateDocument,
43
+ } from "@/hooks/use-documents";
44
+ import {
45
+ useCreateGitHubCollection,
46
+ useGitHubConnection,
47
+ useLinkGitHubFolder,
48
+ useRefreshAfterGitHubFolder,
49
+ type GitHubFolderSyncSummary,
50
+ } from "@/hooks/use-github-folder";
51
+ import { documentQueryFilter } from "@/lib/document-query";
52
+ import {
53
+ GITHUB_LINK_DIALOG_OPEN_EVENT,
54
+ githubLinkRequestFromSearch,
55
+ githubLinkReturnPath,
56
+ searchWithoutGitHubLink,
57
+ type GitHubLinkDialogRequest,
58
+ type GitHubLinkTarget,
59
+ } from "@/lib/github-link-dialog";
60
+
61
+ import {
62
+ githubBlockedFromErrorCode,
63
+ githubBlockedText,
64
+ githubErrorNeedsConnection,
65
+ githubExampleText,
66
+ githubLinkErrorText,
67
+ githubList,
68
+ githubProblemText,
69
+ githubSkipReasonText,
70
+ githubUrlProblem,
71
+ shortCommit,
72
+ } from "./github-source-text";
73
+ import { dbText } from "./text";
74
+
75
+ /** Pages, columns or files a details list shows before "and N more". */
76
+ const DETAILS_LIST_LIMIT = 8;
77
+ /** Page titles the summary lists before "N more". */
78
+ const SUMMARY_TITLES = 3;
79
+ /** Columns the summary shows before "N more". */
80
+ const SUMMARY_COLUMNS = 8;
81
+
82
+ interface CheckedFolder {
83
+ url: string;
84
+ preview: GitHubFolderPreview;
85
+ blockedReason: GitHubLinkBlockedReason | null;
86
+ }
87
+
88
+ interface LinkProblem {
89
+ text: string;
90
+ /** The folder a file link points into, to use instead. */
91
+ folder?: { url: string; name: string } | null;
92
+ /** The collection can't take a folder, so offer a new one. */
93
+ offerNewCollection?: boolean;
94
+ }
95
+
96
+ /** Whether the folder link would fill in fewer pages than it has files. */
97
+ function pagesAreEstimate(preview: GitHubFolderPreview): boolean {
98
+ // Only the first files are read before linking; the others can still turn
99
+ // out unreadable, so the count is a ceiling until then.
100
+ return preview.sampledFiles < preview.markdownFiles;
101
+ }
102
+
103
+ function pagesHeading(preview: GitHubFolderPreview): string {
104
+ const count = preview.markdownFiles;
105
+ if (pagesAreEstimate(preview)) {
106
+ return dbText("githubPreviewPagesUpTo", { count });
107
+ }
108
+ return count === 1
109
+ ? dbText("githubPreviewPagesOne")
110
+ : dbText("githubPreviewPages", { count });
111
+ }
112
+
113
+ function linkButtonText(preview: GitHubFolderPreview): string {
114
+ const count = preview.markdownFiles;
115
+ if (pagesAreEstimate(preview)) {
116
+ return dbText("githubLinkSubmitUpTo", { count });
117
+ }
118
+ return count === 1
119
+ ? dbText("githubLinkSubmitOne")
120
+ : dbText("githubLinkSubmit", { count });
121
+ }
122
+
123
+ /** Says how the link went, once the dialog closes. */
124
+ function announceLink(
125
+ preview: GitHubFolderPreview,
126
+ sync: GitHubFolderSyncSummary,
127
+ source: ContentDatabaseSource | null,
128
+ ) {
129
+ const folder = githubFolderName(preview.location);
130
+ // The link is saved even when its first update fails; say both, and the
131
+ // status line keeps showing the problem after this closes.
132
+ if (sync.state === "error") {
133
+ toast.error(dbText("githubLinkedSyncFailed", { folder }), {
134
+ description: githubProblemText(
135
+ sync.problem?.code ?? "unavailable",
136
+ preview.location,
137
+ ),
138
+ });
139
+ return;
140
+ }
141
+ if (sync.state === "partial") {
142
+ const progress = source?.metadata.github?.progress;
143
+ toast.success(
144
+ dbText("githubLinkedPartialToast", {
145
+ done: progress?.done ?? sync.created,
146
+ total: progress?.total ?? sync.created + sync.remaining,
147
+ }),
148
+ );
149
+ return;
150
+ }
151
+ const count = sync.created || preview.markdownFiles;
152
+ toast.success(
153
+ count === 1
154
+ ? dbText("githubLinkedToastOne", { folder })
155
+ : dbText("githubLinkedToast", { count, folder }),
156
+ );
157
+ }
158
+
159
+ function DetailsSection({
160
+ title,
161
+ children,
162
+ }: {
163
+ title: string;
164
+ children: ReactNode;
165
+ }) {
166
+ return (
167
+ <section className="grid min-w-0 gap-1">
168
+ <h3 className="text-xs font-medium text-foreground">{title}</h3>
169
+ {children}
170
+ </section>
171
+ );
172
+ }
173
+
174
+ /** Everything the summary leaves out, for the person who wants to look. */
175
+ function GitHubFolderDetails({ preview }: { preview: GitHubFolderPreview }) {
176
+ const t = useT();
177
+ const morePages = preview.markdownFiles - DETAILS_LIST_LIMIT;
178
+ const moreSkipped = preview.skipped.length - DETAILS_LIST_LIMIT;
179
+ return (
180
+ <div
181
+ id="github-folder-details"
182
+ className="grid min-w-0 gap-3 border-t border-border pt-3 text-xs"
183
+ data-github-folder-details
184
+ >
185
+ {preview.sample.length > 0 ? (
186
+ <DetailsSection title={dbText("githubDetailsPages")}>
187
+ <ul className="grid min-w-0 gap-0.5">
188
+ {preview.sample.slice(0, DETAILS_LIST_LIMIT).map((page) => (
189
+ <li key={page.path} className="flex min-w-0 items-baseline gap-2">
190
+ <span className="min-w-0 truncate">{page.title}</span>
191
+ <span className="min-w-0 truncate text-muted-foreground">
192
+ {page.path}
193
+ </span>
194
+ </li>
195
+ ))}
196
+ {morePages > 0 ? (
197
+ <li className="text-muted-foreground">
198
+ {dbText("githubLinkMore", { count: morePages })}
199
+ </li>
200
+ ) : null}
201
+ </ul>
202
+ </DetailsSection>
203
+ ) : null}
204
+ {preview.fields.length > 0 ? (
205
+ <DetailsSection title={dbText("githubDetailsColumns")}>
206
+ <ul className="grid min-w-0 gap-0.5">
207
+ {preview.fields.map((field) => (
208
+ <li key={field.key} className="flex min-w-0 items-baseline gap-2">
209
+ <span className="shrink-0">{field.key}</span>
210
+ <span className="shrink-0 text-muted-foreground">
211
+ {t(`editor.propertyTypes.${field.type}`)}
212
+ </span>
213
+ {field.examples.length > 0 ? (
214
+ <span className="min-w-0 truncate text-muted-foreground/80">
215
+ {githubList(
216
+ [...new Set(field.examples)]
217
+ .slice(0, 3)
218
+ .map((example) =>
219
+ githubExampleText(field.type, example),
220
+ ),
221
+ )}
222
+ </span>
223
+ ) : null}
224
+ </li>
225
+ ))}
226
+ </ul>
227
+ {pagesAreEstimate(preview) ? (
228
+ <p className="text-muted-foreground">
229
+ {dbText("githubDetailsColumnsSampled", {
230
+ count: preview.sampledFiles,
231
+ total: preview.markdownFiles,
232
+ })}
233
+ </p>
234
+ ) : null}
235
+ </DetailsSection>
236
+ ) : null}
237
+ {preview.skipped.length > 0 ? (
238
+ <DetailsSection title={dbText("githubDetailsNotShown")}>
239
+ <ul className="grid min-w-0 gap-0.5">
240
+ {preview.skipped.slice(0, DETAILS_LIST_LIMIT).map((file) => (
241
+ <li key={file.path} className="flex min-w-0 items-baseline gap-2">
242
+ <span className="min-w-0 truncate">{file.path}</span>
243
+ <span className="shrink-0 text-muted-foreground">
244
+ {githubSkipReasonText(file.reason)}
245
+ </span>
246
+ </li>
247
+ ))}
248
+ {moreSkipped > 0 ? (
249
+ <li className="text-muted-foreground">
250
+ {dbText("githubLinkMore", { count: moreSkipped })}
251
+ </li>
252
+ ) : null}
253
+ </ul>
254
+ </DetailsSection>
255
+ ) : null}
256
+ <DetailsSection title={dbText("githubDetailsFrom")}>
257
+ <p className="text-muted-foreground">
258
+ {dbText("githubDetailsBranchVersion", {
259
+ branch: preview.location.branch,
260
+ commit: shortCommit(preview.commitSha),
261
+ })}
262
+ </p>
263
+ </DetailsSection>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ /** A heading and its list in the folder preview. */
269
+ function SummarySection({
270
+ title,
271
+ children,
272
+ }: {
273
+ title: string;
274
+ children: ReactNode;
275
+ }) {
276
+ return (
277
+ <section className="grid min-w-0 gap-1.5">
278
+ <h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
279
+ {children}
280
+ </section>
281
+ );
282
+ }
283
+
284
+ /**
285
+ * What linking the folder would do: which folder, a few of its pages, the
286
+ * columns they'll get, and what won't show. The rest waits behind "Show
287
+ * details".
288
+ */
289
+ export function GitHubFolderSummary({
290
+ preview,
291
+ blockedReason,
292
+ onNewCollection,
293
+ }: {
294
+ preview: GitHubFolderPreview;
295
+ blockedReason: GitHubLinkBlockedReason | null;
296
+ onNewCollection?: () => void;
297
+ }) {
298
+ const t = useT();
299
+ const [showDetails, setShowDetails] = useState(false);
300
+ const { location } = preview;
301
+ const hasPages = preview.markdownFiles > 0;
302
+ const titles = preview.sample.slice(0, SUMMARY_TITLES);
303
+ const morePages = preview.markdownFiles - titles.length;
304
+ const columns = preview.fields.slice(0, SUMMARY_COLUMNS);
305
+ const moreColumns = preview.fields.length - columns.length;
306
+ const skipped = preview.skipped.length;
307
+ return (
308
+ <div
309
+ className="grid min-w-0 overflow-hidden rounded-lg border border-border text-sm"
310
+ data-github-folder-preview
311
+ >
312
+ <a
313
+ href={preview.folderUrl}
314
+ target="_blank"
315
+ rel="noreferrer"
316
+ className="group flex min-w-0 items-center gap-2.5 border-b border-border bg-muted/30 px-3 py-2.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
317
+ >
318
+ <IconFolder
319
+ className="size-5 shrink-0 text-muted-foreground"
320
+ aria-hidden
321
+ />
322
+ <span className="grid min-w-0 flex-1">
323
+ <span className="truncate font-medium">
324
+ {githubFolderName(location)}
325
+ </span>
326
+ <span className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
327
+ <span className="truncate">
328
+ {location.owner}/{location.repo}
329
+ </span>
330
+ <IconGitBranch className="ms-1.5 size-3 shrink-0" aria-hidden />
331
+ <span className="truncate">{location.branch}</span>
332
+ </span>
333
+ </span>
334
+ <IconExternalLink
335
+ className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
336
+ aria-hidden
337
+ />
338
+ </a>
339
+ <div className="grid min-w-0 gap-3 p-3">
340
+ {hasPages ? (
341
+ <SummarySection title={pagesHeading(preview)}>
342
+ <ul className="grid min-w-0 gap-1">
343
+ {titles.map((page) => (
344
+ <li key={page.path} className="flex min-w-0 items-center gap-2">
345
+ <IconFileText
346
+ className="size-3.5 shrink-0 text-muted-foreground"
347
+ aria-hidden
348
+ />
349
+ <span className="min-w-0 truncate">{page.title}</span>
350
+ </li>
351
+ ))}
352
+ {morePages > 0 ? (
353
+ <li className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
354
+ <span className="size-3.5 shrink-0" aria-hidden />
355
+ {dbText("githubLinkMore", { count: morePages })}
356
+ </li>
357
+ ) : null}
358
+ </ul>
359
+ </SummarySection>
360
+ ) : (
361
+ <p className="break-words text-muted-foreground">
362
+ {dbText("githubLinkNoFiles")}
363
+ </p>
364
+ )}
365
+ {hasPages && preview.fields.length > 0 ? (
366
+ <SummarySection
367
+ title={
368
+ preview.fields.length === 1
369
+ ? dbText("githubPreviewColumnsOne")
370
+ : dbText("githubPreviewColumns", {
371
+ count: preview.fields.length,
372
+ })
373
+ }
374
+ >
375
+ <ul className="flex min-w-0 flex-wrap gap-1">
376
+ {columns.map((field) => {
377
+ const TypeIcon = TYPE_ICONS[field.type];
378
+ return (
379
+ <li
380
+ key={field.key}
381
+ className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs"
382
+ title={t(`editor.propertyTypes.${field.type}`)}
383
+ >
384
+ <TypeIcon
385
+ className="size-3 shrink-0 text-muted-foreground"
386
+ aria-hidden
387
+ />
388
+ <span className="min-w-0 truncate">{field.key}</span>
389
+ </li>
390
+ );
391
+ })}
392
+ {moreColumns > 0 ? (
393
+ <li className="inline-flex items-center px-1 py-0.5 text-xs text-muted-foreground">
394
+ {dbText("githubLinkMore", { count: moreColumns })}
395
+ </li>
396
+ ) : null}
397
+ </ul>
398
+ </SummarySection>
399
+ ) : hasPages ? (
400
+ <p className="break-words text-muted-foreground">
401
+ {dbText("githubLinkNoColumns")}
402
+ </p>
403
+ ) : null}
404
+ {preview.images > 0 && !preview.storageReady ? (
405
+ <p className="break-words text-xs text-muted-foreground">
406
+ {dbText("githubLinkPicturesWaiting")}
407
+ </p>
408
+ ) : null}
409
+ <div className="flex min-w-0 flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-xs">
410
+ <span className="break-words text-muted-foreground">
411
+ {skipped === 0
412
+ ? null
413
+ : skipped === 1
414
+ ? dbText("githubLinkNotShownOne")
415
+ : dbText("githubLinkNotShown", { count: skipped })}
416
+ </span>
417
+ <button
418
+ type="button"
419
+ className="ms-auto rounded-sm text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
420
+ aria-expanded={showDetails}
421
+ aria-controls="github-folder-details"
422
+ onClick={() => setShowDetails((open) => !open)}
423
+ >
424
+ {showDetails
425
+ ? dbText("githubHideDetails")
426
+ : dbText("githubShowDetails")}
427
+ </button>
428
+ </div>
429
+ {blockedReason ? (
430
+ <div
431
+ className="grid min-w-0 gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
432
+ role="alert"
433
+ >
434
+ <span className="break-words">
435
+ {githubBlockedText(blockedReason)}
436
+ </span>
437
+ {onNewCollection ? (
438
+ <Button
439
+ type="button"
440
+ size="xs"
441
+ variant="outline"
442
+ className="justify-self-start"
443
+ onClick={onNewCollection}
444
+ >
445
+ {dbText("githubNewCollectionInstead")}
446
+ </Button>
447
+ ) : null}
448
+ </div>
449
+ ) : null}
450
+ {showDetails ? <GitHubFolderDetails preview={preview} /> : null}
451
+ </div>
452
+ </div>
453
+ );
454
+ }
455
+
456
+ /**
457
+ * Links a collection to a GitHub folder, or makes a new collection from one:
458
+ * connect GitHub if needed, paste the folder's link, check what it becomes,
459
+ * then link it.
460
+ */
461
+ export function GitHubLinkDialog({
462
+ request,
463
+ open,
464
+ onOpenChange,
465
+ }: {
466
+ request: GitHubLinkDialogRequest;
467
+ open: boolean;
468
+ onOpenChange: (open: boolean) => void;
469
+ }) {
470
+ const t = useT();
471
+ const navigate = useNavigate();
472
+ const queryClient = useQueryClient();
473
+ const updateDocument = useUpdateDocument();
474
+ const refreshAfterGitHubFolder = useRefreshAfterGitHubFolder();
475
+ const linkFolder = useLinkGitHubFolder();
476
+ const createCollection = useCreateGitHubCollection();
477
+ const connection = useGitHubConnection(open);
478
+ const [target, setTarget] = useState<GitHubLinkTarget>(request.target);
479
+ const [url, setUrl] = useState(request.url ?? "");
480
+ const [checked, setChecked] = useState<CheckedFolder | null>(null);
481
+ const [problem, setProblem] = useState<LinkProblem | null>(null);
482
+ const [checking, setChecking] = useState(false);
483
+ const [linking, setLinking] = useState(false);
484
+ const [needsConnection, setNeedsConnection] = useState(false);
485
+ const [triedOnce, setTriedOnce] = useState(false);
486
+ // Counts checks and edits, so a check that answers after the link changed
487
+ // is dropped rather than shown under the new link.
488
+ const checkRun = useRef(0);
489
+ const startedFromUrl = useRef(false);
490
+
491
+ const isNew = target.kind === "new";
492
+ const newCollectionPlace =
493
+ target.kind === "new"
494
+ ? target
495
+ : target.spaceId
496
+ ? { spaceId: target.spaceId, parentId: target.parentId ?? null }
497
+ : null;
498
+ const connected = connection.data?.connected;
499
+ const showConnect =
500
+ needsConnection || (connected === false && !checked && !linking);
501
+ const waitingForConnection =
502
+ !needsConnection && connection.isPending && !checked;
503
+ const ready =
504
+ !!checked &&
505
+ !checked.blockedReason &&
506
+ checked.preview.markdownFiles > 0 &&
507
+ checked.url === url.trim();
508
+
509
+ function clearCheck() {
510
+ checkRun.current += 1;
511
+ setChecked(null);
512
+ setProblem(null);
513
+ setChecking(false);
514
+ }
515
+
516
+ function handleError(error: unknown, typed: string) {
517
+ if (githubErrorNeedsConnection(error)) {
518
+ setNeedsConnection(true);
519
+ setProblem(null);
520
+ return;
521
+ }
522
+ const blocked = githubBlockedFromErrorCode(
523
+ (error as { errorCode?: unknown } | null)?.errorCode,
524
+ );
525
+ setProblem({
526
+ text: githubLinkErrorText(error, typed),
527
+ offerNewCollection: !!blocked && !!newCollectionPlace,
528
+ });
529
+ }
530
+
531
+ async function check(nextUrl = url, into: GitHubLinkTarget = target) {
532
+ const typed = nextUrl.trim();
533
+ clearCheck();
534
+ if (!typed) return;
535
+ setTriedOnce(true);
536
+ const urlProblem = githubUrlProblem(typed);
537
+ if (urlProblem) {
538
+ setProblem({
539
+ text: urlProblem.text,
540
+ folder:
541
+ urlProblem.folderUrl && urlProblem.folder
542
+ ? { url: urlProblem.folderUrl, name: urlProblem.folder }
543
+ : null,
544
+ });
545
+ return;
546
+ }
547
+ const run = checkRun.current;
548
+ setChecking(true);
549
+ try {
550
+ const response =
551
+ into.kind === "collection"
552
+ ? await linkFolder.mutateAsync({
553
+ databaseId: into.databaseId,
554
+ url: typed,
555
+ dryRun: true,
556
+ })
557
+ : await createCollection.mutateAsync({
558
+ url: typed,
559
+ spaceId: into.spaceId,
560
+ parentId: into.parentId,
561
+ dryRun: true,
562
+ });
563
+ if (run !== checkRun.current || !response.dryRun) return;
564
+ setChecked({
565
+ url: typed,
566
+ preview: response.preview,
567
+ blockedReason:
568
+ "blockedReason" in response ? response.blockedReason : null,
569
+ });
570
+ } catch (error) {
571
+ if (run === checkRun.current) handleError(error, typed);
572
+ } finally {
573
+ if (run === checkRun.current) setChecking(false);
574
+ }
575
+ }
576
+
577
+ async function link() {
578
+ if (!checked || !ready) return;
579
+ const { preview } = checked;
580
+ setLinking(true);
581
+ setProblem(null);
582
+ try {
583
+ if (target.kind === "collection") {
584
+ const response = await linkFolder.mutateAsync({
585
+ databaseId: target.databaseId,
586
+ url: checked.url,
587
+ });
588
+ if (response.dryRun) return;
589
+ announceLink(preview, response.sync, response.source);
590
+ nameUntitledCollection(
591
+ target.documentId,
592
+ githubFolderName(preview.location),
593
+ );
594
+ onOpenChange(false);
595
+ return;
596
+ }
597
+ const response = await createCollection.mutateAsync({
598
+ url: checked.url,
599
+ spaceId: target.spaceId,
600
+ parentId: target.parentId,
601
+ });
602
+ if (response.dryRun) return;
603
+ announceLink(preview, response.sync, response.source);
604
+ onOpenChange(false);
605
+ const documentId = response.database.documentId;
606
+ startPageOpenDocumentReads(queryClient, documentId);
607
+ void navigate(`/page/${documentId}`);
608
+ } catch (error) {
609
+ handleError(error, checked.url);
610
+ } finally {
611
+ setLinking(false);
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Gives a collection still called "Untitled collection" the folder's name,
617
+ * like one made from the folder gets. A name someone typed stays.
618
+ */
619
+ function nameUntitledCollection(documentId: string, folder: string) {
620
+ const cached = queryClient
621
+ .getQueriesData<{ title?: string }>(documentQueryFilter(documentId))
622
+ .map(([, document]) => document?.title)
623
+ .find((title) => title !== undefined);
624
+ const title = cached?.trim();
625
+ if (title === undefined) return;
626
+ if (title !== "" && title !== t("editor.untitledDatabase")) return;
627
+ // The link already worked; a name that didn't save is only cosmetic.
628
+ // Renaming cancels reads in flight, the link's refresh among them, so the
629
+ // rows refresh again once it settles.
630
+ updateDocument
631
+ .mutateAsync({ id: documentId, title: folder })
632
+ .catch(() => undefined)
633
+ .finally(() => refreshAfterGitHubFolder("link-github-folder"));
634
+ }
635
+
636
+ function connectGitHub() {
637
+ startWorkspaceProviderOAuth("github", {
638
+ appId: "content",
639
+ returnPath: githubLinkReturnPath(window.location, target, url),
640
+ scope: "user",
641
+ });
642
+ }
643
+
644
+ /** Keeps the link and checks it again, for a new collection beside this one. */
645
+ function switchToNewCollection() {
646
+ if (!newCollectionPlace) return;
647
+ const next: GitHubLinkTarget = { kind: "new", ...newCollectionPlace };
648
+ setTarget(next);
649
+ void check(url, next);
650
+ }
651
+
652
+ // A link carried back from GitHub's sign-in is checked once the
653
+ // connection allows it.
654
+ const canCheckStartUrl =
655
+ !!request.url?.trim() && !waitingForConnection && !showConnect;
656
+ useEffect(() => {
657
+ if (startedFromUrl.current || !canCheckStartUrl) return;
658
+ startedFromUrl.current = true;
659
+ void check(request.url);
660
+ // Runs once, when the dialog can first check; `check` is the latest.
661
+ // eslint-disable-next-line react-hooks/exhaustive-deps
662
+ }, [canCheckStartUrl]);
663
+
664
+ function submit(event: FormEvent) {
665
+ event.preventDefault();
666
+ if (ready) void link();
667
+ else if (!checking) void check();
668
+ }
669
+
670
+ function pasteLink(event: ClipboardEvent<HTMLInputElement>) {
671
+ const input = event.currentTarget;
672
+ const replacesAll =
673
+ input.value === "" ||
674
+ (input.selectionStart === 0 && input.selectionEnd === input.value.length);
675
+ const pasted = event.clipboardData.getData("text").trim();
676
+ if (!replacesAll || !pasted) return;
677
+ event.preventDefault();
678
+ setUrl(pasted);
679
+ void check(pasted);
680
+ }
681
+
682
+ const primaryText = linking
683
+ ? dbText("githubLinking")
684
+ : ready && checked
685
+ ? linkButtonText(checked.preview)
686
+ : dbText("githubLinkCheck");
687
+
688
+ return (
689
+ <Dialog
690
+ open={open}
691
+ onOpenChange={(next) => {
692
+ if (linking && !next) return;
693
+ onOpenChange(next);
694
+ }}
695
+ >
696
+ <DialogContent
697
+ hideClose={linking}
698
+ onCloseAutoFocus={(event) => {
699
+ const returnTo = request.returnFocusTo;
700
+ if (returnTo?.isConnected) {
701
+ event.preventDefault();
702
+ returnTo.focus();
703
+ }
704
+ }}
705
+ >
706
+ <DialogHeader>
707
+ <DialogTitle>
708
+ <span className="flex items-center gap-2">
709
+ <IconBrandGithub className="size-4" aria-hidden />
710
+ {isNew
711
+ ? dbText("githubNewCollectionTitle")
712
+ : dbText("githubLinkTitle")}
713
+ </span>
714
+ </DialogTitle>
715
+ <DialogDescription>
716
+ {isNew
717
+ ? dbText("githubNewCollectionDescription")
718
+ : dbText("githubLinkDescription")}
719
+ </DialogDescription>
720
+ </DialogHeader>
721
+
722
+ {waitingForConnection ? (
723
+ <div
724
+ className="flex items-center gap-2 text-sm text-muted-foreground"
725
+ role="status"
726
+ >
727
+ <Spinner className="size-3.5" />
728
+ {dbText("githubConnectChecking")}
729
+ </div>
730
+ ) : showConnect ? (
731
+ <p className="break-words text-sm" data-github-connect-step>
732
+ {dbText("githubConnectPrompt")}
733
+ </p>
734
+ ) : (
735
+ <form
736
+ id="github-link-form"
737
+ className="grid min-w-0 gap-1.5"
738
+ onSubmit={submit}
739
+ >
740
+ <label
741
+ htmlFor="github-folder-url"
742
+ className="text-xs font-medium text-muted-foreground"
743
+ >
744
+ {dbText("githubLinkUrlLabel")}
745
+ </label>
746
+ <Input
747
+ id="github-folder-url"
748
+ value={url}
749
+ placeholder="https://github.com/your-team/website/tree/main/docs"
750
+ autoComplete="off"
751
+ spellCheck={false}
752
+ autoFocus
753
+ disabled={linking}
754
+ aria-describedby={
755
+ triedOnce ? undefined : "github-folder-url-help"
756
+ }
757
+ aria-invalid={problem ? true : undefined}
758
+ onPaste={pasteLink}
759
+ onChange={(event) => {
760
+ setUrl(event.target.value);
761
+ clearCheck();
762
+ }}
763
+ />
764
+ {triedOnce ? null : (
765
+ <p
766
+ id="github-folder-url-help"
767
+ className="text-xs text-muted-foreground"
768
+ >
769
+ {dbText("githubLinkUrlHelp")}
770
+ </p>
771
+ )}
772
+ </form>
773
+ )}
774
+
775
+ {checking ? (
776
+ <div
777
+ className="flex items-center gap-2 text-xs text-muted-foreground"
778
+ role="status"
779
+ >
780
+ <Spinner className="size-3.5" />
781
+ {dbText("githubLinkReading")}
782
+ </div>
783
+ ) : null}
784
+
785
+ {problem ? (
786
+ <div className="grid min-w-0 gap-2" role="alert">
787
+ <p className="break-words text-xs text-destructive">
788
+ {problem.text}
789
+ </p>
790
+ {problem.folder ? (
791
+ <Button
792
+ type="button"
793
+ size="xs"
794
+ variant="outline"
795
+ className="justify-self-start"
796
+ onClick={() => {
797
+ const folderUrl = problem.folder!.url;
798
+ setUrl(folderUrl);
799
+ void check(folderUrl);
800
+ }}
801
+ >
802
+ {dbText("githubLinkUseFolder", { folder: problem.folder.name })}
803
+ </Button>
804
+ ) : null}
805
+ {problem.offerNewCollection ? (
806
+ <Button
807
+ type="button"
808
+ size="xs"
809
+ variant="outline"
810
+ className="justify-self-start"
811
+ onClick={switchToNewCollection}
812
+ >
813
+ {dbText("githubNewCollectionInstead")}
814
+ </Button>
815
+ ) : null}
816
+ </div>
817
+ ) : null}
818
+
819
+ {checked && !checking && !showConnect ? (
820
+ <GitHubFolderSummary
821
+ preview={checked.preview}
822
+ blockedReason={checked.blockedReason}
823
+ onNewCollection={
824
+ newCollectionPlace ? switchToNewCollection : undefined
825
+ }
826
+ />
827
+ ) : null}
828
+
829
+ {linking && checked ? (
830
+ <div
831
+ className="flex items-center gap-2 text-xs text-muted-foreground"
832
+ role="status"
833
+ >
834
+ <Spinner className="size-3.5" />
835
+ {checked.preview.markdownFiles === 1
836
+ ? dbText("githubLinkingProgressOne")
837
+ : dbText("githubLinkingProgress", {
838
+ count: checked.preview.markdownFiles,
839
+ })}
840
+ </div>
841
+ ) : null}
842
+
843
+ <DialogFooter>
844
+ <Button
845
+ type="button"
846
+ variant="ghost"
847
+ disabled={linking}
848
+ onClick={() => onOpenChange(false)}
849
+ >
850
+ {dbText("cancel")}
851
+ </Button>
852
+ {waitingForConnection ? null : showConnect ? (
853
+ <Button type="button" onClick={connectGitHub}>
854
+ <IconBrandGithub className="mr-1 size-3.5" aria-hidden />
855
+ {dbText("githubConnect")}
856
+ </Button>
857
+ ) : (
858
+ <Button
859
+ type="submit"
860
+ form="github-link-form"
861
+ disabled={
862
+ linking ||
863
+ checking ||
864
+ !url.trim() ||
865
+ (!!checked && !ready && checked.url === url.trim())
866
+ }
867
+ >
868
+ {linking ? <Spinner className="mr-1 size-3.5" /> : null}
869
+ {primaryText}
870
+ </Button>
871
+ )}
872
+ </DialogFooter>
873
+ </DialogContent>
874
+ </Dialog>
875
+ );
876
+ }
877
+
878
+ /**
879
+ * The one GitHub folder dialog for the app. Anything can open it with
880
+ * `openGitHubLinkDialog`, and it reopens after GitHub's sign-in from the
881
+ * page's address.
882
+ */
883
+ export function GitHubLinkDialogHost() {
884
+ const location = useLocation();
885
+ const navigate = useNavigate();
886
+ const [state, setState] = useState<{
887
+ request: GitHubLinkDialogRequest;
888
+ open: boolean;
889
+ key: number;
890
+ } | null>(null);
891
+
892
+ useEffect(() => {
893
+ const onOpen = (event: Event) => {
894
+ const request = (event as CustomEvent<GitHubLinkDialogRequest>).detail;
895
+ if (!request?.target) return;
896
+ setState((current) => ({
897
+ request,
898
+ open: true,
899
+ key: (current?.key ?? 0) + 1,
900
+ }));
901
+ };
902
+ window.addEventListener(GITHUB_LINK_DIALOG_OPEN_EVENT, onOpen);
903
+ return () =>
904
+ window.removeEventListener(GITHUB_LINK_DIALOG_OPEN_EVENT, onOpen);
905
+ }, []);
906
+
907
+ // Back from GitHub's sign-in: reopen the dialog the address names, then
908
+ // drop it from the address so a reload doesn't open it again.
909
+ useEffect(() => {
910
+ const request = githubLinkRequestFromSearch(location.search);
911
+ if (!request) return;
912
+ setState((current) => ({
913
+ request,
914
+ open: true,
915
+ key: (current?.key ?? 0) + 1,
916
+ }));
917
+ void navigate(
918
+ {
919
+ pathname: location.pathname,
920
+ search: searchWithoutGitHubLink(location.search),
921
+ hash: location.hash,
922
+ },
923
+ { replace: true },
924
+ );
925
+ }, [location.hash, location.pathname, location.search, navigate]);
926
+
927
+ if (!state) return null;
928
+ return (
929
+ <GitHubLinkDialog
930
+ key={state.key}
931
+ request={state.request}
932
+ open={state.open}
933
+ onOpenChange={(open) =>
934
+ setState((current) => (current ? { ...current, open } : current))
935
+ }
936
+ />
937
+ );
938
+ }